From e245c0aa3a87166d141c17e514e4812daf147be6 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 22 Jan 2026 02:22:59 +0000 Subject: [PATCH 001/627] Fix 0.2 CHANGELOG to note that offers will break on downgrade It turns out we also switched the key we use to authenticate offers *created* in the 0.2 upgrade and as a result downgrading to 0.2 will break any offers created on 0.2. This wasn't intentional but it doesn't really seem worth fixing at this point, so just document it. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e83ef2a14d..12f926cacad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -201,6 +201,8 @@ generated for inclusion in BOLT 12 `Offer`s will no longer be accepted. As most blinded message paths are ephemeral, this should only invalidate issued BOLT 12 `Refund`s in practice (#3917). + * Blinded message paths included in BOLT 12 `Offer`s generated by LDK 0.2 will + not be accepted by prior versions of LDK after downgrade (#3917). * Once a channel has been spliced, LDK can no longer be downgraded. `UserConfig::reject_inbound_splices` can be set to block inbound ones (#4150) * Downgrading after setting `UserConfig::enable_htlc_hold` is not supported From db2a7eb716babf3645bf89f10e9bfd70a0b67eb5 Mon Sep 17 00:00:00 2001 From: elnosh Date: Mon, 9 Feb 2026 20:22:01 -0500 Subject: [PATCH 002/627] Update changelog and remove manually_accept references --- lightning/src/ln/async_signer_tests.rs | 4 +--- lightning/src/ln/priv_short_conf_tests.rs | 4 +--- .../3137-accept-dual-funding-without-contributing.txt | 5 ++--- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/lightning/src/ln/async_signer_tests.rs b/lightning/src/ln/async_signer_tests.rs index 7d28a137d0a..04bca524925 100644 --- a/lightning/src/ln/async_signer_tests.rs +++ b/lightning/src/ln/async_signer_tests.rs @@ -372,11 +372,9 @@ fn test_funding_signed_0conf() { fn do_test_funding_signed_0conf(signer_ops: Vec) { // Simulate acquiring the signature for `funding_signed` asynchronously for a zero-conf channel. - let mut manually_accept_config = test_default_channel_config(); - let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, Some(manually_accept_config)]); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let node_a_id = nodes[0].node.get_our_node_id(); let node_b_id = nodes[1].node.get_our_node_id(); diff --git a/lightning/src/ln/priv_short_conf_tests.rs b/lightning/src/ln/priv_short_conf_tests.rs index 9d30d749aa2..a5ccac780f9 100644 --- a/lightning/src/ln/priv_short_conf_tests.rs +++ b/lightning/src/ln/priv_short_conf_tests.rs @@ -1396,9 +1396,7 @@ fn test_connect_before_funding() { let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - let mut manually_accept_conf = test_default_channel_config(); - - let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, Some(manually_accept_conf)]); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let node_a_id = nodes[0].node.get_our_node_id(); let node_b_id = nodes[1].node.get_our_node_id(); diff --git a/pending_changelog/3137-accept-dual-funding-without-contributing.txt b/pending_changelog/3137-accept-dual-funding-without-contributing.txt index 9ea8de24e54..5e1d0de2d86 100644 --- a/pending_changelog/3137-accept-dual-funding-without-contributing.txt +++ b/pending_changelog/3137-accept-dual-funding-without-contributing.txt @@ -7,9 +7,8 @@ differentiate between an inbound request for a dual-funded (V2) or non-dual-funded (V1) channel to be opened, with value being either of the enum variants `InboundChannelFunds::DualFunded` and `InboundChannelFunds::PushMsat(u64)` corresponding to V2 and V1 channel open requests respectively. - * If `manually_accept_inbound_channels` is false, then V2 channels will be accepted automatically; the - same behaviour as V1 channels. Otherwise, `ChannelManager::accept_inbound_channel()` can also be used - to manually accept an inbound V2 channel. + * Similar to V1 channels, `ChannelManager::accept_inbound_channel()` can also be used + to accept an inbound V2 channel. * 0conf dual-funded channels are not supported. * RBF of dual-funded channel funding transactions is not supported. From 8edfc91522e19df5bb3c7ce0002541c510621582 Mon Sep 17 00:00:00 2001 From: elnosh Date: Mon, 9 Feb 2026 20:30:47 -0500 Subject: [PATCH 003/627] Use handle_and_accept_open_channel in async_signer test --- lightning/src/ln/async_signer_tests.rs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/lightning/src/ln/async_signer_tests.rs b/lightning/src/ln/async_signer_tests.rs index 04bca524925..b81279c10ac 100644 --- a/lightning/src/ln/async_signer_tests.rs +++ b/lightning/src/ln/async_signer_tests.rs @@ -70,9 +70,9 @@ fn do_test_open_channel(zero_conf: bool) { // Handle an inbound channel simulating an async signer. nodes[1].disable_next_channel_signer_op(SignerOp::GetPerCommitmentPoint); - nodes[1].node.handle_open_channel(node_a_id, &open_chan_msg); if zero_conf { + nodes[1].node.handle_open_channel(node_a_id, &open_chan_msg); let events = nodes[1].node.get_and_clear_pending_events(); assert_eq!(events.len(), 1, "Expected one event, got {}", events.len()); match &events[0] { @@ -90,15 +90,7 @@ fn do_test_open_channel(zero_conf: bool) { ev => panic!("Expected OpenChannelRequest, not {:?}", ev), } } else { - let events = nodes[1].node.get_and_clear_pending_events(); - assert_eq!(events.len(), 1, "Expected one event, got {}", events.len()); - match &events[0] { - Event::OpenChannelRequest { temporary_channel_id, .. } => nodes[1] - .node - .accept_inbound_channel(temporary_channel_id, &node_a_id, 0, None) - .unwrap(), - ev => panic!("Expected OpenChannelRequest, not {:?}", ev), - } + handle_and_accept_open_channel(&nodes[1], node_a_id, &open_chan_msg); } let channel_id_1 = { From 4e32d105ed3ad0b0a8dc49fb39e7da585ec877af Mon Sep 17 00:00:00 2001 From: elnosh Date: Mon, 9 Feb 2026 20:38:22 -0500 Subject: [PATCH 004/627] Remove explicit usage of test_default_channel_config test_default_channel_config is the default now so it does not need to be set explicitly in some of the tests. Removes unnecessary extra None config. --- lightning/src/ln/reorg_tests.rs | 4 ++-- lightning/src/ln/splicing_tests.rs | 18 ++++++------------ 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/lightning/src/ln/reorg_tests.rs b/lightning/src/ln/reorg_tests.rs index dac92cddc97..b39e8d31a75 100644 --- a/lightning/src/ln/reorg_tests.rs +++ b/lightning/src/ln/reorg_tests.rs @@ -686,7 +686,7 @@ fn test_htlc_preimage_claim_holder_commitment_after_counterparty_commitment_reor let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let legacy_cfg = test_legacy_channel_config(); - let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(legacy_cfg), None, None]); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(legacy_cfg), None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let (_, _, chan_id, funding_tx) = create_announced_chan_between_nodes(&nodes, 0, 1); @@ -762,7 +762,7 @@ fn test_htlc_preimage_claim_prev_counterparty_commitment_after_current_counterpa let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let legacy_cfg = test_legacy_channel_config(); - let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(legacy_cfg), None, None]); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(legacy_cfg), None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let (_, _, chan_id, funding_tx) = create_announced_chan_between_nodes(&nodes, 0, 1); diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index ace1783327d..4846f7137cc 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -1081,8 +1081,7 @@ fn do_test_splice_commitment_broadcast(splice_status: SpliceStatus, claim_htlcs: // Tests that we're able to enforce HTLCs onchain during the different stages of a splice. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - let config = test_default_channel_config(); - let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let node_id_0 = nodes[0].node.get_our_node_id(); @@ -1833,8 +1832,7 @@ fn do_test_propose_splice_while_disconnected(reload: bool, use_0conf: bool) { fn disconnect_on_unexpected_interactive_tx_message() { let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - let config = test_default_channel_config(); - let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let initiator = &nodes[0]; @@ -1872,8 +1870,7 @@ fn disconnect_on_unexpected_interactive_tx_message() { fn fail_splice_on_interactive_tx_error() { let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - let config = test_default_channel_config(); - let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let initiator = &nodes[0]; @@ -1926,8 +1923,7 @@ fn fail_splice_on_interactive_tx_error() { fn fail_splice_on_tx_abort() { let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - let config = test_default_channel_config(); - let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let initiator = &nodes[0]; @@ -1980,8 +1976,7 @@ fn fail_splice_on_tx_abort() { fn fail_splice_on_channel_close() { let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - let config = test_default_channel_config(); - let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let initiator = &nodes[0]; @@ -2031,8 +2026,7 @@ fn fail_splice_on_channel_close() { fn fail_quiescent_action_on_channel_close() { let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - let config = test_default_channel_config(); - let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let initiator = &nodes[0]; From eb31aeb1b8c8ac7093678abe4441be111d7cf558 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Fri, 6 Feb 2026 15:12:45 -0500 Subject: [PATCH 005/627] Trivial: use full path in test macros Useful when using these macros in lightning-tests/upgrade_downgrade_tests --- lightning/src/ln/functional_test_utils.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index a5461154a02..d5a29785a94 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -1383,7 +1383,7 @@ macro_rules! _reload_node_inner { ); $node.chain_monitor = &$new_chain_monitor; - $new_channelmanager = _reload_node( + $new_channelmanager = $crate::ln::functional_test_utils::_reload_node( &$node, $new_config, &chanman_encoded, @@ -1401,7 +1401,7 @@ macro_rules! reload_node { // Reload the node using the node's current config ($node: expr, $chanman_encoded: expr, $monitors_encoded: expr, $persister: ident, $new_chain_monitor: ident, $new_channelmanager: ident) => { let config = $node.node.get_current_config(); - _reload_node_inner!( + $crate::_reload_node_inner!( $node, config, $chanman_encoded, @@ -1414,7 +1414,7 @@ macro_rules! reload_node { }; // Reload the node with the new provided config ($node: expr, $new_config: expr, $chanman_encoded: expr, $monitors_encoded: expr, $persister: ident, $new_chain_monitor: ident, $new_channelmanager: ident) => { - _reload_node_inner!( + $crate::_reload_node_inner!( $node, $new_config, $chanman_encoded, @@ -1431,7 +1431,7 @@ macro_rules! reload_node { ident, $new_chain_monitor: ident, $new_channelmanager: ident, $reconstruct_pending_htlcs: expr ) => { let config = $node.node.get_current_config(); - _reload_node_inner!( + $crate::_reload_node_inner!( $node, config, $chanman_encoded, @@ -2971,7 +2971,7 @@ pub fn check_payment_claimable( #[cfg(any(test, ldk_bench, feature = "_test_utils"))] macro_rules! expect_payment_claimable { ($node: expr, $expected_payment_hash: expr, $expected_payment_secret: expr, $expected_recv_value: expr) => { - expect_payment_claimable!( + $crate::expect_payment_claimable!( $node, $expected_payment_hash, $expected_payment_secret, From 07b3deff29e4e3d6eeb164780170e9d9247352b5 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Mon, 9 Feb 2026 14:16:12 -0500 Subject: [PATCH 006/627] Split method to reconstruct pending HTLCs into two In the next commit, we want to dedup fields between the InboundUpdateAdd::Forwarded's HTLCPreviousHopData and the outer InboundHTLCOutput/Channel structs, since many fields are duplicated in both places at the moment. As part of doing this cleanly, we first refactor the method that retrieves these InboundUpdateAdds for reconstructing the set of pending HTLCs during ChannelManager deconstruction. Co-Authored-By: Claude Opus 4.5 --- lightning/src/ln/channel.rs | 57 +++++++++++++++++++++--------- lightning/src/ln/channelmanager.rs | 57 +++++++++++++----------------- 2 files changed, 64 insertions(+), 50 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 3236ebdefed..88d2e32e764 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -315,7 +315,7 @@ impl InboundHTLCState { /// /// Useful for reconstructing the pending HTLC set on startup. #[derive(Debug, Clone)] -pub(super) enum InboundUpdateAdd { +enum InboundUpdateAdd { /// The inbound committed HTLC's update_add_htlc message. WithOnion { update_add_htlc: msgs::UpdateAddHTLC }, /// This inbound HTLC is a forward that was irrevocably committed to the outbound edge, allowing @@ -7885,10 +7885,35 @@ where Ok(()) } - /// Useful for reconstructing the set of pending HTLCs when deserializing the `ChannelManager`. - pub(super) fn inbound_committed_unresolved_htlcs( + /// Returns true if any committed inbound HTLCs were received pre-LDK 0.3 and cannot be used + /// during `ChannelManager` deserialization to reconstruct the set of pending HTLCs. + pub(super) fn has_legacy_inbound_htlcs(&self) -> bool { + self.context.pending_inbound_htlcs.iter().any(|htlc| { + matches!( + &htlc.state, + InboundHTLCState::Committed { update_add_htlc: InboundUpdateAdd::Legacy } + ) + }) + } + + /// Returns committed inbound HTLCs whose onion has not yet been decoded and processed. Useful + /// for reconstructing the set of pending HTLCs when deserializing the `ChannelManager`. + pub(super) fn inbound_htlcs_pending_decode( + &self, + ) -> impl Iterator + '_ { + self.context.pending_inbound_htlcs.iter().filter_map(|htlc| match &htlc.state { + InboundHTLCState::Committed { + update_add_htlc: InboundUpdateAdd::WithOnion { update_add_htlc }, + } => Some(update_add_htlc.clone()), + _ => None, + }) + } + + /// Returns committed inbound HTLCs that have been forwarded but not yet fully resolved. Useful + /// when reconstructing the set of pending HTLCs when deserializing the `ChannelManager`. + pub(super) fn inbound_forwarded_htlcs( &self, - ) -> Vec<(PaymentHash, InboundUpdateAdd)> { + ) -> impl Iterator + '_ { // We don't want to return an HTLC as needing processing if it already has a resolution that's // pending in the holding cell. let htlc_resolution_in_holding_cell = |id: u64| -> bool { @@ -7902,19 +7927,17 @@ where }) }; - self.context - .pending_inbound_htlcs - .iter() - .filter_map(|htlc| match &htlc.state { - InboundHTLCState::Committed { update_add_htlc } => { - if htlc_resolution_in_holding_cell(htlc.htlc_id) { - return None; - } - Some((htlc.payment_hash, update_add_htlc.clone())) - }, - _ => None, - }) - .collect() + self.context.pending_inbound_htlcs.iter().filter_map(move |htlc| match &htlc.state { + InboundHTLCState::Committed { + update_add_htlc: InboundUpdateAdd::Forwarded { hop_data, outbound_amt_msat }, + } => { + if htlc_resolution_in_holding_cell(htlc.htlc_id) { + return None; + } + Some((htlc.payment_hash, hop_data.clone(), *outbound_amt_msat)) + }, + _ => None, + }) } /// Useful when reconstructing the set of pending HTLC forwards when deserializing the diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index e840d705b8e..bdc0155054f 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -59,9 +59,9 @@ use crate::ln::chan_utils::selected_commitment_sat_per_1000_weight; use crate::ln::channel::QuiescentAction; use crate::ln::channel::{ self, hold_time_since, Channel, ChannelError, ChannelUpdateStatus, DisconnectResult, - FundedChannel, FundingTxSigned, InboundUpdateAdd, InboundV1Channel, OutboundV1Channel, - PendingV2Channel, ReconnectionMsg, ShutdownResult, SpliceFundingFailed, StfuResponse, - UpdateFulfillCommitFetch, WithChannelContext, + FundedChannel, FundingTxSigned, InboundV1Channel, OutboundV1Channel, PendingV2Channel, + ReconnectionMsg, ShutdownResult, SpliceFundingFailed, StfuResponse, UpdateFulfillCommitFetch, + WithChannelContext, }; use crate::ln::channel_state::ChannelDetails; use crate::ln::funding::SpliceContribution; @@ -10185,10 +10185,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state = per_peer_state.get(&cp_id).map(|state| state.lock().unwrap()).unwrap(); let chan = peer_state.channel_by_id.get(&chan_id).and_then(|c| c.as_funded()).unwrap(); - chan.inbound_committed_unresolved_htlcs() - .iter() - .filter(|(_, htlc)| matches!(htlc, InboundUpdateAdd::WithOnion { .. })) - .count() + chan.inbound_htlcs_pending_decode().count() } #[cfg(test)] @@ -18626,33 +18623,27 @@ impl< if reconstruct_manager_from_monitors { if let Some(chan) = peer_state.channel_by_id.get(channel_id) { if let Some(funded_chan) = chan.as_funded() { + // Legacy HTLCs are from pre-LDK 0.3 and cannot be reconstructed. + if funded_chan.has_legacy_inbound_htlcs() { + return Err(DecodeError::InvalidValue); + } + // Reconstruct `ChannelManager::decode_update_add_htlcs` from the serialized + // `Channel` as part of removing the requirement to regularly persist the + // `ChannelManager`. let scid_alias = funded_chan.context.outbound_scid_alias(); - let inbound_committed_update_adds = - funded_chan.inbound_committed_unresolved_htlcs(); - for (payment_hash, htlc) in inbound_committed_update_adds { - match htlc { - InboundUpdateAdd::WithOnion { update_add_htlc } => { - // Reconstruct `ChannelManager::decode_update_add_htlcs` from the serialized - // `Channel` as part of removing the requirement to regularly persist the - // `ChannelManager`. - decode_update_add_htlcs - .entry(scid_alias) - .or_insert_with(Vec::new) - .push(update_add_htlc); - }, - InboundUpdateAdd::Forwarded { - hop_data, - outbound_amt_msat, - } => { - already_forwarded_htlcs - .entry((hop_data.channel_id, payment_hash)) - .or_insert_with(Vec::new) - .push((hop_data, outbound_amt_msat)); - }, - InboundUpdateAdd::Legacy => { - return Err(DecodeError::InvalidValue) - }, - } + for update_add_htlc in funded_chan.inbound_htlcs_pending_decode() { + decode_update_add_htlcs + .entry(scid_alias) + .or_insert_with(Vec::new) + .push(update_add_htlc); + } + for (payment_hash, hop_data, outbound_amt_msat) in + funded_chan.inbound_forwarded_htlcs() + { + already_forwarded_htlcs + .entry((hop_data.channel_id, payment_hash)) + .or_insert_with(Vec::new) + .push((hop_data, outbound_amt_msat)); } } } From d3e9cd018dfeab5e4c0884eee73988c3ecc1fb1e Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Mon, 9 Feb 2026 14:29:11 -0500 Subject: [PATCH 007/627] Dedup data in InboundUpdateAdd::Forwarded::hop_data Previously, the InboundUpdateAdd::Forwarded enum variant contained an HTLCPreviousHopData, which had a lot of fields that were redundant with the outer InboundHTLCOutput/Channel structs. Here we dedup those fields, which is important because the pending InboundUpdateAdds are persisted whenever the ChannelManager is persisted. --- lightning/src/ln/channel.rs | 69 ++++++++++++++++++++++++------ lightning/src/ln/channelmanager.rs | 2 +- 2 files changed, 56 insertions(+), 15 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 88d2e32e764..b12061bf118 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -50,10 +50,10 @@ use crate::ln::channel_state::{ OutboundHTLCDetails, OutboundHTLCStateDetails, }; use crate::ln::channelmanager::{ - self, ChannelReadyOrder, FundingConfirmedMessage, HTLCFailureMsg, HTLCPreviousHopData, - HTLCSource, OpenChannelMessage, PaymentClaimDetails, PendingHTLCInfo, PendingHTLCStatus, - RAACommitmentOrder, SentHTLCId, BREAKDOWN_TIMEOUT, MAX_LOCAL_BREAKDOWN_TIMEOUT, - MIN_CLTV_EXPIRY_DELTA, + self, BlindedFailure, ChannelReadyOrder, FundingConfirmedMessage, HTLCFailureMsg, + HTLCPreviousHopData, HTLCSource, OpenChannelMessage, PaymentClaimDetails, PendingHTLCInfo, + PendingHTLCStatus, RAACommitmentOrder, SentHTLCId, BREAKDOWN_TIMEOUT, + MAX_LOCAL_BREAKDOWN_TIMEOUT, MIN_CLTV_EXPIRY_DELTA, }; use crate::ln::funding::{FundingTxInput, SpliceContribution}; use crate::ln::interactivetxs::{ @@ -320,12 +320,16 @@ enum InboundUpdateAdd { WithOnion { update_add_htlc: msgs::UpdateAddHTLC }, /// This inbound HTLC is a forward that was irrevocably committed to the outbound edge, allowing /// its onion to be pruned and no longer persisted. + /// + /// Contains data that is useful if we need to fail or claim this HTLC backwards after a restart + /// and it's missing in the outbound edge. Forwarded { - /// Useful if we need to fail or claim this HTLC backwards after restart, if it's missing in the - /// outbound edge. - hop_data: HTLCPreviousHopData, - /// Useful if we need to claim this HTLC backwards after a restart and it's missing in the - /// outbound edge, to generate an accurate [`Event::PaymentForwarded`]. + incoming_packet_shared_secret: [u8; 32], + phantom_shared_secret: Option<[u8; 32]>, + trampoline_shared_secret: Option<[u8; 32]>, + blinded_failure: Option, + /// Useful for generating an accurate [`Event::PaymentForwarded`], if we need to claim this + /// HTLC post-restart. /// /// [`Event::PaymentForwarded`]: crate::events::Event::PaymentForwarded outbound_amt_msat: u64, @@ -341,8 +345,11 @@ impl_writeable_tlv_based_enum_upgradable!(InboundUpdateAdd, }, (2, Legacy) => {}, (4, Forwarded) => { - (0, hop_data, required), + (0, incoming_packet_shared_secret, required), (2, outbound_amt_msat, required), + (4, phantom_shared_secret, option), + (6, trampoline_shared_secret, option), + (8, blinded_failure, option), }, ); @@ -7927,14 +7934,42 @@ where }) }; + let prev_outbound_scid_alias = self.context.outbound_scid_alias(); + let user_channel_id = self.context.get_user_id(); + let channel_id = self.context.channel_id(); + let outpoint = self.funding_outpoint(); + let counterparty_node_id = self.context.get_counterparty_node_id(); + self.context.pending_inbound_htlcs.iter().filter_map(move |htlc| match &htlc.state { InboundHTLCState::Committed { - update_add_htlc: InboundUpdateAdd::Forwarded { hop_data, outbound_amt_msat }, + update_add_htlc: + InboundUpdateAdd::Forwarded { + incoming_packet_shared_secret, + phantom_shared_secret, + trampoline_shared_secret, + blinded_failure, + outbound_amt_msat, + }, } => { if htlc_resolution_in_holding_cell(htlc.htlc_id) { return None; } - Some((htlc.payment_hash, hop_data.clone(), *outbound_amt_msat)) + // The reconstructed `HTLCPreviousHopData` is used to fail or claim the HTLC backwards + // post-restart, if it is missing in the outbound edge. + let hop_data = HTLCPreviousHopData { + prev_outbound_scid_alias, + user_channel_id: Some(user_channel_id), + htlc_id: htlc.htlc_id, + incoming_packet_shared_secret: *incoming_packet_shared_secret, + phantom_shared_secret: *phantom_shared_secret, + trampoline_shared_secret: *trampoline_shared_secret, + blinded_failure: *blinded_failure, + channel_id, + outpoint, + counterparty_node_id: Some(counterparty_node_id), + cltv_expiry: Some(htlc.cltv_expiry), + }; + Some((htlc.payment_hash, hop_data, *outbound_amt_msat)) }, _ => None, }) @@ -7984,12 +8019,18 @@ where /// This inbound HTLC was irrevocably forwarded to the outbound edge, so we no longer need to /// persist its onion. pub(super) fn prune_inbound_htlc_onion( - &mut self, htlc_id: u64, hop_data: HTLCPreviousHopData, outbound_amt_msat: u64, + &mut self, htlc_id: u64, hop_data: &HTLCPreviousHopData, outbound_amt_msat: u64, ) { for htlc in self.context.pending_inbound_htlcs.iter_mut() { if htlc.htlc_id == htlc_id { if let InboundHTLCState::Committed { ref mut update_add_htlc } = htlc.state { - *update_add_htlc = InboundUpdateAdd::Forwarded { hop_data, outbound_amt_msat }; + *update_add_htlc = InboundUpdateAdd::Forwarded { + incoming_packet_shared_secret: hop_data.incoming_packet_shared_secret, + phantom_shared_secret: hop_data.phantom_shared_secret, + trampoline_shared_secret: hop_data.trampoline_shared_secret, + blinded_failure: hop_data.blinded_failure, + outbound_amt_msat, + }; return; } } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index bdc0155054f..68eeb7c4e15 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -10161,7 +10161,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ if let Some(chan) = peer_state.channel_by_id.get_mut(&source.channel_id).and_then(|c| c.as_funded_mut()) { - chan.prune_inbound_htlc_onion(source.htlc_id, source, outbound_amt_msat); + chan.prune_inbound_htlc_onion(source.htlc_id, &source, outbound_amt_msat); } } } From 1685661365f3a4c6c8df6011ab2970322c5335d7 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Wed, 11 Feb 2026 14:35:41 +0100 Subject: [PATCH 008/627] Restrict CI build matrix to Linux+MSRV for PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only run the full build matrix (Linux/Windows/macOS × stable/beta/MSRV) on pushes to main. PR and non-main push builds now only run Linux with the MSRV toolchain (1.75.0), which is the most important gate for catching issues. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6ae6d83ddd3..c0593d43def 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,8 +30,14 @@ jobs: strategy: fail-fast: false matrix: - platform: [ self-hosted, windows-latest, macos-latest ] - toolchain: [ stable, beta, 1.75.0 ] # 1.75.0 is the MSRV for all crates + platform: >- + ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' + && fromJSON('["self-hosted","windows-latest","macos-latest"]') + || fromJSON('["self-hosted"]') }} + toolchain: >- + ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' + && fromJSON('["stable","beta","1.75.0"]') + || fromJSON('["1.75.0"]') }} exclude: - platform: windows-latest toolchain: 1.75.0 From 14a47405899696fd89c42f14c46e127008c46bbc Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 22 Jan 2026 12:10:32 +0000 Subject: [PATCH 009/627] Add an `ExpandedKey` key for phantom blinded path authentication In the coming commits we'll add support for building a blinded path which can be received to any one of several nodes in a "phantom" configuration (terminology we retain from BOLT 11 though there are no longer any phantom nodes in the paths). Here we adda new key in `ExpandedKey` which we can use to authenticate blinded paths as coming from a phantom node participant. --- lightning/src/crypto/utils.rs | 15 ++++++++++----- lightning/src/ln/inbound_payment.rs | 10 ++++++++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/lightning/src/crypto/utils.rs b/lightning/src/crypto/utils.rs index 1570b3a0b2f..88911b0baf8 100644 --- a/lightning/src/crypto/utils.rs +++ b/lightning/src/crypto/utils.rs @@ -22,7 +22,7 @@ macro_rules! hkdf_extract_expand { let (k1, k2, _) = hkdf_extract_expand!($salt, $ikm); (k1, k2) }}; - ($salt: expr, $ikm: expr, 6) => {{ + ($salt: expr, $ikm: expr, 7) => {{ let (k1, k2, prk) = hkdf_extract_expand!($salt, $ikm); let mut hmac = HmacEngine::::new(&prk[..]); @@ -45,7 +45,12 @@ macro_rules! hkdf_extract_expand { hmac.input(&[6; 1]); let k6 = Hmac::from_engine(hmac).to_byte_array(); - (k1, k2, k3, k4, k5, k6) + let mut hmac = HmacEngine::::new(&prk[..]); + hmac.input(&k6); + hmac.input(&[7; 1]); + let k7 = Hmac::from_engine(hmac).to_byte_array(); + + (k1, k2, k3, k4, k5, k6, k7) }}; } @@ -53,10 +58,10 @@ pub fn hkdf_extract_expand_twice(salt: &[u8], ikm: &[u8]) -> ([u8; 32], [u8; 32] hkdf_extract_expand!(salt, ikm, 2) } -pub fn hkdf_extract_expand_6x( +pub fn hkdf_extract_expand_7x( salt: &[u8], ikm: &[u8], -) -> ([u8; 32], [u8; 32], [u8; 32], [u8; 32], [u8; 32], [u8; 32]) { - hkdf_extract_expand!(salt, ikm, 6) +) -> ([u8; 32], [u8; 32], [u8; 32], [u8; 32], [u8; 32], [u8; 32], [u8; 32]) { + hkdf_extract_expand!(salt, ikm, 7) } #[inline] diff --git a/lightning/src/ln/inbound_payment.rs b/lightning/src/ln/inbound_payment.rs index 51f8b7bfce9..d70a20eaf44 100644 --- a/lightning/src/ln/inbound_payment.rs +++ b/lightning/src/ln/inbound_payment.rs @@ -15,7 +15,7 @@ use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::{Hash, HashEngine}; use crate::crypto::chacha20::ChaCha20; -use crate::crypto::utils::hkdf_extract_expand_6x; +use crate::crypto::utils::hkdf_extract_expand_7x; use crate::ln::msgs; use crate::ln::msgs::MAX_VALUE_MSAT; use crate::offers::nonce::Nonce; @@ -56,6 +56,10 @@ pub struct ExpandedKey { /// The key used to authenticate spontaneous payments' metadata as previously registered with LDK /// for inclusion in a blinded path. spontaneous_pmt_key: [u8; 32], + /// The key used to authenticate phantom-node-shared blinded paths as generated by us. Note + /// that this is not used for blinded paths that are not expected to be shared across nodes + /// participating in a "phantom node". + pub(crate) phantom_node_blinded_path_key: [u8; 32], } impl ExpandedKey { @@ -70,7 +74,8 @@ impl ExpandedKey { offers_base_key, offers_encryption_key, spontaneous_pmt_key, - ) = hkdf_extract_expand_6x(b"LDK Inbound Payment Key Expansion", &key_material); + phantom_node_blinded_path_key, + ) = hkdf_extract_expand_7x(b"LDK Inbound Payment Key Expansion", &key_material); Self { metadata_key, ldk_pmt_hash_key, @@ -78,6 +83,7 @@ impl ExpandedKey { offers_base_key, offers_encryption_key, spontaneous_pmt_key, + phantom_node_blinded_path_key, } } From c10a0af666baced9f545b879ea295595882a81f8 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 22 Jan 2026 12:11:28 +0000 Subject: [PATCH 010/627] Accept blinded paths built by a phantom node participant In the next commit we'll add support for building a BOLT 12 offer which can be paid to any one of a number of participant nodes. Here we add support for validating blinded paths as coming from one of the participating nodes by deriving a new key as a part of the `ExpandedKey`. We keep this separate from the existing `ReceiveAuthKey` which is node-specific to ensure that we only allow this key to be used for blinded payment paths and contexts in `invoice_request` messages. This ensures that normal onion messages are still tied to specific nodes. Note that we will not yet use the blinded payment path phantom support which requires additional future work. However, allowing them to be authenticated in a phantom configuration should allow for compatibility across versions once the building logic lands. --- fuzz/src/onion_message.rs | 2 +- lightning/src/blinded_path/payment.rs | 20 ++++--- lightning/src/crypto/streams.rs | 70 +++++++++++++++-------- lightning/src/ln/blinded_payment_tests.rs | 4 +- lightning/src/ln/msgs.rs | 31 +++++----- lightning/src/onion_message/messenger.rs | 26 +++++---- lightning/src/onion_message/packet.rs | 46 +++++++++------ lightning/src/util/test_utils.rs | 2 +- 8 files changed, 124 insertions(+), 77 deletions(-) diff --git a/fuzz/src/onion_message.rs b/fuzz/src/onion_message.rs index 09634a1c373..70dfb0753d3 100644 --- a/fuzz/src/onion_message.rs +++ b/fuzz/src/onion_message.rs @@ -260,7 +260,7 @@ impl NodeSigner for KeyProvider { } fn get_expanded_key(&self) -> ExpandedKey { - unreachable!() + ExpandedKey::new([42; 32]) } fn sign_invoice( diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs index 27292bacf4d..03b676adc92 100644 --- a/lightning/src/blinded_path/payment.rs +++ b/lightning/src/blinded_path/payment.rs @@ -14,7 +14,7 @@ use bitcoin::secp256k1::{self, PublicKey, Secp256k1, SecretKey}; use crate::blinded_path::utils::{self, BlindedPathWithPadding}; use crate::blinded_path::{BlindedHop, BlindedPath, IntroductionNode, NodeIdLookUp}; -use crate::crypto::streams::ChaChaDualPolyReadAdapter; +use crate::crypto::streams::{ChaChaTriPolyReadAdapter, TriPolyAADUsed}; use crate::io; use crate::io::Cursor; use crate::ln::channel_state::CounterpartyForwardingInfo; @@ -268,18 +268,20 @@ impl BlindedPaymentPath { node_signer.ecdh(Recipient::Node, &self.inner_path.blinding_point, None)?; let rho = onion_utils::gen_rho_from_shared_secret(&control_tlvs_ss.secret_bytes()); let receive_auth_key = node_signer.get_receive_auth_key(); + let phantom_auth_key = node_signer.get_expanded_key().phantom_node_blinded_path_key; + let read_arg = (rho, receive_auth_key.0, phantom_auth_key); + let encrypted_control_tlvs = &self.inner_path.blinded_hops.get(0).ok_or(())?.encrypted_payload; let mut s = Cursor::new(encrypted_control_tlvs); let mut reader = FixedLengthReader::new(&mut s, encrypted_control_tlvs.len() as u64); - let ChaChaDualPolyReadAdapter { readable, used_aad } = - ChaChaDualPolyReadAdapter::read(&mut reader, (rho, receive_auth_key.0)) - .map_err(|_| ())?; - - match (&readable, used_aad) { - (BlindedPaymentTlvs::Forward(_), false) - | (BlindedPaymentTlvs::Dummy(_), true) - | (BlindedPaymentTlvs::Receive(_), true) => Ok((readable, control_tlvs_ss)), + let ChaChaTriPolyReadAdapter { readable, used_aad } = + ChaChaTriPolyReadAdapter::read(&mut reader, read_arg).map_err(|_| ())?; + + match (&readable, used_aad == TriPolyAADUsed::None) { + (BlindedPaymentTlvs::Forward(_), true) + | (BlindedPaymentTlvs::Dummy(_), false) + | (BlindedPaymentTlvs::Receive(_), false) => Ok((readable, control_tlvs_ss)), _ => Err(()), } } diff --git a/lightning/src/crypto/streams.rs b/lightning/src/crypto/streams.rs index c406e933bc9..23a23154307 100644 --- a/lightning/src/crypto/streams.rs +++ b/lightning/src/crypto/streams.rs @@ -58,7 +58,7 @@ impl<'a, T: Writeable> Writeable for ChaChaPolyWriteAdapter<'a, T> { } /// Encrypts the provided plaintext with the given key using ChaCha20Poly1305 in the modified -/// with-AAD form used in [`ChaChaDualPolyReadAdapter`]. +/// with-AAD form used in [`ChaChaTriPolyReadAdapter`]. pub(crate) fn chachapoly_encrypt_with_swapped_aad( mut plaintext: Vec, key: [u8; 32], aad: [u8; 32], ) -> Vec { @@ -84,34 +84,48 @@ pub(crate) fn chachapoly_encrypt_with_swapped_aad( plaintext } +#[derive(PartialEq, Eq)] +pub(crate) enum TriPolyAADUsed { + /// No AAD was used. + /// + /// The HMAC validated with standard ChaCha20Poly1305. + None, + /// The HMAC vlidated using the first AAD provided. + First, + /// The HMAC vlidated using the second AAD provided. + Second, +} + /// Enables the use of the serialization macros for objects that need to be simultaneously decrypted /// and deserialized. This allows us to avoid an intermediate Vec allocation. /// -/// This variant of [`ChaChaPolyReadAdapter`] calculates Poly1305 tags twice, once using the given -/// key and once with the given 32-byte AAD appended after the encrypted stream, accepting either -/// being correct as sufficient. +/// This variant of [`ChaChaPolyReadAdapter`] calculates Poly1305 tags thrice, once using the given +/// key and once each for the two given 32-byte AADs appended after the encrypted stream, accepting +/// any being correct as sufficient. /// -/// Note that we do *not* use the provided AAD as the standard ChaCha20Poly1305 AAD as that would +/// Note that we do *not* use the provided AADs as the standard ChaCha20Poly1305 AAD as that would /// require placing it first and prevent us from avoiding redundant Poly1305 rounds. Instead, the /// ChaCha20Poly1305 MAC check is tweaked to move the AAD to *after* the the contents being /// checked, effectively treating the contents as the AAD for the AAD-containing MAC but behaving /// like classic ChaCha20Poly1305 for the non-AAD-containing MAC. -pub(crate) struct ChaChaDualPolyReadAdapter { +pub(crate) struct ChaChaTriPolyReadAdapter { pub readable: R, - pub used_aad: bool, + pub used_aad: TriPolyAADUsed, } -impl LengthReadableArgs<([u8; 32], [u8; 32])> for ChaChaDualPolyReadAdapter { +impl LengthReadableArgs<([u8; 32], [u8; 32], [u8; 32])> + for ChaChaTriPolyReadAdapter +{ // Simultaneously read and decrypt an object from a LengthLimitedRead storing it in // Self::readable. LengthLimitedRead must be used instead of std::io::Read because we need the // total length to separate out the tag at the end. fn read( - r: &mut R, params: ([u8; 32], [u8; 32]), + r: &mut R, params: ([u8; 32], [u8; 32], [u8; 32]), ) -> Result { if r.remaining_bytes() < 16 { return Err(DecodeError::InvalidValue); } - let (key, aad) = params; + let (key, aad_a, aad_b) = params; let mut chacha = ChaCha20::new(&key[..], &[0; 12]); let mut mac_key = [0u8; 64]; @@ -125,7 +139,7 @@ impl LengthReadableArgs<([u8; 32], [u8; 32])> for ChaChaDualPolyRea let decrypted_len = r.remaining_bytes() - 16; let s = FixedLengthReader::new(r, decrypted_len); let mut chacha_stream = - ChaChaDualPolyReader { chacha: &mut chacha, poly: &mut mac, read_len: 0, read: s }; + ChaChaTriPolyReader { chacha: &mut chacha, poly: &mut mac, read_len: 0, read: s }; let readable: T = Readable::read(&mut chacha_stream)?; while chacha_stream.read.bytes_remain() { @@ -142,14 +156,18 @@ impl LengthReadableArgs<([u8; 32], [u8; 32])> for ChaChaDualPolyRea mac.input(&[0; 16][0..16 - (read_len % 16)]); } - let mut mac_aad = mac; + let mut mac_aad_a = mac; + let mut mac_aad_b = mac; - mac_aad.input(&aad[..]); + mac_aad_a.input(&aad_a[..]); + mac_aad_b.input(&aad_b[..]); // Note that we don't need to pad the AAD since its a multiple of 16 bytes // For the AAD-containing MAC, swap the AAD and the read data, effectively. - mac_aad.input(&(read_len as u64).to_le_bytes()); - mac_aad.input(&32u64.to_le_bytes()); + mac_aad_a.input(&(read_len as u64).to_le_bytes()); + mac_aad_b.input(&(read_len as u64).to_le_bytes()); + mac_aad_a.input(&32u64.to_le_bytes()); + mac_aad_b.input(&32u64.to_le_bytes()); // For the non-AAD-containing MAC, leave the data and AAD where they belong. mac.input(&0u64.to_le_bytes()); @@ -158,23 +176,25 @@ impl LengthReadableArgs<([u8; 32], [u8; 32])> for ChaChaDualPolyRea let mut tag = [0 as u8; 16]; r.read_exact(&mut tag)?; if fixed_time_eq(&mac.result(), &tag) { - Ok(Self { readable, used_aad: false }) - } else if fixed_time_eq(&mac_aad.result(), &tag) { - Ok(Self { readable, used_aad: true }) + Ok(Self { readable, used_aad: TriPolyAADUsed::None }) + } else if fixed_time_eq(&mac_aad_a.result(), &tag) { + Ok(Self { readable, used_aad: TriPolyAADUsed::First }) + } else if fixed_time_eq(&mac_aad_b.result(), &tag) { + Ok(Self { readable, used_aad: TriPolyAADUsed::Second }) } else { return Err(DecodeError::InvalidValue); } } } -struct ChaChaDualPolyReader<'a, R: Read> { +struct ChaChaTriPolyReader<'a, R: Read> { chacha: &'a mut ChaCha20, poly: &'a mut Poly1305, read_len: usize, pub read: R, } -impl<'a, R: Read> Read for ChaChaDualPolyReader<'a, R> { +impl<'a, R: Read> Read for ChaChaTriPolyReader<'a, R> { // Decrypts bytes from Self::read into `dest`. // After all reads complete, the caller must compare the expected tag with // the result of `Poly1305::result()`. @@ -349,15 +369,15 @@ mod tests { } #[test] - fn short_read_chacha_dual_read_adapter() { - // Previously, if we attempted to read from a ChaChaDualPolyReadAdapter but the object + fn short_read_chacha_tri_read_adapter() { + // Previously, if we attempted to read from a ChaChaTriPolyReadAdapter but the object // being read is shorter than the available buffer while the buffer passed to - // ChaChaDualPolyReadAdapter itself always thinks it has room, we'd end up + // ChaChaTriPolyReadAdapter itself always thinks it has room, we'd end up // infinite-looping as we didn't handle `Read::read`'s 0 return values at EOF. let mut stream = &[0; 1024][..]; let mut too_long_stream = FixedLengthReader::new(&mut stream, 2048); - let keys = ([42; 32], [99; 32]); - let res = super::ChaChaDualPolyReadAdapter::::read(&mut too_long_stream, keys); + let keys = ([42; 32], [98; 32], [99; 32]); + let res = super::ChaChaTriPolyReadAdapter::::read(&mut too_long_stream, keys); match res { Ok(_) => panic!(), Err(e) => assert_eq!(e, DecodeError::ShortRead), diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index d78b9dfa4f2..d9f3374d481 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -1696,7 +1696,7 @@ fn route_blinding_spec_test_vector() { } Ok(SharedSecret::new(other_key, &node_secret)) } - fn get_expanded_key(&self) -> ExpandedKey { unreachable!() } + fn get_expanded_key(&self) -> ExpandedKey { ExpandedKey::new([42; 32]) } fn get_node_id(&self, _recipient: Recipient) -> Result { unreachable!() } fn sign_invoice( &self, _invoice: &RawBolt11Invoice, _recipient: Recipient, @@ -2011,7 +2011,7 @@ fn test_trampoline_inbound_payment_decoding() { } Ok(SharedSecret::new(other_key, &node_secret)) } - fn get_expanded_key(&self) -> ExpandedKey { unreachable!() } + fn get_expanded_key(&self) -> ExpandedKey { ExpandedKey::new([42; 32]) } fn get_node_id(&self, _recipient: Recipient) -> Result { unreachable!() } fn sign_invoice( &self, _invoice: &RawBolt11Invoice, _recipient: Recipient, diff --git a/lightning/src/ln/msgs.rs b/lightning/src/ln/msgs.rs index 67f7807a487..ac549ddd50c 100644 --- a/lightning/src/ln/msgs.rs +++ b/lightning/src/ln/msgs.rs @@ -56,7 +56,7 @@ use core::str::FromStr; #[cfg(feature = "std")] use std::net::SocketAddr; -use crate::crypto::streams::ChaChaDualPolyReadAdapter; +use crate::crypto::streams::{ChaChaTriPolyReadAdapter, TriPolyAADUsed}; use crate::util::base32; use crate::util::logger; use crate::util::ser::{ @@ -3924,10 +3924,13 @@ impl ReadableArgs<(Option, NS)> for InboundOnionPaylo .map_err(|_| DecodeError::InvalidValue)?; let rho = onion_utils::gen_rho_from_shared_secret(&enc_tlvs_ss.secret_bytes()); let receive_auth_key = node_signer.get_receive_auth_key(); + let phantom_auth_key = node_signer.get_expanded_key().phantom_node_blinded_path_key; + let read_args = (rho, receive_auth_key.0, phantom_auth_key); + let mut s = Cursor::new(&enc_tlvs); let mut reader = FixedLengthReader::new(&mut s, enc_tlvs.len() as u64); - match ChaChaDualPolyReadAdapter::read(&mut reader, (rho, receive_auth_key.0))? { - ChaChaDualPolyReadAdapter { + match ChaChaTriPolyReadAdapter::read(&mut reader, read_args)? { + ChaChaTriPolyReadAdapter { readable: BlindedPaymentTlvs::Forward(ForwardTlvs { short_channel_id, @@ -3942,7 +3945,7 @@ impl ReadableArgs<(Option, NS)> for InboundOnionPaylo || cltv_value.is_some() || total_msat.is_some() || keysend_preimage.is_some() || invoice_request.is_some() - || used_aad + || used_aad != TriPolyAADUsed::None { return Err(DecodeError::InvalidValue); } @@ -3955,7 +3958,7 @@ impl ReadableArgs<(Option, NS)> for InboundOnionPaylo next_blinding_override, })) }, - ChaChaDualPolyReadAdapter { + ChaChaTriPolyReadAdapter { readable: BlindedPaymentTlvs::Dummy(DummyTlvs { payment_relay, payment_constraints }), used_aad, @@ -3964,7 +3967,7 @@ impl ReadableArgs<(Option, NS)> for InboundOnionPaylo || cltv_value.is_some() || total_msat.is_some() || keysend_preimage.is_some() || invoice_request.is_some() - || !used_aad + || used_aad == TriPolyAADUsed::None { return Err(DecodeError::InvalidValue); } @@ -3974,11 +3977,11 @@ impl ReadableArgs<(Option, NS)> for InboundOnionPaylo intro_node_blinding_point, })) }, - ChaChaDualPolyReadAdapter { + ChaChaTriPolyReadAdapter { readable: BlindedPaymentTlvs::Receive(receive_tlvs), used_aad, } => { - if !used_aad { + if used_aad == TriPolyAADUsed::None { return Err(DecodeError::InvalidValue); } @@ -4041,6 +4044,7 @@ impl ReadableArgs<(Option, NS)> for InboundTrampoline fn read(r: &mut R, args: (Option, NS)) -> Result { let (update_add_blinding_point, node_signer) = args; let receive_auth_key = node_signer.get_receive_auth_key(); + let phantom_auth_key = node_signer.get_expanded_key().phantom_node_blinded_path_key; let mut amt = None; let mut cltv_value = None; @@ -4094,8 +4098,9 @@ impl ReadableArgs<(Option, NS)> for InboundTrampoline let rho = onion_utils::gen_rho_from_shared_secret(&enc_tlvs_ss.secret_bytes()); let mut s = Cursor::new(&enc_tlvs); let mut reader = FixedLengthReader::new(&mut s, enc_tlvs.len() as u64); - match ChaChaDualPolyReadAdapter::read(&mut reader, (rho, receive_auth_key.0))? { - ChaChaDualPolyReadAdapter { + let read_args = (rho, receive_auth_key.0, phantom_auth_key); + match ChaChaTriPolyReadAdapter::read(&mut reader, read_args)? { + ChaChaTriPolyReadAdapter { readable: BlindedTrampolineTlvs::Forward(TrampolineForwardTlvs { next_trampoline, @@ -4110,7 +4115,7 @@ impl ReadableArgs<(Option, NS)> for InboundTrampoline || cltv_value.is_some() || total_msat.is_some() || keysend_preimage.is_some() || invoice_request.is_some() - || used_aad + || used_aad != TriPolyAADUsed::None { return Err(DecodeError::InvalidValue); } @@ -4123,11 +4128,11 @@ impl ReadableArgs<(Option, NS)> for InboundTrampoline next_blinding_override, })) }, - ChaChaDualPolyReadAdapter { + ChaChaTriPolyReadAdapter { readable: BlindedTrampolineTlvs::Receive(receive_tlvs), used_aad, } => { - if !used_aad { + if used_aad == TriPolyAADUsed::None { return Err(DecodeError::InvalidValue); } diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index e688c020ac6..f94eb7877f5 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -1168,12 +1168,13 @@ pub fn peel_onion_message match (message, context) { (ParsedOnionMessageContents::Offers(msg), Some(MessageContext::Offers(ctx))) => { match ctx { OffersContext::InvoiceRequest { .. } => { - // Note: We introduced the `control_tlvs_authenticated` check in LDK v0.2 + // Note: We introduced the `control_tlvs_from_*` check in LDK v0.2 // to simplify and standardize onion message authentication. // To continue supporting offers created before v0.2, we allow // unauthenticated control TLVs for these messages, as they can be // verified using the legacy method. }, _ => { - if !control_tlvs_authenticated { + // In any other offers context, we only allow message authenticated as + // coming from our local, node, not any other phantom participant. + if !control_tlvs_from_local_node { log_trace!(logger, "Received an unauthenticated offers onion message"); return Err(()); } @@ -1248,14 +1252,14 @@ pub fn peel_onion_message { - if !control_tlvs_authenticated { + if !control_tlvs_from_local_node { log_trace!(logger, "Received an unauthenticated async payments onion message"); return Err(()); } Ok(PeeledOnion::AsyncPayments(msg, ctx, reply_path)) }, (ParsedOnionMessageContents::Custom(msg), Some(MessageContext::Custom(ctx))) => { - if !control_tlvs_authenticated { + if !control_tlvs_from_local_node { log_trace!(logger, "Received an unauthenticated custom onion message"); return Err(()); } @@ -1268,7 +1272,7 @@ pub fn peel_onion_message { - if !control_tlvs_authenticated { + if !control_tlvs_from_local_node { log_trace!(logger, "Received an unauthenticated DNS resolver onion message"); return Err(()); } @@ -2504,7 +2508,8 @@ fn packet_payloads_and_keys< control_tlvs, reply_path: reply_path.take(), message, - control_tlvs_authenticated: false, + control_tlvs_from_local_node: false, + control_tlvs_from_phantom_participant: false, }, prev_control_tlvs_ss.unwrap(), )); @@ -2514,7 +2519,8 @@ fn packet_payloads_and_keys< control_tlvs: ReceiveControlTlvs::Unblinded(ReceiveTlvs { context: None }), reply_path: reply_path.take(), message, - control_tlvs_authenticated: false, + control_tlvs_from_local_node: false, + control_tlvs_from_phantom_participant: false, }, prev_control_tlvs_ss.unwrap(), )); diff --git a/lightning/src/onion_message/packet.rs b/lightning/src/onion_message/packet.rs index 2e0ccaf3a3e..cd9a923b070 100644 --- a/lightning/src/onion_message/packet.rs +++ b/lightning/src/onion_message/packet.rs @@ -19,7 +19,8 @@ use super::offers::OffersMessage; use crate::blinded_path::message::{ BlindedMessagePath, DummyTlv, ForwardTlvs, NextMessageHop, ReceiveTlvs, }; -use crate::crypto::streams::{ChaChaDualPolyReadAdapter, ChaChaPolyWriteAdapter}; +use crate::crypto::streams::{ChaChaPolyWriteAdapter, ChaChaTriPolyReadAdapter, TriPolyAADUsed}; +use crate::ln::inbound_payment::ExpandedKey; use crate::ln::msgs::DecodeError; use crate::ln::onion_utils; use crate::sign::ReceiveAuthKey; @@ -121,9 +122,16 @@ pub(super) enum Payload { }, /// This payload is for the final hop. Receive { - /// The [`ReceiveControlTlvs`] were authenticated with the additional key which was + /// The [`ReceiveControlTlvs`] were authenticated with the [`ReceiveAuthKey`] which was /// provided to [`ReadableArgs::read`]. - control_tlvs_authenticated: bool, + control_tlvs_from_local_node: bool, + /// The [`ReceiveControlTlvs`] were authenticated with the + /// [`ExpandedKey::phantom_node_blinded_path_key`] which was provided to + /// [`ReadableArgs::read`]. + /// Note that this is currently never actually read, but exists to signal the type of + /// authentication we can do. + #[allow(dead_code)] + control_tlvs_from_phantom_participant: bool, control_tlvs: ReceiveControlTlvs, reply_path: Option, message: T, @@ -233,7 +241,8 @@ impl Writeable for (Payload, [u8; 32]) { control_tlvs: ReceiveControlTlvs::Blinded(encrypted_bytes), reply_path, message, - control_tlvs_authenticated: _, + control_tlvs_from_local_node: _, + control_tlvs_from_phantom_participant: _, } => { _encode_varint_length_prefixed_tlv!(w, { (2, reply_path, option), @@ -253,7 +262,8 @@ impl Writeable for (Payload, [u8; 32]) { control_tlvs: ReceiveControlTlvs::Unblinded(control_tlvs), reply_path, message, - control_tlvs_authenticated: _, + control_tlvs_from_local_node: _, + control_tlvs_from_phantom_participant: _, } => { let write_adapter = ChaChaPolyWriteAdapter::new(self.1, &control_tlvs); _encode_varint_length_prefixed_tlv!(w, { @@ -269,24 +279,27 @@ impl Writeable for (Payload, [u8; 32]) { // Uses the provided secret to simultaneously decode and decrypt the control TLVs and data TLV. impl - ReadableArgs<(SharedSecret, &H, ReceiveAuthKey, &L)> + ReadableArgs<(SharedSecret, &H, ReceiveAuthKey, &ExpandedKey, &L)> for Payload::CustomMessage>> { fn read( - r: &mut R, args: (SharedSecret, &H, ReceiveAuthKey, &L), + r: &mut R, args: (SharedSecret, &H, ReceiveAuthKey, &ExpandedKey, &L), ) -> Result { - let (encrypted_tlvs_ss, handler, receive_tlvs_key, logger) = args; + let (encrypted_tlvs_ss, handler, receive_tlvs_key, expanded_key, logger) = args; let v: BigSize = Readable::read(r)?; let mut rd = FixedLengthReader::new(r, v.0); let mut reply_path: Option = None; - let mut read_adapter: Option> = None; + let mut read_adapter: Option> = None; let rho = onion_utils::gen_rho_from_shared_secret(&encrypted_tlvs_ss.secret_bytes()); + let read_adapter_args = + (rho, receive_tlvs_key.0, expanded_key.phantom_node_blinded_path_key); let mut message_type: Option = None; let mut message = None; + decode_tlv_stream_with_custom_tlv_decode!(&mut rd, { (2, reply_path, option), - (4, read_adapter, (option: LengthReadableArgs, (rho, receive_tlvs_key.0))), + (4, read_adapter, (option: LengthReadableArgs, read_adapter_args)), }, |msg_type, msg_reader| { if msg_type < 64 { return Ok(false) } // Don't allow reading more than one data TLV from an onion message. @@ -322,21 +335,22 @@ impl match read_adapter { None => return Err(DecodeError::InvalidValue), - Some(ChaChaDualPolyReadAdapter { readable: ControlTlvs::Forward(tlvs), used_aad }) => { - if used_aad || message_type.is_some() { + Some(ChaChaTriPolyReadAdapter { readable: ControlTlvs::Forward(tlvs), used_aad }) => { + if used_aad != TriPolyAADUsed::None || message_type.is_some() { return Err(DecodeError::InvalidValue); } Ok(Payload::Forward(ForwardControlTlvs::Unblinded(tlvs))) }, - Some(ChaChaDualPolyReadAdapter { readable: ControlTlvs::Dummy, used_aad }) => { - Ok(Payload::Dummy { control_tlvs_authenticated: used_aad }) + Some(ChaChaTriPolyReadAdapter { readable: ControlTlvs::Dummy, used_aad }) => { + Ok(Payload::Dummy { control_tlvs_authenticated: used_aad != TriPolyAADUsed::None }) }, - Some(ChaChaDualPolyReadAdapter { readable: ControlTlvs::Receive(tlvs), used_aad }) => { + Some(ChaChaTriPolyReadAdapter { readable: ControlTlvs::Receive(tlvs), used_aad }) => { Ok(Payload::Receive { control_tlvs: ReceiveControlTlvs::Unblinded(tlvs), reply_path, message: message.ok_or(DecodeError::InvalidValue)?, - control_tlvs_authenticated: used_aad, + control_tlvs_from_local_node: used_aad == TriPolyAADUsed::First, + control_tlvs_from_phantom_participant: used_aad == TriPolyAADUsed::Second, }) }, } diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 34f5d5fe36e..a12b113b293 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -1772,7 +1772,7 @@ impl TestNodeSigner { impl NodeSigner for TestNodeSigner { fn get_expanded_key(&self) -> ExpandedKey { - unreachable!() + ExpandedKey::new([42; 32]) } fn get_peer_storage_key(&self) -> PeerStorageKey { From 10391b710b93e398ea187a676cba7da6e8bf683c Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Wed, 21 Jan 2026 21:36:17 +0000 Subject: [PATCH 011/627] Add methods to fetch an `OfferBuilder` for "phantom" node configs In the BOLT 11 world, we have specific support for what we call "phantom nodes" - creating invoices which can be paid to any one of a number of nodes by adding route-hints which represent nodes that do not exist. In BOLT 12, blinded paths make a similar feature much simpler - we can simply add blinded paths which terminate at different nodes. The blinding means that the sender is none the wiser. Here we add logic to fetch an `OfferBuilder` which can generate an offer payable to any one of a set of nodes. We retain the "phantom" terminology even though there are no longer any "phantom" nodes. Note that the current logic only supports the `invoice_request` message going to any of the participating nodes, it then replies with a `Bolt12Invoice` which can only be paid to the responding node. Future work may relax this restriction. --- ext-functional-test-demo/src/main.rs | 1 + lightning/src/ln/channelmanager.rs | 76 +++++++++++++++ lightning/src/ln/functional_test_utils.rs | 32 +++++-- lightning/src/ln/offers_tests.rs | 111 ++++++++++++++++++++-- lightning/src/offers/flow.rs | 55 ++++++++++- lightning/src/util/test_utils.rs | 22 ++++- 6 files changed, 278 insertions(+), 19 deletions(-) diff --git a/ext-functional-test-demo/src/main.rs b/ext-functional-test-demo/src/main.rs index 654cf91e01c..67eb8c776fe 100644 --- a/ext-functional-test-demo/src/main.rs +++ b/ext-functional-test-demo/src/main.rs @@ -17,6 +17,7 @@ mod tests { impl TestSignerFactory for BrokenSignerFactory { fn make_signer( &self, _seed: &[u8; 32], _now: Duration, _v2_remote_key_derivation: bool, + _phantom_seed: Option<&[u8; 32]>, ) -> Box> { panic!() } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index bbede9589db..64cbc92a22b 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -13402,6 +13402,47 @@ macro_rules! create_offer_builder { ($self: ident, $builder: ty) => { Ok(builder.into()) } + + /// Creates an [`OfferBuilder`] such that the [`Offer`] it builds is recognized by any + /// [`ChannelManager`] (or [`OffersMessageFlow`]) using the same [`ExpandedKey`] (as returned + /// from [`NodeSigner::get_expanded_key`]). This allows any nodes participating in a BOLT 11 + /// "phantom node" cluster to also receive BOLT 12 payments. + /// + /// Note that, unlike with BOLT 11 invoices, BOLT 12 "phantom" offers do not in fact have any + /// "phantom node" appended to receiving paths. Instead, multiple blinded paths are simply + /// included which terminate at different final nodes. + /// + /// `other_nodes_channels` must be set to a list of each participating node's `node_id` (from + /// [`NodeSigner::get_node_id`] with a [`Recipient::Node`]) and its channels. + /// + /// `path_count_limit` is used to limit the number of blinded paths included in the resulting + /// [`Offer`]. Note that if this is less than the number of participating nodes (i.e. + /// `other_nodes_channels.len() + 1`) not all nodes will participate in receiving funds. + /// Because the parameterized [`MessageRouter`] will only get a chance to limit the number of + /// paths *per-node*, it is important to set this for offers that will be included in a QR + /// code. + /// + /// See [`Self::create_offer_builder`] for more details on the blinded path construction. + /// + /// [`ExpandedKey`]: inbound_payment::ExpandedKey + pub fn create_phantom_offer_builder( + &$self, other_nodes_channels: Vec<(PublicKey, Vec)>, + path_count_limit: usize, + ) -> Result<$builder, Bolt12SemanticError> { + let mut peers = Vec::with_capacity(other_nodes_channels.len() + 1); + if !other_nodes_channels.iter().any(|(node_id, _)| *node_id == $self.get_our_node_id()) { + peers.push(($self.get_our_node_id(), $self.get_peers_for_blinded_path())); + } + for (node_id, peer_chans) in other_nodes_channels { + peers.push((node_id, Self::channel_details_to_forward_nodes(peer_chans))); + } + + let builder = $self.flow.create_phantom_offer_builder( + &$self.entropy_source, peers, path_count_limit + )?; + + Ok(builder.into()) + } } } macro_rules! create_refund_builder { ($self: ident, $builder: ty) => { @@ -14018,6 +14059,41 @@ impl< now } + /// Converts a list of channels to a list of peers which may be suitable to receive onion + /// messages through. + fn channel_details_to_forward_nodes( + mut channel_list: Vec, + ) -> Vec { + channel_list.sort_unstable_by_key(|chan| chan.counterparty.node_id); + let mut res = Vec::new(); + // TODO: When MSRV reaches 1.77 use chunk_by + let mut start = 0; + while start < channel_list.len() { + let counterparty_node_id = channel_list[start].counterparty.node_id; + let end = channel_list[start..] + .iter() + .position(|chan| chan.counterparty.node_id != counterparty_node_id) + .map(|pos| start + pos) + .unwrap_or(channel_list.len()); + + let peer_chans = &channel_list[start..end]; + if peer_chans.iter().any(|chan| chan.is_usable) + && peer_chans.iter().any(|c| c.counterparty.features.supports_onion_messages()) + { + res.push(MessageForwardNode { + node_id: peer_chans[0].counterparty.node_id, + short_channel_id: peer_chans + .iter() + .filter(|chan| chan.is_usable) + .min_by_key(|chan| chan.short_channel_id) + .and_then(|chan| chan.get_inbound_payment_scid()), + }) + } + start = end; + } + res + } + fn get_peers_for_blinded_path(&self) -> Vec { let per_peer_state = self.per_peer_state.read().unwrap(); per_peer_state diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index e8965752331..01de988144b 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -4405,21 +4405,41 @@ pub fn create_chanmon_cfgs(node_count: usize) -> Vec { pub fn create_chanmon_cfgs_with_legacy_keys( node_count: usize, predefined_keys_ids: Option>, +) -> Vec { + create_chanmon_cfgs_internal(node_count, predefined_keys_ids, false) +} + +pub fn create_phantom_chanmon_cfgs(node_count: usize) -> Vec { + create_chanmon_cfgs_internal(node_count, None, true) +} + +pub fn create_chanmon_cfgs_internal( + node_count: usize, predefined_keys_ids: Option>, phantom: bool, ) -> Vec { let mut chan_mon_cfgs = Vec::new(); + let phantom_seed = if phantom { Some(&[42; 32]) } else { None }; for i in 0..node_count { let tx_broadcaster = test_utils::TestBroadcaster::new(Network::Testnet); let fee_estimator = test_utils::TestFeeEstimator::new(253); let chain_source = test_utils::TestChainSource::new(Network::Testnet); let logger = test_utils::TestLogger::with_id(format!("node {}", i)); let persister = test_utils::TestPersister::new(); - let seed = [i as u8; 32]; - let keys_manager = if predefined_keys_ids.is_some() { + let mut seed = [i as u8; 32]; + if phantom { + // We would ideally randomize keys on every test run, but some tests fail in that case. + // Instead, we only randomize in the phantom case. + use core::hash::{BuildHasher, Hasher}; + // Get a random value using the only std API to do so - the DefaultHasher + let rand_val = std::collections::hash_map::RandomState::new().build_hasher().finish(); + seed[..8].copy_from_slice(&rand_val.to_ne_bytes()); + } + let keys_manager = test_utils::TestKeysInterface::with_settings( + &seed, + Network::Testnet, // Use legacy (V1) remote_key derivation for tests using legacy key sets. - test_utils::TestKeysInterface::with_v1_remote_key_derivation(&seed, Network::Testnet) - } else { - test_utils::TestKeysInterface::new(&seed, Network::Testnet) - }; + predefined_keys_ids.is_some(), + phantom_seed, + ); let scorer = RwLock::new(test_utils::TestScorer::new()); // Set predefined keys_id if provided diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 12e631b4042..a4a09dd1910 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -75,15 +75,21 @@ const MAX_SHORT_LIVED_RELATIVE_EXPIRY: Duration = Duration::from_secs(60 * 60 * use crate::prelude::*; macro_rules! expect_recent_payment { - ($node: expr, $payment_state: path, $payment_id: expr) => { - match $node.node.list_recent_payments().first() { - Some(&$payment_state { payment_id: actual_payment_id, .. }) => { - assert_eq!($payment_id, actual_payment_id); - }, - Some(_) => panic!("Unexpected recent payment state"), - None => panic!("No recent payments"), + ($node: expr, $payment_state: path, $payment_id: expr) => {{ + let mut found_payment = false; + for payment in $node.node.list_recent_payments().iter() { + match payment { + $payment_state { payment_id: actual_payment_id, .. } => { + if $payment_id == *actual_payment_id { + found_payment = true; + break; + } + }, + _ => {}, + } } - } + assert!(found_payment); + }} } fn connect_peers<'a, 'b, 'c>(node_a: &Node<'a, 'b, 'c>, node_b: &Node<'a, 'b, 'c>) { @@ -2572,3 +2578,92 @@ fn no_double_pay_with_stale_channelmanager() { // generated in response to the duplicate invoice. assert!(nodes[0].node.get_and_clear_pending_events().is_empty()); } + +#[test] +fn creates_and_pays_for_phantom_offer() { + // Tests that we can pay a "phantom offer" to any participating node. + let mut chanmon_cfgs = create_chanmon_cfgs(1); + chanmon_cfgs.append(&mut create_phantom_chanmon_cfgs(2)); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + create_announced_chan_between_nodes_with_value(&nodes, 0, 2, 10_000_000, 1_000_000_000); + + let node_a_id = nodes[0].node.get_our_node_id(); + let node_b_id = nodes[1].node.get_our_node_id(); + let node_c_id = nodes[2].node.get_our_node_id(); + + let offer = nodes[1].node + .create_phantom_offer_builder(vec![(node_c_id, nodes[2].node.list_channels())], 2) + .unwrap() + .amount_msats(10_000_000) + .build().unwrap(); + + // The offer should be resolvable by either of node B or C but signed by a derived key + assert!(offer.issuer_signing_pubkey().is_some()); + assert_ne!(offer.issuer_signing_pubkey(), Some(node_b_id)); + assert_ne!(offer.issuer_signing_pubkey(), Some(node_c_id)); + assert_eq!(offer.paths().len(), 2); + let mut b_path_count = 0; + let mut c_path_count = 0; + for path in offer.paths() { + if check_compact_path_introduction_node(&path, &nodes[0], node_b_id) { + b_path_count += 1; + } + if check_compact_path_introduction_node(&path, &nodes[0], node_c_id) { + c_path_count += 1; + } + } + assert_eq!(b_path_count, 1); + assert_eq!(c_path_count, 1); + + // Pay twice, first via node B (the node that actually built the offer) then pay via node C + // (which won't have seen the offer until it receives the invoice_request). + for (payment_id, recipient) in [([1; 32], &nodes[1]), ([2; 32], &nodes[2])] { + let payment_id = PaymentId(payment_id); + nodes[0].node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + expect_recent_payment!(nodes[0], RecentPaymentDetails::AwaitingInvoice, payment_id); + + let recipient_id = recipient.node.get_our_node_id(); + let non_recipient_id = if node_b_id == recipient_id { + node_c_id + } else { + node_b_id + }; + + let onion_message = + nodes[0].onion_messenger.next_onion_message_for_peer(recipient_id).unwrap(); + let _discard = + nodes[0].onion_messenger.next_onion_message_for_peer(non_recipient_id).unwrap(); + recipient.onion_messenger.handle_onion_message(node_a_id, &onion_message); + + let (invoice_request, _) = extract_invoice_request(&recipient, &onion_message); + let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id: offer.id(), + invoice_request: InvoiceRequestFields { + payer_signing_pubkey: invoice_request.payer_signing_pubkey(), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + }, + }); + + let onion_message = + recipient.onion_messenger.next_onion_message_for_peer(node_a_id).unwrap(); + nodes[0].onion_messenger.handle_onion_message(recipient_id, &onion_message); + + let (invoice, _) = extract_invoice(&nodes[0], &onion_message); + assert_eq!(invoice.amount_msats(), 10_000_000); + + route_bolt12_payment(&nodes[0], &[recipient], &invoice); + expect_recent_payment!(&nodes[0], RecentPaymentDetails::Pending, payment_id); + + claim_bolt12_payment(&nodes[0], &[recipient], payment_context, &invoice); + expect_recent_payment!(&nodes[0], RecentPaymentDetails::Fulfilled, payment_id); + + assert!(nodes[0].onion_messenger.next_onion_message_for_peer(node_b_id).is_none()); + assert!(nodes[0].onion_messenger.next_onion_message_for_peer(node_c_id).is_none()); + } +} diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 0bb98777227..efd53035158 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -286,6 +286,39 @@ impl OffersMessageFlow { self.create_blinded_paths(peers, context) } + fn blinded_paths_for_phantom_offer( + &self, per_node_peers: Vec<(PublicKey, Vec)>, path_count_limit: usize, + context: MessageContext, + ) -> Result, ()> { + let receive_key = ReceiveAuthKey(self.inbound_payment_key.phantom_node_blinded_path_key); + let secp_ctx = &self.secp_ctx; + + let mut per_node_paths: Vec<_> = per_node_peers + .into_iter() + .filter_map(|(recipient, peers)| { + self.message_router + .create_blinded_paths(recipient, receive_key, context.clone(), peers, secp_ctx) + .ok() + }) + .collect(); + + let mut res = Vec::new(); + while res.len() < path_count_limit && !per_node_paths.is_empty() { + for node_paths in per_node_paths.iter_mut() { + if let Some(path) = node_paths.pop() { + res.push(path); + } + } + per_node_paths.retain(|node_paths| !node_paths.is_empty()); + } + + if res.is_empty() { + Err(()) + } else { + Ok(res) + } + } + /// Creates a collection of blinded paths by delegating to /// [`MessageRouter::create_blinded_paths`]. /// @@ -559,8 +592,7 @@ impl OffersMessageFlow { /// Creates an [`OfferBuilder`] such that the [`Offer`] it builds is recognized by the /// [`OffersMessageFlow`], and any corresponding [`InvoiceRequest`] can be verified using - /// [`Self::verify_invoice_request`]. The offer will expire at `absolute_expiry` if `Some`, - /// or will not expire if `None`. + /// [`Self::verify_invoice_request`]. /// /// # Privacy /// @@ -634,6 +666,25 @@ impl OffersMessageFlow { }) } + /// Creates an [`OfferBuilder`] such that the [`Offer`] it builds is recognized by any + /// [`OffersMessageFlow`] using the same [`ExpandedKey`] (provided in the constructor as + /// `inbound_payment_key`), and any corresponding [`InvoiceRequest`] can be verified using + /// [`Self::verify_invoice_request`]. + /// + /// See [`Self::create_offer_builder`] for more details on privacy and limitations. + /// + /// [`ExpandedKey`]: inbound_payment::ExpandedKey + pub fn create_phantom_offer_builder( + &self, entropy_source: ES, per_node_peers: Vec<(PublicKey, Vec)>, + path_count_limit: usize, + ) -> Result, Bolt12SemanticError> { + self.create_offer_builder_intern(entropy_source, |_, context, _| { + self.blinded_paths_for_phantom_offer(per_node_peers, path_count_limit, context) + .map_err(|_| Bolt12SemanticError::MissingPaths) + }) + .map(|(builder, _)| builder) + } + fn create_refund_builder_intern( &self, entropy_source: ES, make_paths: PF, amount_msats: u64, absolute_expiry: Duration, payment_id: PaymentId, diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index a12b113b293..f9115e4bbcf 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -1954,6 +1954,7 @@ pub trait TestSignerFactory: Send + Sync { /// Make a dynamic signer fn make_signer( &self, seed: &[u8; 32], now: Duration, v2_remote_key_derivation: bool, + phantom_seed: Option<&[u8; 32]>, ) -> Box>; } @@ -1963,12 +1964,13 @@ struct DefaultSignerFactory(); impl TestSignerFactory for DefaultSignerFactory { fn make_signer( &self, seed: &[u8; 32], now: Duration, v2_remote_key_derivation: bool, + phantom_seed: Option<&[u8; 32]>, ) -> Box> { let phantom = sign::PhantomKeysManager::new( seed, now.as_secs(), now.subsec_nanos(), - seed, + if let Some(provided_seed) = phantom_seed { provided_seed } else { seed }, v2_remote_key_derivation, ); let dphantom = DynPhantomKeysInterface::new(phantom); @@ -2000,7 +2002,7 @@ impl TestKeysInterface { let factory = DefaultSignerFactory(); let now = Duration::from_secs(genesis_block(network).header.time as u64); - let backing = factory.make_signer(seed, now, true); + let backing = factory.make_signer(seed, now, true, None); Self::build(backing) } @@ -2012,7 +2014,21 @@ impl TestKeysInterface { let factory = DefaultSignerFactory(); let now = Duration::from_secs(genesis_block(network).header.time as u64); - let backing = factory.make_signer(seed, now, false); + let backing = factory.make_signer(seed, now, false, None); + Self::build(backing) + } + + pub fn with_settings( + seed: &[u8; 32], network: Network, v1_derivation: bool, phantom_seed: Option<&[u8; 32]>, + ) -> Self { + #[cfg(feature = "std")] + let factory = SIGNER_FACTORY.get(); + + #[cfg(not(feature = "std"))] + let factory = DefaultSignerFactory(); + + let now = Duration::from_secs(genesis_block(network).header.time as u64); + let backing = factory.make_signer(seed, now, !v1_derivation, phantom_seed); Self::build(backing) } From 13a83d562221460bb9798a075dd2bd05342a9c36 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Fri, 6 Feb 2026 10:11:01 -0800 Subject: [PATCH 012/627] Refactor missing peer/channel error from ChannelManager message handlers We have the same error being returned from several `ChannelManager` message handlers, so we DRY it up. Doing so also lets us get rid of the inlined `format!` call, which for some reason prevents `rustfmt` from formatting code around it. --- lightning/src/ln/channelmanager.rs | 343 ++++++++++++++++------------- lightning/src/ln/payment_tests.rs | 18 +- lightning/src/ln/reload_tests.rs | 14 +- lightning/src/ln/shutdown_tests.rs | 6 +- 4 files changed, 212 insertions(+), 169 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index e840d705b8e..a069b01f532 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -939,6 +939,7 @@ struct MsgHandleErrInternal { shutdown_finish: Option<(ShutdownResult, Option<(msgs::ChannelUpdate, NodeId, NodeId)>)>, tx_abort: Option, } + impl MsgHandleErrInternal { fn send_err_msg_no_close(err: String, channel_id: ChannelId) -> Self { Self { @@ -954,6 +955,20 @@ impl MsgHandleErrInternal { } } + fn no_such_peer(counterparty_node_id: &PublicKey, channel_id: ChannelId) -> Self { + let err = + format!("No such peer for the passed counterparty_node_id {counterparty_node_id}"); + Self::send_err_msg_no_close(err, channel_id) + } + + fn no_such_channel_for_peer(counterparty_node_id: &PublicKey, channel_id: ChannelId) -> Self { + let err = format!( + "Got a message for a channel from the wrong node! No such channel_id {} for the passed counterparty_node_id {}", + channel_id, counterparty_node_id + ); + Self::send_err_msg_no_close(err, channel_id) + } + fn from_no_close(err: msgs::LightningError) -> Self { Self { err, closes_channel: false, shutdown_finish: None, tx_abort: None } } @@ -10812,9 +10827,10 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close( - format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), - common_fields.temporary_channel_id) + MsgHandleErrInternal::no_such_peer( + counterparty_node_id, + common_fields.temporary_channel_id, + ) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -10884,7 +10900,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let peer_state_mutex = per_peer_state.get(counterparty_node_id) .ok_or_else(|| { debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close(format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), msg.common_fields.temporary_channel_id) + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.common_fields.temporary_channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -10905,7 +10921,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } } }, - hash_map::Entry::Vacant(_) => return Err(MsgHandleErrInternal::send_err_msg_no_close(format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", counterparty_node_id), msg.common_fields.temporary_channel_id)) + hash_map::Entry::Vacant(_) => return Err(MsgHandleErrInternal::no_such_channel_for_peer(counterparty_node_id, msg.common_fields.temporary_channel_id)) } }; let mut pending_events = self.pending_events.lock().unwrap(); @@ -10925,49 +10941,59 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let best_block = *self.best_block.read().unwrap(); let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = per_peer_state.get(counterparty_node_id) - .ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close(format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), msg.temporary_channel_id) - })?; + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + debug_assert!(false); + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.temporary_channel_id) + })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; - let (mut chan, funding_msg_opt, monitor) = - match peer_state.channel_by_id.remove(&msg.temporary_channel_id) - .map(Channel::into_unfunded_inbound_v1) - { - Some(Ok(inbound_chan)) => { - let logger = WithChannelContext::from(&self.logger, &inbound_chan.context, None); - match inbound_chan.funding_created(msg, best_block, &self.signer_provider, &&logger) { - Ok(res) => res, - Err((inbound_chan, err)) => { - // We've already removed this inbound channel from the map in `PeerState` - // above so at this point we just need to clean up any lingering entries - // concerning this channel as it is safe to do so. - debug_assert!(matches!(err, ChannelError::Close(_))); - let mut chan = Channel::from(inbound_chan); - return Err(self.locked_handle_force_close( + let (mut chan, funding_msg_opt, monitor) = match peer_state + .channel_by_id + .remove(&msg.temporary_channel_id) + .map(Channel::into_unfunded_inbound_v1) + { + Some(Ok(inbound_chan)) => { + let logger = WithChannelContext::from(&self.logger, &inbound_chan.context, None); + match inbound_chan.funding_created(msg, best_block, &self.signer_provider, &&logger) + { + Ok(res) => res, + Err((inbound_chan, err)) => { + // We've already removed this inbound channel from the map in `PeerState` + // above so at this point we just need to clean up any lingering entries + // concerning this channel as it is safe to do so. + debug_assert!(matches!(err, ChannelError::Close(_))); + let mut chan = Channel::from(inbound_chan); + return Err(self + .locked_handle_force_close( &mut peer_state.closed_channel_monitor_update_ids, &mut peer_state.in_flight_monitor_updates, err, &mut chan, - ).1); - }, - } - }, - Some(Err(mut chan)) => { - let err_msg = format!("Got an unexpected funding_created message from peer with counterparty_node_id {}", counterparty_node_id); - let err = ChannelError::close(err_msg); - return Err(self.locked_handle_force_close( + ) + .1); + }, + } + }, + Some(Err(mut chan)) => { + let err_msg = format!("Got an unexpected funding_created message from peer with counterparty_node_id {}", counterparty_node_id); + let err = ChannelError::close(err_msg); + return Err(self + .locked_handle_force_close( &mut peer_state.closed_channel_monitor_update_ids, &mut peer_state.in_flight_monitor_updates, err, &mut chan, - ).1); - }, - None => return Err(MsgHandleErrInternal::send_err_msg_no_close(format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", counterparty_node_id), msg.temporary_channel_id)) - }; + ) + .1); + }, + None => { + return Err(MsgHandleErrInternal::no_such_channel_for_peer( + counterparty_node_id, + msg.temporary_channel_id, + )) + }, + }; let funded_channel_id = chan.context.channel_id(); @@ -11114,7 +11140,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let peer_state_mutex = per_peer_state.get(&counterparty_node_id) .ok_or_else(|| { debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close(format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), ChannelId([0; 32])) + MsgHandleErrInternal::no_such_peer(&counterparty_node_id, ChannelId([0; 32])) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); @@ -11152,7 +11178,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let peer_state_mutex = per_peer_state.get(counterparty_node_id) .ok_or_else(|| { debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close(format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), msg.channel_id) + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); @@ -11209,10 +11235,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close( - format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), - channel_id, - ) + MsgHandleErrInternal::no_such_peer(counterparty_node_id, channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -11228,26 +11251,27 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ Err((error, splice_funding_failed)) => { if let Some(splice_funding_failed) = splice_funding_failed { let pending_events = &mut self.pending_events.lock().unwrap(); - pending_events.push_back((events::Event::SpliceFailed { - channel_id, - counterparty_node_id: *counterparty_node_id, - user_channel_id: channel.context().get_user_id(), - abandoned_funding_txo: splice_funding_failed.funding_txo, - channel_type: splice_funding_failed.channel_type.clone(), - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, - }, None)); + pending_events.push_back(( + events::Event::SpliceFailed { + channel_id, + counterparty_node_id: *counterparty_node_id, + user_channel_id: channel.context().get_user_id(), + abandoned_funding_txo: splice_funding_failed.funding_txo, + channel_type: splice_funding_failed.channel_type.clone(), + contributed_inputs: splice_funding_failed.contributed_inputs, + contributed_outputs: splice_funding_failed.contributed_outputs, + }, + None, + )); } Err(MsgHandleErrInternal::from_chan_no_close(error, channel_id)) }, } }, - hash_map::Entry::Vacant(_) => { - Err(MsgHandleErrInternal::send_err_msg_no_close(format!( - "Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", - counterparty_node_id), channel_id) - ) - } + hash_map::Entry::Vacant(_) => Err(MsgHandleErrInternal::no_such_channel_for_peer( + counterparty_node_id, + channel_id, + )), } } @@ -11289,9 +11313,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let peer_state_mutex = per_peer_state.get(&counterparty_node_id) .ok_or_else(|| { debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close( - format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), - msg.channel_id) + MsgHandleErrInternal::no_such_peer(&counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -11386,7 +11408,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } }, hash_map::Entry::Vacant(_) => { - Err(MsgHandleErrInternal::send_err_msg_no_close(format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", counterparty_node_id), msg.channel_id)) + Err(MsgHandleErrInternal::no_such_channel_for_peer(&counterparty_node_id, msg.channel_id)) } } } @@ -11398,9 +11420,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let peer_state_mutex = per_peer_state.get(counterparty_node_id) .ok_or_else(|| { debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close( - format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), - msg.channel_id) + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -11467,7 +11487,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ Ok(()) }, hash_map::Entry::Vacant(_) => { - Err(MsgHandleErrInternal::send_err_msg_no_close(format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", counterparty_node_id), msg.channel_id)) + Err(MsgHandleErrInternal::no_such_channel_for_peer(counterparty_node_id, msg.channel_id)) } } } @@ -11479,9 +11499,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let peer_state_mutex = per_peer_state.get(counterparty_node_id) .ok_or_else(|| { debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close( - format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), - msg.channel_id) + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -11519,7 +11537,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ Ok(persist) }, hash_map::Entry::Vacant(_) => { - Err(MsgHandleErrInternal::send_err_msg_no_close(format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", counterparty_node_id), msg.channel_id)) + Err(MsgHandleErrInternal::no_such_channel_for_peer(counterparty_node_id, msg.channel_id)) } } } @@ -11532,7 +11550,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let peer_state_mutex = per_peer_state.get(counterparty_node_id) .ok_or_else(|| { debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close(format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), msg.channel_id) + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -11583,7 +11601,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } }, hash_map::Entry::Vacant(_) => { - Err(MsgHandleErrInternal::send_err_msg_no_close(format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", counterparty_node_id), msg.channel_id)) + Err(MsgHandleErrInternal::no_such_channel_for_peer(counterparty_node_id, msg.channel_id)) } } } @@ -11596,13 +11614,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close( - format!( - "Can't find a peer matching the passed counterparty node_id {}", - counterparty_node_id - ), - msg.channel_id, - ) + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -11681,7 +11693,10 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }, } } else { - return Err(MsgHandleErrInternal::send_err_msg_no_close(format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", counterparty_node_id), msg.channel_id)); + return Err(MsgHandleErrInternal::no_such_channel_for_peer( + counterparty_node_id, + msg.channel_id, + )); } } for htlc_source in dropped_htlcs.drain(..) { @@ -11703,13 +11718,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close( - format!( - "Can't find a peer matching the passed counterparty node_id {}", - counterparty_node_id - ), - msg.channel_id, - ) + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) })?; let logger; let tx_err: Option<(_, Result)> = { @@ -11724,10 +11733,12 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ try_channel_entry!(self, peer_state, res, chan_entry); debug_assert_eq!(tx_shutdown_result.is_some(), chan.is_shutdown()); if let Some(msg) = closing_signed { - peer_state.pending_msg_events.push(MessageSendEvent::SendClosingSigned { - node_id: counterparty_node_id.clone(), - msg, - }); + peer_state.pending_msg_events.push( + MessageSendEvent::SendClosingSigned { + node_id: counterparty_node_id.clone(), + msg, + }, + ); } if let Some((tx, close_res)) = tx_shutdown_result { // We're done with this channel, we've got a signed closing transaction and @@ -11735,18 +11746,34 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ // also implies there are no pending HTLCs left on the channel, so we can // fully delete it from tracking (the channel monitor is still around to // watch for old state broadcasts)! - let err = self.locked_handle_funded_coop_close(&mut peer_state.closed_channel_monitor_update_ids, &mut peer_state.in_flight_monitor_updates, close_res, chan); + let err = self.locked_handle_funded_coop_close( + &mut peer_state.closed_channel_monitor_update_ids, + &mut peer_state.in_flight_monitor_updates, + close_res, + chan, + ); chan_entry.remove(); Some((tx, Err(err))) } else { None } } else { - return try_channel_entry!(self, peer_state, Err(ChannelError::close( - "Got a closing_signed message for an unfunded channel!".into())), chan_entry); + return try_channel_entry!( + self, + peer_state, + Err(ChannelError::close( + "Got a closing_signed message for an unfunded channel!".into() + )), + chan_entry + ); } }, - hash_map::Entry::Vacant(_) => return Err(MsgHandleErrInternal::send_err_msg_no_close(format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", counterparty_node_id), msg.channel_id)) + hash_map::Entry::Vacant(_) => { + return Err(MsgHandleErrInternal::no_such_channel_for_peer( + counterparty_node_id, + msg.channel_id, + )) + }, } }; mem::drop(per_peer_state); @@ -11796,7 +11823,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let peer_state_mutex = per_peer_state.get(counterparty_node_id) .ok_or_else(|| { debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close(format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), msg.channel_id) + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -11809,7 +11836,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ "Got an update_add_htlc message for an unfunded channel!".into())), chan_entry); } }, - hash_map::Entry::Vacant(_) => return Err(MsgHandleErrInternal::send_err_msg_no_close(format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", counterparty_node_id), msg.channel_id)) + hash_map::Entry::Vacant(_) => return Err(MsgHandleErrInternal::no_such_channel_for_peer(counterparty_node_id, msg.channel_id)) } Ok(()) } @@ -11823,28 +11850,32 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close( - format!( - "Can't find a peer matching the passed counterparty node_id {}", - counterparty_node_id - ), - msg.channel_id, - ) + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; match peer_state.channel_by_id.entry(msg.channel_id) { hash_map::Entry::Occupied(mut chan_entry) => { if let Some(chan) = chan_entry.get_mut().as_funded_mut() { - let res = try_channel_entry!(self, peer_state, chan.update_fulfill_htlc(&msg), chan_entry); + let res = try_channel_entry!( + self, + peer_state, + chan.update_fulfill_htlc(&msg), + chan_entry + ); if let HTLCSource::PreviousHopData(prev_hop) = &res.0 { - let logger = WithChannelContext::from(&self.logger, &chan.context, None); + let logger = + WithChannelContext::from(&self.logger, &chan.context, None); log_trace!(logger, "Holding the next revoke_and_ack until the preimage is durably persisted in the inbound edge's ChannelMonitor", ); - peer_state.actions_blocking_raa_monitor_updates.entry(msg.channel_id) + peer_state + .actions_blocking_raa_monitor_updates + .entry(msg.channel_id) .or_insert_with(Vec::new) - .push(RAAMonitorUpdateBlockingAction::from_prev_hop_data(&prev_hop)); + .push(RAAMonitorUpdateBlockingAction::from_prev_hop_data( + &prev_hop, + )); } // Note that we do not need to push an `actions_blocking_raa_monitor_updates` // entry here, even though we *do* need to block the next RAA monitor update. @@ -11852,15 +11883,30 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ // `ReleaseRAAChannelMonitorUpdate` action to the event generated when the // outbound HTLC is claimed. This is guaranteed to all complete before we // process the RAA as messages are processed from single peers serially. - funding_txo = chan.funding.get_funding_txo().expect("We won't accept a fulfill until funded"); + funding_txo = chan + .funding + .get_funding_txo() + .expect("We won't accept a fulfill until funded"); next_user_channel_id = chan.context.get_user_id(); res } else { - return try_channel_entry!(self, peer_state, Err(ChannelError::close( - "Got an update_fulfill_htlc message for an unfunded channel!".into())), chan_entry); + return try_channel_entry!( + self, + peer_state, + Err(ChannelError::close( + "Got an update_fulfill_htlc message for an unfunded channel!" + .into() + )), + chan_entry + ); } }, - hash_map::Entry::Vacant(_) => return Err(MsgHandleErrInternal::send_err_msg_no_close(format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", counterparty_node_id), msg.channel_id)) + hash_map::Entry::Vacant(_) => { + return Err(MsgHandleErrInternal::no_such_channel_for_peer( + counterparty_node_id, + msg.channel_id, + )) + }, } }; self.claim_funds_internal( @@ -11888,7 +11934,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let peer_state_mutex = per_peer_state.get(counterparty_node_id) .ok_or_else(|| { debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close(format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), msg.channel_id) + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -11901,7 +11947,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ "Got an update_fail_htlc message for an unfunded channel!".into())), chan_entry); } }, - hash_map::Entry::Vacant(_) => return Err(MsgHandleErrInternal::send_err_msg_no_close(format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", counterparty_node_id), msg.channel_id)) + hash_map::Entry::Vacant(_) => return Err(MsgHandleErrInternal::no_such_channel_for_peer(counterparty_node_id, msg.channel_id)) } Ok(()) } @@ -11914,7 +11960,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let peer_state_mutex = per_peer_state.get(counterparty_node_id) .ok_or_else(|| { debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close(format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), msg.channel_id) + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -11932,7 +11978,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } Ok(()) }, - hash_map::Entry::Vacant(_) => return Err(MsgHandleErrInternal::send_err_msg_no_close(format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", counterparty_node_id), msg.channel_id)) + hash_map::Entry::Vacant(_) => return Err(MsgHandleErrInternal::no_such_channel_for_peer(counterparty_node_id, msg.channel_id)) } } @@ -11943,7 +11989,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let peer_state_mutex = per_peer_state.get(counterparty_node_id) .ok_or_else(|| { debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close(format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), msg.channel_id) + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -11998,7 +12044,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } Ok(()) }, - hash_map::Entry::Vacant(_) => Err(MsgHandleErrInternal::send_err_msg_no_close(format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", counterparty_node_id), msg.channel_id)) + hash_map::Entry::Vacant(_) => Err(MsgHandleErrInternal::no_such_channel_for_peer(counterparty_node_id, msg.channel_id)) } } @@ -12008,7 +12054,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let peer_state_mutex = per_peer_state.get(counterparty_node_id) .ok_or_else(|| { debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close(format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), channel_id) + MsgHandleErrInternal::no_such_peer(counterparty_node_id, channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -12040,7 +12086,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } Ok(()) }, - hash_map::Entry::Vacant(_) => Err(MsgHandleErrInternal::send_err_msg_no_close(format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", counterparty_node_id), channel_id)) + hash_map::Entry::Vacant(_) => Err(MsgHandleErrInternal::no_such_channel_for_peer(counterparty_node_id, channel_id)) } } @@ -12150,7 +12196,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let mut peer_state_lock = per_peer_state.get(counterparty_node_id) .ok_or_else(|| { debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close(format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), msg.channel_id) + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) }).map(|mtx| mtx.lock().unwrap())?; let peer_state = &mut *peer_state_lock; match peer_state.channel_by_id.entry(msg.channel_id) { @@ -12186,7 +12232,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ "Got a revoke_and_ack message for an unfunded channel!".into())), chan_entry); } }, - hash_map::Entry::Vacant(_) => return Err(MsgHandleErrInternal::send_err_msg_no_close(format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", counterparty_node_id), msg.channel_id)) + hash_map::Entry::Vacant(_) => return Err(MsgHandleErrInternal::no_such_channel_for_peer(counterparty_node_id, msg.channel_id)) } }; self.fail_holding_cell_htlcs(htlcs_to_fail, msg.channel_id, counterparty_node_id); @@ -12203,7 +12249,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let peer_state_mutex = per_peer_state.get(counterparty_node_id) .ok_or_else(|| { debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close(format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), msg.channel_id) + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -12217,7 +12263,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ "Got an update_fee message for an unfunded channel!".into())), chan_entry); } }, - hash_map::Entry::Vacant(_) => return Err(MsgHandleErrInternal::send_err_msg_no_close(format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", counterparty_node_id), msg.channel_id)) + hash_map::Entry::Vacant(_) => return Err(MsgHandleErrInternal::no_such_channel_for_peer(counterparty_node_id, msg.channel_id)) } Ok(()) } @@ -12227,9 +12273,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close( - format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), - msg.channel_id + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id ) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); @@ -12275,9 +12319,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ return try_channel_entry!(self, peer_state, err, chan_entry); } }, - hash_map::Entry::Vacant(_) => return Err(MsgHandleErrInternal::send_err_msg_no_close( - format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", counterparty_node_id), - msg.channel_id + hash_map::Entry::Vacant(_) => return Err(MsgHandleErrInternal::no_such_channel_for_peer(counterparty_node_id, msg.channel_id )) } } @@ -12288,7 +12330,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let peer_state_mutex = per_peer_state.get(counterparty_node_id) .ok_or_else(|| { debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close(format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), msg.channel_id) + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -12318,7 +12360,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ "Got an announcement_signatures message for an unfunded channel!".into())), chan_entry); } }, - hash_map::Entry::Vacant(_) => return Err(MsgHandleErrInternal::send_err_msg_no_close(format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", counterparty_node_id), msg.channel_id)) + hash_map::Entry::Vacant(_) => return Err(MsgHandleErrInternal::no_such_channel_for_peer(counterparty_node_id, msg.channel_id)) } Ok(()) } @@ -12387,9 +12429,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let peer_state_mutex = per_peer_state.get(counterparty_node_id) .ok_or_else(|| { debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close( - format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), - msg.channel_id + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id ) })?; let logger = WithContext::from(&self.logger, Some(*counterparty_node_id), Some(msg.channel_id), None); @@ -12478,9 +12518,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ my_current_funding_locked: None, }, }); - return Err(MsgHandleErrInternal::send_err_msg_no_close( - format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", - counterparty_node_id), msg.channel_id) + return Err(MsgHandleErrInternal::no_such_channel_for_peer(counterparty_node_id, msg.channel_id) ) } } @@ -12506,7 +12544,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let peer_state_mutex = per_peer_state.get(counterparty_node_id) .ok_or_else(|| { debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close(format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), msg.channel_id) + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -12516,10 +12554,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ // Look for the channel match peer_state.channel_by_id.entry(msg.channel_id) { - hash_map::Entry::Vacant(_) => return Err(MsgHandleErrInternal::send_err_msg_no_close(format!( - "Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}, channel_id {}", - counterparty_node_id, msg.channel_id, - ), msg.channel_id)), + hash_map::Entry::Vacant(_) => return Err(MsgHandleErrInternal::no_such_channel_for_peer(counterparty_node_id, msg.channel_id)), hash_map::Entry::Occupied(mut chan_entry) => { if self.config.read().unwrap().reject_inbound_splices { let err = ChannelError::WarnAndDisconnect( @@ -12553,17 +12588,14 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let peer_state_mutex = per_peer_state.get(counterparty_node_id) .ok_or_else(|| { debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close(format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), msg.channel_id) + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; // Look for the channel match peer_state.channel_by_id.entry(msg.channel_id) { - hash_map::Entry::Vacant(_) => Err(MsgHandleErrInternal::send_err_msg_no_close(format!( - "Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", - counterparty_node_id - ), msg.channel_id)), + hash_map::Entry::Vacant(_) => Err(MsgHandleErrInternal::no_such_channel_for_peer(counterparty_node_id, msg.channel_id)), hash_map::Entry::Occupied(mut chan_entry) => { if let Some(ref mut funded_channel) = chan_entry.get_mut().as_funded_mut() { let splice_ack_res = funded_channel.splice_ack( @@ -12588,13 +12620,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close( - format!( - "Can't find a peer matching the passed counterparty node_id {}", - counterparty_node_id - ), - msg.channel_id, - ) + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -12602,11 +12628,10 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ // Look for the channel match peer_state.channel_by_id.entry(msg.channel_id) { hash_map::Entry::Vacant(_) => { - let err = format!( - "Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", + return Err(MsgHandleErrInternal::no_such_channel_for_peer( counterparty_node_id, - ); - return Err(MsgHandleErrInternal::send_err_msg_no_close(err, msg.channel_id)); + msg.channel_id, + )); }, hash_map::Entry::Occupied(mut chan_entry) => { if let Some(chan) = chan_entry.get_mut().as_funded_mut() { diff --git a/lightning/src/ln/payment_tests.rs b/lightning/src/ln/payment_tests.rs index 6e47e21ca8b..32a93d20936 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -895,8 +895,13 @@ fn do_retry_with_no_persist(confirm_before_reload: bool) { } => { assert_eq!(node_id, node_b_id); nodes[1].node.handle_error(node_a_id, msg); - check_closed_event(&nodes[1], 1, ClosureReason::CounterpartyForceClosed { peer_msg: UntrustedString(format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", - &node_b_id)) }, &[node_a_id], 100000); + let peer_msg = format!( + "Got a message for a channel from the wrong node! No such channel_id {} for the passed counterparty_node_id {}", + chan_id, node_b_id + ); + let reason = + ClosureReason::CounterpartyForceClosed { peer_msg: UntrustedString(peer_msg) }; + check_closed_event(&nodes[1], 1, reason, &[node_a_id], 100000); check_added_monitors(&nodes[1], 1); assert_eq!(nodes[1].tx_broadcaster.txn_broadcasted.lock().unwrap().len(), 1); nodes[1].tx_broadcaster.clear(); @@ -1101,11 +1106,12 @@ fn do_test_completed_payment_not_retryable_on_reload(use_dust: bool) { } => { assert_eq!(node_id, node_b_id); nodes[1].node.handle_error(node_a_id, msg); - let msg = format!( - "Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", - &node_b_id + let peer_msg = format!( + "Got a message for a channel from the wrong node! No such channel_id {} for the passed counterparty_node_id {}", + chan_id, node_b_id ); - let reason = ClosureReason::CounterpartyForceClosed { peer_msg: UntrustedString(msg) }; + let reason = + ClosureReason::CounterpartyForceClosed { peer_msg: UntrustedString(peer_msg) }; check_closed_event(&nodes[1], 1, reason, &[node_a_id], 100000); check_added_monitors(&nodes[1], 1); bs_commitment_tx = nodes[1].tx_broadcaster.txn_broadcasted.lock().unwrap().split_off(0); diff --git a/lightning/src/ln/reload_tests.rs b/lightning/src/ln/reload_tests.rs index c7e7175602d..919ed969161 100644 --- a/lightning/src/ln/reload_tests.rs +++ b/lightning/src/ln/reload_tests.rs @@ -691,7 +691,11 @@ fn do_test_data_loss_protect(reconnect_panicing: bool, substantially_old: bool, if let MessageSendEvent::HandleError { ref action, .. } = nodes[0].node.get_and_clear_pending_msg_events()[1] { match action { &ErrorAction::SendErrorMessage { ref msg } => { - assert_eq!(msg.data, format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", &nodes[1].node.get_our_node_id())); + let peer_msg = format!( + "Got a message for a channel from the wrong node! No such channel_id {} for the passed counterparty_node_id {}", + chan.2, nodes[1].node.get_our_node_id() + ); + assert_eq!(msg.data, peer_msg); err_msgs_0.push(msg.clone()); }, _ => panic!("Unexpected event!"), @@ -703,8 +707,12 @@ fn do_test_data_loss_protect(reconnect_panicing: bool, substantially_old: bool, nodes[1].node.handle_error(nodes[0].node.get_our_node_id(), &err_msgs_0[0]); assert!(nodes[1].node.list_usable_channels().is_empty()); check_added_monitors(&nodes[1], 1); - check_closed_event(&nodes[1], 1, ClosureReason::CounterpartyForceClosed { peer_msg: UntrustedString(format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", &nodes[1].node.get_our_node_id())) } - , &[nodes[0].node.get_our_node_id()], 1000000); + let peer_msg = format!( + "Got a message for a channel from the wrong node! No such channel_id {} for the passed counterparty_node_id {}", + chan.2, nodes[1].node.get_our_node_id() + ); + let reason = ClosureReason::CounterpartyForceClosed { peer_msg: UntrustedString(peer_msg) }; + check_closed_event(&nodes[1], 1, reason, &[nodes[0].node.get_our_node_id()], 1000000); check_closed_broadcast!(nodes[1], false); } } diff --git a/lightning/src/ln/shutdown_tests.rs b/lightning/src/ln/shutdown_tests.rs index 50c8f72f9be..870f00ee9df 100644 --- a/lightning/src/ln/shutdown_tests.rs +++ b/lightning/src/ln/shutdown_tests.rs @@ -836,7 +836,11 @@ fn do_test_shutdown_rebroadcast(recv_count: u8) { // closing_signed so we do it ourselves check_closed_broadcast!(nodes[1], false); check_added_monitors(&nodes[1], 1); - let reason = ClosureReason::CounterpartyForceClosed { peer_msg: UntrustedString(format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", &node_b_id)) }; + let peer_msg = format!( + "Got a message for a channel from the wrong node! No such channel_id {} for the passed counterparty_node_id {}", + chan_1.2, node_b_id + ); + let reason = ClosureReason::CounterpartyForceClosed { peer_msg: UntrustedString(peer_msg) }; check_closed_event(&nodes[1], 1, reason, &[node_a_id], 100000); } From b967390934c871b13c4af3713908e7530f499aae Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Fri, 6 Feb 2026 13:50:43 -0800 Subject: [PATCH 013/627] Refactor missing peer/channel APIError from ChannelManager methods We have the same error being returned from several `ChannelManager` API methods, so we DRY it up. Doing so also lets us get rid of the inlined `format!` call, which for some reason prevents `rustfmt` from formatting code around it. --- lightning/src/ln/channelmanager.rs | 143 +++++++++++------------------ lightning/src/ln/payment_tests.rs | 2 +- lightning/src/util/errors.rs | 25 +++++ 3 files changed, 80 insertions(+), 90 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index a069b01f532..efd5026ff25 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -3844,8 +3844,9 @@ impl< { let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = per_peer_state.get(counterparty_node_id) - .ok_or_else(|| APIError::ChannelUnavailable { err: format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}") })?; + let peer_state_mutex = per_peer_state + .get(counterparty_node_id) + .ok_or_else(|| APIError::no_such_peer(counterparty_node_id))?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -3909,12 +3910,7 @@ impl< } }, hash_map::Entry::Vacant(_) => { - return Err(APIError::ChannelUnavailable { - err: format!( - "Channel with id {} not found for the passed counterparty node_id {}", - chan_id, counterparty_node_id, - ), - }); + return Err(APIError::no_such_channel_for_peer(chan_id, counterparty_node_id)); }, } } @@ -4209,11 +4205,7 @@ impl< ) -> Result<(), APIError> { let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = - per_peer_state.get(peer_node_id).ok_or_else(|| APIError::ChannelUnavailable { - err: format!( - "Can't find a peer matching the passed counterparty node_id {peer_node_id}", - ), - })?; + per_peer_state.get(peer_node_id).ok_or_else(|| APIError::no_such_peer(peer_node_id))?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; let logger = WithContext::from(&self.logger, Some(*peer_node_id), Some(*channel_id), None); @@ -4257,11 +4249,7 @@ impl< // events anyway. Ok(()) } else { - Err(APIError::ChannelUnavailable { - err: format!( - "Channel with id {channel_id} not found for the passed counterparty node_id {peer_node_id}", - ), - }) + Err(APIError::no_such_channel_for_peer(channel_id, peer_node_id)) } } @@ -4605,11 +4593,10 @@ impl< ) -> Result<(), APIError> { let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = match per_peer_state.get(counterparty_node_id).ok_or_else(|| { - APIError::ChannelUnavailable { - err: format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), - } - }) { + let peer_state_mutex = match per_peer_state + .get(counterparty_node_id) + .ok_or_else(|| APIError::no_such_peer(counterparty_node_id)) + { Ok(p) => p, Err(e) => return Err(e), }; @@ -4654,12 +4641,9 @@ impl< }) } }, - hash_map::Entry::Vacant(_) => Err(APIError::ChannelUnavailable { - err: format!( - "Channel with id {} not found for the passed counterparty node_id {}", - channel_id, counterparty_node_id, - ), - }), + hash_map::Entry::Vacant(_) => { + Err(APIError::no_such_channel_for_peer(channel_id, counterparty_node_id)) + }, } } @@ -4685,11 +4669,10 @@ impl< ) -> Result<(), APIError> { let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = match per_peer_state.get(counterparty_node_id).ok_or_else(|| { - APIError::ChannelUnavailable { - err: format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), - } - }) { + let peer_state_mutex = match per_peer_state + .get(counterparty_node_id) + .ok_or_else(|| APIError::no_such_peer(counterparty_node_id)) + { Ok(p) => p, Err(e) => return Err(e), }; @@ -4742,12 +4725,9 @@ impl< }) } }, - hash_map::Entry::Vacant(_) => Err(APIError::ChannelUnavailable { - err: format!( - "Channel with id {} not found for the passed counterparty node_id {}", - channel_id, counterparty_node_id, - ), - }), + hash_map::Entry::Vacant(_) => { + Err(APIError::no_such_channel_for_peer(channel_id, counterparty_node_id)) + }, } } @@ -5965,12 +5945,12 @@ impl< /// which checks the correctness of the funding transaction given the associated channel. #[rustfmt::skip] fn funding_transaction_generated_intern) -> Result>( - &self, temporary_channel_id: ChannelId, counterparty_node_id: PublicKey, funding_transaction: Transaction, is_batch_funding: bool, - mut find_funding_output: FundingOutput, is_manual_broadcast: bool, - ) -> Result<(), APIError> { + &self, temporary_channel_id: ChannelId, counterparty_node_id: PublicKey, funding_transaction: Transaction, is_batch_funding: bool, + mut find_funding_output: FundingOutput, is_manual_broadcast: bool, + ) -> Result<(), APIError> { let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(&counterparty_node_id) - .ok_or_else(|| APIError::ChannelUnavailable { err: format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}") })?; + .ok_or_else(|| APIError::no_such_peer(&counterparty_node_id))?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -6410,9 +6390,7 @@ impl< let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex_opt = per_peer_state.get(counterparty_node_id); if peer_state_mutex_opt.is_none() { - funding_tx_signed_result = Err(APIError::ChannelUnavailable { - err: format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}") - }); + funding_tx_signed_result = Err(APIError::no_such_peer(counterparty_node_id)); return NotifyOption::SkipPersistNoEvents; } @@ -6550,12 +6528,8 @@ impl< } }, hash_map::Entry::Vacant(_) => { - funding_tx_signed_result = Err(APIError::ChannelUnavailable { - err: format!( - "Channel with id {} not found for the passed counterparty node_id {}", - channel_id, counterparty_node_id - ), - }); + funding_tx_signed_result = + Err(APIError::no_such_channel_for_peer(channel_id, counterparty_node_id)); return NotifyOption::SkipPersistNoEvents; }, } @@ -6638,15 +6612,16 @@ impl< let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(counterparty_node_id) - .ok_or_else(|| APIError::ChannelUnavailable { err: format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}") })?; + .ok_or_else(|| APIError::no_such_peer(counterparty_node_id))?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; for channel_id in channel_ids { if !peer_state.has_channel(channel_id) { - return Err(APIError::ChannelUnavailable { - err: format!("Channel with id {} not found for the passed counterparty node_id {}", channel_id, counterparty_node_id), - }); + return Err(APIError::no_such_channel_for_peer( + channel_id, + counterparty_node_id, + )); }; } for channel_id in channel_ids { @@ -6741,12 +6716,9 @@ impl< let outbound_scid_alias = { let peer_state_lock = self.per_peer_state.read().unwrap(); - let peer_state_mutex = - peer_state_lock.get(&next_node_id).ok_or_else(|| APIError::ChannelUnavailable { - err: format!( - "Can't find a peer matching the passed counterparty node_id {next_node_id}" - ), - })?; + let peer_state_mutex = peer_state_lock + .get(&next_node_id) + .ok_or_else(|| APIError::no_such_peer(&next_node_id))?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; match peer_state.channel_by_id.get(next_hop_channel_id) { @@ -6779,11 +6751,10 @@ impl< logger, "Channel not found when attempting to forward intercepted HTLC" ); - return Err(APIError::ChannelUnavailable { - err: format!( - "Channel with id {next_hop_channel_id} not found for the passed counterparty node_id {next_node_id}" - ), - }); + return Err(APIError::no_such_channel_for_peer( + next_hop_channel_id, + &next_node_id, + )); }, } }; @@ -10565,11 +10536,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { log_error!(logger, "Can't find peer matching the passed counterparty node_id"); - - let err_str = format!( - "Can't find a peer matching the passed counterparty node_id {counterparty_node_id}" - ); - APIError::ChannelUnavailable { err: err_str } + APIError::no_such_peer(counterparty_node_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -13236,9 +13203,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex_opt = per_peer_state.get(counterparty_node_id); if peer_state_mutex_opt.is_none() { - result = Err(APIError::ChannelUnavailable { - err: format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}") - }); + result = Err(APIError::no_such_peer(counterparty_node_id)); return notify; } @@ -13272,10 +13237,10 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } }, hash_map::Entry::Vacant(_) => { - result = Err(APIError::ChannelUnavailable { - err: format!("Channel with id {} not found for the passed counterparty node_id {}", - channel_id, counterparty_node_id), - }); + result = Err(APIError::no_such_channel_for_peer( + channel_id, + counterparty_node_id, + )); }, } @@ -13293,9 +13258,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let initiator = { let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(counterparty_node_id) - .ok_or_else(|| APIError::ChannelUnavailable { - err: format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}") - })?; + .ok_or_else(|| APIError::no_such_peer(counterparty_node_id))?; let mut peer_state = peer_state_mutex.lock().unwrap(); match peer_state.channel_by_id.entry(*channel_id) { hash_map::Entry::Occupied(mut chan_entry) => { @@ -13307,10 +13270,12 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }) } }, - hash_map::Entry::Vacant(_) => return Err(APIError::ChannelUnavailable { - err: format!("Channel with id {} not found for the passed counterparty node_id {}", - channel_id, counterparty_node_id), - }), + hash_map::Entry::Vacant(_) => { + return Err(APIError::no_such_channel_for_peer( + channel_id, + counterparty_node_id, + )) + }, } }; self.check_free_holding_cells(); @@ -20340,13 +20305,13 @@ mod tests { #[rustfmt::skip] fn check_unkown_peer_error(res_err: Result, expected_public_key: PublicKey) { - let expected_message = format!("Can't find a peer matching the passed counterparty node_id {}", expected_public_key); + let expected_message = format!("No such peer for the passed counterparty_node_id {}", expected_public_key); check_api_error_message(expected_message, res_err) } #[rustfmt::skip] fn check_channel_unavailable_error(res_err: Result, expected_channel_id: ChannelId, peer_node_id: PublicKey) { - let expected_message = format!("Channel with id {} not found for the passed counterparty node_id {}", expected_channel_id, peer_node_id); + let expected_message = format!("No such channel_id {} for the passed counterparty_node_id {}", expected_channel_id, peer_node_id); check_api_error_message(expected_message, res_err) } diff --git a/lightning/src/ln/payment_tests.rs b/lightning/src/ln/payment_tests.rs index 32a93d20936..0eace2eab08 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -2312,7 +2312,7 @@ fn do_test_intercepted_payment(test: InterceptTest) { let unknown_chan_id_err = nodes[1].node.forward_intercepted_htlc(intercept_id, &chan_id, node_c_id, outbound_amt); let err = format!( - "Channel with id {} not found for the passed counterparty node_id {}", + "No such channel_id {} for the passed counterparty_node_id {}", chan_id, node_c_id, ); assert_eq!(unknown_chan_id_err, Err(APIError::ChannelUnavailable { err })); diff --git a/lightning/src/util/errors.rs b/lightning/src/util/errors.rs index eaaf0130ca2..cd72d60327f 100644 --- a/lightning/src/util/errors.rs +++ b/lightning/src/util/errors.rs @@ -9,7 +9,10 @@ //! Error types live here. +use bitcoin::secp256k1::PublicKey; + use crate::ln::script::ShutdownScript; +use crate::ln::types::ChannelId; #[allow(unused_imports)] use crate::prelude::*; @@ -90,6 +93,28 @@ impl fmt::Debug for APIError { } } +impl APIError { + pub(crate) fn no_such_peer(counterparty_node_id: &PublicKey) -> Self { + Self::ChannelUnavailable { + err: format!( + "No such peer for the passed counterparty_node_id {}", + counterparty_node_id + ), + } + } + + pub(crate) fn no_such_channel_for_peer( + channel_id: &ChannelId, counterparty_node_id: &PublicKey, + ) -> Self { + Self::ChannelUnavailable { + err: format!( + "No such channel_id {} for the passed counterparty_node_id {}", + channel_id, counterparty_node_id + ), + } + } +} + impl_writeable_tlv_based_enum_upgradable!(APIError, (0, APIMisuseError) => { (0, err, required), }, (2, FeeRateTooHigh) => { From 4d35de573cac7ac4fe610687b6010cd4284346f4 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Tue, 10 Feb 2026 14:08:59 -0800 Subject: [PATCH 014/627] Rustfmt ChannelManager::internal_tx_complete --- lightning/src/ln/channelmanager.rs | 60 +++++++++++++++++------------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index efd5026ff25..9a62d775e2e 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -11274,14 +11274,14 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }) } - #[rustfmt::skip] - fn internal_tx_complete(&self, counterparty_node_id: PublicKey, msg: &msgs::TxComplete) -> Result { + fn internal_tx_complete( + &self, counterparty_node_id: PublicKey, msg: &msgs::TxComplete, + ) -> Result { let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = per_peer_state.get(&counterparty_node_id) - .ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(&counterparty_node_id, msg.channel_id) - })?; + let peer_state_mutex = per_peer_state.get(&counterparty_node_id).ok_or_else(|| { + debug_assert!(false); + MsgHandleErrInternal::no_such_peer(&counterparty_node_id, msg.channel_id) + })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; match peer_state.channel_by_id.entry(msg.channel_id) { @@ -11291,8 +11291,11 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ Ok(tx_complete_result) => { let mut persist = NotifyOption::SkipPersistNoEvents; - if let Some(interactive_tx_msg_send) = tx_complete_result.interactive_tx_msg_send { - let msg_send_event = interactive_tx_msg_send.into_msg_send_event(counterparty_node_id); + if let Some(interactive_tx_msg_send) = + tx_complete_result.interactive_tx_msg_send + { + let msg_send_event = + interactive_tx_msg_send.into_msg_send_event(counterparty_node_id); peer_state.pending_msg_events.push(msg_send_event); persist = NotifyOption::SkipPersistHandleEvents; }; @@ -11307,7 +11310,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }, None, )); - // // We have a successful signing session that we need to persist. persist = NotifyOption::DoPersist; } @@ -11345,10 +11347,12 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }); } if let Some(tx_signatures) = tx_signatures { - peer_state.pending_msg_events.push(MessageSendEvent::SendTxSignatures { - node_id: counterparty_node_id, - msg: tx_signatures, - }); + peer_state.pending_msg_events.push( + MessageSendEvent::SendTxSignatures { + node_id: counterparty_node_id, + msg: tx_signatures, + }, + ); } // We have a successful signing session that we need to persist. @@ -11360,23 +11364,27 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ Err((error, splice_funding_failed)) => { if let Some(splice_funding_failed) = splice_funding_failed { let pending_events = &mut self.pending_events.lock().unwrap(); - pending_events.push_back((events::Event::SpliceFailed { - channel_id: msg.channel_id, - counterparty_node_id, - user_channel_id: chan.context().get_user_id(), - abandoned_funding_txo: splice_funding_failed.funding_txo, - channel_type: splice_funding_failed.channel_type.clone(), - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, - }, None)); + pending_events.push_back(( + events::Event::SpliceFailed { + channel_id: msg.channel_id, + counterparty_node_id, + user_channel_id: chan.context().get_user_id(), + abandoned_funding_txo: splice_funding_failed.funding_txo, + channel_type: splice_funding_failed.channel_type.clone(), + contributed_inputs: splice_funding_failed.contributed_inputs, + contributed_outputs: splice_funding_failed.contributed_outputs, + }, + None, + )); } Err(MsgHandleErrInternal::from_chan_no_close(error, msg.channel_id)) }, } }, - hash_map::Entry::Vacant(_) => { - Err(MsgHandleErrInternal::no_such_channel_for_peer(&counterparty_node_id, msg.channel_id)) - } + hash_map::Entry::Vacant(_) => Err(MsgHandleErrInternal::no_such_channel_for_peer( + &counterparty_node_id, + msg.channel_id, + )), } } From 146f29a9a9edcac5c1a1cd6b52c28a300d7aced7 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Tue, 10 Feb 2026 14:09:33 -0800 Subject: [PATCH 015/627] Rustfmt ChannelManager::internal_tx_signatures --- lightning/src/ln/channelmanager.rs | 52 ++++++++++++++++++------------ 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 9a62d775e2e..dfdbfe4c07d 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -11388,15 +11388,14 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } } - #[rustfmt::skip] - fn internal_tx_signatures(&self, counterparty_node_id: &PublicKey, msg: &msgs::TxSignatures) - -> Result<(), MsgHandleErrInternal> { + fn internal_tx_signatures( + &self, counterparty_node_id: &PublicKey, msg: &msgs::TxSignatures, + ) -> Result<(), MsgHandleErrInternal> { let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = per_peer_state.get(counterparty_node_id) - .ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) - })?; + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + debug_assert!(false); + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) + })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; match peer_state.channel_by_id.entry(msg.channel_id) { @@ -11424,19 +11423,28 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ debug_assert!(counterparty_initial_commitment_signed_result.is_none()); if let Some(tx_signatures) = tx_signatures { - peer_state.pending_msg_events.push(MessageSendEvent::SendTxSignatures { - node_id: *counterparty_node_id, - msg: tx_signatures, - }); + peer_state.pending_msg_events.push( + MessageSendEvent::SendTxSignatures { + node_id: *counterparty_node_id, + msg: tx_signatures, + }, + ); } if let Some(splice_locked) = splice_locked { - peer_state.pending_msg_events.push(MessageSendEvent::SendSpliceLocked { - node_id: *counterparty_node_id, - msg: splice_locked, - }); + peer_state.pending_msg_events.push( + MessageSendEvent::SendSpliceLocked { + node_id: *counterparty_node_id, + msg: splice_locked, + }, + ); } if let Some((ref funding_tx, ref tx_type)) = funding_tx { - self.broadcast_interactive_funding(chan, funding_tx, Some(tx_type.clone()), &self.logger); + self.broadcast_interactive_funding( + chan, + funding_tx, + Some(tx_type.clone()), + &self.logger, + ); } if let Some(splice_negotiated) = splice_negotiated { self.pending_events.lock().unwrap().push_back(( @@ -11446,7 +11454,8 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ user_channel_id: chan.context.get_user_id(), new_funding_txo: splice_negotiated.funding_txo, channel_type: splice_negotiated.channel_type, - new_funding_redeem_script: splice_negotiated.funding_redeem_script, + new_funding_redeem_script: splice_negotiated + .funding_redeem_script, }, None, )); @@ -11461,9 +11470,10 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } Ok(()) }, - hash_map::Entry::Vacant(_) => { - Err(MsgHandleErrInternal::no_such_channel_for_peer(counterparty_node_id, msg.channel_id)) - } + hash_map::Entry::Vacant(_) => Err(MsgHandleErrInternal::no_such_channel_for_peer( + counterparty_node_id, + msg.channel_id, + )), } } From 0eadc17ebea02f483e2fb36d9e3392928fd39487 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Tue, 10 Feb 2026 14:10:07 -0800 Subject: [PATCH 016/627] Rustfmt ChannelManager::internal_tx_abort --- lightning/src/ln/channelmanager.rs | 46 ++++++++++++++++-------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index dfdbfe4c07d..3a3003eca9d 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -11477,21 +11477,21 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } } - #[rustfmt::skip] - fn internal_tx_abort(&self, counterparty_node_id: &PublicKey, msg: &msgs::TxAbort) - -> Result { + fn internal_tx_abort( + &self, counterparty_node_id: &PublicKey, msg: &msgs::TxAbort, + ) -> Result { let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = per_peer_state.get(counterparty_node_id) - .ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) - })?; + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + debug_assert!(false); + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) + })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; match peer_state.channel_by_id.entry(msg.channel_id) { hash_map::Entry::Occupied(mut chan_entry) => { let res = chan_entry.get_mut().tx_abort(msg, &self.logger); - let (tx_abort, splice_failed) = try_channel_entry!(self, peer_state, res, chan_entry); + let (tx_abort, splice_failed) = + try_channel_entry!(self, peer_state, res, chan_entry); let persist = if tx_abort.is_some() || splice_failed.is_some() { NotifyOption::DoPersist @@ -11508,22 +11508,26 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ if let Some(splice_funding_failed) = splice_failed { let pending_events = &mut self.pending_events.lock().unwrap(); - pending_events.push_back((events::Event::SpliceFailed { - channel_id: msg.channel_id, - counterparty_node_id: *counterparty_node_id, - user_channel_id: chan_entry.get().context().get_user_id(), - abandoned_funding_txo: splice_funding_failed.funding_txo, - channel_type: splice_funding_failed.channel_type, - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, - }, None)); + pending_events.push_back(( + events::Event::SpliceFailed { + channel_id: msg.channel_id, + counterparty_node_id: *counterparty_node_id, + user_channel_id: chan_entry.get().context().get_user_id(), + abandoned_funding_txo: splice_funding_failed.funding_txo, + channel_type: splice_funding_failed.channel_type, + contributed_inputs: splice_funding_failed.contributed_inputs, + contributed_outputs: splice_funding_failed.contributed_outputs, + }, + None, + )); } Ok(persist) }, - hash_map::Entry::Vacant(_) => { - Err(MsgHandleErrInternal::no_such_channel_for_peer(counterparty_node_id, msg.channel_id)) - } + hash_map::Entry::Vacant(_) => Err(MsgHandleErrInternal::no_such_channel_for_peer( + counterparty_node_id, + msg.channel_id, + )), } } From e1d0566dd2d8f163986e4ee0fa971a8986b06112 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Tue, 10 Feb 2026 14:10:23 -0800 Subject: [PATCH 017/627] Rustfmt ChannelManager::internal_splice_ack --- lightning/src/ln/channelmanager.rs | 40 ++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 3a3003eca9d..89056ef11e3 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -12571,33 +12571,47 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } /// Handle incoming splice request ack, transition channel to splice-pending (unless some check fails). - #[rustfmt::skip] - fn internal_splice_ack(&self, counterparty_node_id: &PublicKey, msg: &msgs::SpliceAck) -> Result<(), MsgHandleErrInternal> { + fn internal_splice_ack( + &self, counterparty_node_id: &PublicKey, msg: &msgs::SpliceAck, + ) -> Result<(), MsgHandleErrInternal> { let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = per_peer_state.get(counterparty_node_id) - .ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) - })?; + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + debug_assert!(false); + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) + })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; // Look for the channel match peer_state.channel_by_id.entry(msg.channel_id) { - hash_map::Entry::Vacant(_) => Err(MsgHandleErrInternal::no_such_channel_for_peer(counterparty_node_id, msg.channel_id)), + hash_map::Entry::Vacant(_) => Err(MsgHandleErrInternal::no_such_channel_for_peer( + counterparty_node_id, + msg.channel_id, + )), hash_map::Entry::Occupied(mut chan_entry) => { if let Some(ref mut funded_channel) = chan_entry.get_mut().as_funded_mut() { let splice_ack_res = funded_channel.splice_ack( - msg, &self.signer_provider, &self.entropy_source, - &self.get_our_node_id(), &self.logger + msg, + &self.signer_provider, + &self.entropy_source, + &self.get_our_node_id(), + &self.logger, ); - let tx_msg_opt = try_channel_entry!(self, peer_state, splice_ack_res, chan_entry); + let tx_msg_opt = + try_channel_entry!(self, peer_state, splice_ack_res, chan_entry); if let Some(tx_msg) = tx_msg_opt { - peer_state.pending_msg_events.push(tx_msg.into_msg_send_event(counterparty_node_id.clone())); + peer_state + .pending_msg_events + .push(tx_msg.into_msg_send_event(counterparty_node_id.clone())); } Ok(()) } else { - try_channel_entry!(self, peer_state, Err(ChannelError::close("Channel is not funded, cannot be spliced".into())), chan_entry) + try_channel_entry!( + self, + peer_state, + Err(ChannelError::close("Channel is not funded, cannot be spliced".into())), + chan_entry + ) } }, } From 775261921261ff91b94781edfce10e08a0b0d2be Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Tue, 10 Feb 2026 14:12:12 -0800 Subject: [PATCH 018/627] Rustfmt ChannelManager::internal_splice_init --- lightning/src/ln/channelmanager.rs | 38 ++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 89056ef11e3..eb526c41f19 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -12527,14 +12527,14 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } /// Handle incoming splice request, transition channel to splice-pending (unless some check fails). - #[rustfmt::skip] - fn internal_splice_init(&self, counterparty_node_id: &PublicKey, msg: &msgs::SpliceInit) -> Result<(), MsgHandleErrInternal> { + fn internal_splice_init( + &self, counterparty_node_id: &PublicKey, msg: &msgs::SpliceInit, + ) -> Result<(), MsgHandleErrInternal> { let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = per_peer_state.get(counterparty_node_id) - .ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) - })?; + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + debug_assert!(false); + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) + })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -12543,19 +12543,28 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ // Look for the channel match peer_state.channel_by_id.entry(msg.channel_id) { - hash_map::Entry::Vacant(_) => return Err(MsgHandleErrInternal::no_such_channel_for_peer(counterparty_node_id, msg.channel_id)), + hash_map::Entry::Vacant(_) => { + return Err(MsgHandleErrInternal::no_such_channel_for_peer( + counterparty_node_id, + msg.channel_id, + )) + }, hash_map::Entry::Occupied(mut chan_entry) => { if self.config.read().unwrap().reject_inbound_splices { let err = ChannelError::WarnAndDisconnect( - "Inbound channel splices are currently not allowed".to_owned() + "Inbound channel splices are currently not allowed".to_owned(), ); return Err(MsgHandleErrInternal::from_chan_no_close(err, msg.channel_id)); } if let Some(ref mut funded_channel) = chan_entry.get_mut().as_funded_mut() { let init_res = funded_channel.splice_init( - msg, our_funding_contribution, &self.signer_provider, &self.entropy_source, - &self.get_our_node_id(), &self.logger + msg, + our_funding_contribution, + &self.signer_provider, + &self.entropy_source, + &self.get_our_node_id(), + &self.logger, ); let splice_ack_msg = try_channel_entry!(self, peer_state, init_res, chan_entry); peer_state.pending_msg_events.push(MessageSendEvent::SendSpliceAck { @@ -12564,7 +12573,12 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }); Ok(()) } else { - try_channel_entry!(self, peer_state, Err(ChannelError::close("Channel is not funded, cannot be spliced".into())), chan_entry) + try_channel_entry!( + self, + peer_state, + Err(ChannelError::close("Channel is not funded, cannot be spliced".into())), + chan_entry + ) } }, } From fb6d61aa50226a1eb55b66a1d0e22e5abde85aaf Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Tue, 10 Feb 2026 14:15:24 -0800 Subject: [PATCH 019/627] Rustfmt ChannelManager::internal_commitment_signed --- lightning/src/ln/channelmanager.rs | 38 ++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index eb526c41f19..a21456f0fd5 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -11971,15 +11971,15 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } } - #[rustfmt::skip] - fn internal_commitment_signed(&self, counterparty_node_id: &PublicKey, msg: &msgs::CommitmentSigned) -> Result<(), MsgHandleErrInternal> { + fn internal_commitment_signed( + &self, counterparty_node_id: &PublicKey, msg: &msgs::CommitmentSigned, + ) -> Result<(), MsgHandleErrInternal> { let best_block = *self.best_block.read().unwrap(); let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = per_peer_state.get(counterparty_node_id) - .ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) - })?; + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + debug_assert!(false); + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) + })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; match peer_state.channel_by_id.entry(msg.channel_id) { @@ -11988,12 +11988,22 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let logger = WithChannelContext::from(&self.logger, &chan.context(), None); let funding_txo = chan.funding().get_funding_txo(); let (monitor_opt, monitor_update_opt) = try_channel_entry!( - self, peer_state, chan.commitment_signed(msg, best_block, &self.signer_provider, &self.fee_estimator, &&logger), - chan_entry); + self, + peer_state, + chan.commitment_signed( + msg, + best_block, + &self.signer_provider, + &self.fee_estimator, + &&logger + ), + chan_entry + ); if let Some(chan) = chan.as_funded_mut() { if let Some(monitor) = monitor_opt { - let monitor_res = self.chain_monitor.watch_channel(monitor.channel_id(), monitor); + let monitor_res = + self.chain_monitor.watch_channel(monitor.channel_id(), monitor); if let Ok(persist_state) = monitor_res { if let Some(data) = self.handle_initial_monitor( &mut peer_state.in_flight_monitor_updates, @@ -12008,7 +12018,8 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ self.handle_post_monitor_update_chan_resume(data); } } else { - let logger = WithChannelContext::from(&self.logger, &chan.context, None); + let logger = + WithChannelContext::from(&self.logger, &chan.context, None); log_error!(logger, "Persisting initial ChannelMonitor failed, implying the channel ID was duplicated"); let msg = "Channel ID was a duplicate"; let reason = ClosureReason::ProcessingError { err: msg.to_owned() }; @@ -12033,7 +12044,10 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } Ok(()) }, - hash_map::Entry::Vacant(_) => Err(MsgHandleErrInternal::no_such_channel_for_peer(counterparty_node_id, msg.channel_id)) + hash_map::Entry::Vacant(_) => Err(MsgHandleErrInternal::no_such_channel_for_peer( + counterparty_node_id, + msg.channel_id, + )), } } From 4bb33b2d45f80cfb5a5b922fd20d646288296dd4 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 16 Jan 2026 09:51:29 +0100 Subject: [PATCH 020/627] Drop `ChannelHandshakeLimits::max_funding_satoshis` Previously, LDK would by default limit channels pre-Wumbo sizes and leave it to the user to bump `ChannelHandshakeLimits::max_funding_satoshis`. This has mostly historical reasons that aimed to allow limiting risk when Lightning and LDK were not as matured as today. By now, we do however expect ~all users to eventually want to bump this limit, and having them pick an arbitrary value (or pick a default ourselves) is kinda odd. Users that still want to limit risks have ample other means to do so, for example manually rejecting inbound channels via the manual-acceptence flow (via `Event::OpenChannelRequest`) or soon even limiting risk on a per-HTLC basis via general purpose HTLC interception. Furthermore, it turns out that our current implementation is wrong, as we do always announce `Wumbo`/`option_supports_large_channels` support via the `IN` feature in `ChannelManager` defaults, irrespective of what limit is configured. This has us announcing support for Wumbo channels to only then reject inbound requests in case a counterparty dares to actually try to open one. To address this, we here simply propose to drop the `max_funding_satoshis` field and corresponding checks entirely, and do what we've announced to the network for a long time: enable Wumbo by default. --- fuzz/src/full_stack.rs | 6 +++--- lightning/src/ln/channel.rs | 21 +-------------------- lightning/src/ln/channel_open_tests.rs | 16 +--------------- lightning/src/util/config.rs | 8 -------- 4 files changed, 5 insertions(+), 46 deletions(-) diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index 39588bcdc50..f7f912cfd48 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -1170,7 +1170,7 @@ fn two_peer_forwarding_seed() -> Vec { // our network key ext_from_hex("0100000000000000000000000000000000000000000000000000000000000000", &mut test); // config - ext_from_hex("000000000090000000000000000064000100000000000100ffff0000000000000000ffffffffffffffffffffffffffffffff0000000000000000ffffffffffffffff000000ffffffff00ffff1a000400010000020400000000040200000a08ffffffffffffffff0001000000000000", &mut test); + ext_from_hex("000000000090000000000000000064000100000000000100ffff00000000000000ffffffffffffffffff0000000000000000ffffffffffffffff000000ffffffff00ffff1a000400010000020400000000040200000a08ffffffffffffffff0001000000000000", &mut test); // new outbound connection with id 0 ext_from_hex("00", &mut test); @@ -1624,7 +1624,7 @@ fn gossip_exchange_seed() -> Vec { // our network key ext_from_hex("0100000000000000000000000000000000000000000000000000000000000000", &mut test); // config - ext_from_hex("000000000090000000000000000064000100000000000100ffff0000000000000000ffffffffffffffffffffffffffffffff0000000000000000ffffffffffffffff000000ffffffff00ffff1a000400010000020400000000040200000a08ffffffffffffffff0001000000000000", &mut test); + ext_from_hex("000000000090000000000000000064000100000000000100ffff00000000000000ffffffffffffffffff0000000000000000ffffffffffffffff000000ffffffff00ffff1a000400010000020400000000040200000a08ffffffffffffffff0001000000000000", &mut test); // new outbound connection with id 0 ext_from_hex("00", &mut test); @@ -1706,7 +1706,7 @@ fn splice_seed() -> Vec { // our network key ext_from_hex("0100000000000000000000000000000000000000000000000000000000000000", &mut test); // config - ext_from_hex("000000000090000000000000000064000100000000000100ffff0000000000000000ffffffffffffffffffffffffffffffff0000000000000000ffffffffffffffff000000ffffffff00ffff1a000400010000020400000000040200000a08ffffffffffffffff0001000000000000", &mut test); + ext_from_hex("000000000090000000000000000064000100000000000100ffff00000000000000ffffffffffffffffff0000000000000000ffffffffffffffff000000ffffffff00ffff1a000400010000020400000000040200000a08ffffffffffffffff0001000000000000", &mut test); // new outbound connection with id 0 ext_from_hex("00", &mut test); diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 3236ebdefed..7e6ee7f2c35 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -3563,13 +3563,6 @@ impl ChannelContext { return Err(ChannelError::close(format!("Configured with an unreasonable our_to_self_delay ({}) putting user funds at risks. It must be greater than {}", config.channel_handshake_config.our_to_self_delay, BREAKDOWN_TIMEOUT))); } - // Check sanity of message fields: - if channel_value_satoshis > config.channel_handshake_limits.max_funding_satoshis { - return Err(ChannelError::close(format!( - "Per our config, funding must be at most {}. It was {}. Peer contribution: {}. Our contribution: {}", - config.channel_handshake_limits.max_funding_satoshis, channel_value_satoshis, - open_channel_fields.funding_satoshis, our_funding_satoshis))); - } if channel_value_satoshis >= TOTAL_BITCOIN_SUPPLY_SATOSHIS { return Err(ChannelError::close(format!("Funding must be smaller than the total bitcoin supply. It was {}", channel_value_satoshis))); } @@ -16046,10 +16039,7 @@ mod tests { AwaitingChannelReadyFlags, ChannelState, FundedChannel, HTLCCandidate, HTLCInitiator, HTLCUpdateAwaitingACK, InboundHTLCOutput, InboundHTLCState, InboundUpdateAdd, InboundV1Channel, OutboundHTLCOutput, OutboundHTLCState, OutboundV1Channel, - }; - use crate::ln::channel::{ - MAX_FUNDING_SATOSHIS_NO_WUMBO, MIN_THEIR_CHAN_RESERVE_SATOSHIS, - TOTAL_BITCOIN_SUPPLY_SATOSHIS, + MIN_THEIR_CHAN_RESERVE_SATOSHIS, }; use crate::ln::channel_keys::{RevocationBasepoint, RevocationKey}; use crate::ln::channelmanager::{self, HTLCSource, PaymentId}; @@ -16106,15 +16096,6 @@ mod tests { assert!(ChannelState::ChannelReady(ChannelReadyFlags::new()) < ChannelState::ShutdownComplete); } - #[test] - fn test_max_funding_satoshis_no_wumbo() { - assert_eq!(TOTAL_BITCOIN_SUPPLY_SATOSHIS, 21_000_000 * 100_000_000); - assert!( - MAX_FUNDING_SATOSHIS_NO_WUMBO <= TOTAL_BITCOIN_SUPPLY_SATOSHIS, - "MAX_FUNDING_SATOSHIS_NO_WUMBO is greater than all satoshis in existence" - ); - } - #[cfg(ldk_test_vectors)] struct Keys { signer: crate::sign::InMemorySigner, diff --git a/lightning/src/ln/channel_open_tests.rs b/lightning/src/ln/channel_open_tests.rs index 059639330f8..08cabc053c5 100644 --- a/lightning/src/ln/channel_open_tests.rs +++ b/lightning/src/ln/channel_open_tests.rs @@ -457,8 +457,7 @@ fn test_channel_resumption_fail_post_funding() { pub fn test_insane_channel_opens() { // Stand up a network of 2 nodes use crate::ln::channel::TOTAL_BITCOIN_SUPPLY_SATOSHIS; - let mut legacy_cfg = test_legacy_channel_config(); - legacy_cfg.channel_handshake_limits.max_funding_satoshis = TOTAL_BITCOIN_SUPPLY_SATOSHIS + 1; + let legacy_cfg = test_legacy_channel_config(); let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, Some(legacy_cfg.clone())]); @@ -524,19 +523,6 @@ pub fn test_insane_channel_opens() { use crate::ln::channelmanager::MAX_LOCAL_BREAKDOWN_TIMEOUT; - // Test all mutations that would make the channel open message insane - insane_open_helper( - format!( - "Per our config, funding must be at most {}. It was {}", - TOTAL_BITCOIN_SUPPLY_SATOSHIS + 1, - TOTAL_BITCOIN_SUPPLY_SATOSHIS + 2 - ) - .as_str(), - |mut msg| { - msg.common_fields.funding_satoshis = TOTAL_BITCOIN_SUPPLY_SATOSHIS + 2; - msg - }, - ); insane_open_helper( format!( "Funding must be smaller than the total bitcoin supply. It was {}", diff --git a/lightning/src/util/config.rs b/lightning/src/util/config.rs index 420fad6b1e0..dd55d5c2130 100644 --- a/lightning/src/util/config.rs +++ b/lightning/src/util/config.rs @@ -10,7 +10,6 @@ //! Various user-configurable channel limits and settings which ChannelManager //! applies for you. -use crate::ln::channel::MAX_FUNDING_SATOSHIS_NO_WUMBO; use crate::ln::channelmanager::{BREAKDOWN_TIMEOUT, MAX_LOCAL_BREAKDOWN_TIMEOUT}; #[cfg(fuzzing)] @@ -300,11 +299,6 @@ pub struct ChannelHandshakeLimits { /// Default value: `1000` /// (Minimum of [`ChannelHandshakeConfig::their_channel_reserve_proportional_millionths`]) pub min_funding_satoshis: u64, - /// Maximum allowed satoshis when a channel is funded. This is supplied by the sender and so - /// only applies to inbound channels. - /// - /// Default value: `2^24 - 1` - pub max_funding_satoshis: u64, /// The remote node sets a limit on the minimum size of HTLCs we can send to them. This allows /// you to limit the maximum minimum-size they can require. /// @@ -374,7 +368,6 @@ impl Default for ChannelHandshakeLimits { fn default() -> Self { ChannelHandshakeLimits { min_funding_satoshis: 1000, - max_funding_satoshis: MAX_FUNDING_SATOSHIS_NO_WUMBO, max_htlc_minimum_msat: u64::MAX, min_max_htlc_value_in_flight_msat: 0, max_channel_reserve_satoshis: u64::MAX, @@ -395,7 +388,6 @@ impl Readable for ChannelHandshakeLimits { fn read(reader: &mut R) -> Result { Ok(Self { min_funding_satoshis: Readable::read(reader)?, - max_funding_satoshis: Readable::read(reader)?, max_htlc_minimum_msat: Readable::read(reader)?, min_max_htlc_value_in_flight_msat: Readable::read(reader)?, max_channel_reserve_satoshis: Readable::read(reader)?, From 8022d1e7ac192eddd48e100e740df5408f909549 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 12 Feb 2026 16:39:41 +0000 Subject: [PATCH 021/627] Select the channel with the highest balance for blinded paths When building a compact blinded path we need to pick a channel for an SCID that we think is mostl likely to stick around the longest. We don't really have any great options cause we have no idea what downstream code does, but "highest balance" seems marginally better than the previous criteria of "oldest channel". --- lightning/src/ln/channelmanager.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 64cbc92a22b..3bc105dac47 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -14085,7 +14085,9 @@ impl< short_channel_id: peer_chans .iter() .filter(|chan| chan.is_usable) - .min_by_key(|chan| chan.short_channel_id) + // Select the channel which has the highest local balance. We assume this + // channel is the most likely to stick around. + .max_by_key(|chan| chan.inbound_capacity_msat) .and_then(|chan| chan.get_inbound_payment_scid()), }) } @@ -14108,7 +14110,9 @@ impl< .iter() .filter(|(_, channel)| channel.context().is_usable()) .filter_map(|(_, channel)| channel.as_funded()) - .min_by_key(|funded_channel| funded_channel.context.channel_creation_height) + // Select the channel which has the highest local balance. We assume this + // channel is the most likely to stick around. + .max_by_key(|funded_channel| funded_channel.funding.get_value_to_self_msat()) .and_then(|funded_channel| funded_channel.get_inbound_scid()), }) .collect::>() From 3246010beba323077c90f7e703258f4d5bfc0b44 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Thu, 12 Feb 2026 15:15:51 -0500 Subject: [PATCH 022/627] Trivial: ChannelManager::read var rename prefactor Makes an upcoming commit cleaner: when we add a next_hop variable we want to distinguish it from the previous hop. --- lightning/src/ln/channelmanager.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 68eeb7c4e15..cc95424dbbd 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -18637,13 +18637,13 @@ impl< .or_insert_with(Vec::new) .push(update_add_htlc); } - for (payment_hash, hop_data, outbound_amt_msat) in + for (payment_hash, prev_hop, outbound_amt_msat) in funded_chan.inbound_forwarded_htlcs() { already_forwarded_htlcs - .entry((hop_data.channel_id, payment_hash)) + .entry((prev_hop.channel_id, payment_hash)) .or_insert_with(Vec::new) - .push((hop_data, outbound_amt_msat)); + .push((prev_hop, outbound_amt_msat)); } } } @@ -19352,14 +19352,14 @@ impl< if let Some(forwarded_htlcs) = already_forwarded_htlcs.remove(&(*channel_id, payment_hash)) { - for (hop_data, outbound_amt_msat) in forwarded_htlcs { + for (prev_hop, outbound_amt_msat) in forwarded_htlcs { let new_pending_claim = !pending_claims_to_replay.iter().any(|(src, _, _, _, _, _, _)| { - matches!(src, HTLCSource::PreviousHopData(hop) if hop.htlc_id == hop_data.htlc_id && hop.channel_id == hop_data.channel_id) + matches!(src, HTLCSource::PreviousHopData(hop) if hop.htlc_id == prev_hop.htlc_id && hop.channel_id == prev_hop.channel_id) }); if new_pending_claim { let counterparty_node_id = monitor.get_counterparty_node_id(); - let is_channel_closed = channel_manager + let is_downstream_closed = channel_manager .per_peer_state .read() .unwrap() @@ -19372,10 +19372,10 @@ impl< .contains_key(channel_id) }); pending_claims_to_replay.push(( - HTLCSource::PreviousHopData(hop_data), + HTLCSource::PreviousHopData(prev_hop), payment_preimage, outbound_amt_msat, - is_channel_closed, + is_downstream_closed, counterparty_node_id, monitor.get_funding_txo(), *channel_id, From 70ae54fb21b1440c6532aa95fb1d60ede9df96da Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Thu, 12 Feb 2026 15:20:31 -0500 Subject: [PATCH 023/627] Trivial: user_channel_id in pending_claims_to_replay Adds support for passing user_channel_id into the pending_claims_to_replay vec, which is used by the ChannelManager on startup. For now user_channel_id is always set to None, but in upcoming commits we will set it to Some when the downstream channel is still open (this is currently a bug). Separated out here for reviewability. --- lightning/src/ln/channelmanager.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index cc95424dbbd..ac2af352e34 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -18988,7 +18988,7 @@ impl< Some((htlc_source, payment_preimage, htlc.amount_msat, is_channel_closed, monitor.get_counterparty_node_id(), - monitor.get_funding_txo(), monitor.channel_id())) + monitor.get_funding_txo(), monitor.channel_id(), None)) } else { None } } else { // If it was an outbound payment, we've handled it above - if a preimage @@ -19354,7 +19354,7 @@ impl< { for (prev_hop, outbound_amt_msat) in forwarded_htlcs { let new_pending_claim = - !pending_claims_to_replay.iter().any(|(src, _, _, _, _, _, _)| { + !pending_claims_to_replay.iter().any(|(src, _, _, _, _, _, _, _)| { matches!(src, HTLCSource::PreviousHopData(hop) if hop.htlc_id == prev_hop.htlc_id && hop.channel_id == prev_hop.channel_id) }); if new_pending_claim { @@ -19379,6 +19379,7 @@ impl< counterparty_node_id, monitor.get_funding_txo(), *channel_id, + None, )); } } @@ -19648,6 +19649,7 @@ impl< downstream_node_id, downstream_funding, downstream_channel_id, + downstream_user_channel_id, ) in pending_claims_to_replay { // We use `downstream_closed` in place of `from_onchain` here just as a guess - we @@ -19663,7 +19665,7 @@ impl< downstream_node_id, downstream_funding, downstream_channel_id, - None, + downstream_user_channel_id, None, None, ); From b3b59e6dfa51b7e2f0fe62575c48693ffeb12332 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Tue, 10 Feb 2026 15:18:58 -0500 Subject: [PATCH 024/627] Persist outbound channel info in inbound HTLCs We need these fields to generate a correct PaymentForwarded event if we need to claim this inbound HTLC backwards after restart and it's already been claimed and removed on the outbound edge. --- lightning/src/ln/channel.rs | 53 +++++++++++++++++++++--------- lightning/src/ln/channelmanager.rs | 42 ++++++++++++++++++----- 2 files changed, 72 insertions(+), 23 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index b12061bf118..37a0661de76 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -308,6 +308,32 @@ impl InboundHTLCState { } } +/// Information about the outbound hop for a forwarded HTLC. Useful for generating an accurate +/// [`Event::PaymentForwarded`] if we need to claim this HTLC post-restart. +/// +/// [`Event::PaymentForwarded`]: crate::events::Event::PaymentForwarded +#[derive(Debug, Copy, Clone)] +pub(super) struct OutboundHop { + /// The amount forwarded outbound. + pub(super) amt_msat: u64, + /// The outbound channel this HTLC was forwarded over. + pub(super) channel_id: ChannelId, + /// The next-hop recipient of this HTLC. + pub(super) node_id: PublicKey, + /// The outbound channel's funding outpoint. + pub(super) funding_txo: OutPoint, + /// The outbound channel's user channel ID. + pub(super) user_channel_id: u128, +} + +impl_writeable_tlv_based!(OutboundHop, { + (0, amt_msat, required), + (2, channel_id, required), + (4, node_id, required), + (6, funding_txo, required), + (8, user_channel_id, required), +}); + /// A field of `InboundHTLCState::Committed` containing the HTLC's `update_add_htlc` message. If /// the HTLC is a forward and gets irrevocably committed to the outbound edge, we convert to /// `InboundUpdateAdd::Forwarded`, thus pruning the onion and not persisting it on every @@ -328,11 +354,7 @@ enum InboundUpdateAdd { phantom_shared_secret: Option<[u8; 32]>, trampoline_shared_secret: Option<[u8; 32]>, blinded_failure: Option, - /// Useful for generating an accurate [`Event::PaymentForwarded`], if we need to claim this - /// HTLC post-restart. - /// - /// [`Event::PaymentForwarded`]: crate::events::Event::PaymentForwarded - outbound_amt_msat: u64, + outbound_hop: OutboundHop, }, /// This HTLC was received pre-LDK 0.3, before we started persisting the onion for inbound /// committed HTLCs. @@ -346,7 +368,7 @@ impl_writeable_tlv_based_enum_upgradable!(InboundUpdateAdd, (2, Legacy) => {}, (4, Forwarded) => { (0, incoming_packet_shared_secret, required), - (2, outbound_amt_msat, required), + (2, outbound_hop, required), (4, phantom_shared_secret, option), (6, trampoline_shared_secret, option), (8, blinded_failure, option), @@ -7948,7 +7970,7 @@ where phantom_shared_secret, trampoline_shared_secret, blinded_failure, - outbound_amt_msat, + outbound_hop: OutboundHop { amt_msat, .. }, }, } => { if htlc_resolution_in_holding_cell(htlc.htlc_id) { @@ -7956,7 +7978,7 @@ where } // The reconstructed `HTLCPreviousHopData` is used to fail or claim the HTLC backwards // post-restart, if it is missing in the outbound edge. - let hop_data = HTLCPreviousHopData { + let prev_hop_data = HTLCPreviousHopData { prev_outbound_scid_alias, user_channel_id: Some(user_channel_id), htlc_id: htlc.htlc_id, @@ -7969,7 +7991,7 @@ where counterparty_node_id: Some(counterparty_node_id), cltv_expiry: Some(htlc.cltv_expiry), }; - Some((htlc.payment_hash, hop_data, *outbound_amt_msat)) + Some((htlc.payment_hash, prev_hop_data, *amt_msat)) }, _ => None, }) @@ -8019,17 +8041,18 @@ where /// This inbound HTLC was irrevocably forwarded to the outbound edge, so we no longer need to /// persist its onion. pub(super) fn prune_inbound_htlc_onion( - &mut self, htlc_id: u64, hop_data: &HTLCPreviousHopData, outbound_amt_msat: u64, + &mut self, htlc_id: u64, prev_hop_data: &HTLCPreviousHopData, + outbound_hop_data: OutboundHop, ) { for htlc in self.context.pending_inbound_htlcs.iter_mut() { if htlc.htlc_id == htlc_id { if let InboundHTLCState::Committed { ref mut update_add_htlc } = htlc.state { *update_add_htlc = InboundUpdateAdd::Forwarded { - incoming_packet_shared_secret: hop_data.incoming_packet_shared_secret, - phantom_shared_secret: hop_data.phantom_shared_secret, - trampoline_shared_secret: hop_data.trampoline_shared_secret, - blinded_failure: hop_data.blinded_failure, - outbound_amt_msat, + incoming_packet_shared_secret: prev_hop_data.incoming_packet_shared_secret, + phantom_shared_secret: prev_hop_data.phantom_shared_secret, + trampoline_shared_secret: prev_hop_data.trampoline_shared_secret, + blinded_failure: prev_hop_data.blinded_failure, + outbound_hop: outbound_hop_data, }; return; } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index ac2af352e34..b7b39698bb4 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -59,9 +59,9 @@ use crate::ln::chan_utils::selected_commitment_sat_per_1000_weight; use crate::ln::channel::QuiescentAction; use crate::ln::channel::{ self, hold_time_since, Channel, ChannelError, ChannelUpdateStatus, DisconnectResult, - FundedChannel, FundingTxSigned, InboundV1Channel, OutboundV1Channel, PendingV2Channel, - ReconnectionMsg, ShutdownResult, SpliceFundingFailed, StfuResponse, UpdateFulfillCommitFetch, - WithChannelContext, + FundedChannel, FundingTxSigned, InboundV1Channel, OutboundHop, OutboundV1Channel, + PendingV2Channel, ReconnectionMsg, ShutdownResult, SpliceFundingFailed, StfuResponse, + UpdateFulfillCommitFetch, WithChannelContext, }; use crate::ln::channel_state::ChannelDetails; use crate::ln::funding::SpliceContribution; @@ -1402,6 +1402,8 @@ enum PostMonitorUpdateChanResume { Unblocked { channel_id: ChannelId, counterparty_node_id: PublicKey, + funding_txo: OutPoint, + user_channel_id: u128, unbroadcasted_batch_funding_txid: Option, update_actions: Vec, htlc_forwards: Option, @@ -9582,8 +9584,8 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ /// Handles actions which need to complete after a [`ChannelMonitorUpdate`] has been applied /// which can happen after the per-peer state lock has been dropped. fn post_monitor_update_unlock( - &self, channel_id: ChannelId, counterparty_node_id: PublicKey, - unbroadcasted_batch_funding_txid: Option, + &self, channel_id: ChannelId, counterparty_node_id: PublicKey, funding_txo: OutPoint, + user_channel_id: u128, unbroadcasted_batch_funding_txid: Option, update_actions: Vec, htlc_forwards: Option, decode_update_add_htlcs: Option<(u64, Vec)>, @@ -9660,7 +9662,13 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }; self.fail_htlc_backwards_internal(&failure.0, &failure.1, &failure.2, receiver, None); } - self.prune_persisted_inbound_htlc_onions(committed_outbound_htlc_sources); + self.prune_persisted_inbound_htlc_onions( + channel_id, + counterparty_node_id, + funding_txo, + user_channel_id, + committed_outbound_htlc_sources, + ); } fn handle_monitor_update_completion_actions< @@ -10129,6 +10137,8 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ PostMonitorUpdateChanResume::Unblocked { channel_id: chan_id, counterparty_node_id, + funding_txo: chan.funding_outpoint(), + user_channel_id: chan.context.get_user_id(), unbroadcasted_batch_funding_txid, update_actions, htlc_forwards, @@ -10144,7 +10154,9 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ /// HTLC set on `ChannelManager` read. If an HTLC has been irrevocably forwarded to the outbound /// edge, we no longer need to persist the inbound edge's onion and can prune it here. fn prune_persisted_inbound_htlc_onions( - &self, committed_outbound_htlc_sources: Vec<(HTLCPreviousHopData, u64)>, + &self, outbound_channel_id: ChannelId, outbound_node_id: PublicKey, + outbound_funding_txo: OutPoint, outbound_user_channel_id: u128, + committed_outbound_htlc_sources: Vec<(HTLCPreviousHopData, u64)>, ) { let per_peer_state = self.per_peer_state.read().unwrap(); for (source, outbound_amt_msat) in committed_outbound_htlc_sources { @@ -10161,7 +10173,17 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ if let Some(chan) = peer_state.channel_by_id.get_mut(&source.channel_id).and_then(|c| c.as_funded_mut()) { - chan.prune_inbound_htlc_onion(source.htlc_id, &source, outbound_amt_msat); + chan.prune_inbound_htlc_onion( + source.htlc_id, + &source, + OutboundHop { + amt_msat: outbound_amt_msat, + channel_id: outbound_channel_id, + node_id: outbound_node_id, + funding_txo: outbound_funding_txo, + user_channel_id: outbound_user_channel_id, + }, + ); } } } @@ -10217,6 +10239,8 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ PostMonitorUpdateChanResume::Unblocked { channel_id, counterparty_node_id, + funding_txo, + user_channel_id, unbroadcasted_batch_funding_txid, update_actions, htlc_forwards, @@ -10228,6 +10252,8 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ self.post_monitor_update_unlock( channel_id, counterparty_node_id, + funding_txo, + user_channel_id, unbroadcasted_batch_funding_txid, update_actions, htlc_forwards, From 48010cbadf6723b679c90e840de0de36d8415265 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Tue, 10 Feb 2026 16:33:14 -0500 Subject: [PATCH 025/627] Fix PaymentForwarded fields on restart claim Previously, we were spuriously using the upstream channel's info when we should've been using the downstream channel's. --- lightning/src/ln/channel.rs | 6 +++--- lightning/src/ln/channelmanager.rs | 25 ++++++++++++------------- lightning/src/ln/reload_tests.rs | 8 +------- 3 files changed, 16 insertions(+), 23 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 37a0661de76..4a0d1175b8e 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -7942,7 +7942,7 @@ where /// when reconstructing the set of pending HTLCs when deserializing the `ChannelManager`. pub(super) fn inbound_forwarded_htlcs( &self, - ) -> impl Iterator + '_ { + ) -> impl Iterator + '_ { // We don't want to return an HTLC as needing processing if it already has a resolution that's // pending in the holding cell. let htlc_resolution_in_holding_cell = |id: u64| -> bool { @@ -7970,7 +7970,7 @@ where phantom_shared_secret, trampoline_shared_secret, blinded_failure, - outbound_hop: OutboundHop { amt_msat, .. }, + outbound_hop, }, } => { if htlc_resolution_in_holding_cell(htlc.htlc_id) { @@ -7991,7 +7991,7 @@ where counterparty_node_id: Some(counterparty_node_id), cltv_expiry: Some(htlc.cltv_expiry), }; - Some((htlc.payment_hash, prev_hop_data, *amt_msat)) + Some((htlc.payment_hash, prev_hop_data, *outbound_hop)) }, _ => None, }) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index b7b39698bb4..897f10cf2f4 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -18610,11 +18610,11 @@ impl< // that it is handled. let mut already_forwarded_htlcs: HashMap< (ChannelId, PaymentHash), - Vec<(HTLCPreviousHopData, u64)>, + Vec<(HTLCPreviousHopData, OutboundHop)>, > = new_hash_map(); let prune_forwarded_htlc = |already_forwarded_htlcs: &mut HashMap< (ChannelId, PaymentHash), - Vec<(HTLCPreviousHopData, u64)>, + Vec<(HTLCPreviousHopData, OutboundHop)>, >, prev_hop: &HTLCPreviousHopData, payment_hash: &PaymentHash| { @@ -18663,13 +18663,13 @@ impl< .or_insert_with(Vec::new) .push(update_add_htlc); } - for (payment_hash, prev_hop, outbound_amt_msat) in + for (payment_hash, prev_hop, next_hop) in funded_chan.inbound_forwarded_htlcs() { already_forwarded_htlcs .entry((prev_hop.channel_id, payment_hash)) .or_insert_with(Vec::new) - .push((prev_hop, outbound_amt_msat)); + .push((prev_hop, next_hop)); } } } @@ -19378,34 +19378,33 @@ impl< if let Some(forwarded_htlcs) = already_forwarded_htlcs.remove(&(*channel_id, payment_hash)) { - for (prev_hop, outbound_amt_msat) in forwarded_htlcs { + for (prev_hop, next_hop) in forwarded_htlcs { let new_pending_claim = !pending_claims_to_replay.iter().any(|(src, _, _, _, _, _, _, _)| { matches!(src, HTLCSource::PreviousHopData(hop) if hop.htlc_id == prev_hop.htlc_id && hop.channel_id == prev_hop.channel_id) }); if new_pending_claim { - let counterparty_node_id = monitor.get_counterparty_node_id(); let is_downstream_closed = channel_manager .per_peer_state .read() .unwrap() - .get(&counterparty_node_id) + .get(&next_hop.node_id) .map_or(true, |peer_state_mtx| { !peer_state_mtx .lock() .unwrap() .channel_by_id - .contains_key(channel_id) + .contains_key(&next_hop.channel_id) }); pending_claims_to_replay.push(( HTLCSource::PreviousHopData(prev_hop), payment_preimage, - outbound_amt_msat, + next_hop.amt_msat, is_downstream_closed, - counterparty_node_id, - monitor.get_funding_txo(), - *channel_id, - None, + next_hop.node_id, + next_hop.funding_txo, + next_hop.channel_id, + Some(next_hop.user_channel_id), )); } } diff --git a/lightning/src/ln/reload_tests.rs b/lightning/src/ln/reload_tests.rs index c7e7175602d..42986bc41b1 100644 --- a/lightning/src/ln/reload_tests.rs +++ b/lightning/src/ln/reload_tests.rs @@ -1958,14 +1958,8 @@ fn test_reload_node_with_preimage_in_monitor_claims_htlc() { ); // When the claim is reconstructed during reload, a PaymentForwarded event is generated. - // This event has next_user_channel_id as None since the outbound HTLC was already removed. // Fetching events triggers the pending monitor update (adding preimage) to be applied. - let events = nodes[1].node.get_and_clear_pending_events(); - assert_eq!(events.len(), 1); - match &events[0] { - Event::PaymentForwarded { total_fee_earned_msat: Some(1000), .. } => {}, - _ => panic!("Expected PaymentForwarded event"), - } + expect_payment_forwarded!(nodes[1], nodes[0], nodes[2], Some(1000), false, false); check_added_monitors(&nodes[1], 1); // Reconnect nodes[1] to nodes[0]. The claim should be in nodes[1]'s holding cell. From 0d6dcc910558c558e763c02c17d609dc0410d835 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Tue, 10 Feb 2026 16:51:46 -0500 Subject: [PATCH 026/627] Fix missing user_channel_id in PaymentForwarded Previously, if a forwarding node reloaded mid-HTLC-forward with a preimage in the outbound edge monitor and the outbound edge channel still open, and subsequently reclaimed the inbound HTLC backwards, the PaymentForwarded event would be missing the next_user_channel_id field. --- lightning/src/ln/chanmon_update_fail_tests.rs | 7 ++++++- lightning/src/ln/channelmanager.rs | 12 +++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/lightning/src/ln/chanmon_update_fail_tests.rs b/lightning/src/ln/chanmon_update_fail_tests.rs index 5a0c37bd61d..e5f6b7259ff 100644 --- a/lightning/src/ln/chanmon_update_fail_tests.rs +++ b/lightning/src/ln/chanmon_update_fail_tests.rs @@ -3938,7 +3938,12 @@ fn do_test_durable_preimages_on_closed_channel( let evs = nodes[1].node.get_and_clear_pending_events(); assert_eq!(evs.len(), if close_chans_before_reload { 2 } else { 1 }); for ev in evs { - if let Event::PaymentForwarded { .. } = ev { + if let Event::PaymentForwarded { claim_from_onchain_tx, next_user_channel_id, .. } = ev { + if !claim_from_onchain_tx { + // If the outbound channel is still open, the `next_user_channel_id` should be available. + // This was previously broken. + assert!(next_user_channel_id.is_some()) + } } else { panic!(); } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 897f10cf2f4..40342d72700 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -18707,14 +18707,16 @@ impl< } } for (channel_id, monitor) in args.channel_monitors.iter() { - let mut is_channel_closed = true; + let (mut is_channel_closed, mut user_channel_id_opt) = (true, None); let counterparty_node_id = monitor.get_counterparty_node_id(); if let Some(peer_state_mtx) = per_peer_state.get(&counterparty_node_id) { let mut peer_state_lock = peer_state_mtx.lock().unwrap(); let peer_state = &mut *peer_state_lock; - is_channel_closed = !peer_state.channel_by_id.contains_key(channel_id); - if reconstruct_manager_from_monitors && !is_channel_closed { - if let Some(chan) = peer_state.channel_by_id.get(channel_id) { + if let Some(chan) = peer_state.channel_by_id.get(channel_id) { + is_channel_closed = false; + user_channel_id_opt = Some(chan.context().get_user_id()); + + if reconstruct_manager_from_monitors { if let Some(funded_chan) = chan.as_funded() { for (payment_hash, prev_hop) in funded_chan.outbound_htlc_forwards() { @@ -19014,7 +19016,7 @@ impl< Some((htlc_source, payment_preimage, htlc.amount_msat, is_channel_closed, monitor.get_counterparty_node_id(), - monitor.get_funding_txo(), monitor.channel_id(), None)) + monitor.get_funding_txo(), monitor.channel_id(), user_channel_id_opt)) } else { None } } else { // If it was an outbound payment, we've handled it above - if a preimage From 5daf51c00764572f1cecd38c6f9dd16f69f172db Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Wed, 11 Feb 2026 16:14:19 -0500 Subject: [PATCH 027/627] Test restart-claim of two MPP holding cell HTLCs Test that if we restart and had two inbound MPP-part HTLCs received over the same channel in the holding cell prior to shutdown, and we lost the holding cell prior to restart, those HTLCs will still be claimed backwards. Test largely written by Claude --- lightning/src/ln/chanmon_update_fail_tests.rs | 3 +- lightning/src/ln/functional_test_utils.rs | 22 ++- lightning/src/ln/reload_tests.rs | 145 ++++++++++++++++++ 3 files changed, 161 insertions(+), 9 deletions(-) diff --git a/lightning/src/ln/chanmon_update_fail_tests.rs b/lightning/src/ln/chanmon_update_fail_tests.rs index e5f6b7259ff..b421114e911 100644 --- a/lightning/src/ln/chanmon_update_fail_tests.rs +++ b/lightning/src/ln/chanmon_update_fail_tests.rs @@ -3519,8 +3519,9 @@ fn do_test_blocked_chan_preimage_release(completion_mode: BlockedUpdateComplMode .node .handle_commitment_signed_batch_test(node_a_id, &as_htlc_fulfill.commitment_signed); check_added_monitors(&nodes[1], 1); - let (a, raa) = do_main_commitment_signed_dance(&nodes[1], &nodes[0], false); + let (a, raa, holding_cell) = do_main_commitment_signed_dance(&nodes[1], &nodes[0], false); assert!(a.is_none()); + assert!(holding_cell.is_empty()); nodes[1].node.handle_revoke_and_ack(node_a_id, &raa); check_added_monitors(&nodes[1], 1); diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index d5a29785a94..d3902b26201 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -2672,20 +2672,23 @@ pub fn commitment_signed_dance_through_cp_raa( node_a: &Node<'_, '_, '_>, node_b: &Node<'_, '_, '_>, fail_backwards: bool, includes_claim: bool, ) -> Option { - let (extra_msg_option, bs_revoke_and_ack) = + let (extra_msg_option, bs_revoke_and_ack, node_b_holding_cell_htlcs) = do_main_commitment_signed_dance(node_a, node_b, fail_backwards); + assert!(node_b_holding_cell_htlcs.is_empty()); node_a.node.handle_revoke_and_ack(node_b.node.get_our_node_id(), &bs_revoke_and_ack); check_added_monitors(node_a, if includes_claim { 0 } else { 1 }); extra_msg_option } /// Does the main logic in the commitment_signed dance. After the first `commitment_signed` has -/// been delivered, this method picks up and delivers the response `revoke_and_ack` and -/// `commitment_signed`, returning the recipient's `revoke_and_ack` and any extra message it may -/// have included. +/// been delivered, delivers the response `revoke_and_ack` and `commitment_signed`, and returns: +/// - The recipient's `revoke_and_ack` +/// - The recipient's extra message (if any) after handling the commitment_signed +/// - Any messages released from the initiator's holding cell after handling the `revoke_and_ack` +/// (e.g., a second HTLC on the same channel) pub fn do_main_commitment_signed_dance( node_a: &Node<'_, '_, '_>, node_b: &Node<'_, '_, '_>, fail_backwards: bool, -) -> (Option, msgs::RevokeAndACK) { +) -> (Option, msgs::RevokeAndACK, Vec) { let node_a_id = node_a.node.get_our_node_id(); let node_b_id = node_b.node.get_our_node_id(); @@ -2693,7 +2696,9 @@ pub fn do_main_commitment_signed_dance( check_added_monitors(&node_b, 0); assert!(node_b.node.get_and_clear_pending_msg_events().is_empty()); node_b.node.handle_revoke_and_ack(node_a_id, &as_revoke_and_ack); - assert!(node_b.node.get_and_clear_pending_msg_events().is_empty()); + // Handling the RAA may release HTLCs from node_b's holding cell (e.g., if multiple HTLCs + // were sent over the same channel and the second was queued behind the first). + let node_b_holding_cell_htlcs = node_b.node.get_and_clear_pending_msg_events(); check_added_monitors(&node_b, 1); node_b.node.handle_commitment_signed_batch_test(node_a_id, &as_commitment_signed); let (bs_revoke_and_ack, extra_msg_option) = { @@ -2716,7 +2721,7 @@ pub fn do_main_commitment_signed_dance( assert!(node_a.node.get_and_clear_pending_events().is_empty()); assert!(node_a.node.get_and_clear_pending_msg_events().is_empty()); } - (extra_msg_option, bs_revoke_and_ack) + (extra_msg_option, bs_revoke_and_ack, node_b_holding_cell_htlcs) } /// Runs the commitment_signed dance by delivering the commitment_signed and handling the @@ -2733,9 +2738,10 @@ pub fn commitment_signed_dance_return_raa( .node .handle_commitment_signed_batch_test(node_b.node.get_our_node_id(), commitment_signed); check_added_monitors(&node_a, 1); - let (extra_msg_option, bs_revoke_and_ack) = + let (extra_msg_option, bs_revoke_and_ack, node_b_holding_cell_htlcs) = do_main_commitment_signed_dance(&node_a, &node_b, fail_backwards); assert!(extra_msg_option.is_none()); + assert!(node_b_holding_cell_htlcs.is_empty()); bs_revoke_and_ack } diff --git a/lightning/src/ln/reload_tests.rs b/lightning/src/ln/reload_tests.rs index 42986bc41b1..d1e34cb7c71 100644 --- a/lightning/src/ln/reload_tests.rs +++ b/lightning/src/ln/reload_tests.rs @@ -2082,3 +2082,148 @@ fn test_reload_node_without_preimage_fails_htlc() { // nodes[0] should now have received the failure and generate PaymentFailed. expect_payment_failed_conditions(&nodes[0], payment_hash, false, PaymentFailedConditions::new()); } + +#[test] +fn test_reload_with_mpp_claims_on_same_channel() { + // Test that if a forwarding node has two HTLCs for the same MPP payment that were both + // irrevocably removed on the outbound edge via claim but are still forwarded-and-unresolved + // on the inbound edge, both HTLCs will be claimed backwards on restart. + // + // Topology: + // nodes[0] ----chan_0_1----> nodes[1] ----chan_1_2_a----> nodes[2] + // \----chan_1_2_b---/ + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let persister; + let new_chain_monitor; + let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]); + let nodes_1_deserialized; + let mut nodes = create_network(3, &node_cfgs, &node_chanmgrs); + + let node_0_id = nodes[0].node.get_our_node_id(); + let node_1_id = nodes[1].node.get_our_node_id(); + let node_2_id = nodes[2].node.get_our_node_id(); + + let chan_0_1 = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 2_000_000, 0); + let chan_1_2_a = create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0); + let chan_1_2_b = create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0); + + let chan_id_0_1 = chan_0_1.2; + let chan_id_1_2_a = chan_1_2_a.2; + let chan_id_1_2_b = chan_1_2_b.2; + + // Send an MPP payment large enough that the router must split it across both outbound channels. + // Each 1M sat outbound channel has 100M msat max in-flight, so 150M msat requires splitting. + let amt_msat = 150_000_000; + let (route, payment_hash, payment_preimage, payment_secret) = + get_route_and_payment_hash!(nodes[0], nodes[2], amt_msat); + + let payment_id = PaymentId(nodes[0].keys_manager.backing.get_secure_random_bytes()); + nodes[0].node.send_payment_with_route( + route, payment_hash, RecipientOnionFields::secret_only(payment_secret), payment_id, + ).unwrap(); + check_added_monitors(&nodes[0], 1); + + // Forward the first HTLC nodes[0] -> nodes[1] -> nodes[2]. Note that the second HTLC is released + // from the holding cell during the first HTLC's commitment_signed_dance. + let mut events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + let payment_event_1 = SendEvent::from_event(events.remove(0)); + + nodes[1].node.handle_update_add_htlc(node_0_id, &payment_event_1.msgs[0]); + check_added_monitors(&nodes[1], 0); + nodes[1].node.handle_commitment_signed_batch_test(node_0_id, &payment_event_1.commitment_msg); + check_added_monitors(&nodes[1], 1); + let (_, raa, holding_cell_htlcs) = + do_main_commitment_signed_dance(&nodes[1], &nodes[0], false); + assert_eq!(holding_cell_htlcs.len(), 1); + let payment_event_2 = holding_cell_htlcs.into_iter().next().unwrap(); + nodes[1].node.handle_revoke_and_ack(node_0_id, &raa); + check_added_monitors(&nodes[1], 1); + + nodes[1].node.process_pending_htlc_forwards(); + check_added_monitors(&nodes[1], 1); + let mut events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + let ev_1_2 = events.remove(0); + pass_along_path( + &nodes[1], &[&nodes[2]], amt_msat, payment_hash, Some(payment_secret), ev_1_2, false, None, + ); + + // Second HTLC: full path nodes[0] -> nodes[1] -> nodes[2]. PaymentClaimable expected at end. + pass_along_path( + &nodes[0], &[&nodes[1], &nodes[2]], amt_msat, payment_hash, Some(payment_secret), + payment_event_2, true, None, + ); + + // Claim the HTLCs such that they're fully removed from the outbound edge, but disconnect + // node_0<>node_1 so that they can't be claimed backwards by node_1. + nodes[2].node.claim_funds(payment_preimage); + check_added_monitors(&nodes[2], 2); + expect_payment_claimed!(nodes[2], payment_hash, amt_msat); + + nodes[0].node.peer_disconnected(node_1_id); + nodes[1].node.peer_disconnected(node_0_id); + + let mut events = nodes[2].node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 2); + for ev in events { + match ev { + MessageSendEvent::UpdateHTLCs { ref node_id, ref updates, .. } => { + assert_eq!(*node_id, node_1_id); + assert_eq!(updates.update_fulfill_htlcs.len(), 1); + nodes[1].node.handle_update_fulfill_htlc(node_2_id, updates.update_fulfill_htlcs[0].clone()); + check_added_monitors(&nodes[1], 1); + do_commitment_signed_dance(&nodes[1], &nodes[2], &updates.commitment_signed, false, false); + }, + _ => panic!("Unexpected event"), + } + } + + let events = nodes[1].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2); + for event in events { + expect_payment_forwarded( + event, &nodes[1], &nodes[0], &nodes[2], Some(1000), None, false, false, false, + ); + } + + // Clear the holding cell's claim entries on chan_0_1 before serialization. + // This simulates a crash where both HTLCs were fully removed on the outbound edges but are + // still present on the inbound edge without a resolution. + nodes[1].node.test_clear_channel_holding_cell(node_0_id, chan_id_0_1); + + let node_1_serialized = nodes[1].node.encode(); + let mon_0_1_serialized = get_monitor!(nodes[1], chan_id_0_1).encode(); + let mon_1_2_a_serialized = get_monitor!(nodes[1], chan_id_1_2_a).encode(); + let mon_1_2_b_serialized = get_monitor!(nodes[1], chan_id_1_2_b).encode(); + + reload_node!( + nodes[1], + node_1_serialized, + &[&mon_0_1_serialized, &mon_1_2_a_serialized, &mon_1_2_b_serialized], + persister, + new_chain_monitor, + nodes_1_deserialized, + Some(true) + ); + + // When the claims are reconstructed during reload, PaymentForwarded events are regenerated. + let events = nodes[1].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2); + for event in events { + expect_payment_forwarded( + event, &nodes[1], &nodes[0], &nodes[2], Some(1000), None, false, false, false, + ); + } + // Fetching events triggers the pending monitor updates (one for each HTLC preimage) to be applied. + check_added_monitors(&nodes[1], 2); + + // Reconnect nodes[1] to nodes[0]. Both claims should be in nodes[1]'s holding cell. + let mut reconnect_args = ReconnectArgs::new(&nodes[1], &nodes[0]); + reconnect_args.pending_cell_htlc_claims = (0, 2); + reconnect_nodes(reconnect_args); + + // nodes[0] should now have received both fulfills and generate PaymentSent. + expect_payment_sent(&nodes[0], payment_preimage, None, true, true); +} From ab0ba65923158791a9afa196e275c4a9d375a673 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Thu, 12 Feb 2026 15:30:16 -0500 Subject: [PATCH 028/627] Update RECONSTRUCT_HTLCS_FROM_CHANS_VERSION 5 -> 2 We previously had 5 due to wanting some flexibility to bump versions in between, but eventually concluded that wasn't necessary. --- lightning/src/ln/channelmanager.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 40342d72700..5a4f569d879 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -16616,7 +16616,7 @@ const MIN_SERIALIZATION_VERSION: u8 = 1; // // If 0.3 or 0.4 reads this manager version, it knows that the legacy maps were not written and // acts accordingly. -const RECONSTRUCT_HTLCS_FROM_CHANS_VERSION: u8 = 5; +const RECONSTRUCT_HTLCS_FROM_CHANS_VERSION: u8 = 2; impl_writeable_tlv_based!(PhantomRouteHints, { (2, channels, required_vec), From c80afe9e8e07fd7dee7b1b157b9dd7f7104ddde9 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 13 Jan 2026 16:45:52 -0600 Subject: [PATCH 029/627] Move FundingTxInput::sequence to Utxo A forthcoming commit will change CoinSelection to include FundingTxInput instead of Utxo, though the former will probably be renamed. This is so CoinSelectionSource can be used when funding a splice. Further updating WalletSource to use FundingTxInput is not desirable, however, as it would result in looking up each confirmed UTXOs previous transaction even if it is not selected. See Wallet's implementation of CoinSelectionSource, which delegates to WalletSource for listing all confirmed UTXOs. This commit moves FundingTxInput::sequence to Utxo, and thus the responsibility for setting it to WalletSource implementations. Doing so will allow Wallet's CoinSelectionSource implementation to delegate looking up previous transactions to WalletSource without having to explicitly set the sequence on any FundingTxInput. --- lightning/src/events/bump_transaction/mod.rs | 10 +++++++- lightning/src/ln/funding.rs | 25 ++++++++++++------- lightning/src/ln/interactivetxs.rs | 8 ++++-- lightning/src/util/anchor_channel_reserves.rs | 3 ++- 4 files changed, 33 insertions(+), 13 deletions(-) diff --git a/lightning/src/events/bump_transaction/mod.rs b/lightning/src/events/bump_transaction/mod.rs index ff034176385..a79e927e169 100644 --- a/lightning/src/events/bump_transaction/mod.rs +++ b/lightning/src/events/bump_transaction/mod.rs @@ -284,12 +284,15 @@ pub struct Utxo { /// with their lengths included, required to satisfy the output's script. The weight consumed by /// the input's `script_sig` must account for [`WITNESS_SCALE_FACTOR`]. pub satisfaction_weight: u64, + /// The sequence number to use in the [`TxIn`] when spending the UTXO. + pub sequence: Sequence, } impl_writeable_tlv_based!(Utxo, { (1, outpoint, required), (3, output, required), (5, satisfaction_weight, required), + (7, sequence, (default_value, Sequence::ENABLE_RBF_NO_LOCKTIME)), }); impl Utxo { @@ -304,6 +307,7 @@ impl Utxo { outpoint, output: TxOut { value, script_pubkey: ScriptBuf::new_p2pkh(pubkey_hash) }, satisfaction_weight: script_sig_size * WITNESS_SCALE_FACTOR as u64 + 1, /* empty witness */ + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, } } @@ -323,6 +327,7 @@ impl Utxo { }, satisfaction_weight: script_sig_size * WITNESS_SCALE_FACTOR as u64 + P2WPKH_WITNESS_WEIGHT, + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, } } @@ -332,6 +337,7 @@ impl Utxo { outpoint, output: TxOut { value, script_pubkey: ScriptBuf::new_p2wpkh(pubkey_hash) }, satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + P2WPKH_WITNESS_WEIGHT, + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, } } @@ -343,6 +349,7 @@ impl Utxo { outpoint, output: TxOut { value, script_pubkey: ScriptBuf::new_p2tr_tweaked(tweaked_public_key) }, satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + P2TR_KEY_PATH_WITNESS_WEIGHT, + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, } } } @@ -737,7 +744,7 @@ where tx.input.push(TxIn { previous_output: utxo.outpoint, script_sig: ScriptBuf::new(), - sequence: Sequence::ZERO, + sequence: utxo.sequence, witness: Witness::new(), }); } @@ -1392,6 +1399,7 @@ mod tests { script_pubkey: ScriptBuf::new(), }, satisfaction_weight: 5, // Just the script_sig and witness lengths + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, }], change_output: None, }, diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 8092a0e4451..50e0938fc8b 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -108,11 +108,6 @@ pub struct FundingTxInput { /// [`TxOut`]: bitcoin::TxOut pub(super) utxo: Utxo, - /// The sequence number to use in the [`TxIn`]. - /// - /// [`TxIn`]: bitcoin::TxIn - pub(super) sequence: Sequence, - /// The transaction containing the unspent [`TxOut`] referenced by [`utxo`]. /// /// [`TxOut`]: bitcoin::TxOut @@ -122,7 +117,19 @@ pub struct FundingTxInput { impl_writeable_tlv_based!(FundingTxInput, { (1, utxo, required), - (3, sequence, required), + (3, _sequence, (legacy, Sequence, + |read_val: Option<&Sequence>| { + if let Some(sequence) = read_val { + // Utxo contains sequence now, so update it if the value read here differs since + // this indicates Utxo::sequence was read with default_value + let utxo: &mut Utxo = utxo.0.as_mut().expect("utxo is required"); + if utxo.sequence != *sequence { + utxo.sequence = *sequence; + } + } + Ok(()) + }, + |input: &FundingTxInput| Some(input.utxo.sequence))), (5, prevtx, required), }); @@ -140,8 +147,8 @@ impl FundingTxInput { .ok_or(())? .clone(), satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + witness_weight.to_wu(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, }, - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, prevtx, }) } @@ -234,14 +241,14 @@ impl FundingTxInput { /// /// [`TxIn`]: bitcoin::TxIn pub fn sequence(&self) -> Sequence { - self.sequence + self.utxo.sequence } /// Sets the sequence number to use in the [`TxIn`]. /// /// [`TxIn`]: bitcoin::TxIn pub fn set_sequence(&mut self, sequence: Sequence) { - self.sequence = sequence; + self.utxo.sequence = sequence; } /// Converts the [`FundingTxInput`] into a [`Utxo`] for coin selection. diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index a004f6e9f14..3c47658e963 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -2054,9 +2054,13 @@ impl InteractiveTxConstructor { let mut inputs_to_contribute: Vec<(SerialId, InputOwned)> = inputs_to_contribute .into_iter() - .map(|FundingTxInput { utxo, sequence, prevtx: prev_tx }| { + .map(|FundingTxInput { utxo, prevtx: prev_tx }| { let serial_id = generate_holder_serial_id(entropy_source, is_initiator); - let txin = TxIn { previous_output: utxo.outpoint, sequence, ..Default::default() }; + let txin = TxIn { + previous_output: utxo.outpoint, + sequence: utxo.sequence, + ..Default::default() + }; let prev_output = utxo.output; let input = InputOwned::Single(SingleOwnedInput { input: txin, diff --git a/lightning/src/util/anchor_channel_reserves.rs b/lightning/src/util/anchor_channel_reserves.rs index 8026af03d58..25a0e7ca0ba 100644 --- a/lightning/src/util/anchor_channel_reserves.rs +++ b/lightning/src/util/anchor_channel_reserves.rs @@ -315,7 +315,7 @@ where #[cfg(test)] mod test { use super::*; - use bitcoin::{OutPoint, ScriptBuf, TxOut, Txid}; + use bitcoin::{OutPoint, ScriptBuf, Sequence, TxOut, Txid}; use std::str::FromStr; #[test] @@ -343,6 +343,7 @@ mod test { }, output: TxOut { value: amount, script_pubkey: ScriptBuf::new() }, satisfaction_weight: 1 * 4 + (1 + 1 + 72 + 1 + 33), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, } } From 3a61ec2caf0b9d212b7544e145b11d58701b072b Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Fri, 13 Feb 2026 10:56:17 +0100 Subject: [PATCH 030/627] Add CI job to report main branch build failures via GitHub issues Automatically creates or comments on a "build failed" issue when any CI job fails on the main branch, and assigns it to the committer who triggered the failure. Uses inline gh CLI commands to avoid a third-party action dependency. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build.yml | 47 +++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c0593d43def..0fbc9eded5e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -342,3 +342,50 @@ jobs: - name: Test tor connections using lightning-net-tokio run: | TOR_PROXY="127.0.0.1:9050" RUSTFLAGS="--cfg=tor" cargo test --verbose --color always -p lightning-net-tokio + + notify-failure: + needs: [build, fuzz, linting, rustfmt, check_release, check_docs, benchmark, ext-test, tor-connect, coverage] + if: failure() && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Create or update failure issue + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + LABEL="build failed" + TITLE="Failed build: ${{ github.workflow }}" + RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + REPO_URL="https://github.com/${{ github.repository }}" + COMMITTER="${{ github.event.head_commit.author.username }}" + BODY="GitHub Actions workflow [${{ github.workflow }} #${{ github.run_number }}](${RUN_URL}) failed." + BODY="${BODY}"$'\n\n'"Event: ${{ github.event_name }}" + BRANCH="${{ github.ref_name }}" + BODY="${BODY}"$'\n'"Branch: [${BRANCH}](${REPO_URL}/tree/${BRANCH})" + BODY="${BODY}"$'\n'"Commit: [${{ github.sha }}](${REPO_URL}/commit/${{ github.sha }})" + if [ -n "$COMMITTER" ]; then + BODY="${BODY}"$'\n'"Committer: @${COMMITTER}" + fi + + # Ensure label exists + if ! gh label list --search "$LABEL" --json name --jq '.[].name' | grep -qxF "$LABEL"; then + gh label create "$LABEL" + fi + + # Find existing open issue with this label + ISSUE_NUMBER=$(gh issue list --label "$LABEL" --state open --json number --jq '.[0].number // empty') + + if [ -n "$ISSUE_NUMBER" ]; then + gh issue comment "$ISSUE_NUMBER" --body "$BODY" + else + ISSUE_URL=$(gh issue create --title "$TITLE" --label "$LABEL" --body "$BODY") + ISSUE_NUMBER=$(echo "$ISSUE_URL" | grep -o '[0-9]*$') + fi + + # Assign issue to committer if no one is assigned yet + ASSIGNEE_COUNT=$(gh issue view "$ISSUE_NUMBER" --json assignees --jq '.assignees | length') + if [ "$ASSIGNEE_COUNT" = "0" ] && [ -n "$COMMITTER" ]; then + gh issue edit "$ISSUE_NUMBER" --add-assignee "$COMMITTER" || true + fi From 8cd166073e86f4b270f7ba1bc2b606d575fa0850 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Tue, 17 Feb 2026 15:50:05 +0100 Subject: [PATCH 031/627] Move shellcheck step from build job to linting job Shellcheck is a static analysis tool for shell scripts and belongs with the other linting checks rather than in the build matrix. This also prepares for splitting the build job into parallel sub-jobs, where running shellcheck in each sub-job would be redundant. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0fbc9eded5e..abc580baf13 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -60,11 +60,6 @@ jobs: if: "matrix.platform == 'self-hosted'" run: | rustup target add thumbv7m-none-eabi - - name: shellcheck the CI and `contrib` scripts - if: "matrix.platform == 'self-hosted'" - run: | - shellcheck ci/*.sh -aP ci - shellcheck contrib/*.sh -aP contrib - name: Set RUSTFLAGS to deny warnings if: "matrix.toolchain == '1.75.0'" run: echo "RUSTFLAGS=-D warnings" >> "$GITHUB_ENV" @@ -305,6 +300,10 @@ jobs: - name: Install clippy run: | rustup component add clippy + - name: shellcheck the CI and `contrib` scripts + run: | + shellcheck ci/*.sh -aP ci + shellcheck contrib/*.sh -aP contrib - name: Run default clippy linting run: | ./ci/check-lint.sh From fc3fa7cabcab007b3a6ea69af8be4cf5cd3764d8 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 13 Jan 2026 21:30:46 -0600 Subject: [PATCH 032/627] Use FundingTxInput instead of Utxo in CoinSelection In order to reuse CoinSelectionSource for splicing, the previous transaction of each UTXO is needed. Update CoinSelection to use FundingTxInput (renamed to ConfirmedUtxo) so that it is available. This requires adding a method to WalletSource to look up a previous transaction for a UTXO. Otherwise, Wallet's implementation of CoinSelectionSource would need WalletSource to include the previous transactions when listing confirmed UTXOs to select from. But this would be inefficient since only some UTXOs are selected. --- fuzz/src/full_stack.rs | 4 +- lightning/src/events/bump_transaction/mod.rs | 110 +++++++++++++----- lightning/src/events/bump_transaction/sync.rs | 14 ++- lightning/src/ln/functional_test_utils.rs | 3 +- lightning/src/ln/funding.rs | 19 ++- lightning/src/util/test_utils.rs | 36 +++--- 6 files changed, 129 insertions(+), 57 deletions(-) diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index f7f912cfd48..11eca60151e 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -668,9 +668,7 @@ pub fn do_test(mut data: &[u8], logger: &Arc) { script_pubkey: wallet.get_change_script().unwrap(), }], }; - let coinbase_txid = coinbase_tx.compute_txid(); - wallet - .add_utxo(bitcoin::OutPoint { txid: coinbase_txid, vout: 0 }, Amount::from_sat(1_000_000)); + wallet.add_utxo(coinbase_tx.clone(), 0); loop { match get_slice!(1)[0] { diff --git a/lightning/src/events/bump_transaction/mod.rs b/lightning/src/events/bump_transaction/mod.rs index a79e927e169..8a04f622f7c 100644 --- a/lightning/src/events/bump_transaction/mod.rs +++ b/lightning/src/events/bump_transaction/mod.rs @@ -30,6 +30,7 @@ use crate::ln::chan_utils::{ HTLC_TIMEOUT_INPUT_KEYED_ANCHOR_WITNESS_WEIGHT, HTLC_TIMEOUT_INPUT_P2A_ANCHOR_WITNESS_WEIGHT, P2WSH_TXOUT_WEIGHT, SEGWIT_MARKER_FLAG_WEIGHT, TRUC_CHILD_MAX_WEIGHT, TRUC_MAX_WEIGHT, }; +use crate::ln::funding::FundingTxInput; use crate::ln::types::ChannelId; use crate::prelude::*; use crate::sign::ecdsa::EcdsaChannelSigner; @@ -354,13 +355,16 @@ impl Utxo { } } +/// An unspent transaction output with at least one confirmation. +pub type ConfirmedUtxo = FundingTxInput; + /// The result of a successful coin selection attempt for a transaction requiring additional UTXOs /// to cover its fees. #[derive(Clone, Debug)] pub struct CoinSelection { /// The set of UTXOs (with at least 1 confirmation) to spend and use within a transaction /// requiring additional fees. - pub confirmed_utxos: Vec, + pub confirmed_utxos: Vec, /// An additional output tracking whether any change remained after coin selection. This output /// should always have a value above dust for its given `script_pubkey`. It should not be /// spent until the transaction it belongs to confirms to ensure mempool descendant limits are @@ -368,6 +372,16 @@ pub struct CoinSelection { pub change_output: Option, } +impl CoinSelection { + fn satisfaction_weight(&self) -> u64 { + self.confirmed_utxos.iter().map(|ConfirmedUtxo { utxo, .. }| utxo.satisfaction_weight).sum() + } + + fn input_amount(&self) -> Amount { + self.confirmed_utxos.iter().map(|ConfirmedUtxo { utxo, .. }| utxo.output.value).sum() + } +} + /// An abstraction over a bitcoin wallet that can perform coin selection over a set of UTXOs and can /// sign for them. The coin selection method aims to mimic Bitcoin Core's `fundrawtransaction` RPC, /// which most wallets should be able to satisfy. Otherwise, consider implementing [`WalletSource`], @@ -438,11 +452,18 @@ pub trait WalletSource { fn list_confirmed_utxos<'a>( &'a self, ) -> impl Future, ()>> + MaybeSend + 'a; + + /// Returns the previous transaction containing the UTXO referenced by the outpoint. + fn get_prevtx<'a>( + &'a self, outpoint: OutPoint, + ) -> impl Future> + MaybeSend + 'a; + /// Returns a script to use for change above dust resulting from a successful coin selection /// attempt. fn get_change_script<'a>( &'a self, ) -> impl Future> + MaybeSend + 'a; + /// Signs and provides the full [`TxIn::script_sig`] and [`TxIn::witness`] for all inputs within /// the transaction known to the wallet (i.e., any provided via /// [`WalletSource::list_confirmed_utxos`]). @@ -628,10 +649,26 @@ where Some(TxOut { script_pubkey: change_script, value: change_output_amount }) }; - Ok(CoinSelection { - confirmed_utxos: selected_utxos.into_iter().map(|(utxo, _)| utxo).collect(), - change_output, - }) + let mut confirmed_utxos = Vec::with_capacity(selected_utxos.len()); + for (utxo, _) in selected_utxos { + let prevtx = self.source.get_prevtx(utxo.outpoint).await?; + let prevtx_id = prevtx.compute_txid(); + if prevtx_id != utxo.outpoint.txid + || prevtx.output.get(utxo.outpoint.vout as usize).is_none() + { + log_error!( + self.logger, + "Tx {} from wallet source doesn't contain output referenced by outpoint: {}", + prevtx_id, + utxo.outpoint, + ); + return Err(()); + } + + confirmed_utxos.push(ConfirmedUtxo { utxo, prevtx }); + } + + Ok(CoinSelection { confirmed_utxos, change_output }) } } @@ -740,7 +777,7 @@ where /// Updates a transaction with the result of a successful coin selection attempt. fn process_coin_selection(&self, tx: &mut Transaction, coin_selection: &CoinSelection) { - for utxo in coin_selection.confirmed_utxos.iter() { + for ConfirmedUtxo { utxo, .. } in coin_selection.confirmed_utxos.iter() { tx.input.push(TxIn { previous_output: utxo.outpoint, script_sig: ScriptBuf::new(), @@ -865,12 +902,10 @@ where output: vec![], }; - let input_satisfaction_weight: u64 = - coin_selection.confirmed_utxos.iter().map(|utxo| utxo.satisfaction_weight).sum(); + let input_satisfaction_weight = coin_selection.satisfaction_weight(); let total_satisfaction_weight = anchor_input_witness_weight + EMPTY_SCRIPT_SIG_WEIGHT + input_satisfaction_weight; - let total_input_amount = must_spend_amount - + coin_selection.confirmed_utxos.iter().map(|utxo| utxo.output.value).sum(); + let total_input_amount = must_spend_amount + coin_selection.input_amount(); self.process_coin_selection(&mut anchor_tx, &coin_selection); let anchor_txid = anchor_tx.compute_txid(); @@ -885,10 +920,10 @@ where let index = idx + 1; debug_assert_eq!( anchor_psbt.unsigned_tx.input[index].previous_output, - utxo.outpoint + utxo.outpoint() ); - if utxo.output.script_pubkey.is_witness_program() { - anchor_psbt.inputs[index].witness_utxo = Some(utxo.output); + if utxo.output().script_pubkey.is_witness_program() { + anchor_psbt.inputs[index].witness_utxo = Some(utxo.into_output()); } } @@ -1127,13 +1162,11 @@ where utxo_id = claim_id.step_with_bytes(&broadcasted_htlcs.to_be_bytes()); #[cfg(debug_assertions)] - let input_satisfaction_weight: u64 = - coin_selection.confirmed_utxos.iter().map(|utxo| utxo.satisfaction_weight).sum(); + let input_satisfaction_weight = coin_selection.satisfaction_weight(); #[cfg(debug_assertions)] let total_satisfaction_weight = must_spend_satisfaction_weight + input_satisfaction_weight; #[cfg(debug_assertions)] - let input_value: u64 = - coin_selection.confirmed_utxos.iter().map(|utxo| utxo.output.value.to_sat()).sum(); + let input_value = coin_selection.input_amount().to_sat(); #[cfg(debug_assertions)] let total_input_amount = must_spend_amount + input_value; @@ -1154,9 +1187,12 @@ where for (idx, utxo) in coin_selection.confirmed_utxos.into_iter().enumerate() { // offset to skip the htlc inputs let index = idx + selected_htlcs.len(); - debug_assert_eq!(htlc_psbt.unsigned_tx.input[index].previous_output, utxo.outpoint); - if utxo.output.script_pubkey.is_witness_program() { - htlc_psbt.inputs[index].witness_utxo = Some(utxo.output); + debug_assert_eq!( + htlc_psbt.unsigned_tx.input[index].previous_output, + utxo.outpoint() + ); + if utxo.output().script_pubkey.is_witness_program() { + htlc_psbt.inputs[index].witness_utxo = Some(utxo.into_output()); } } @@ -1311,10 +1347,9 @@ mod tests { use crate::util::ser::Readable; use crate::util::test_utils::{TestBroadcaster, TestLogger}; - use bitcoin::hashes::Hash; use bitcoin::hex::FromHex; use bitcoin::{ - Network, ScriptBuf, Transaction, Txid, WitnessProgram, WitnessVersion, XOnlyPublicKey, + Network, ScriptBuf, Transaction, WitnessProgram, WitnessVersion, XOnlyPublicKey, }; struct TestCoinSelectionSource { @@ -1335,9 +1370,17 @@ mod tests { Ok(res) } fn sign_psbt(&self, psbt: Psbt) -> Result { + let prevtx_ids: Vec<_> = self + .expected_selects + .lock() + .unwrap() + .iter() + .flat_map(|selection| selection.3.confirmed_utxos.iter()) + .map(|utxo| utxo.prevtx.compute_txid()) + .collect(); let mut tx = psbt.unsigned_tx; for input in tx.input.iter_mut() { - if input.previous_output.txid != Txid::from_byte_array([44; 32]) { + if prevtx_ids.contains(&input.previous_output.txid) { // Channel output, add a realistic size witness to make the assertions happy input.witness = Witness::from_slice(&[vec![42; 162]]); } @@ -1378,6 +1421,13 @@ mod tests { .weight() .to_wu(); + let prevtx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![], + output: vec![TxOut { value: Amount::from_sat(200), script_pubkey: ScriptBuf::new() }], + }; + let broadcaster = TestBroadcaster::new(Network::Testnet); let source = TestCoinSelectionSource { expected_selects: Mutex::new(vec![ @@ -1392,14 +1442,14 @@ mod tests { commitment_and_anchor_fee, 868, CoinSelection { - confirmed_utxos: vec![Utxo { - outpoint: OutPoint { txid: Txid::from_byte_array([44; 32]), vout: 0 }, - output: TxOut { - value: Amount::from_sat(200), - script_pubkey: ScriptBuf::new(), + confirmed_utxos: vec![ConfirmedUtxo { + utxo: Utxo { + outpoint: OutPoint { txid: prevtx.compute_txid(), vout: 0 }, + output: prevtx.output[0].clone(), + satisfaction_weight: 5, // Just the script_sig and witness lengths + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, }, - satisfaction_weight: 5, // Just the script_sig and witness lengths - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + prevtx, }], change_output: None, }, diff --git a/lightning/src/events/bump_transaction/sync.rs b/lightning/src/events/bump_transaction/sync.rs index f4245cd5194..a521fa9c48a 100644 --- a/lightning/src/events/bump_transaction/sync.rs +++ b/lightning/src/events/bump_transaction/sync.rs @@ -21,7 +21,7 @@ use crate::sign::SignerProvider; use crate::util::async_poll::{dummy_waker, MaybeSend, MaybeSync}; use crate::util::logger::Logger; -use bitcoin::{Psbt, ScriptBuf, Transaction, TxOut}; +use bitcoin::{OutPoint, Psbt, ScriptBuf, Transaction, TxOut}; use super::BumpTransactionEvent; use super::{ @@ -37,9 +37,14 @@ use super::{ pub trait WalletSourceSync { /// Returns all UTXOs, with at least 1 confirmation each, that are available to spend. fn list_confirmed_utxos(&self) -> Result, ()>; + + /// Returns the previous transaction containing the UTXO referenced by the outpoint. + fn get_prevtx(&self, outpoint: OutPoint) -> Result; + /// Returns a script to use for change above dust resulting from a successful coin selection /// attempt. fn get_change_script(&self) -> Result; + /// Signs and provides the full [`TxIn::script_sig`] and [`TxIn::witness`] for all inputs within /// the transaction known to the wallet (i.e., any provided via /// [`WalletSource::list_confirmed_utxos`]). @@ -79,6 +84,13 @@ where async move { utxos } } + fn get_prevtx<'a>( + &'a self, outpoint: OutPoint, + ) -> impl Future> + MaybeSend + 'a { + let prevtx = self.0.get_prevtx(outpoint); + Box::pin(async move { prevtx }) + } + fn get_change_script<'a>( &'a self, ) -> impl Future> + MaybeSend + 'a { diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index a0246a9d091..33f78b13553 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -397,8 +397,7 @@ fn do_connect_block_without_consistency_checks<'a, 'b, 'c, 'd>( let wallet_script = node.wallet_source.get_change_script().unwrap(); for (idx, output) in tx.output.iter().enumerate() { if output.script_pubkey == wallet_script { - let outpoint = bitcoin::OutPoint { txid: tx.compute_txid(), vout: idx as u32 }; - node.wallet_source.add_utxo(outpoint, output.value); + node.wallet_source.add_utxo(tx.clone(), idx as u32); } } } diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 50e0938fc8b..9981250b05e 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -103,16 +103,17 @@ impl SpliceContribution { /// establishment protocol or when splicing. #[derive(Debug, Clone)] pub struct FundingTxInput { - /// The unspent [`TxOut`] that the input spends. + /// The unspent [`TxOut`] found in [`prevtx`]. /// /// [`TxOut`]: bitcoin::TxOut - pub(super) utxo: Utxo, + /// [`prevtx`]: Self::prevtx + pub(crate) utxo: Utxo, /// The transaction containing the unspent [`TxOut`] referenced by [`utxo`]. /// /// [`TxOut`]: bitcoin::TxOut /// [`utxo`]: Self::utxo - pub(super) prevtx: Transaction, + pub(crate) prevtx: Transaction, } impl_writeable_tlv_based!(FundingTxInput, { @@ -237,6 +238,11 @@ impl FundingTxInput { self.utxo.outpoint } + /// The unspent output. + pub fn output(&self) -> &TxOut { + &self.utxo.output + } + /// The sequence number to use in the [`TxIn`]. /// /// [`TxIn`]: bitcoin::TxIn @@ -251,8 +257,13 @@ impl FundingTxInput { self.utxo.sequence = sequence; } - /// Converts the [`FundingTxInput`] into a [`Utxo`] for coin selection. + /// Converts the [`FundingTxInput`] into a [`Utxo`]. pub fn into_utxo(self) -> Utxo { self.utxo } + + /// Converts the [`FundingTxInput`] into a [`TxOut`]. + pub fn into_output(self) -> TxOut { + self.utxo.output + } } diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 1d3137a75aa..02b63a61eae 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -22,7 +22,7 @@ use crate::chain::channelmonitor::{ use crate::chain::transaction::OutPoint; use crate::chain::WatchedOutput; use crate::events::bump_transaction::sync::WalletSourceSync; -use crate::events::bump_transaction::Utxo; +use crate::events::bump_transaction::{ConfirmedUtxo, Utxo}; #[cfg(any(test, feature = "_externalize_tests"))] use crate::ln::chan_utils::CommitmentTransaction; use crate::ln::channel_state::ChannelDetails; @@ -2256,7 +2256,7 @@ impl Drop for TestScorer { pub struct TestWalletSource { secret_key: SecretKey, - utxos: Mutex>, + utxos: Mutex>, secp: Secp256k1, } @@ -2265,21 +2265,13 @@ impl TestWalletSource { Self { secret_key, utxos: Mutex::new(Vec::new()), secp: Secp256k1::new() } } - pub fn add_utxo(&self, outpoint: bitcoin::OutPoint, value: Amount) -> TxOut { - let public_key = bitcoin::PublicKey::new(self.secret_key.public_key(&self.secp)); - let utxo = Utxo::new_v0_p2wpkh(outpoint, value, &public_key.wpubkey_hash().unwrap()); - self.utxos.lock().unwrap().push(utxo.clone()); - utxo.output - } - - pub fn add_custom_utxo(&self, utxo: Utxo) -> TxOut { - let output = utxo.output.clone(); + pub fn add_utxo(&self, prevtx: Transaction, vout: u32) { + let utxo = ConfirmedUtxo::new_p2wpkh(prevtx, vout).unwrap(); self.utxos.lock().unwrap().push(utxo); - output } pub fn remove_utxo(&self, outpoint: bitcoin::OutPoint) { - self.utxos.lock().unwrap().retain(|utxo| utxo.outpoint != outpoint); + self.utxos.lock().unwrap().retain(|utxo| utxo.outpoint() != outpoint); } pub fn clear_utxos(&self) { @@ -2292,12 +2284,12 @@ impl TestWalletSource { let utxos = self.utxos.lock().unwrap(); for i in 0..tx.input.len() { if let Some(utxo) = - utxos.iter().find(|utxo| utxo.outpoint == tx.input[i].previous_output) + utxos.iter().find(|utxo| utxo.outpoint() == tx.input[i].previous_output) { let sighash = SighashCache::new(&tx).p2wpkh_signature_hash( i, - &utxo.output.script_pubkey, - utxo.output.value, + &utxo.output().script_pubkey, + utxo.output().value, EcdsaSighashType::All, )?; #[cfg(not(feature = "grind_signatures"))] @@ -2322,7 +2314,17 @@ impl TestWalletSource { impl WalletSourceSync for TestWalletSource { fn list_confirmed_utxos(&self) -> Result, ()> { - Ok(self.utxos.lock().unwrap().clone()) + let utxos = self.utxos.lock().unwrap(); + Ok(utxos.iter().map(|ConfirmedUtxo { utxo, .. }| utxo.clone()).collect()) + } + + fn get_prevtx(&self, outpoint: bitcoin::OutPoint) -> Result { + let utxos = self.utxos.lock().unwrap(); + utxos + .iter() + .find(|confirmed_utxo| confirmed_utxo.utxo.outpoint == outpoint) + .map(|ConfirmedUtxo { prevtx, .. }| prevtx.clone()) + .ok_or(()) } fn get_change_script(&self) -> Result { From 4cae129b1233404fb48b85ae107ad4f0a72c2de3 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 28 Jan 2026 15:32:51 -0600 Subject: [PATCH 033/627] Make ClaimId optional in coin selection CoinSelectionSource is used for anchor bumping where a ClaimId is passed in to avoid double spending other claims. To re-use this trait for funding a splice, the ClaimId must be optional. And, if None, then any locked UTXOs may be considered ineligible by an implementation. --- lightning/src/events/bump_transaction/mod.rs | 29 ++++++++++++++----- lightning/src/events/bump_transaction/sync.rs | 6 ++-- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/lightning/src/events/bump_transaction/mod.rs b/lightning/src/events/bump_transaction/mod.rs index 8a04f622f7c..c783e96381a 100644 --- a/lightning/src/events/bump_transaction/mod.rs +++ b/lightning/src/events/bump_transaction/mod.rs @@ -425,9 +425,12 @@ pub trait CoinSelectionSource { /// which UTXOs to double spend is left to the implementation, but it must strive to keep the /// set of other claims being double spent to a minimum. /// + /// If `claim_id` is not set, then the selection should be treated as if it were for a unique + /// claim and must NOT be double-spent rather than being kept to a minimum. + /// /// [`ChannelMonitor::rebroadcast_pending_claims`]: crate::chain::channelmonitor::ChannelMonitor::rebroadcast_pending_claims fn select_confirmed_utxos<'a>( - &'a self, claim_id: ClaimId, must_spend: Vec, must_pay_to: &'a [TxOut], + &'a self, claim_id: Option, must_spend: Vec, must_pay_to: &'a [TxOut], target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, ) -> impl Future> + MaybeSend + 'a; /// Signs and provides the full witness for all inputs within the transaction known to the @@ -492,7 +495,7 @@ where // TODO: Do we care about cleaning this up once the UTXOs have a confirmed spend? We can do so // by checking whether any UTXOs that exist in the map are no longer returned in // `list_confirmed_utxos`. - locked_utxos: Mutex>, + locked_utxos: Mutex>>, } impl Wallet @@ -514,11 +517,13 @@ where /// least 1 satoshi at the current feerate, otherwise, we'll only attempt to spend those which /// contribute at least twice their fee. async fn select_confirmed_utxos_internal( - &self, utxos: &[Utxo], claim_id: ClaimId, force_conflicting_utxo_spend: bool, + &self, utxos: &[Utxo], claim_id: Option, force_conflicting_utxo_spend: bool, tolerate_high_network_feerates: bool, target_feerate_sat_per_1000_weight: u32, preexisting_tx_weight: u64, input_amount_sat: Amount, target_amount_sat: Amount, max_tx_weight: u64, ) -> Result { + debug_assert!(!(claim_id.is_none() && force_conflicting_utxo_spend)); + // P2WSH and P2TR outputs are both the heaviest-weight standard outputs at 34 bytes let max_coin_selection_weight = max_tx_weight .checked_sub(preexisting_tx_weight + P2WSH_TXOUT_WEIGHT) @@ -538,7 +543,12 @@ where .iter() .filter_map(|utxo| { if let Some(utxo_claim_id) = locked_utxos.get(&utxo.outpoint) { - if *utxo_claim_id != claim_id && !force_conflicting_utxo_spend { + // TODO(splicing): For splicing (i.e., claim_id.is_none()), ideally we'd + // allow force_conflicting_utxo_spend for an RBF attempt. However, we'd need + // something similar to a ClaimId to identify a splice. + if (utxo_claim_id.is_none() || claim_id.is_none()) + || (*utxo_claim_id != claim_id && !force_conflicting_utxo_spend) + { log_trace!( self.logger, "Skipping UTXO {} to prevent conflicting spend", @@ -678,7 +688,7 @@ where W::Target: WalletSource + MaybeSend + MaybeSync, { fn select_confirmed_utxos<'a>( - &'a self, claim_id: ClaimId, must_spend: Vec, must_pay_to: &'a [TxOut], + &'a self, claim_id: Option, must_spend: Vec, must_pay_to: &'a [TxOut], target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, ) -> impl Future> + MaybeSend + 'a { async move { @@ -703,6 +713,9 @@ where let configs = [(false, false), (false, true), (true, false), (true, true)]; for (force_conflicting_utxo_spend, tolerate_high_network_feerates) in configs { + if claim_id.is_none() && force_conflicting_utxo_spend { + continue; + } log_debug!( self.logger, "Attempting coin selection targeting {} sat/kW (force_conflicting_utxo_spend = {}, tolerate_high_network_feerates = {})", @@ -874,7 +887,7 @@ where let coin_selection: CoinSelection = self .utxo_source .select_confirmed_utxos( - claim_id, + Some(claim_id), must_spend, &[], package_target_feerate_sat_per_1000_weight, @@ -1137,7 +1150,7 @@ where let coin_selection: CoinSelection = match self .utxo_source .select_confirmed_utxos( - utxo_id, + Some(utxo_id), must_spend, &htlc_tx.output, target_feerate_sat_per_1000_weight, @@ -1358,7 +1371,7 @@ mod tests { } impl CoinSelectionSourceSync for TestCoinSelectionSource { fn select_confirmed_utxos( - &self, _claim_id: ClaimId, must_spend: Vec, _must_pay_to: &[TxOut], + &self, _claim_id: Option, must_spend: Vec, _must_pay_to: &[TxOut], target_feerate_sat_per_1000_weight: u32, _max_tx_weight: u64, ) -> Result { let mut expected_selects = self.expected_selects.lock().unwrap(); diff --git a/lightning/src/events/bump_transaction/sync.rs b/lightning/src/events/bump_transaction/sync.rs index a521fa9c48a..39088bb0e97 100644 --- a/lightning/src/events/bump_transaction/sync.rs +++ b/lightning/src/events/bump_transaction/sync.rs @@ -135,7 +135,7 @@ where W::Target: WalletSourceSync + MaybeSend + MaybeSync, { fn select_confirmed_utxos( - &self, claim_id: ClaimId, must_spend: Vec, must_pay_to: &[TxOut], + &self, claim_id: Option, must_spend: Vec, must_pay_to: &[TxOut], target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, ) -> Result { let fut = self.wallet.select_confirmed_utxos( @@ -214,7 +214,7 @@ pub trait CoinSelectionSourceSync { /// /// [`ChannelMonitor::rebroadcast_pending_claims`]: crate::chain::channelmonitor::ChannelMonitor::rebroadcast_pending_claims fn select_confirmed_utxos( - &self, claim_id: ClaimId, must_spend: Vec, must_pay_to: &[TxOut], + &self, claim_id: Option, must_spend: Vec, must_pay_to: &[TxOut], target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, ) -> Result; @@ -247,7 +247,7 @@ where T::Target: CoinSelectionSourceSync, { fn select_confirmed_utxos<'a>( - &'a self, claim_id: ClaimId, must_spend: Vec, must_pay_to: &'a [TxOut], + &'a self, claim_id: Option, must_spend: Vec, must_pay_to: &'a [TxOut], target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, ) -> impl Future> + MaybeSend + 'a { let coins = self.0.select_confirmed_utxos( From df777e8d3cb552034465b244354e7f1450dbb058 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 2 Feb 2026 14:02:04 -0600 Subject: [PATCH 034/627] Run rustfmt on fuzz --- fuzz/src/chanmon_consistency.rs | 2 +- fuzz/src/full_stack.rs | 2 +- fuzz/src/lsps_message.rs | 25 ++++++++++++++----------- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 87d58da4832..bb800045ca9 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -37,7 +37,7 @@ use lightning::blinded_path::message::{BlindedMessagePath, MessageContext, Messa use lightning::blinded_path::payment::{BlindedPaymentPath, ReceiveTlvs}; use lightning::chain; use lightning::chain::chaininterface::{ - TransactionType, BroadcasterInterface, ConfirmationTarget, FeeEstimator, + BroadcasterInterface, ConfirmationTarget, FeeEstimator, TransactionType, }; use lightning::chain::channelmonitor::{ChannelMonitor, MonitorEvent}; use lightning::chain::transaction::OutPoint; diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index 11eca60151e..c55da4f19ee 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -36,7 +36,7 @@ use lightning::blinded_path::message::{BlindedMessagePath, MessageContext, Messa use lightning::blinded_path::payment::{BlindedPaymentPath, ReceiveTlvs}; use lightning::chain; use lightning::chain::chaininterface::{ - TransactionType, BroadcasterInterface, ConfirmationTarget, FeeEstimator, + BroadcasterInterface, ConfirmationTarget, FeeEstimator, TransactionType, }; use lightning::chain::chainmonitor; use lightning::chain::transaction::OutPoint; diff --git a/fuzz/src/lsps_message.rs b/fuzz/src/lsps_message.rs index 547a27b70ee..8371d1c5fc7 100644 --- a/fuzz/src/lsps_message.rs +++ b/fuzz/src/lsps_message.rs @@ -77,17 +77,20 @@ pub fn do_test(data: &[u8]) { genesis_block.header.time, )); - let liquidity_manager = Arc::new(LiquidityManagerSync::new( - Arc::clone(&keys_manager), - Arc::clone(&keys_manager), - Arc::clone(&manager), - None::>, - None, - kv_store, - Arc::clone(&tx_broadcaster), - None, - None, - ).unwrap()); + let liquidity_manager = Arc::new( + LiquidityManagerSync::new( + Arc::clone(&keys_manager), + Arc::clone(&keys_manager), + Arc::clone(&manager), + None::>, + None, + kv_store, + Arc::clone(&tx_broadcaster), + None, + None, + ) + .unwrap(), + ); let mut reader = data; if let Ok(Some(msg)) = liquidity_manager.read(LSPS_MESSAGE_TYPE_ID, &mut reader) { let secp = Secp256k1::signing_only(); From 2fd335106bc207d600a15a3b33c70de1061c4806 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 18 Dec 2025 09:54:00 -0600 Subject: [PATCH 035/627] Split splice initiation into two phases Previously, splice_channel required callers to manually construct funding inputs and pass them directly, making coin selection the caller's responsibility. This made the API difficult to use and prevented reuse of the existing CoinSelectionSource trait. Introduce a two-phase API: splice_channel now returns a FundingTemplate that callers use to build a FundingContribution via wallet-backed splice methods (e.g., splice_in_sync, splice_out_sync), which handle coin selection automatically. The completed contribution is then passed to a new funding_contributed method to begin quiescence and negotiation. This also renames SpliceContribution to FundingContribution and moves fee estimation and input validation into the funding module, co-located with the types they operate on. Co-Authored-By: Claude Opus 4.6 --- fuzz/src/chanmon_consistency.rs | 439 ++++++----- fuzz/src/full_stack.rs | 106 ++- .../src/upgrade_downgrade_tests.rs | 9 +- lightning/src/ln/async_signer_tests.rs | 8 +- lightning/src/ln/channel.rs | 659 +++++----------- lightning/src/ln/channelmanager.rs | 158 +++- lightning/src/ln/functional_test_utils.rs | 8 +- lightning/src/ln/funding.rs | 729 ++++++++++++++++-- lightning/src/ln/splicing_tests.rs | 616 ++++++++------- lightning/src/ln/zero_fee_commitment_tests.rs | 4 +- lightning/src/util/ser.rs | 14 + 11 files changed, 1660 insertions(+), 1090 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index bb800045ca9..70dda138e43 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -26,6 +26,7 @@ use bitcoin::opcodes; use bitcoin::script::{Builder, ScriptBuf}; use bitcoin::transaction::Version; use bitcoin::transaction::{Transaction, TxOut}; +use bitcoin::FeeRate; use bitcoin::hash_types::BlockHash; use bitcoin::hashes::sha256::Hash as Sha256; @@ -45,6 +46,7 @@ use lightning::chain::{ chainmonitor, channelmonitor, BestBlock, ChannelMonitorUpdateStatus, Confirm, Watch, }; use lightning::events; +use lightning::events::bump_transaction::sync::{WalletSourceSync, WalletSync}; use lightning::ln::channel::{ FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE, MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS, }; @@ -53,7 +55,6 @@ use lightning::ln::channelmanager::{ ChainParameters, ChannelManager, ChannelManagerReadArgs, PaymentId, RecentPaymentDetails, }; use lightning::ln::functional_test_utils::*; -use lightning::ln::funding::{FundingTxInput, SpliceContribution}; use lightning::ln::inbound_payment::ExpandedKey; use lightning::ln::msgs::{ BaseMessageHandler, ChannelMessageHandler, CommitmentUpdate, Init, MessageSendEvent, @@ -72,12 +73,14 @@ use lightning::sign::{ SignerProvider, }; use lightning::types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; +use lightning::util::async_poll::{MaybeSend, MaybeSync}; use lightning::util::config::UserConfig; use lightning::util::errors::APIError; use lightning::util::hash_tables::*; use lightning::util::logger::Logger; use lightning::util::ser::{LengthReadable, ReadableArgs, Writeable, Writer}; use lightning::util::test_channel_signer::{EnforcementState, TestChannelSigner}; +use lightning::util::test_utils::TestWalletSource; use lightning_invoice::RawBolt11Invoice; @@ -176,63 +179,6 @@ impl Writer for VecWriter { } } -pub struct TestWallet { - secret_key: SecretKey, - utxos: Mutex>, - secp: Secp256k1, -} - -impl TestWallet { - pub fn new(secret_key: SecretKey) -> Self { - Self { secret_key, utxos: Mutex::new(Vec::new()), secp: Secp256k1::new() } - } - - fn get_change_script(&self) -> Result { - let public_key = bitcoin::PublicKey::new(self.secret_key.public_key(&self.secp)); - Ok(ScriptBuf::new_p2wpkh(&public_key.wpubkey_hash().unwrap())) - } - - pub fn add_utxo(&self, outpoint: bitcoin::OutPoint, value: Amount) -> TxOut { - let public_key = bitcoin::PublicKey::new(self.secret_key.public_key(&self.secp)); - let utxo = lightning::events::bump_transaction::Utxo::new_v0_p2wpkh( - outpoint, - value, - &public_key.wpubkey_hash().unwrap(), - ); - self.utxos.lock().unwrap().push(utxo.clone()); - utxo.output - } - - pub fn sign_tx( - &self, mut tx: Transaction, - ) -> Result { - let utxos = self.utxos.lock().unwrap(); - for i in 0..tx.input.len() { - if let Some(utxo) = - utxos.iter().find(|utxo| utxo.outpoint == tx.input[i].previous_output) - { - let sighash = bitcoin::sighash::SighashCache::new(&tx).p2wpkh_signature_hash( - i, - &utxo.output.script_pubkey, - utxo.output.value, - bitcoin::EcdsaSighashType::All, - )?; - let signature = self.secp.sign_ecdsa( - &secp256k1::Message::from_digest(sighash.to_byte_array()), - &self.secret_key, - ); - let bitcoin_sig = bitcoin::ecdsa::Signature { - signature, - sighash_type: bitcoin::EcdsaSighashType::All, - }; - tx.input[i].witness = - bitcoin::Witness::p2wpkh(&bitcoin_sig, &self.secret_key.public_key(&self.secp)); - } - } - Ok(tx) - } -} - /// The LDK API requires that any time we tell it we're done persisting a `ChannelMonitor[Update]` /// we never pass it in as the "latest" `ChannelMonitor` on startup. However, we can pass /// out-of-date monitors as long as we never told LDK we finished persisting them, which we do by @@ -542,7 +488,7 @@ type ChanMan<'a> = ChannelManager< Arc, &'a FuzzRouter, &'a FuzzRouter, - Arc, + Arc, >; #[inline] @@ -778,7 +724,9 @@ fn send_mpp_hop_payment( } #[inline] -pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { +pub fn do_test( + data: &[u8], underlying_out: Out, anchors: bool, +) { let out = SearchingOutput::new(underlying_out); let broadcast = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); let router = FuzzRouter {}; @@ -805,7 +753,7 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { macro_rules! make_node { ($node_id: expr, $fee_estimator: expr) => {{ - let logger: Arc = + let logger: Arc = Arc::new(test_logger::TestLogger::new($node_id.to_string(), out.clone())); let node_secret = SecretKey::from_slice(&[ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, @@ -854,6 +802,7 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { ), monitor, keys_manager, + logger, ) }}; } @@ -865,7 +814,7 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { keys, fee_estimator| { let keys_manager = Arc::clone(keys); - let logger: Arc = + let logger: Arc = Arc::new(test_logger::TestLogger::new(node_id.to_string(), out.clone())); let chain_monitor = Arc::new(TestChainMonitor::new( broadcast.clone(), @@ -1159,9 +1108,9 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { }}; } - let wallet_a = TestWallet::new(SecretKey::from_slice(&[1; 32]).unwrap()); - let wallet_b = TestWallet::new(SecretKey::from_slice(&[2; 32]).unwrap()); - let wallet_c = TestWallet::new(SecretKey::from_slice(&[3; 32]).unwrap()); + let wallet_a = TestWalletSource::new(SecretKey::from_slice(&[1; 32]).unwrap()); + let wallet_b = TestWalletSource::new(SecretKey::from_slice(&[2; 32]).unwrap()); + let wallet_c = TestWalletSource::new(SecretKey::from_slice(&[3; 32]).unwrap()); let wallets = vec![wallet_a, wallet_b, wallet_c]; let coinbase_tx = bitcoin::Transaction { version: bitcoin::transaction::Version::TWO, @@ -1175,12 +1124,8 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { }) .collect(), }; - let coinbase_txid = coinbase_tx.compute_txid(); wallets.iter().enumerate().for_each(|(i, w)| { - w.add_utxo( - bitcoin::OutPoint { txid: coinbase_txid, vout: i as u32 }, - Amount::from_sat(100_000), - ); + w.add_utxo(coinbase_tx.clone(), i as u32); }); let fee_est_a = Arc::new(FuzzEstimator { ret_val: atomic::AtomicU32::new(253) }); @@ -1192,11 +1137,13 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { // 3 nodes is enough to hit all the possible cases, notably unknown-source-unknown-dest // forwarding. - let (node_a, mut monitor_a, keys_manager_a) = make_node!(0, fee_est_a); - let (node_b, mut monitor_b, keys_manager_b) = make_node!(1, fee_est_b); - let (node_c, mut monitor_c, keys_manager_c) = make_node!(2, fee_est_c); + let (node_a, mut monitor_a, keys_manager_a, logger_a) = make_node!(0, fee_est_a); + let (node_b, mut monitor_b, keys_manager_b, logger_b) = make_node!(1, fee_est_b); + let (node_c, mut monitor_c, keys_manager_c, logger_c) = make_node!(2, fee_est_c); let mut nodes = [node_a, node_b, node_c]; + let loggers = [logger_a, logger_b, logger_c]; + let fee_estimators = [Arc::clone(&fee_est_a), Arc::clone(&fee_est_b), Arc::clone(&fee_est_c)]; // Connect peers first, then create channels connect_peers!(nodes[0], nodes[1]); @@ -2130,79 +2077,107 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { }, 0xa0 => { - let input = FundingTxInput::new_p2wpkh(coinbase_tx.clone(), 0).unwrap(); - let contribution = - SpliceContribution::splice_in(Amount::from_sat(10_000), vec![input], None); - let funding_feerate_sat_per_kw = fee_est_a.ret_val.load(atomic::Ordering::Acquire); - if let Err(e) = nodes[0].splice_channel( - &chan_a_id, - &nodes[1].get_our_node_id(), - contribution, - funding_feerate_sat_per_kw, - None, - ) { - assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice pending")), - "{:?}", - e - ); + let feerate_sat_per_kw = fee_estimators[0].ret_val.load(atomic::Ordering::Acquire); + let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); + match nodes[0].splice_channel(&chan_a_id, &nodes[1].get_our_node_id(), feerate) { + Ok(funding_template) => { + let wallet = WalletSync::new(&wallets[0], Arc::clone(&loggers[0])); + if let Ok(contribution) = + funding_template.splice_in_sync(None, Amount::from_sat(10_000), &wallet) + { + let _ = nodes[0].funding_contributed( + &chan_a_id, + &nodes[1].get_our_node_id(), + contribution, + None, + ); + } + }, + Err(e) => { + assert!( + matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), + "{:?}", + e + ); + }, } }, 0xa1 => { - let input = FundingTxInput::new_p2wpkh(coinbase_tx.clone(), 1).unwrap(); - let contribution = - SpliceContribution::splice_in(Amount::from_sat(10_000), vec![input], None); - let funding_feerate_sat_per_kw = fee_est_b.ret_val.load(atomic::Ordering::Acquire); - if let Err(e) = nodes[1].splice_channel( - &chan_a_id, - &nodes[0].get_our_node_id(), - contribution, - funding_feerate_sat_per_kw, - None, - ) { - assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice pending")), - "{:?}", - e - ); + let feerate_sat_per_kw = fee_estimators[1].ret_val.load(atomic::Ordering::Acquire); + let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); + match nodes[1].splice_channel(&chan_a_id, &nodes[0].get_our_node_id(), feerate) { + Ok(funding_template) => { + let wallet = WalletSync::new(&wallets[1], Arc::clone(&loggers[1])); + if let Ok(contribution) = + funding_template.splice_in_sync(None, Amount::from_sat(10_000), &wallet) + { + let _ = nodes[1].funding_contributed( + &chan_a_id, + &nodes[0].get_our_node_id(), + contribution, + None, + ); + } + }, + Err(e) => { + assert!( + matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), + "{:?}", + e + ); + }, } }, 0xa2 => { - let input = FundingTxInput::new_p2wpkh(coinbase_tx.clone(), 0).unwrap(); - let contribution = - SpliceContribution::splice_in(Amount::from_sat(10_000), vec![input], None); - let funding_feerate_sat_per_kw = fee_est_b.ret_val.load(atomic::Ordering::Acquire); - if let Err(e) = nodes[1].splice_channel( - &chan_b_id, - &nodes[2].get_our_node_id(), - contribution, - funding_feerate_sat_per_kw, - None, - ) { - assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice pending")), - "{:?}", - e - ); + let feerate_sat_per_kw = fee_estimators[1].ret_val.load(atomic::Ordering::Acquire); + let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); + match nodes[1].splice_channel(&chan_b_id, &nodes[2].get_our_node_id(), feerate) { + Ok(funding_template) => { + let wallet = WalletSync::new(&wallets[1], Arc::clone(&loggers[1])); + if let Ok(contribution) = + funding_template.splice_in_sync(None, Amount::from_sat(10_000), &wallet) + { + let _ = nodes[1].funding_contributed( + &chan_b_id, + &nodes[2].get_our_node_id(), + contribution, + None, + ); + } + }, + Err(e) => { + assert!( + matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), + "{:?}", + e + ); + }, } }, 0xa3 => { - let input = FundingTxInput::new_p2wpkh(coinbase_tx.clone(), 1).unwrap(); - let contribution = - SpliceContribution::splice_in(Amount::from_sat(10_000), vec![input], None); - let funding_feerate_sat_per_kw = fee_est_c.ret_val.load(atomic::Ordering::Acquire); - if let Err(e) = nodes[2].splice_channel( - &chan_b_id, - &nodes[1].get_our_node_id(), - contribution, - funding_feerate_sat_per_kw, - None, - ) { - assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice pending")), - "{:?}", - e - ); + let feerate_sat_per_kw = fee_estimators[2].ret_val.load(atomic::Ordering::Acquire); + let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); + match nodes[2].splice_channel(&chan_b_id, &nodes[1].get_our_node_id(), feerate) { + Ok(funding_template) => { + let wallet = WalletSync::new(&wallets[2], Arc::clone(&loggers[2])); + if let Ok(contribution) = + funding_template.splice_in_sync(None, Amount::from_sat(10_000), &wallet) + { + let _ = nodes[2].funding_contributed( + &chan_b_id, + &nodes[1].get_our_node_id(), + contribution, + None, + ); + } + }, + Err(e) => { + assert!( + matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), + "{:?}", + e + ); + }, } }, @@ -2217,24 +2192,35 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { .map(|chan| chan.outbound_capacity_msat) .unwrap(); if outbound_capacity_msat >= 20_000_000 { - let contribution = SpliceContribution::splice_out(vec![TxOut { - value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), - script_pubkey: coinbase_tx.output[0].script_pubkey.clone(), - }]); - let funding_feerate_sat_per_kw = - fee_est_a.ret_val.load(atomic::Ordering::Acquire); - if let Err(e) = nodes[0].splice_channel( - &chan_a_id, - &nodes[1].get_our_node_id(), - contribution, - funding_feerate_sat_per_kw, - None, - ) { - assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice pending")), - "{:?}", - e - ); + let feerate_sat_per_kw = + fee_estimators[0].ret_val.load(atomic::Ordering::Acquire); + let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); + match nodes[0].splice_channel(&chan_a_id, &nodes[1].get_our_node_id(), feerate) + { + Ok(funding_template) => { + let outputs = vec![TxOut { + value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), + script_pubkey: coinbase_tx.output[0].script_pubkey.clone(), + }]; + let wallet = WalletSync::new(&wallets[0], Arc::clone(&loggers[0])); + if let Ok(contribution) = + funding_template.splice_out_sync(outputs, &wallet) + { + let _ = nodes[0].funding_contributed( + &chan_a_id, + &nodes[1].get_our_node_id(), + contribution, + None, + ); + } + }, + Err(e) => { + assert!( + matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), + "{:?}", + e + ); + }, } } }, @@ -2246,24 +2232,35 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { .map(|chan| chan.outbound_capacity_msat) .unwrap(); if outbound_capacity_msat >= 20_000_000 { - let contribution = SpliceContribution::splice_out(vec![TxOut { - value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), - script_pubkey: coinbase_tx.output[1].script_pubkey.clone(), - }]); - let funding_feerate_sat_per_kw = - fee_est_b.ret_val.load(atomic::Ordering::Acquire); - if let Err(e) = nodes[1].splice_channel( - &chan_a_id, - &nodes[0].get_our_node_id(), - contribution, - funding_feerate_sat_per_kw, - None, - ) { - assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice pending")), - "{:?}", - e - ); + let feerate_sat_per_kw = + fee_estimators[1].ret_val.load(atomic::Ordering::Acquire); + let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); + match nodes[1].splice_channel(&chan_a_id, &nodes[0].get_our_node_id(), feerate) + { + Ok(funding_template) => { + let outputs = vec![TxOut { + value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), + script_pubkey: coinbase_tx.output[1].script_pubkey.clone(), + }]; + let wallet = WalletSync::new(&wallets[1], Arc::clone(&loggers[1])); + if let Ok(contribution) = + funding_template.splice_out_sync(outputs, &wallet) + { + let _ = nodes[1].funding_contributed( + &chan_a_id, + &nodes[0].get_our_node_id(), + contribution, + None, + ); + } + }, + Err(e) => { + assert!( + matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), + "{:?}", + e + ); + }, } } }, @@ -2275,24 +2272,35 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { .map(|chan| chan.outbound_capacity_msat) .unwrap(); if outbound_capacity_msat >= 20_000_000 { - let contribution = SpliceContribution::splice_out(vec![TxOut { - value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), - script_pubkey: coinbase_tx.output[1].script_pubkey.clone(), - }]); - let funding_feerate_sat_per_kw = - fee_est_b.ret_val.load(atomic::Ordering::Acquire); - if let Err(e) = nodes[1].splice_channel( - &chan_b_id, - &nodes[2].get_our_node_id(), - contribution, - funding_feerate_sat_per_kw, - None, - ) { - assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice pending")), - "{:?}", - e - ); + let feerate_sat_per_kw = + fee_estimators[1].ret_val.load(atomic::Ordering::Acquire); + let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); + match nodes[1].splice_channel(&chan_b_id, &nodes[2].get_our_node_id(), feerate) + { + Ok(funding_template) => { + let outputs = vec![TxOut { + value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), + script_pubkey: coinbase_tx.output[1].script_pubkey.clone(), + }]; + let wallet = WalletSync::new(&wallets[1], Arc::clone(&loggers[1])); + if let Ok(contribution) = + funding_template.splice_out_sync(outputs, &wallet) + { + let _ = nodes[1].funding_contributed( + &chan_b_id, + &nodes[2].get_our_node_id(), + contribution, + None, + ); + } + }, + Err(e) => { + assert!( + matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), + "{:?}", + e + ); + }, } } }, @@ -2304,24 +2312,35 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { .map(|chan| chan.outbound_capacity_msat) .unwrap(); if outbound_capacity_msat >= 20_000_000 { - let contribution = SpliceContribution::splice_out(vec![TxOut { - value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), - script_pubkey: coinbase_tx.output[2].script_pubkey.clone(), - }]); - let funding_feerate_sat_per_kw = - fee_est_c.ret_val.load(atomic::Ordering::Acquire); - if let Err(e) = nodes[2].splice_channel( - &chan_b_id, - &nodes[1].get_our_node_id(), - contribution, - funding_feerate_sat_per_kw, - None, - ) { - assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice pending")), - "{:?}", - e - ); + let feerate_sat_per_kw = + fee_estimators[2].ret_val.load(atomic::Ordering::Acquire); + let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); + match nodes[2].splice_channel(&chan_b_id, &nodes[1].get_our_node_id(), feerate) + { + Ok(funding_template) => { + let outputs = vec![TxOut { + value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), + script_pubkey: coinbase_tx.output[2].script_pubkey.clone(), + }]; + let wallet = WalletSync::new(&wallets[2], Arc::clone(&loggers[2])); + if let Ok(contribution) = + funding_template.splice_out_sync(outputs, &wallet) + { + let _ = nodes[2].funding_contributed( + &chan_b_id, + &nodes[1].get_our_node_id(), + contribution, + None, + ); + } + }, + Err(e) => { + assert!( + matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), + "{:?}", + e + ); + }, } } }, @@ -2609,7 +2628,7 @@ impl SearchingOutput { } } -pub fn chanmon_consistency_test(data: &[u8], out: Out) { +pub fn chanmon_consistency_test(data: &[u8], out: Out) { do_test(data, out.clone(), false); do_test(data, out, true); } diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index c55da4f19ee..2163ca0fb5f 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -22,6 +22,7 @@ use bitcoin::opcodes; use bitcoin::script::{Builder, ScriptBuf}; use bitcoin::transaction::Version; use bitcoin::transaction::{Transaction, TxIn, TxOut}; +use bitcoin::FeeRate; use bitcoin::hash_types::{BlockHash, Txid}; use bitcoin::hashes::sha256::Hash as Sha256; @@ -30,8 +31,6 @@ use bitcoin::hashes::Hash as _; use bitcoin::hex::FromHex; use bitcoin::WPubkeyHash; -use lightning::ln::funding::{FundingTxInput, SpliceContribution}; - use lightning::blinded_path::message::{BlindedMessagePath, MessageContext, MessageForwardNode}; use lightning::blinded_path::payment::{BlindedPaymentPath, ReceiveTlvs}; use lightning::chain; @@ -41,7 +40,7 @@ use lightning::chain::chaininterface::{ use lightning::chain::chainmonitor; use lightning::chain::transaction::OutPoint; use lightning::chain::{BestBlock, ChannelMonitorUpdateStatus, Confirm, Listen}; -use lightning::events::bump_transaction::sync::WalletSourceSync; +use lightning::events::bump_transaction::sync::{WalletSourceSync, WalletSync}; use lightning::events::Event; use lightning::ln::channel_state::ChannelDetails; use lightning::ln::channelmanager::{ChainParameters, ChannelManager, InterceptId, PaymentId}; @@ -65,6 +64,7 @@ use lightning::sign::{ SignerProvider, }; use lightning::types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; +use lightning::util::async_poll::{MaybeSend, MaybeSync}; use lightning::util::config::{ChannelConfig, UserConfig}; use lightning::util::hash_tables::*; use lightning::util::logger::Logger; @@ -227,7 +227,7 @@ type ChannelMan<'a> = ChannelManager< Arc, Arc, Arc, - Arc, + Arc, Arc, Arc, >, @@ -239,14 +239,20 @@ type ChannelMan<'a> = ChannelManager< Arc, &'a FuzzRouter, &'a FuzzRouter, - Arc, + Arc, >; type PeerMan<'a> = PeerManager< Peer<'a>, Arc>, - Arc>>, Arc, Arc>>, + Arc< + P2PGossipSync< + Arc>>, + Arc, + Arc, + >, + >, IgnoringMessageHandler, - Arc, + Arc, IgnoringMessageHandler, Arc, IgnoringMessageHandler, @@ -260,7 +266,7 @@ struct MoneyLossDetector<'a> { Arc, Arc, Arc, - Arc, + Arc, Arc, Arc, >, @@ -285,7 +291,7 @@ impl<'a> MoneyLossDetector<'a> { Arc, Arc, Arc, - Arc, + Arc, Arc, Arc, >, @@ -520,7 +526,7 @@ impl SignerProvider for KeyProvider { } #[inline] -pub fn do_test(mut data: &[u8], logger: &Arc) { +pub fn do_test(mut data: &[u8], logger: &Arc) { if data.len() < 32 { return; } @@ -1024,20 +1030,26 @@ pub fn do_test(mut data: &[u8], logger: &Arc) { if splice_in_sats == 0 { continue; } - // Create a funding input from the coinbase transaction - if let Ok(input) = FundingTxInput::new_p2wpkh(coinbase_tx.clone(), 0) { - let contribution = SpliceContribution::splice_in( - Amount::from_sat(splice_in_sats.min(900_000)), // Cap at available funds minus fees - vec![input], - Some(wallet.get_change_script().unwrap()), - ); - let _ = channelmanager.splice_channel( - &chan.channel_id, - &chan.counterparty.node_id, - contribution, - 253, // funding_feerate_per_kw + let chan_id = chan.channel_id; + let counterparty = chan.counterparty.node_id; + if let Ok(funding_template) = channelmanager.splice_channel( + &chan_id, + &counterparty, + FeeRate::from_sat_per_kwu(253), + ) { + let wallet_sync = WalletSync::new(&wallet, Arc::clone(&logger)); + if let Ok(contribution) = funding_template.splice_in_sync( None, - ); + Amount::from_sat(splice_in_sats.min(900_000)), + &wallet_sync, + ) { + let _ = channelmanager.funding_contributed( + &chan_id, + &counterparty, + contribution, + None, + ); + } } }, // Splice-out: remove funds from a channel @@ -1060,17 +1072,29 @@ pub fn do_test(mut data: &[u8], logger: &Arc) { // Cap splice-out at a reasonable portion of channel capacity let max_splice_out = chan.channel_value_satoshis / 4; let splice_out_sats = splice_out_sats.min(max_splice_out).max(546); // At least dust limit - let contribution = SpliceContribution::splice_out(vec![TxOut { - value: Amount::from_sat(splice_out_sats), - script_pubkey: wallet.get_change_script().unwrap(), - }]); - let _ = channelmanager.splice_channel( - &chan.channel_id, - &chan.counterparty.node_id, - contribution, - 253, // funding_feerate_per_kw - None, - ); + let chan_id = chan.channel_id; + let counterparty = chan.counterparty.node_id; + if let Ok(funding_template) = channelmanager.splice_channel( + &chan_id, + &counterparty, + FeeRate::from_sat_per_kwu(253), + ) { + let outputs = vec![TxOut { + value: Amount::from_sat(splice_out_sats), + script_pubkey: wallet.get_change_script().unwrap(), + }]; + let wallet_sync = WalletSync::new(&wallet, Arc::clone(&logger)); + if let Ok(contribution) = + funding_template.splice_out_sync(outputs, &wallet_sync) + { + let _ = channelmanager.funding_contributed( + &chan_id, + &counterparty, + contribution, + None, + ); + } + } }, _ => return, } @@ -1137,14 +1161,15 @@ pub fn do_test(mut data: &[u8], logger: &Arc) { } } -pub fn full_stack_test(data: &[u8], out: Out) { - let logger: Arc = Arc::new(test_logger::TestLogger::new("".to_owned(), out)); +pub fn full_stack_test(data: &[u8], out: Out) { + let logger: Arc = + Arc::new(test_logger::TestLogger::new("".to_owned(), out)); do_test(data, &logger); } #[no_mangle] pub extern "C" fn full_stack_run(data: *const u8, datalen: usize) { - let logger: Arc = + let logger: Arc = Arc::new(test_logger::TestLogger::new("".to_owned(), test_logger::DevNull {})); do_test(unsafe { std::slice::from_raw_parts(data, datalen) }, &logger); } @@ -1930,6 +1955,7 @@ pub fn write_fst_seeds(path: &str) { #[cfg(test)] mod tests { + use lightning::util::async_poll::{MaybeSend, MaybeSync}; use lightning::util::logger::{Logger, Record}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -1961,7 +1987,7 @@ mod tests { let test = super::two_peer_forwarding_seed(); let logger = Arc::new(TrackingLogger { lines: Mutex::new(HashMap::new()) }); - super::do_test(&test, &(Arc::clone(&logger) as Arc)); + super::do_test(&test, &(Arc::clone(&logger) as Arc)); let log_entries = logger.lines.lock().unwrap(); // 1 @@ -1996,7 +2022,7 @@ mod tests { let test = super::gossip_exchange_seed(); let logger = Arc::new(TrackingLogger { lines: Mutex::new(HashMap::new()) }); - super::do_test(&test, &(Arc::clone(&logger) as Arc)); + super::do_test(&test, &(Arc::clone(&logger) as Arc)); let log_entries = logger.lines.lock().unwrap(); assert_eq!(log_entries.get(&("lightning::ln::peer_handler".to_string(), "Sending message to all peers except Some(PublicKey(0000000000000000000000000000000000000000000000000000000000000002ff00000000000000000000000000000000000000000000000000000000000002)) or the announced channel's counterparties: ChannelAnnouncement { node_signature_1: 3026020200b202200303030303030303030303030303030303030303030303030303030303030303, node_signature_2: 3026020200b202200202020202020202020202020202020202020202020202020202020202020202, bitcoin_signature_1: 3026020200b202200303030303030303030303030303030303030303030303030303030303030303, bitcoin_signature_2: 3026020200b202200202020202020202020202020202020202020202020202020202020202020202, contents: UnsignedChannelAnnouncement { features: [], chain_hash: 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000, short_channel_id: 42, node_id_1: NodeId(030303030303030303030303030303030303030303030303030303030303030303), node_id_2: NodeId(020202020202020202020202020202020202020202020202020202020202020202), bitcoin_key_1: NodeId(030303030303030303030303030303030303030303030303030303030303030303), bitcoin_key_2: NodeId(020202020202020202020202020202020202020202020202020202020202020202), excess_data: [] } }".to_string())), Some(&1)); @@ -2009,7 +2035,7 @@ mod tests { let test = super::splice_seed(); let logger = Arc::new(TrackingLogger { lines: Mutex::new(HashMap::new()) }); - super::do_test(&test, &(Arc::clone(&logger) as Arc)); + super::do_test(&test, &(Arc::clone(&logger) as Arc)); let log_entries = logger.lines.lock().unwrap(); diff --git a/lightning-tests/src/upgrade_downgrade_tests.rs b/lightning-tests/src/upgrade_downgrade_tests.rs index 14b0a5c5822..dde194105c3 100644 --- a/lightning-tests/src/upgrade_downgrade_tests.rs +++ b/lightning-tests/src/upgrade_downgrade_tests.rs @@ -49,7 +49,6 @@ use lightning::chain::channelmonitor::{ANTI_REORG_DELAY, HTLC_FAIL_BACK_BUFFER}; use lightning::events::bump_transaction::sync::WalletSourceSync; use lightning::events::{ClosureReason, Event, HTLCHandlingFailureType}; use lightning::ln::functional_test_utils::*; -use lightning::ln::funding::SpliceContribution; use lightning::ln::msgs::BaseMessageHandler as _; use lightning::ln::msgs::ChannelMessageHandler as _; use lightning::ln::msgs::MessageSendEvent; @@ -453,11 +452,13 @@ fn do_test_0_1_htlc_forward_after_splice(fail_htlc: bool) { reconnect_b_c_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_b_c_args); - let contribution = SpliceContribution::splice_out(vec![TxOut { + let outputs = vec![TxOut { value: Amount::from_sat(1_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), - }]); - let splice_tx = splice_channel(&nodes[0], &nodes[1], ChannelId(chan_id_bytes_a), contribution); + }]; + let channel_id = ChannelId(chan_id_bytes_a); + let funding_contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs); + let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); for node in nodes.iter() { mine_transaction(node, &splice_tx); connect_blocks(node, ANTI_REORG_DELAY - 1); diff --git a/lightning/src/ln/async_signer_tests.rs b/lightning/src/ln/async_signer_tests.rs index b81279c10ac..f34a2b3275c 100644 --- a/lightning/src/ln/async_signer_tests.rs +++ b/lightning/src/ln/async_signer_tests.rs @@ -11,8 +11,7 @@ //! properly with a signer implementation that asynchronously derives signatures. use crate::events::bump_transaction::sync::WalletSourceSync; -use crate::ln::funding::SpliceContribution; -use crate::ln::splicing_tests::negotiate_splice_tx; +use crate::ln::splicing_tests::{initiate_splice_out, negotiate_splice_tx}; use crate::prelude::*; use crate::util::ser::Writeable; use bitcoin::secp256k1::Secp256k1; @@ -1573,10 +1572,11 @@ fn test_async_splice_initial_commit_sig() { ); // Negotiate a splice up until the signature exchange. - let contribution = SpliceContribution::splice_out(vec![TxOut { + let outputs = vec![TxOut { value: Amount::from_sat(1_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), - }]); + }]; + let contribution = initiate_splice_out(initiator, acceptor, channel_id, outputs); negotiate_splice_tx(initiator, acceptor, channel_id, contribution); assert!(initiator.node.get_and_clear_pending_msg_events().is_empty()); diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 48b52992953..e000ebc93eb 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -11,7 +11,7 @@ use bitcoin::absolute::LockTime; use bitcoin::amount::{Amount, SignedAmount}; use bitcoin::consensus::encode; use bitcoin::constants::ChainHash; -use bitcoin::script::{Builder, Script, ScriptBuf, WScriptHash}; +use bitcoin::script::{Builder, Script, ScriptBuf}; use bitcoin::sighash::EcdsaSighashType; use bitcoin::transaction::{Transaction, TxOut}; use bitcoin::Witness; @@ -36,6 +36,7 @@ use crate::chain::channelmonitor::{ }; use crate::chain::transaction::{OutPoint, TransactionData}; use crate::chain::BestBlock; +use crate::events::bump_transaction::Input; use crate::events::{ClosureReason, FundingInfo}; use crate::ln::chan_utils; use crate::ln::chan_utils::{ @@ -43,7 +44,7 @@ use crate::ln::chan_utils::{ selected_commitment_sat_per_1000_weight, ChannelPublicKeys, ChannelTransactionParameters, ClosingTransaction, CommitmentTransaction, CounterpartyChannelTransactionParameters, CounterpartyCommitmentSecrets, HTLCOutputInCommitment, HolderCommitmentTransaction, - BASE_INPUT_WEIGHT, EMPTY_SCRIPT_SIG_WEIGHT, FUNDING_TRANSACTION_WITNESS_WEIGHT, + EMPTY_SCRIPT_SIG_WEIGHT, FUNDING_TRANSACTION_WITNESS_WEIGHT, }; use crate::ln::channel_state::{ ChannelShutdownState, CounterpartyForwardingInfo, InboundHTLCDetails, InboundHTLCStateDetails, @@ -55,12 +56,11 @@ use crate::ln::channelmanager::{ PendingHTLCStatus, RAACommitmentOrder, SentHTLCId, BREAKDOWN_TIMEOUT, MAX_LOCAL_BREAKDOWN_TIMEOUT, MIN_CLTV_EXPIRY_DELTA, }; -use crate::ln::funding::{FundingTxInput, SpliceContribution}; +use crate::ln::funding::{FundingContribution, FundingTemplate, FundingTxInput}; use crate::ln::interactivetxs::{ calculate_change_output_value, get_output_weight, AbortReason, HandleTxCompleteValue, InteractiveTxConstructor, InteractiveTxConstructorArgs, InteractiveTxMessageSend, InteractiveTxSigningSession, NegotiationError, SharedOwnedInput, SharedOwnedOutput, - TX_COMMON_FIELDS_WEIGHT, }; use crate::ln::msgs; use crate::ln::msgs::{ClosingSigned, ClosingSignedFeeRange, DecodeError, OnionErrorPacket}; @@ -69,7 +69,6 @@ use crate::ln::onion_utils::{ }; use crate::ln::script::{self, ShutdownScript}; use crate::ln::types::ChannelId; -use crate::ln::LN_MAX_MSG_LEN; use crate::offers::static_invoice::StaticInvoice; use crate::routing::gossip::NodeId; use crate::sign::ecdsa::EcdsaChannelSigner; @@ -2686,6 +2685,20 @@ impl FundingScope { self.channel_transaction_parameters.funding_outpoint } + /// Gets the funding output for this channel, if available. + /// + /// When a channel is spliced, this continues to refer to the original funding output (which + /// was spent by the splice transaction) until the splice transaction reaches sufficient + /// confirmations to be locked (and we exchange `splice_locked` messages with our peer). + pub fn get_funding_output(&self) -> Option { + self.channel_transaction_parameters.make_funding_redeemscript_opt().map(|redeem_script| { + TxOut { + value: Amount::from_sat(self.get_value_satoshis()), + script_pubkey: redeem_script.to_p2wsh(), + } + }) + } + fn get_funding_txid(&self) -> Option { self.channel_transaction_parameters.funding_outpoint.map(|txo| txo.txid) } @@ -3010,7 +3023,12 @@ impl_writeable_tlv_based!(SpliceInstructions, { #[derive(Debug)] pub(crate) enum QuiescentAction { - Splice(SpliceInstructions), + // Deprecated in favor of the Splice variant and no longer produced as of LDK 0.3. + LegacySplice(SpliceInstructions), + Splice { + contribution: FundingContribution, + locktime: LockTime, + }, #[cfg(any(test, fuzzing))] DoNothing, } @@ -3023,11 +3041,19 @@ pub(crate) enum StfuResponse { #[cfg(any(test, fuzzing))] impl_writeable_tlv_based_enum_upgradable!(QuiescentAction, (0, DoNothing) => {}, - {1, Splice} => (), + (2, Splice) => { + (0, contribution, required), + (1, locktime, required), + }, + {1, LegacySplice} => (), ); #[cfg(not(any(test, fuzzing)))] -impl_writeable_tlv_based_enum_upgradable!(QuiescentAction,, - {1, Splice} => (), +impl_writeable_tlv_based_enum_upgradable!(QuiescentAction, + (2, Splice) => { + (0, contribution, required), + (1, locktime, required), + }, + {1, LegacySplice} => (), ); /// Wrapper around a [`Transaction`] useful for caching the result of [`Transaction::compute_txid`]. @@ -6632,130 +6658,6 @@ fn get_v2_channel_reserve_satoshis(channel_value_satoshis: u64, dust_limit_satos cmp::min(channel_value_satoshis, cmp::max(q, dust_limit_satoshis)) } -fn check_splice_contribution_sufficient( - contribution: &SpliceContribution, is_initiator: bool, funding_feerate: FeeRate, -) -> Result { - if contribution.inputs().is_empty() { - let estimated_fee = Amount::from_sat(estimate_v2_funding_transaction_fee( - contribution.inputs(), - contribution.outputs(), - is_initiator, - true, // is_splice - funding_feerate.to_sat_per_kwu() as u32, - )); - - let contribution_amount = contribution.net_value(); - contribution_amount - .checked_sub( - estimated_fee.to_signed().expect("fees should never exceed Amount::MAX_MONEY"), - ) - .ok_or(format!( - "{estimated_fee} splice-out amount plus {} fee estimate exceeds the total bitcoin supply", - contribution_amount.unsigned_abs(), - )) - } else { - check_v2_funding_inputs_sufficient( - contribution.value_added(), - contribution.inputs(), - contribution.outputs(), - is_initiator, - true, - funding_feerate.to_sat_per_kwu() as u32, - ) - .map(|_| contribution.net_value()) - } -} - -/// Estimate our part of the fee of the new funding transaction. -#[allow(dead_code)] // TODO(dual_funding): TODO(splicing): Remove allow once used. -#[rustfmt::skip] -fn estimate_v2_funding_transaction_fee( - funding_inputs: &[FundingTxInput], outputs: &[TxOut], is_initiator: bool, is_splice: bool, - funding_feerate_sat_per_1000_weight: u32, -) -> u64 { - let input_weight: u64 = funding_inputs - .iter() - .map(|input| BASE_INPUT_WEIGHT.saturating_add(input.utxo.satisfaction_weight)) - .fold(0, |total_weight, input_weight| total_weight.saturating_add(input_weight)); - - let output_weight: u64 = outputs - .iter() - .map(|txout| txout.weight().to_wu()) - .fold(0, |total_weight, output_weight| total_weight.saturating_add(output_weight)); - - let mut weight = input_weight.saturating_add(output_weight); - - // The initiator pays for all common fields and the shared output in the funding transaction. - if is_initiator { - weight = weight - .saturating_add(TX_COMMON_FIELDS_WEIGHT) - // The weight of the funding output, a P2WSH output - // NOTE: The witness script hash given here is irrelevant as it's a fixed size and we just want - // to calculate the contributed weight, so we use an all-zero hash. - .saturating_add(get_output_weight(&ScriptBuf::new_p2wsh( - &WScriptHash::from_raw_hash(Hash::all_zeros()) - )).to_wu()); - - // The splice initiator pays for the input spending the previous funding output. - if is_splice { - weight = weight - .saturating_add(BASE_INPUT_WEIGHT) - .saturating_add(EMPTY_SCRIPT_SIG_WEIGHT) - .saturating_add(FUNDING_TRANSACTION_WITNESS_WEIGHT); - #[cfg(feature = "grind_signatures")] - { - // Guarantees a low R signature - weight -= 1; - } - } - } - - fee_for_weight(funding_feerate_sat_per_1000_weight, weight) -} - -/// Verify that the provided inputs to the funding transaction are enough -/// to cover the intended contribution amount *plus* the proportional fees. -/// Fees are computed using `estimate_v2_funding_transaction_fee`, and contain -/// the fees of the inputs, fees of the inputs weight, and for the initiator, -/// the fees of the common fields as well as the output and extra input weights. -/// Returns estimated (partial) fees as additional information -#[rustfmt::skip] -fn check_v2_funding_inputs_sufficient( - contributed_input_value: Amount, funding_inputs: &[FundingTxInput], outputs: &[TxOut], - is_initiator: bool, is_splice: bool, funding_feerate_sat_per_1000_weight: u32, -) -> Result { - let estimated_fee = Amount::from_sat(estimate_v2_funding_transaction_fee( - funding_inputs, outputs, is_initiator, is_splice, funding_feerate_sat_per_1000_weight, - )); - - let mut total_input_value = Amount::ZERO; - for FundingTxInput { utxo, .. } in funding_inputs.iter() { - total_input_value = total_input_value.checked_add(utxo.output.value) - .ok_or("Sum of input values is greater than the total bitcoin supply")?; - } - - // If the inputs are enough to cover intended contribution amount, with fees even when - // there is a change output, we are fine. - // If the inputs are less, but enough to cover intended contribution amount, with - // (lower) fees with no change, we are also fine (change will not be generated). - // So it's enough to check considering the lower, no-change fees. - // - // Note: dust limit is not relevant in this check. - // - // TODO(splicing): refine check including the fact wether a change will be added or not. - // Can be done once dual funding preparation is included. - - let minimal_input_amount_needed = contributed_input_value.checked_add(estimated_fee) - .ok_or(format!("{contributed_input_value} contribution plus {estimated_fee} fee estimate exceeds the total bitcoin supply"))?; - if total_input_value < minimal_input_amount_needed { - Err(format!( - "Total input amount {total_input_value} is lower than needed for splice-in contribution {contributed_input_value}, considering fees of {estimated_fee}. Need more inputs.", - )) - } else { - Ok(estimated_fee) - } -} - /// Context for negotiating channels (dual-funded V2 open, splicing) #[derive(Debug)] pub(super) struct FundingNegotiationContext { @@ -7121,7 +7023,7 @@ where self.reset_pending_splice_state() } else { match self.quiescent_action.take() { - Some(QuiescentAction::Splice(instructions)) => { + Some(QuiescentAction::LegacySplice(instructions)) => { self.context.channel_state.clear_awaiting_quiescence(); let (inputs, outputs) = instructions.into_contributed_inputs_and_outputs(); Some(SpliceFundingFailed { @@ -7131,6 +7033,16 @@ where contributed_outputs: outputs, }) }, + Some(QuiescentAction::Splice { contribution, .. }) => { + self.context.channel_state.clear_awaiting_quiescence(); + let (inputs, outputs) = contribution.into_contributed_inputs_and_outputs(); + Some(SpliceFundingFailed { + funding_txo: None, + channel_type: None, + contributed_inputs: inputs, + contributed_outputs: outputs, + }) + }, #[cfg(any(test, fuzzing))] Some(quiescent_action) => { self.quiescent_action = Some(quiescent_action); @@ -11551,7 +11463,12 @@ where self.get_announcement_sigs(node_signer, chain_hash, user_config, block_height, logger); if let Some(quiescent_action) = self.quiescent_action.as_ref() { - if matches!(quiescent_action, QuiescentAction::Splice(_)) { + // TODO(splicing): If we didn't win quiescence, then we can contribute as an acceptor + // instead of waiting for the splice to lock. + if matches!( + quiescent_action, + QuiescentAction::Splice { .. } | QuiescentAction::LegacySplice(_) + ) { self.context.channel_state.set_awaiting_quiescence(); } } @@ -12196,14 +12113,7 @@ where } /// Initiate splicing. - /// - `our_funding_inputs`: the inputs we contribute to the new funding transaction. - /// Includes the witness weight for this input (e.g. P2WPKH_WITNESS_WEIGHT=109 for typical P2WPKH inputs). - /// - `change_script`: an option change output script. If `None` and needed, one will be - /// generated by `SignerProvider::get_destination_script`. - pub fn splice_channel( - &mut self, contribution: SpliceContribution, funding_feerate_per_kw: u32, locktime: u32, - logger: &L, - ) -> Result, APIError> { + pub fn splice_channel(&mut self, feerate: FeeRate) -> Result { if self.holder_commitment_point.current_point().is_none() { return Err(APIError::APIMisuseError { err: format!( @@ -12213,17 +12123,29 @@ where }); } - // Check if a splice has been initiated already. - // Note: only a single outstanding splice is supported (per spec) - if self.pending_splice.is_some() || self.quiescent_action.is_some() { + if self.quiescent_action.is_some() { return Err(APIError::APIMisuseError { err: format!( - "Channel {} cannot be spliced, as it has already a splice pending", + "Channel {} cannot be spliced as one is waiting to be negotiated", self.context.channel_id(), ), }); } + if let Some(pending_splice) = &self.pending_splice { + if let Some(funding_negotiation) = &pending_splice.funding_negotiation { + debug_assert!(self.context.channel_state.is_quiescent()); + if funding_negotiation.is_initiator() { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot be spliced as one is currently being negotiated", + self.context.channel_id(), + ), + }); + } + } + } + if !self.context.is_usable() { return Err(APIError::APIMisuseError { err: format!( @@ -12233,81 +12155,68 @@ where }); } - let our_funding_contribution = contribution.net_value(); - if our_funding_contribution == SignedAmount::ZERO { - return Err(APIError::APIMisuseError { - err: format!( - "Channel {} cannot be spliced; contribution cannot be zero", - self.context.channel_id(), - ), - }); - } + let funding_txo = self.funding.get_funding_txo().expect("funding_txo should be set"); + let previous_utxo = + self.funding.get_funding_output().expect("funding_output should be set"); + let shared_input = Input { + outpoint: funding_txo.into_bitcoin_outpoint(), + previous_utxo, + satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + FUNDING_TRANSACTION_WITNESS_WEIGHT, + }; - // Fees for splice-out are paid from the channel balance whereas fees for splice-in - // are paid by the funding inputs. Therefore, in the case of splice-out, we add the - // fees on top of the user-specified contribution. We leave the user-specified - // contribution as-is for splice-ins. - let adjusted_funding_contribution = check_splice_contribution_sufficient( - &contribution, - true, - FeeRate::from_sat_per_kwu(u64::from(funding_feerate_per_kw)), - ) - .map_err(|e| APIError::APIMisuseError { - err: format!( - "Channel {} cannot be {}; {}", - self.context.channel_id(), - if our_funding_contribution.is_positive() { "spliced in" } else { "spliced out" }, - e - ), - })?; + Ok(FundingTemplate::new(Some(shared_input), feerate, true)) + } - // Note: post-splice channel value is not yet known at this point, counterparty contribution is not known - // (Cannot test for miminum required post-splice channel value) - let their_funding_contribution = SignedAmount::ZERO; - self.validate_splice_contributions( - adjusted_funding_contribution, - their_funding_contribution, - ) - .map_err(|err| APIError::APIMisuseError { err })?; - - for FundingTxInput { utxo, prevtx, .. } in contribution.inputs().iter() { - const MESSAGE_TEMPLATE: msgs::TxAddInput = msgs::TxAddInput { - channel_id: ChannelId([0; 32]), - serial_id: 0, - prevtx: None, - prevtx_out: 0, - sequence: 0, - // Mutually exclusive with prevtx, which is accounted for below. - shared_input_txid: None, - }; - let message_len = MESSAGE_TEMPLATE.serialized_length() + prevtx.serialized_length(); - if message_len > LN_MAX_MSG_LEN { - return Err(APIError::APIMisuseError { - err: format!( - "Funding input references a prevtx that is too large for tx_add_input: {}", - utxo.outpoint, - ), - }); - } - } + pub fn funding_contributed( + &mut self, contribution: FundingContribution, locktime: LockTime, logger: &L, + ) -> Result, SpliceFundingFailed> { + debug_assert!(contribution.is_splice()); - let (our_funding_inputs, our_funding_outputs, change_script) = contribution.into_tx_parts(); + if let Err(e) = contribution.net_value().and_then(|our_funding_contribution| { + // For splice-out, our_funding_contribution is adjusted to cover fees if there + // aren't any inputs. + self.validate_splice_contributions(our_funding_contribution, SignedAmount::ZERO) + }) { + log_error!(logger, "Channel {} cannot be funded: {}", self.context.channel_id(), e); - let action = QuiescentAction::Splice(SpliceInstructions { - adjusted_funding_contribution, - our_funding_inputs, - our_funding_outputs, - change_script, - funding_feerate_per_kw, - locktime, - }); - self.propose_quiescence(logger, action) - .map_err(|e| APIError::APIMisuseError { err: e.to_owned() }) + let (contributed_inputs, contributed_outputs) = + contribution.into_contributed_inputs_and_outputs(); + + return Err(SpliceFundingFailed { + funding_txo: None, + channel_type: None, + contributed_inputs, + contributed_outputs, + }); + } + + self.propose_quiescence(logger, QuiescentAction::Splice { contribution, locktime }).map_err( + |(e, action)| { + log_error!(logger, "{}", e); + // FIXME: Any better way to do this? + if let QuiescentAction::Splice { contribution, .. } = action { + let (contributed_inputs, contributed_outputs) = + contribution.into_contributed_inputs_and_outputs(); + SpliceFundingFailed { + funding_txo: None, + channel_type: None, + contributed_inputs, + contributed_outputs, + } + } else { + debug_assert!(false); + SpliceFundingFailed { + funding_txo: None, + channel_type: None, + contributed_inputs: vec![], + contributed_outputs: vec![], + } + } + }, + ) } fn send_splice_init(&mut self, instructions: SpliceInstructions) -> msgs::SpliceInit { - debug_assert!(self.pending_splice.is_none()); - let SpliceInstructions { adjusted_funding_contribution, our_funding_inputs, @@ -12329,6 +12238,13 @@ where change_script, }; + self.send_splice_init_internal(context) + } + + fn send_splice_init_internal( + &mut self, context: FundingNegotiationContext, + ) -> msgs::SpliceInit { + debug_assert!(self.pending_splice.is_none()); // Rotate the funding pubkey using the prev_funding_txid as a tweak let prev_funding_txid = self.funding.get_funding_txid(); let funding_pubkey = match (prev_funding_txid, &self.context.holder_signer) { @@ -12343,6 +12259,10 @@ where _ => todo!(), }; + let funding_feerate_per_kw = context.funding_feerate_sat_per_1000_weight; + let funding_contribution_satoshis = context.our_funding_contribution.to_sat(); + let locktime = context.funding_tx_locktime.to_consensus_u32(); + let funding_negotiation = FundingNegotiation::AwaitingAck { context, new_holder_funding_key: funding_pubkey }; self.pending_splice = Some(PendingFunding { @@ -12354,7 +12274,7 @@ where msgs::SpliceInit { channel_id: self.context.channel_id, - funding_contribution_satoshis: adjusted_funding_contribution.to_sat(), + funding_contribution_satoshis, funding_feerate_per_kw, locktime, funding_pubkey, @@ -12421,7 +12341,7 @@ where } // TODO(splicing): Once splice acceptor can contribute, check that inputs are sufficient, - // similarly to the check in `splice_channel`. + // similarly to the check in `funding_contributed`. debug_assert_eq!(our_funding_contribution, SignedAmount::ZERO); let their_funding_contribution = SignedAmount::from_sat(msg.funding_contribution_satoshis); @@ -13408,14 +13328,14 @@ where #[rustfmt::skip] pub fn propose_quiescence( &mut self, logger: &L, action: QuiescentAction, - ) -> Result, &'static str> { + ) -> Result, (&'static str, QuiescentAction)> { log_debug!(logger, "Attempting to initiate quiescence"); if !self.context.is_usable() { - return Err("Channel is not in a usable state to propose quiescence"); + return Err(("Channel is not in a usable state to propose quiescence", action)); } if self.quiescent_action.is_some() { - return Err("Channel already has a pending quiescent action and cannot start another"); + return Err(("Channel already has a pending quiescent action and cannot start another", action)); } self.quiescent_action = Some(action); @@ -13556,9 +13476,10 @@ where "Internal Error: Didn't have anything to do after reaching quiescence".to_owned() )); }, - Some(QuiescentAction::Splice(instructions)) => { + Some(QuiescentAction::LegacySplice(instructions)) => { if self.pending_splice.is_some() { - self.quiescent_action = Some(QuiescentAction::Splice(instructions)); + debug_assert!(false); + self.quiescent_action = Some(QuiescentAction::LegacySplice(instructions)); return Err(ChannelError::WarnAndDisconnect( format!( @@ -13571,6 +13492,53 @@ where let splice_init = self.send_splice_init(instructions); return Ok(Some(StfuResponse::SpliceInit(splice_init))); }, + Some(QuiescentAction::Splice { contribution, locktime }) => { + // TODO(splicing): If the splice has been negotiated but has not been locked, we + // can RBF here to add the contribution. + if self.pending_splice.is_some() { + debug_assert!(false); + self.quiescent_action = + Some(QuiescentAction::Splice { contribution, locktime }); + + return Err(ChannelError::WarnAndDisconnect( + format!( + "Channel {} cannot be spliced as it already has a splice pending", + self.context.channel_id(), + ), + )); + } + + let prev_funding_input = self.funding.to_splice_funding_input(); + let is_initiator = contribution.is_initiator(); + let our_funding_contribution = match contribution.net_value() { + Ok(net_value) => net_value, + Err(e) => { + debug_assert!(false); + return Err(ChannelError::WarnAndDisconnect( + format!( + "Internal Error: Insufficient funding contribution: {}", + e, + ) + )); + }, + }; + let funding_feerate_per_kw = contribution.feerate().to_sat_per_kwu() as u32; + let (our_funding_inputs, our_funding_outputs, change_script) = contribution.into_tx_parts(); + + let context = FundingNegotiationContext { + is_initiator, + our_funding_contribution, + funding_tx_locktime: locktime, + funding_feerate_sat_per_1000_weight: funding_feerate_per_kw, + shared_funding_input: Some(prev_funding_input), + our_funding_inputs, + our_funding_outputs, + change_script, + }; + + let splice_init = self.send_splice_init_internal(context); + return Ok(Some(StfuResponse::SpliceInit(splice_init))); + }, #[cfg(any(test, fuzzing))] Some(QuiescentAction::DoNothing) => { // In quiescence test we want to just hang out here, letting the test manually @@ -16130,7 +16098,6 @@ mod tests { }; use crate::ln::channel_keys::{RevocationBasepoint, RevocationKey}; use crate::ln::channelmanager::{self, HTLCSource, PaymentId}; - use crate::ln::funding::FundingTxInput; use crate::ln::msgs; use crate::ln::msgs::{ChannelUpdate, UnsignedChannelUpdate, MAX_VALUE_MSAT}; use crate::ln::onion_utils::{AttributionData, LocalHTLCFailureReason}; @@ -16162,7 +16129,7 @@ mod tests { use bitcoin::secp256k1::{ecdsa::Signature, Secp256k1}; use bitcoin::secp256k1::{PublicKey, SecretKey}; use bitcoin::transaction::{Transaction, TxOut, Version}; - use bitcoin::{ScriptBuf, WPubkeyHash, WitnessProgram, WitnessVersion}; + use bitcoin::{WitnessProgram, WitnessVersion}; use std::cmp; fn dummy_inbound_update_add() -> InboundUpdateAdd { @@ -18510,250 +18477,6 @@ mod tests { assert!(node_a_chan.check_get_channel_ready(0, &&logger).is_some()); } - #[test] - #[rustfmt::skip] - fn test_estimate_v2_funding_transaction_fee() { - use crate::ln::channel::estimate_v2_funding_transaction_fee; - - let one_input = [funding_input_sats(1_000)]; - let two_inputs = [funding_input_sats(1_000), funding_input_sats(1_000)]; - - // 2 inputs, initiator, 2000 sat/kw feerate - assert_eq!( - estimate_v2_funding_transaction_fee(&two_inputs, &[], true, false, 2000), - if cfg!(feature = "grind_signatures") { 1512 } else { 1516 }, - ); - - // higher feerate - assert_eq!( - estimate_v2_funding_transaction_fee(&two_inputs, &[], true, false, 3000), - if cfg!(feature = "grind_signatures") { 2268 } else { 2274 }, - ); - - // only 1 input - assert_eq!( - estimate_v2_funding_transaction_fee(&one_input, &[], true, false, 2000), - if cfg!(feature = "grind_signatures") { 970 } else { 972 }, - ); - - // 0 inputs - assert_eq!( - estimate_v2_funding_transaction_fee(&[], &[], true, false, 2000), - 428, - ); - - // not initiator - assert_eq!( - estimate_v2_funding_transaction_fee(&[], &[], false, false, 2000), - 0, - ); - - // splice initiator - assert_eq!( - estimate_v2_funding_transaction_fee(&one_input, &[], true, true, 2000), - if cfg!(feature = "grind_signatures") { 1736 } else { 1740 }, - ); - - // splice acceptor - assert_eq!( - estimate_v2_funding_transaction_fee(&one_input, &[], false, true, 2000), - if cfg!(feature = "grind_signatures") { 542 } else { 544 }, - ); - } - - #[rustfmt::skip] - fn funding_input_sats(input_value_sats: u64) -> FundingTxInput { - let prevout = TxOut { - value: Amount::from_sat(input_value_sats), - script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()), - }; - let prevtx = Transaction { - input: vec![], output: vec![prevout], - version: Version::TWO, lock_time: bitcoin::absolute::LockTime::ZERO, - }; - - FundingTxInput::new_p2wpkh(prevtx, 0).unwrap() - } - - fn funding_output_sats(output_value_sats: u64) -> TxOut { - TxOut { - value: Amount::from_sat(output_value_sats), - script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()), - } - } - - #[test] - #[rustfmt::skip] - fn test_check_v2_funding_inputs_sufficient() { - use crate::ln::channel::check_v2_funding_inputs_sufficient; - - // positive case, inputs well over intended contribution - { - let expected_fee = if cfg!(feature = "grind_signatures") { 2278 } else { 2284 }; - assert_eq!( - check_v2_funding_inputs_sufficient( - Amount::from_sat(220_000), - &[ - funding_input_sats(200_000), - funding_input_sats(100_000), - ], - &[], - true, - true, - 2000, - ).unwrap(), - Amount::from_sat(expected_fee), - ); - } - - // Net splice-in - { - let expected_fee = if cfg!(feature = "grind_signatures") { 2526 } else { 2532 }; - assert_eq!( - check_v2_funding_inputs_sufficient( - Amount::from_sat(220_000), - &[ - funding_input_sats(200_000), - funding_input_sats(100_000), - ], - &[ - funding_output_sats(200_000), - ], - true, - true, - 2000, - ).unwrap(), - Amount::from_sat(expected_fee), - ); - } - - // Net splice-out - { - let expected_fee = if cfg!(feature = "grind_signatures") { 2526 } else { 2532 }; - assert_eq!( - check_v2_funding_inputs_sufficient( - Amount::from_sat(220_000), - &[ - funding_input_sats(200_000), - funding_input_sats(100_000), - ], - &[ - funding_output_sats(400_000), - ], - true, - true, - 2000, - ).unwrap(), - Amount::from_sat(expected_fee), - ); - } - - // Net splice-out, inputs insufficient to cover fees - { - let expected_fee = if cfg!(feature = "grind_signatures") { 113670 } else { 113940 }; - assert_eq!( - check_v2_funding_inputs_sufficient( - Amount::from_sat(220_000), - &[ - funding_input_sats(200_000), - funding_input_sats(100_000), - ], - &[ - funding_output_sats(400_000), - ], - true, - true, - 90000, - ), - Err(format!( - "Total input amount 0.00300000 BTC is lower than needed for splice-in contribution 0.00220000 BTC, considering fees of {}. Need more inputs.", - Amount::from_sat(expected_fee), - )), - ); - } - - // negative case, inputs clearly insufficient - { - let expected_fee = if cfg!(feature = "grind_signatures") { 1736 } else { 1740 }; - assert_eq!( - check_v2_funding_inputs_sufficient( - Amount::from_sat(220_000), - &[ - funding_input_sats(100_000), - ], - &[], - true, - true, - 2000, - ), - Err(format!( - "Total input amount 0.00100000 BTC is lower than needed for splice-in contribution 0.00220000 BTC, considering fees of {}. Need more inputs.", - Amount::from_sat(expected_fee), - )), - ); - } - - // barely covers - { - let expected_fee = if cfg!(feature = "grind_signatures") { 2278 } else { 2284 }; - assert_eq!( - check_v2_funding_inputs_sufficient( - Amount::from_sat(300_000 - expected_fee - 20), - &[ - funding_input_sats(200_000), - funding_input_sats(100_000), - ], - &[], - true, - true, - 2000, - ).unwrap(), - Amount::from_sat(expected_fee), - ); - } - - // higher fee rate, does not cover - { - let expected_fee = if cfg!(feature = "grind_signatures") { 2506 } else { 2513 }; - assert_eq!( - check_v2_funding_inputs_sufficient( - Amount::from_sat(298032), - &[ - funding_input_sats(200_000), - funding_input_sats(100_000), - ], - &[], - true, - true, - 2200, - ), - Err(format!( - "Total input amount 0.00300000 BTC is lower than needed for splice-in contribution 0.00298032 BTC, considering fees of {}. Need more inputs.", - Amount::from_sat(expected_fee), - )), - ); - } - - // barely covers, less fees (no extra weight, not initiator) - { - let expected_fee = if cfg!(feature = "grind_signatures") { 1084 } else { 1088 }; - assert_eq!( - check_v2_funding_inputs_sufficient( - Amount::from_sat(300_000 - expected_fee - 20), - &[ - funding_input_sats(200_000), - funding_input_sats(100_000), - ], - &[], - false, - false, - 2000, - ).unwrap(), - Amount::from_sat(expected_fee), - ); - } - } - fn get_pre_and_post( pre_channel_value: u64, our_funding_contribution: i64, their_funding_contribution: i64, ) -> (u64, u64) { diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index eae26cc2d91..f70f4b133d0 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -30,7 +30,7 @@ use bitcoin::hashes::{Hash, HashEngine, HmacEngine}; use bitcoin::secp256k1::Secp256k1; use bitcoin::secp256k1::{PublicKey, SecretKey}; -use bitcoin::{secp256k1, Sequence, SignedAmount}; +use bitcoin::{secp256k1, FeeRate, Sequence, SignedAmount}; use crate::blinded_path::message::{ AsyncPaymentsContext, BlindedMessagePath, MessageForwardNode, OffersContext, @@ -64,7 +64,7 @@ use crate::ln::channel::{ UpdateFulfillCommitFetch, WithChannelContext, }; use crate::ln::channel_state::ChannelDetails; -use crate::ln::funding::SpliceContribution; +use crate::ln::funding::{FundingContribution, FundingTemplate}; use crate::ln::inbound_payment; use crate::ln::interactivetxs::InteractiveTxMessageSend; use crate::ln::msgs; @@ -4546,13 +4546,14 @@ impl< /// /// # Arguments /// - /// Provide a `contribution` to determine if value is spliced in or out. The splice initiator is - /// responsible for paying fees for common fields, shared inputs, and shared outputs along with - /// any contributed inputs and outputs. Fees are determined using `funding_feerate_per_kw` and - /// must be covered by the supplied inputs for splice-in or the channel balance for splice-out. + /// The splice initiator is responsible for paying fees for common fields, shared inputs, and + /// shared outputs along with any contributed inputs and outputs. Fees are determined using + /// `feerate` and must be covered by the supplied inputs for splice-in or the channel balance + /// for splice-out. /// - /// An optional `locktime` for the funding transaction may be specified. If not given, the - /// current best block height is used. + /// Returns a [`FundingTemplate`] which should be used to build a [`FundingContribution`] via + /// one of its splice methods (e.g., [`FundingTemplate::splice_in_sync`]). The resulting + /// contribution must then be passed to [`ChannelManager::funding_contributed`]. /// /// # Events /// @@ -4570,29 +4571,26 @@ impl< /// Once the splice has been locked by both counterparties, an [`Event::ChannelReady`] will be /// emitted with the new funding output. At this point, a new splice can be negotiated by /// calling `splice_channel` again on this channel. + /// + /// [`FundingContribution`]: crate::ln::funding::FundingContribution #[rustfmt::skip] pub fn splice_channel( - &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, - contribution: SpliceContribution, funding_feerate_per_kw: u32, locktime: Option, - ) -> Result<(), APIError> { - let mut res = Ok(()); + &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, feerate: FeeRate, + ) -> Result { + let mut res = Err(APIError::APIMisuseError { err: String::new() }); PersistenceNotifierGuard::optionally_notify(self, || { let result = self.internal_splice_channel( - channel_id, counterparty_node_id, contribution, funding_feerate_per_kw, locktime + channel_id, counterparty_node_id, feerate, ); res = result; - match res { - Ok(_) => NotifyOption::DoPersist, - Err(_) => NotifyOption::SkipPersistNoEvents, - } + NotifyOption::SkipPersistNoEvents }); res } fn internal_splice_channel( - &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, - contribution: SpliceContribution, funding_feerate_per_kw: u32, locktime: Option, - ) -> Result<(), APIError> { + &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, feerate: FeeRate, + ) -> Result { let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = match per_peer_state @@ -4618,22 +4616,8 @@ impl< // Look for the channel match peer_state.channel_by_id.entry(*channel_id) { hash_map::Entry::Occupied(mut chan_phase_entry) => { - let locktime = locktime.unwrap_or_else(|| self.current_best_block().height); if let Some(chan) = chan_phase_entry.get_mut().as_funded_mut() { - let logger = WithChannelContext::from(&self.logger, &chan.context, None); - let msg_opt = chan.splice_channel( - contribution, - funding_feerate_per_kw, - locktime, - &&logger, - )?; - if let Some(msg) = msg_opt { - peer_state.pending_msg_events.push(MessageSendEvent::SendStfu { - node_id: *counterparty_node_id, - msg, - }); - } - Ok(()) + chan.splice_channel(feerate) } else { Err(APIError::ChannelUnavailable { err: format!( @@ -6342,6 +6326,108 @@ impl< result } + /// Adds or removes funds from the given channel as specified by a [`FundingContribution`]. + /// + /// Used after [`ChannelManager::splice_channel`] by constructing a [`FundingContribution`] + /// from the returned [`FundingTemplate`] and passing it here. + /// + /// Calling this method will commence the process of creating a new funding transaction for the + /// channel. An [`Event::FundingTransactionReadyForSigning`] will be generated once the + /// transaction is successfully constructed interactively with the counterparty. + /// If unsuccessful, an [`Event::SpliceFailed`] will be surfaced instead. + /// + /// An optional `locktime` for the funding transaction may be specified. If not given, the + /// current best block height is used. + /// + /// Returns [`ChannelUnavailable`] when a channel is not found or an incorrect + /// `counterparty_node_id` is provided. + /// + /// Returns [`APIMisuseError`] when a channel is not in a state where it is expecting funding + /// contribution. + /// + /// [`ChannelUnavailable`]: APIError::ChannelUnavailable + /// [`APIMisuseError`]: APIError::APIMisuseError + pub fn funding_contributed( + &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, + contribution: FundingContribution, locktime: Option, + ) -> Result<(), APIError> { + let mut result = Ok(()); + PersistenceNotifierGuard::optionally_notify(self, || { + let per_peer_state = self.per_peer_state.read().unwrap(); + let peer_state_mutex_opt = per_peer_state.get(counterparty_node_id); + if peer_state_mutex_opt.is_none() { + result = Err(APIError::ChannelUnavailable { + err: format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}") + }); + return NotifyOption::SkipPersistNoEvents; + } + + let mut peer_state = peer_state_mutex_opt.unwrap().lock().unwrap(); + + match peer_state.channel_by_id.get_mut(channel_id) { + Some(channel) => match channel.as_funded_mut() { + Some(chan) => { + let locktime = bitcoin::absolute::LockTime::from_consensus( + locktime.unwrap_or_else(|| self.current_best_block().height), + ); + let logger = WithChannelContext::from(&self.logger, chan.context(), None); + match chan.funding_contributed(contribution, locktime, &&logger) { + Ok(msg_opt) => { + if let Some(msg) = msg_opt { + peer_state.pending_msg_events.push( + MessageSendEvent::SendStfu { + node_id: *counterparty_node_id, + msg, + }, + ); + } + }, + Err(splice_funding_failed) => { + let pending_events = &mut self.pending_events.lock().unwrap(); + pending_events.push_back(( + events::Event::SpliceFailed { + channel_id: *channel_id, + counterparty_node_id: *counterparty_node_id, + user_channel_id: channel.context().get_user_id(), + abandoned_funding_txo: splice_funding_failed.funding_txo, + channel_type: splice_funding_failed.channel_type.clone(), + contributed_inputs: splice_funding_failed + .contributed_inputs, + contributed_outputs: splice_funding_failed + .contributed_outputs, + }, + None, + )); + }, + } + + return NotifyOption::DoPersist; + }, + None => { + result = Err(APIError::APIMisuseError { + err: format!( + "Channel with id {} not expecting funding contribution", + channel_id + ), + }); + return NotifyOption::SkipPersistNoEvents; + }, + }, + None => { + result = Err(APIError::ChannelUnavailable { + err: format!( + "Channel with id {} not found for the passed counterparty node_id {}", + channel_id, counterparty_node_id + ), + }); + return NotifyOption::SkipPersistNoEvents; + }, + } + }); + + result + } + /// Handles a signed funding transaction generated by interactive transaction construction and /// provided by the client. Should only be called in response to a [`FundingTransactionReadyForSigning`] /// event. @@ -13315,7 +13401,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }); notify = NotifyOption::SkipPersistHandleEvents; }, - Err(msg) => log_trace!(logger, "{}", msg), + Err((msg, _action)) => log_trace!(logger, "{}", msg), } } else { result = Err(APIError::APIMisuseError { diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 33f78b13553..66a0147e131 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -404,10 +404,10 @@ fn do_connect_block_without_consistency_checks<'a, 'b, 'c, 'd>( } pub fn provide_anchor_reserves<'a, 'b, 'c>(nodes: &[Node<'a, 'b, 'c>]) -> Transaction { - provide_anchor_utxo_reserves(nodes, 1, Amount::ONE_BTC) + provide_utxo_reserves(nodes, 1, Amount::ONE_BTC) } -pub fn provide_anchor_utxo_reserves<'a, 'b, 'c>( +pub fn provide_utxo_reserves<'a, 'b, 'c>( nodes: &[Node<'a, 'b, 'c>], utxos: usize, amount: Amount, ) -> Transaction { let mut output = Vec::with_capacity(nodes.len()); @@ -614,6 +614,10 @@ impl<'a, 'b, 'c> Node<'a, 'b, 'c> { self.blocks.lock().unwrap()[height as usize].0.header } + pub fn provide_funding_utxos(&self, utxos: usize, amount: Amount) -> Transaction { + provide_utxo_reserves(core::slice::from_ref(self), utxos, amount) + } + /// Executes `enable_channel_signer_op` for every single signer operation for this channel. #[cfg(test)] pub fn enable_all_channel_signer_ops(&self, peer_id: &PublicKey, chan_id: &ChannelId) { diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 9981250b05e..e369bd8a3ec 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -9,29 +9,329 @@ //! Types pertaining to funding channels. -use alloc::vec::Vec; +use bitcoin::hashes::Hash; +use bitcoin::secp256k1::PublicKey; +use bitcoin::{ + Amount, FeeRate, OutPoint, Script, ScriptBuf, Sequence, SignedAmount, Transaction, TxOut, + WScriptHash, Weight, +}; + +use core::ops::Deref; + +use crate::events::bump_transaction::sync::CoinSelectionSourceSync; +use crate::events::bump_transaction::{CoinSelectionSource, Input, Utxo}; +use crate::ln::chan_utils::{ + make_funding_redeemscript, BASE_INPUT_WEIGHT, EMPTY_SCRIPT_SIG_WEIGHT, + FUNDING_TRANSACTION_WITNESS_WEIGHT, +}; +use crate::ln::interactivetxs::{get_output_weight, TX_COMMON_FIELDS_WEIGHT}; +use crate::ln::msgs; +use crate::ln::types::ChannelId; +use crate::ln::LN_MAX_MSG_LEN; +use crate::prelude::*; +use crate::sign::{P2TR_KEY_PATH_WITNESS_WEIGHT, P2WPKH_WITNESS_WEIGHT}; +use crate::util::async_poll::MaybeSend; + +/// A template for contributing to a channel's splice funding transaction. +/// +/// This is returned from [`ChannelManager::splice_channel`] when a channel is ready to be +/// spliced. It must be converted to a [`FundingContribution`] using one of the splice methods +/// and passed to [`ChannelManager::funding_contributed`] in order to resume the splicing +/// process. +/// +/// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel +/// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FundingTemplate { + /// The shared input, which, if present indicates the funding template is for a splice funding + /// transaction. + shared_input: Option, + + /// The fee rate to use for coin selection. + feerate: FeeRate, + + /// Whether the contributor initiated the funding, and thus is responsible for fees incurred for + /// common fields and shared inputs and outputs. + is_initiator: bool, +} -use bitcoin::{Amount, ScriptBuf, SignedAmount, TxOut}; -use bitcoin::{Script, Sequence, Transaction, Weight}; +impl FundingTemplate { + /// Constructs a [`FundingTemplate`] for a splice using the provided shared input. + pub(super) fn new( + shared_input: Option, feerate: FeeRate, is_initiator: bool, + ) -> Self { + Self { shared_input, feerate, is_initiator } + } +} -use crate::events::bump_transaction::Utxo; -use crate::ln::chan_utils::EMPTY_SCRIPT_SIG_WEIGHT; -use crate::sign::{P2TR_KEY_PATH_WITNESS_WEIGHT, P2WPKH_WITNESS_WEIGHT}; +macro_rules! build_funding_contribution { + ($value_added:expr, $outputs:expr, $change_script:expr, $shared_input:expr, $feerate:expr, $is_initiator:expr, $wallet:ident, $($await:tt)*) => {{ + let value_added: Amount = $value_added; + let outputs: Vec = $outputs; + let change_script: Option = $change_script; + let shared_input: Option = $shared_input; + let feerate: FeeRate = $feerate; + let is_initiator: bool = $is_initiator; + + let value_removed = outputs.iter().map(|txout| txout.value).sum(); + let is_splice = shared_input.is_some(); + + let inputs = if value_added == Amount::ZERO { + vec![] + } else { + // Used for creating a redeem script for the new funding txo, since the funding pubkeys + // are unknown at this point. Only needed when selecting which UTXOs to include in the + // funding tx that would be sufficient to pay for fees. Hence, the value doesn't matter. + let dummy_pubkey = PublicKey::from_slice(&[2; 33]).unwrap(); + + let shared_output = bitcoin::TxOut { + value: shared_input + .as_ref() + .map(|shared_input| shared_input.previous_utxo.value) + .unwrap_or(Amount::ZERO) + .checked_add(value_added) + .ok_or(())? + .checked_sub(value_removed) + .ok_or(())?, + script_pubkey: make_funding_redeemscript(&dummy_pubkey, &dummy_pubkey).to_p2wsh(), + }; + + let claim_id = None; + let must_spend = shared_input.map(|input| vec![input]).unwrap_or_default(); + let selection = if outputs.is_empty() { + let must_pay_to = &[shared_output]; + $wallet.select_confirmed_utxos(claim_id, must_spend, must_pay_to, feerate.to_sat_per_kwu() as u32, u64::MAX)$(.$await)*? + } else { + let must_pay_to: Vec<_> = outputs.iter().cloned().chain(core::iter::once(shared_output)).collect(); + $wallet.select_confirmed_utxos(claim_id, must_spend, &must_pay_to, feerate.to_sat_per_kwu() as u32, u64::MAX)$(.$await)*? + }; + selection.confirmed_utxos + }; + + // NOTE: Must NOT fail after UTXO selection + + let estimated_fee = estimate_transaction_fee(&inputs, &outputs, is_initiator, is_splice, feerate); + + let contribution = FundingContribution { + value_added, + estimated_fee, + inputs, + outputs, + change_script, + feerate, + is_initiator, + is_splice, + }; + + Ok(contribution) + }}; +} + +impl FundingTemplate { + /// Creates a [`FundingContribution`] for adding funds to a channel using `wallet` to perform + /// coin selection. + /// + /// An optional `change_script` may be given to use as a change output. If `None` and change is + /// needed, one will be generated using [`SignerProvider::get_destination_script`]. + /// + /// [`SignerProvider::get_destination_script`]: crate::sign::SignerProvider::get_destination_script + pub async fn splice_in( + self, change_script: Option, value_added: Amount, wallet: W, + ) -> Result + where + W::Target: CoinSelectionSource + MaybeSend, + { + if value_added == Amount::ZERO { + return Err(()); + } + let FundingTemplate { shared_input, feerate, is_initiator } = self; + build_funding_contribution!(value_added, vec![], change_script, shared_input, feerate, is_initiator, wallet, await) + } + + /// Creates a [`FundingContribution`] for adding funds to a channel using `wallet` to perform + /// coin selection. + /// + /// An optional `change_script` may be given to use as a change output. If `None` and change is + /// needed, one will be generated using [`SignerProvider::get_destination_script`]. + /// + /// [`SignerProvider::get_destination_script`]: crate::sign::SignerProvider::get_destination_script + pub fn splice_in_sync( + self, change_script: Option, value_added: Amount, wallet: W, + ) -> Result + where + W::Target: CoinSelectionSourceSync, + { + if value_added == Amount::ZERO { + return Err(()); + } + let FundingTemplate { shared_input, feerate, is_initiator } = self; + build_funding_contribution!( + value_added, + vec![], + change_script, + shared_input, + feerate, + is_initiator, + wallet, + ) + } + + /// Creates a [`FundingContribution`] for removing funds from a channel using `wallet` to + /// perform coin selection. + pub async fn splice_out( + self, outputs: Vec, wallet: W, + ) -> Result + where + W::Target: CoinSelectionSource + MaybeSend, + { + if outputs.is_empty() { + return Err(()); + } + let FundingTemplate { shared_input, feerate, is_initiator } = self; + build_funding_contribution!(Amount::ZERO, outputs, None, shared_input, feerate, is_initiator, wallet, await) + } + + /// Creates a [`FundingContribution`] for removing funds from a channel using `wallet` to + /// perform coin selection. + pub fn splice_out_sync( + self, outputs: Vec, wallet: W, + ) -> Result + where + W::Target: CoinSelectionSourceSync, + { + if outputs.is_empty() { + return Err(()); + } + let FundingTemplate { shared_input, feerate, is_initiator } = self; + build_funding_contribution!( + Amount::ZERO, + outputs, + None, + shared_input, + feerate, + is_initiator, + wallet, + ) + } + + /// Creates a [`FundingContribution`] for both adding and removing funds from a channel using + /// `wallet` to perform coin selection. + /// + /// An optional `change_script` may be given to use as a change output. If `None` and change is + /// needed, one will be generated using [`SignerProvider::get_destination_script`]. + /// + /// [`SignerProvider::get_destination_script`]: crate::sign::SignerProvider::get_destination_script + pub async fn splice_in_and_out( + self, change_script: Option, value_added: Amount, outputs: Vec, + wallet: W, + ) -> Result + where + W::Target: CoinSelectionSource + MaybeSend, + { + if value_added == Amount::ZERO && outputs.is_empty() { + return Err(()); + } + let FundingTemplate { shared_input, feerate, is_initiator } = self; + build_funding_contribution!(value_added, outputs, change_script, shared_input, feerate, is_initiator, wallet, await) + } -/// The components of a splice's funding transaction that are contributed by one party. + /// Creates a [`FundingContribution`] for both adding and removing funds from a channel using + /// `wallet` to perform coin selection. + /// + /// An optional `change_script` may be given to use as a change output. If `None` and change is + /// needed, one will be generated using [`SignerProvider::get_destination_script`]. + /// + /// [`SignerProvider::get_destination_script`]: crate::sign::SignerProvider::get_destination_script + pub fn splice_in_and_out_sync( + self, change_script: Option, value_added: Amount, outputs: Vec, + wallet: W, + ) -> Result + where + W::Target: CoinSelectionSourceSync, + { + if value_added == Amount::ZERO && outputs.is_empty() { + return Err(()); + } + let FundingTemplate { shared_input, feerate, is_initiator } = self; + build_funding_contribution!( + value_added, + outputs, + change_script, + shared_input, + feerate, + is_initiator, + wallet, + ) + } +} + +fn estimate_transaction_fee( + inputs: &[FundingTxInput], outputs: &[TxOut], is_initiator: bool, is_splice: bool, + feerate: FeeRate, +) -> Amount { + let input_weight: u64 = inputs + .iter() + .map(|input| BASE_INPUT_WEIGHT.saturating_add(input.utxo.satisfaction_weight)) + .fold(0, |total_weight, input_weight| total_weight.saturating_add(input_weight)); + + let output_weight: u64 = outputs + .iter() + .map(|txout| txout.weight().to_wu()) + .fold(0, |total_weight, output_weight| total_weight.saturating_add(output_weight)); + + let mut weight = input_weight.saturating_add(output_weight); + + // The initiator pays for all common fields and the shared output in the funding transaction. + if is_initiator { + weight = weight + .saturating_add(TX_COMMON_FIELDS_WEIGHT) + // The weight of the funding output, a P2WSH output + // NOTE: The witness script hash given here is irrelevant as it's a fixed size and we just want + // to calculate the contributed weight, so we use an all-zero hash. + // + // TODO(taproot): Needs to consider different weights based on channel type + .saturating_add( + get_output_weight(&ScriptBuf::new_p2wsh(&WScriptHash::from_raw_hash( + Hash::all_zeros(), + ))) + .to_wu(), + ); + + // The splice initiator pays for the input spending the previous funding output. + if is_splice { + weight = weight + .saturating_add(BASE_INPUT_WEIGHT) + .saturating_add(EMPTY_SCRIPT_SIG_WEIGHT) + .saturating_add(FUNDING_TRANSACTION_WITNESS_WEIGHT); + #[cfg(feature = "grind_signatures")] + { + // Guarantees a low R signature + weight -= 1; + } + } + } + + Weight::from_wu(weight) * feerate +} + +/// The components of a funding transaction contributed by one party. #[derive(Debug, Clone)] -pub struct SpliceContribution { - /// The amount from [`inputs`] to contribute to the splice. +pub struct FundingContribution { + /// The amount to contribute to the channel. /// - /// [`inputs`]: Self::inputs + /// If `value_added` is [`Amount::ZERO`], then any fees will be deducted from the channel + /// balance instead of paid by `inputs`. value_added: Amount, - /// The inputs included in the splice's funding transaction to meet the contributed amount - /// plus fees. Any excess amount will be sent to a change output. + /// The estimate fees responsible to be paid for the contribution. + estimated_fee: Amount, + + /// The inputs included in the funding transaction to meet the contributed amount plus fees. Any + /// excess amount will be sent to a change output. inputs: Vec, - /// The outputs to include in the splice's funding transaction. The total value of all - /// outputs plus fees will be the amount that is removed. + /// The outputs to include in the funding transaction. The total value of all outputs plus fees + /// will be the amount that is removed. outputs: Vec, /// An optional change output script. This will be used if needed or, when not set, @@ -39,63 +339,127 @@ pub struct SpliceContribution { /// /// [`SignerProvider::get_destination_script`]: crate::sign::SignerProvider::get_destination_script change_script: Option, + + /// The fee rate used to select `inputs`. + feerate: FeeRate, + + /// Whether the contributor initiated the funding, and thus is responsible for fees incurred for + /// common fields and shared inputs and outputs. + is_initiator: bool, + + /// Whether the contribution is for funding a splice. + is_splice: bool, } -impl SpliceContribution { - /// Creates a contribution for when funds are only added to a channel. - pub fn splice_in( - value_added: Amount, inputs: Vec, change_script: Option, - ) -> Self { - Self { value_added, inputs, outputs: vec![], change_script } +impl_writeable_tlv_based!(FundingContribution, { + (1, value_added, required), + (3, estimated_fee, required), + (5, inputs, optional_vec), + (7, outputs, optional_vec), + (9, change_script, option), + (11, feerate, required), + (13, is_initiator, required), + (15, is_splice, required), +}); + +impl FundingContribution { + pub(super) fn feerate(&self) -> FeeRate { + self.feerate } - /// Creates a contribution for when funds are only removed from a channel. - pub fn splice_out(outputs: Vec) -> Self { - Self { value_added: Amount::ZERO, inputs: vec![], outputs, change_script: None } + pub(super) fn is_initiator(&self) -> bool { + self.is_initiator } - /// Creates a contribution for when funds are both added to and removed from a channel. - /// - /// Note that `value_added` represents the value added by `inputs` but should not account for - /// value removed by `outputs`. The net value contributed can be obtained by calling - /// [`SpliceContribution::net_value`]. - pub fn splice_in_and_out( - value_added: Amount, inputs: Vec, outputs: Vec, - change_script: Option, - ) -> Self { - Self { value_added, inputs, outputs, change_script } + pub(super) fn is_splice(&self) -> bool { + self.is_splice + } + + pub(super) fn into_tx_parts(self) -> (Vec, Vec, Option) { + let FundingContribution { inputs, outputs, change_script, .. } = self; + (inputs, outputs, change_script) + } + + pub(super) fn into_contributed_inputs_and_outputs(self) -> (Vec, Vec) { + (self.inputs.into_iter().map(|input| input.utxo.outpoint).collect(), self.outputs) } /// The net value contributed to a channel by the splice. If negative, more value will be - /// spliced out than spliced in. - pub fn net_value(&self) -> SignedAmount { - let value_added = self.value_added.to_signed().unwrap_or(SignedAmount::MAX); + /// spliced out than spliced in. Fees will be deducted from the expected splice-out amount + /// if no inputs were included. + pub fn net_value(&self) -> Result { + for FundingTxInput { utxo, prevtx, .. } in self.inputs.iter() { + use crate::util::ser::Writeable; + const MESSAGE_TEMPLATE: msgs::TxAddInput = msgs::TxAddInput { + channel_id: ChannelId([0; 32]), + serial_id: 0, + prevtx: None, + prevtx_out: 0, + sequence: 0, + // Mutually exclusive with prevtx, which is accounted for below. + shared_input_txid: None, + }; + let message_len = MESSAGE_TEMPLATE.serialized_length() + prevtx.serialized_length(); + if message_len > LN_MAX_MSG_LEN { + return Err(format!( + "Funding input references a prevtx that is too large for tx_add_input: {}", + utxo.outpoint + )); + } + } + + // Fees for splice-out are paid from the channel balance whereas fees for splice-in + // are paid by the funding inputs. Therefore, in the case of splice-out, we add the + // fees on top of the user-specified contribution. We leave the user-specified + // contribution as-is for splice-ins. + if !self.inputs.is_empty() { + let mut total_input_value = Amount::ZERO; + for FundingTxInput { utxo, .. } in self.inputs.iter() { + total_input_value = total_input_value + .checked_add(utxo.output.value) + .ok_or("Sum of input values is greater than the total bitcoin supply")?; + } + + // If the inputs are enough to cover intended contribution amount, with fees even when + // there is a change output, we are fine. + // If the inputs are less, but enough to cover intended contribution amount, with + // (lower) fees with no change, we are also fine (change will not be generated). + // So it's enough to check considering the lower, no-change fees. + // + // Note: dust limit is not relevant in this check. + + let contributed_input_value = self.value_added; + let estimated_fee = self.estimated_fee; + let minimal_input_amount_needed = contributed_input_value + .checked_add(estimated_fee) + .ok_or(format!("{contributed_input_value} contribution plus {estimated_fee} fee estimate exceeds the total bitcoin supply"))?; + if total_input_value < minimal_input_amount_needed { + return Err(format!( + "Total input amount {total_input_value} is lower than needed for splice-in contribution {contributed_input_value}, considering fees of {estimated_fee}. Need more inputs.", + )); + } + } + + let unpaid_fees = if self.inputs.is_empty() { self.estimated_fee } else { Amount::ZERO } + .to_signed() + .expect("fees should never exceed Amount::MAX_MONEY"); + let value_added = self.value_added.to_signed().map_err(|_| "Value added too large")?; let value_removed = self .outputs .iter() .map(|txout| txout.value) .sum::() .to_signed() - .unwrap_or(SignedAmount::MAX); + .map_err(|_| "Value removed too large")?; - value_added - value_removed - } + let contribution_amount = value_added - value_removed; + let adjusted_contribution = contribution_amount.checked_sub(unpaid_fees).ok_or(format!( + "{} splice-out amount plus {} fee estimate exceeds the total bitcoin supply", + contribution_amount.unsigned_abs(), + self.estimated_fee, + ))?; - pub(super) fn value_added(&self) -> Amount { - self.value_added - } - - pub(super) fn inputs(&self) -> &[FundingTxInput] { - &self.inputs[..] - } - - pub(super) fn outputs(&self) -> &[TxOut] { - &self.outputs[..] - } - - pub(super) fn into_tx_parts(self) -> (Vec, Vec, Option) { - let SpliceContribution { value_added: _, inputs, outputs, change_script } = self; - (inputs, outputs, change_script) + Ok(adjusted_contribution) } } @@ -267,3 +631,260 @@ impl FundingTxInput { self.utxo.output } } + +#[cfg(test)] +mod tests { + use super::{estimate_transaction_fee, FundingContribution, FundingTxInput}; + use bitcoin::hashes::Hash; + use bitcoin::transaction::{Transaction, TxOut, Version}; + use bitcoin::{Amount, FeeRate, ScriptBuf, SignedAmount, WPubkeyHash}; + + #[test] + #[rustfmt::skip] + fn test_estimate_transaction_fee() { + let one_input = [funding_input_sats(1_000)]; + let two_inputs = [funding_input_sats(1_000), funding_input_sats(1_000)]; + + // 2 inputs, initiator, 2000 sat/kw feerate + assert_eq!( + estimate_transaction_fee(&two_inputs, &[], true, false, FeeRate::from_sat_per_kwu(2000)), + Amount::from_sat(if cfg!(feature = "grind_signatures") { 1512 } else { 1516 }), + ); + + // higher feerate + assert_eq!( + estimate_transaction_fee(&two_inputs, &[], true, false, FeeRate::from_sat_per_kwu(3000)), + Amount::from_sat(if cfg!(feature = "grind_signatures") { 2268 } else { 2274 }), + ); + + // only 1 input + assert_eq!( + estimate_transaction_fee(&one_input, &[], true, false, FeeRate::from_sat_per_kwu(2000)), + Amount::from_sat(if cfg!(feature = "grind_signatures") { 970 } else { 972 }), + ); + + // 0 inputs + assert_eq!( + estimate_transaction_fee(&[], &[], true, false, FeeRate::from_sat_per_kwu(2000)), + Amount::from_sat(428), + ); + + // not initiator + assert_eq!( + estimate_transaction_fee(&[], &[], false, false, FeeRate::from_sat_per_kwu(2000)), + Amount::from_sat(0), + ); + + // splice initiator + assert_eq!( + estimate_transaction_fee(&one_input, &[], true, true, FeeRate::from_sat_per_kwu(2000)), + Amount::from_sat(if cfg!(feature = "grind_signatures") { 1736 } else { 1740 }), + ); + + // splice acceptor + assert_eq!( + estimate_transaction_fee(&one_input, &[], false, true, FeeRate::from_sat_per_kwu(2000)), + Amount::from_sat(if cfg!(feature = "grind_signatures") { 542 } else { 544 }), + ); + } + + #[rustfmt::skip] + fn funding_input_sats(input_value_sats: u64) -> FundingTxInput { + let prevout = TxOut { + value: Amount::from_sat(input_value_sats), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()), + }; + let prevtx = Transaction { + input: vec![], output: vec![prevout], + version: Version::TWO, lock_time: bitcoin::absolute::LockTime::ZERO, + }; + + FundingTxInput::new_p2wpkh(prevtx, 0).unwrap() + } + + fn funding_output_sats(output_value_sats: u64) -> TxOut { + TxOut { + value: Amount::from_sat(output_value_sats), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()), + } + } + + #[test] + #[rustfmt::skip] + fn test_check_v2_funding_inputs_sufficient() { + // positive case, inputs well over intended contribution + { + let expected_fee = if cfg!(feature = "grind_signatures") { 2278 } else { 2284 }; + let contribution = FundingContribution { + value_added: Amount::from_sat(220_000), + estimated_fee: Amount::from_sat(expected_fee), + inputs: vec![ + funding_input_sats(200_000), + funding_input_sats(100_000), + ], + outputs: vec![], + change_script: None, + is_initiator: true, + is_splice: true, + feerate: FeeRate::from_sat_per_kwu(2000), + }; + assert_eq!(contribution.net_value(), Ok(contribution.value_added.to_signed().unwrap())); + } + + // Net splice-in + { + let expected_fee = if cfg!(feature = "grind_signatures") { 2526 } else { 2532 }; + let contribution = FundingContribution { + value_added: Amount::from_sat(220_000), + estimated_fee: Amount::from_sat(expected_fee), + inputs: vec![ + funding_input_sats(200_000), + funding_input_sats(100_000), + ], + outputs: vec![ + funding_output_sats(200_000), + ], + change_script: None, + is_initiator: true, + is_splice: true, + feerate: FeeRate::from_sat_per_kwu(2000), + }; + assert_eq!(contribution.net_value(), Ok(SignedAmount::from_sat(220_000 - 200_000))); + } + + // Net splice-out + { + let expected_fee = if cfg!(feature = "grind_signatures") { 2526 } else { 2532 }; + let contribution = FundingContribution { + value_added: Amount::from_sat(220_000), + estimated_fee: Amount::from_sat(expected_fee), + inputs: vec![ + funding_input_sats(200_000), + funding_input_sats(100_000), + ], + outputs: vec![ + funding_output_sats(400_000), + ], + change_script: None, + is_initiator: true, + is_splice: true, + feerate: FeeRate::from_sat_per_kwu(2000), + }; + assert_eq!(contribution.net_value(), Ok(SignedAmount::from_sat(220_000 - 400_000))); + } + + // Net splice-out, inputs insufficient to cover fees + { + let expected_fee = if cfg!(feature = "grind_signatures") { 113670 } else { 113940 }; + let contribution = FundingContribution { + value_added: Amount::from_sat(220_000), + estimated_fee: Amount::from_sat(expected_fee), + inputs: vec![ + funding_input_sats(200_000), + funding_input_sats(100_000), + ], + outputs: vec![ + funding_output_sats(400_000), + ], + change_script: None, + is_initiator: true, + is_splice: true, + feerate: FeeRate::from_sat_per_kwu(90000), + }; + assert_eq!( + contribution.net_value(), + Err(format!( + "Total input amount 0.00300000 BTC is lower than needed for splice-in contribution 0.00220000 BTC, considering fees of {}. Need more inputs.", + Amount::from_sat(expected_fee), + )), + ); + } + + // negative case, inputs clearly insufficient + { + let expected_fee = if cfg!(feature = "grind_signatures") { 1736 } else { 1740 }; + let contribution = FundingContribution { + value_added: Amount::from_sat(220_000), + estimated_fee: Amount::from_sat(expected_fee), + inputs: vec![ + funding_input_sats(100_000), + ], + outputs: vec![], + change_script: None, + is_initiator: true, + is_splice: true, + feerate: FeeRate::from_sat_per_kwu(2000), + }; + assert_eq!( + contribution.net_value(), + Err(format!( + "Total input amount 0.00100000 BTC is lower than needed for splice-in contribution 0.00220000 BTC, considering fees of {}. Need more inputs.", + Amount::from_sat(expected_fee), + )), + ); + } + + // barely covers + { + let expected_fee = if cfg!(feature = "grind_signatures") { 2278 } else { 2284 }; + let contribution = FundingContribution { + value_added: Amount::from_sat(300_000 - expected_fee - 20), + estimated_fee: Amount::from_sat(expected_fee), + inputs: vec![ + funding_input_sats(200_000), + funding_input_sats(100_000), + ], + outputs: vec![], + change_script: None, + is_initiator: true, + is_splice: true, + feerate: FeeRate::from_sat_per_kwu(2000), + }; + assert_eq!(contribution.net_value(), Ok(contribution.value_added.to_signed().unwrap())); + } + + // higher fee rate, does not cover + { + let expected_fee = if cfg!(feature = "grind_signatures") { 2506 } else { 2513 }; + let contribution = FundingContribution { + value_added: Amount::from_sat(298032), + estimated_fee: Amount::from_sat(expected_fee), + inputs: vec![ + funding_input_sats(200_000), + funding_input_sats(100_000), + ], + outputs: vec![], + change_script: None, + is_initiator: true, + is_splice: true, + feerate: FeeRate::from_sat_per_kwu(2200), + }; + assert_eq!( + contribution.net_value(), + Err(format!( + "Total input amount 0.00300000 BTC is lower than needed for splice-in contribution 0.00298032 BTC, considering fees of {}. Need more inputs.", + Amount::from_sat(expected_fee), + )), + ); + } + + // barely covers, less fees (no extra weight, not initiator) + { + let expected_fee = if cfg!(feature = "grind_signatures") { 1084 } else { 1088 }; + let contribution = FundingContribution { + value_added: Amount::from_sat(300_000 - expected_fee - 20), + estimated_fee: Amount::from_sat(expected_fee), + inputs: vec![ + funding_input_sats(200_000), + funding_input_sats(100_000), + ], + outputs: vec![], + change_script: None, + is_initiator: false, + is_splice: false, + feerate: FeeRate::from_sat_per_kwu(2000), + }; + assert_eq!(contribution.net_value(), Ok(contribution.value_added.to_signed().unwrap())); + } + } +} diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 4846f7137cc..31c13e124d4 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -13,13 +13,13 @@ use crate::chain::chaininterface::{TransactionType, FEERATE_FLOOR_SATS_PER_KW}; use crate::chain::channelmonitor::{ANTI_REORG_DELAY, LATENCY_GRACE_PERIOD_BLOCKS}; use crate::chain::transaction::OutPoint; use crate::chain::ChannelMonitorUpdateStatus; -use crate::events::bump_transaction::sync::WalletSourceSync; +use crate::events::bump_transaction::sync::{WalletSourceSync, WalletSync}; use crate::events::{ClosureReason, Event, FundingInfo, HTLCHandlingFailureType}; use crate::ln::chan_utils; use crate::ln::channel::CHANNEL_ANNOUNCEMENT_PROPAGATION_DELAY; use crate::ln::channelmanager::{provided_init_features, PaymentId, BREAKDOWN_TIMEOUT}; use crate::ln::functional_test_utils::*; -use crate::ln::funding::{FundingTxInput, SpliceContribution}; +use crate::ln::funding::FundingContribution; use crate::ln::msgs::{self, BaseMessageHandler, ChannelMessageHandler, MessageSendEvent}; use crate::ln::outbound_payment::RecipientOnionFields; use crate::ln::types::ChannelId; @@ -27,10 +27,14 @@ use crate::routing::router::{PaymentParameters, RouteParameters}; use crate::util::errors::APIError; use crate::util::ser::Writeable; +use crate::sync::Arc; + use bitcoin::hashes::Hash; use bitcoin::secp256k1::ecdsa::Signature; use bitcoin::secp256k1::PublicKey; -use bitcoin::{Amount, OutPoint as BitcoinOutPoint, ScriptBuf, Transaction, TxOut, WPubkeyHash}; +use bitcoin::{ + Amount, FeeRate, OutPoint as BitcoinOutPoint, ScriptBuf, Transaction, TxOut, WPubkeyHash, +}; #[test] fn test_splicing_not_supported_api_error() { @@ -47,15 +51,8 @@ fn test_splicing_not_supported_api_error() { let (_, _, channel_id, _) = create_announced_chan_between_nodes(&nodes, 0, 1); - let bs_contribution = SpliceContribution::splice_in(Amount::ZERO, Vec::new(), None); - - let res = nodes[1].node.splice_channel( - &channel_id, - &node_id_0, - bs_contribution.clone(), - 0, // funding_feerate_per_kw, - None, // locktime - ); + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let res = nodes[1].node.splice_channel(&channel_id, &node_id_0, feerate); match res { Err(APIError::ChannelUnavailable { err }) => { assert!(err.contains("Peer does not support splicing")) @@ -76,13 +73,7 @@ fn test_splicing_not_supported_api_error() { reconnect_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_args); - let res = nodes[1].node.splice_channel( - &channel_id, - &node_id_0, - bs_contribution, - 0, // funding_feerate_per_kw, - None, // locktime - ); + let res = nodes[1].node.splice_channel(&channel_id, &node_id_0, feerate); match res { Err(APIError::ChannelUnavailable { err }) => { assert!(err.contains("Peer does not support quiescence, a splicing prerequisite")) @@ -102,64 +93,122 @@ fn test_v1_splice_in_negative_insufficient_inputs() { create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); // Amount being added to the channel through the splice-in - let splice_in_sats = 20_000; + let splice_in_value = Amount::from_sat(20_000); // Create additional inputs, but insufficient - let extra_splice_funding_input_sats = splice_in_sats - 1; - let funding_inputs = - create_dual_funding_utxos_with_prev_txs(&nodes[0], &[extra_splice_funding_input_sats]); + let extra_splice_funding_input = splice_in_value - Amount::ONE_SAT; - let contribution = - SpliceContribution::splice_in(Amount::from_sat(splice_in_sats), funding_inputs, None); + provide_utxo_reserves(&nodes, 1, extra_splice_funding_input); + + let feerate = FeeRate::from_sat_per_kwu(1024); // Initiate splice-in, with insufficient input contribution - let res = nodes[0].node.splice_channel( - &channel_id, - &nodes[1].node.get_our_node_id(), - contribution, - 1024, // funding_feerate_per_kw, - None, // locktime - ); - match res { - Err(APIError::APIMisuseError { err }) => { - assert!(err.contains("Need more inputs")) - }, - _ => panic!("Wrong error {:?}", res.err().unwrap()), - } + let funding_template = nodes[0] + .node + .splice_channel(&channel_id, &nodes[1].node.get_our_node_id(), feerate) + .unwrap(); + + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + assert!(funding_template.splice_in_sync(None, splice_in_value, &wallet).is_err()); } pub fn negotiate_splice_tx<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, - initiator_contribution: SpliceContribution, + funding_contribution: FundingContribution, ) { - let new_funding_script = - complete_splice_handshake(initiator, acceptor, channel_id, initiator_contribution.clone()); + let new_funding_script = complete_splice_handshake(initiator, acceptor); + complete_interactive_funding_negotiation( initiator, acceptor, channel_id, - initiator_contribution, + funding_contribution, new_funding_script, ); } -pub fn complete_splice_handshake<'a, 'b, 'c, 'd>( +pub fn initiate_splice_in<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, - initiator_contribution: SpliceContribution, -) -> ScriptBuf { - let node_id_initiator = initiator.node.get_our_node_id(); + value_added: Amount, +) -> FundingContribution { + let change_script = Some(initiator.wallet_source.get_change_script().unwrap()); + do_initiate_splice_in(initiator, acceptor, channel_id, value_added, change_script) +} + +pub fn do_initiate_splice_in<'a, 'b, 'c, 'd>( + initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, + value_added: Amount, change_script: Option, +) -> FundingContribution { + let node_id_acceptor = acceptor.node.get_our_node_id(); + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = + initiator.node.splice_channel(&channel_id, &node_id_acceptor, feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&initiator.wallet_source), initiator.logger); + let funding_contribution = + funding_template.splice_in_sync(change_script, value_added, &wallet).unwrap(); + initiator + .node + .funding_contributed(&channel_id, &node_id_acceptor, funding_contribution.clone(), None) + .unwrap(); + funding_contribution +} + +pub fn initiate_splice_out<'a, 'b, 'c, 'd>( + initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, + outputs: Vec, +) -> FundingContribution { let node_id_acceptor = acceptor.node.get_our_node_id(); + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = + initiator.node.splice_channel(&channel_id, &node_id_acceptor, feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&initiator.wallet_source), initiator.logger); + let funding_contribution = funding_template.splice_out_sync(outputs, &wallet).unwrap(); + initiator + .node + .funding_contributed(&channel_id, &node_id_acceptor, funding_contribution.clone(), None) + .unwrap(); + funding_contribution +} + +pub fn initiate_splice_in_and_out<'a, 'b, 'c, 'd>( + initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, + value_added: Amount, outputs: Vec, +) -> FundingContribution { + let change_script = Some(initiator.wallet_source.get_change_script().unwrap()); + do_initiate_splice_in_and_out( + initiator, + acceptor, + channel_id, + value_added, + outputs, + change_script, + ) +} +pub fn do_initiate_splice_in_and_out<'a, 'b, 'c, 'd>( + initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, + value_added: Amount, outputs: Vec, change_script: Option, +) -> FundingContribution { + let node_id_acceptor = acceptor.node.get_our_node_id(); + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = + initiator.node.splice_channel(&channel_id, &node_id_acceptor, feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&initiator.wallet_source), initiator.logger); + let funding_contribution = funding_template + .splice_in_and_out_sync(change_script, value_added, outputs, &wallet) + .unwrap(); initiator .node - .splice_channel( - &channel_id, - &node_id_acceptor, - initiator_contribution, - FEERATE_FLOOR_SATS_PER_KW, - None, - ) + .funding_contributed(&channel_id, &node_id_acceptor, funding_contribution.clone(), None) .unwrap(); + funding_contribution +} + +pub fn complete_splice_handshake<'a, 'b, 'c, 'd>( + initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, +) -> ScriptBuf { + let node_id_initiator = initiator.node.get_our_node_id(); + let node_id_acceptor = acceptor.node.get_our_node_id(); let stfu_init = get_event_msg!(initiator, MessageSendEvent::SendStfu, node_id_acceptor); acceptor.node.handle_stfu(node_id_initiator, &stfu_init); @@ -182,7 +231,7 @@ pub fn complete_splice_handshake<'a, 'b, 'c, 'd>( pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, - initiator_contribution: SpliceContribution, new_funding_script: ScriptBuf, + initiator_contribution: FundingContribution, new_funding_script: ScriptBuf, ) { let node_id_initiator = initiator.node.get_our_node_id(); let node_id_acceptor = acceptor.node.get_our_node_id(); @@ -358,19 +407,18 @@ pub fn sign_interactive_funding_tx<'a, 'b, 'c, 'd>( pub fn splice_channel<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, - initiator_contribution: SpliceContribution, + funding_contribution: FundingContribution, ) -> Transaction { let node_id_initiator = initiator.node.get_our_node_id(); let node_id_acceptor = acceptor.node.get_our_node_id(); - let new_funding_script = - complete_splice_handshake(initiator, acceptor, channel_id, initiator_contribution.clone()); + let new_funding_script = complete_splice_handshake(initiator, acceptor); complete_interactive_funding_negotiation( initiator, acceptor, channel_id, - initiator_contribution, + funding_contribution, new_funding_script, ); let (splice_tx, splice_locked) = sign_interactive_funding_tx(initiator, acceptor, false); @@ -384,20 +432,20 @@ pub fn splice_channel<'a, 'b, 'c, 'd>( pub fn lock_splice_after_blocks<'a, 'b, 'c, 'd>( node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>, num_blocks: u32, -) { +) -> Option { connect_blocks(node_a, num_blocks); connect_blocks(node_b, num_blocks); let node_id_b = node_b.node.get_our_node_id(); let splice_locked_for_node_b = get_event_msg!(node_a, MessageSendEvent::SendSpliceLocked, node_id_b); - lock_splice(node_a, node_b, &splice_locked_for_node_b, false); + lock_splice(node_a, node_b, &splice_locked_for_node_b, false) } pub fn lock_splice<'a, 'b, 'c, 'd>( node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>, splice_locked_for_node_b: &msgs::SpliceLocked, is_0conf: bool, -) { +) -> Option { let (prev_funding_outpoint, prev_funding_script) = node_a .chain_monitor .chain_monitor @@ -411,6 +459,15 @@ pub fn lock_splice<'a, 'b, 'c, 'd>( node_b.node.handle_splice_locked(node_id_a, splice_locked_for_node_b); let mut msg_events = node_b.node.get_and_clear_pending_msg_events(); + + // If the acceptor had a pending QuiescentAction, return the stfu message so that it can be used + // for the next splice attempt. + let node_b_stfu = msg_events + .last() + .filter(|event| matches!(event, MessageSendEvent::SendStfu { .. })) + .is_some() + .then(|| msg_events.pop().unwrap()); + assert_eq!(msg_events.len(), if is_0conf { 1 } else { 2 }, "{msg_events:?}"); if let MessageSendEvent::SendSpliceLocked { msg, .. } = msg_events.remove(0) { node_a.node.handle_splice_locked(node_id_b, &msg); @@ -457,6 +514,8 @@ pub fn lock_splice<'a, 'b, 'c, 'd>( .chain_source .remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script.clone()); node_b.chain_source.remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script); + + node_b_stfu } #[test] @@ -501,20 +560,11 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 50_000_000); - let contribution = SpliceContribution::splice_out(vec![TxOut { + let outputs = vec![TxOut { value: Amount::from_sat(1_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), - }]); - nodes[0] - .node - .splice_channel( - &channel_id, - &node_id_1, - contribution.clone(), - FEERATE_FLOOR_SATS_PER_KW, - None, - ) - .unwrap(); + }]; + let _ = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); // Attempt a splice negotiation that only goes up to receiving `splice_init`. Reconnecting // should implicitly abort the negotiation and reset the splice state such that we're able to @@ -559,16 +609,7 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { reconnect_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_args); - nodes[0] - .node - .splice_channel( - &channel_id, - &node_id_1, - contribution.clone(), - FEERATE_FLOOR_SATS_PER_KW, - None, - ) - .unwrap(); + let _ = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); // Attempt a splice negotiation that ends mid-construction of the funding transaction. // Reconnecting should implicitly abort the negotiation and reset the splice state such that @@ -618,16 +659,7 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { reconnect_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_args); - nodes[0] - .node - .splice_channel( - &channel_id, - &node_id_1, - contribution.clone(), - FEERATE_FLOOR_SATS_PER_KW, - None, - ) - .unwrap(); + let _ = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); // Attempt a splice negotiation that ends before the initial `commitment_signed` messages are // exchanged. The node missing the other's `commitment_signed` upon reconnecting should @@ -705,7 +737,8 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { // Attempt a splice negotiation that completes, (i.e. `tx_signatures` are exchanged). Reconnecting // should not abort the negotiation or reset the splice state. - let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, contribution); + let funding_contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs); + let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); if reload { let encoded_monitor_0 = get_monitor!(nodes[0], channel_id).encode(); @@ -757,20 +790,11 @@ fn test_config_reject_inbound_splices() { let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 50_000_000); - let contribution = SpliceContribution::splice_out(vec![TxOut { + let outputs = vec![TxOut { value: Amount::from_sat(1_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), - }]); - nodes[0] - .node - .splice_channel( - &channel_id, - &node_id_1, - contribution.clone(), - FEERATE_FLOOR_SATS_PER_KW, - None, - ) - .unwrap(); + }]; + let _ = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); let stfu = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); nodes[1].node.handle_stfu(node_id_0, &stfu); @@ -798,7 +822,8 @@ fn test_config_reject_inbound_splices() { reconnect_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_args); - let _ = splice_channel(&nodes[1], &nodes[0], channel_id, contribution); + let funding_contribution = initiate_splice_out(&nodes[1], &nodes[0], channel_id, outputs); + let _ = splice_channel(&nodes[1], &nodes[0], channel_id, funding_contribution); } #[test] @@ -816,24 +841,23 @@ fn test_splice_in() { let _ = send_payment(&nodes[0], &[&nodes[1]], 100_000); - let coinbase_tx1 = provide_anchor_reserves(&nodes); - let coinbase_tx2 = provide_anchor_reserves(&nodes); - let added_value = Amount::from_sat(initial_channel_value_sat * 2); + let utxo_value = added_value * 3 / 4; let change_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()); let fees = Amount::from_sat(321); - let initiator_contribution = SpliceContribution::splice_in( + provide_utxo_reserves(&nodes, 2, utxo_value); + + let funding_contribution = do_initiate_splice_in( + &nodes[0], + &nodes[1], + channel_id, added_value, - vec![ - FundingTxInput::new_p2wpkh(coinbase_tx1, 0).unwrap(), - FundingTxInput::new_p2wpkh(coinbase_tx2, 0).unwrap(), - ], Some(change_script.clone()), ); - let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); - let expected_change = Amount::ONE_BTC * 2 - added_value - fees; + let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + let expected_change = utxo_value * 2 - added_value - fees; assert_eq!( splice_tx.output.iter().find(|txout| txout.script_pubkey == change_script).unwrap().value, expected_change, @@ -868,7 +892,7 @@ fn test_splice_out() { let _ = send_payment(&nodes[0], &[&nodes[1]], 100_000); - let initiator_contribution = SpliceContribution::splice_out(vec![ + let outputs = vec![ TxOut { value: Amount::from_sat(initial_channel_value_sat / 4), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), @@ -877,9 +901,10 @@ fn test_splice_out() { value: Amount::from_sat(initial_channel_value_sat / 4), script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), }, - ]); + ]; + let funding_contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs); - let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); + let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); mine_transaction(&nodes[0], &splice_tx); mine_transaction(&nodes[1], &splice_tx); @@ -909,14 +934,12 @@ fn test_splice_in_and_out() { let _ = send_payment(&nodes[0], &[&nodes[1]], 100_000); - let coinbase_tx1 = provide_anchor_reserves(&nodes); - let coinbase_tx2 = provide_anchor_reserves(&nodes); - // Contribute a net negative value, with fees taken from the contributed inputs and the // remaining value sent to change let htlc_limit_msat = nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat; let added_value = Amount::from_sat(htlc_limit_msat / 1000); let removed_value = added_value * 2; + let utxo_value = added_value * 3 / 4; let change_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()); let fees = if cfg!(feature = "grind_signatures") { Amount::from_sat(383) @@ -926,27 +949,29 @@ fn test_splice_in_and_out() { assert!(htlc_limit_msat > initial_channel_value_sat / 2 * 1000); - let initiator_contribution = SpliceContribution::splice_in_and_out( + provide_utxo_reserves(&nodes, 2, utxo_value); + + let outputs = vec![ + TxOut { + value: removed_value / 2, + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }, + TxOut { + value: removed_value / 2, + script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), + }, + ]; + let funding_contribution = do_initiate_splice_in_and_out( + &nodes[0], + &nodes[1], + channel_id, added_value, - vec![ - FundingTxInput::new_p2wpkh(coinbase_tx1, 0).unwrap(), - FundingTxInput::new_p2wpkh(coinbase_tx2, 0).unwrap(), - ], - vec![ - TxOut { - value: removed_value / 2, - script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), - }, - TxOut { - value: removed_value / 2, - script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), - }, - ], + outputs, Some(change_script.clone()), ); - let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); - let expected_change = Amount::ONE_BTC * 2 - added_value - fees; + let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + let expected_change = utxo_value * 2 - added_value - fees; assert_eq!( splice_tx.output.iter().find(|txout| txout.script_pubkey == change_script).unwrap().value, expected_change, @@ -965,13 +990,11 @@ fn test_splice_in_and_out() { assert!(htlc_limit_msat < added_value.to_sat() * 1000); let _ = send_payment(&nodes[0], &[&nodes[1]], htlc_limit_msat); - let coinbase_tx1 = provide_anchor_reserves(&nodes); - let coinbase_tx2 = provide_anchor_reserves(&nodes); - // Contribute a net positive value, with fees taken from the contributed inputs and the // remaining value sent to change let added_value = Amount::from_sat(initial_channel_value_sat * 2); let removed_value = added_value / 2; + let utxo_value = added_value * 3 / 4; let change_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()); let fees = if cfg!(feature = "grind_signatures") { Amount::from_sat(383) @@ -979,27 +1002,32 @@ fn test_splice_in_and_out() { Amount::from_sat(384) }; - let initiator_contribution = SpliceContribution::splice_in_and_out( + // Clear UTXOs so that the change output from the previous splice isn't considered + nodes[0].wallet_source.clear_utxos(); + + provide_utxo_reserves(&nodes, 2, utxo_value); + + let outputs = vec![ + TxOut { + value: removed_value / 2, + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }, + TxOut { + value: removed_value / 2, + script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), + }, + ]; + let funding_contribution = do_initiate_splice_in_and_out( + &nodes[0], + &nodes[1], + channel_id, added_value, - vec![ - FundingTxInput::new_p2wpkh(coinbase_tx1, 0).unwrap(), - FundingTxInput::new_p2wpkh(coinbase_tx2, 0).unwrap(), - ], - vec![ - TxOut { - value: removed_value / 2, - script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), - }, - TxOut { - value: removed_value / 2, - script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), - }, - ], + outputs, Some(change_script.clone()), ); - let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); - let expected_change = Amount::ONE_BTC * 2 - added_value - fees; + let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + let expected_change = utxo_value * 2 - added_value - fees; assert_eq!( splice_tx.output.iter().find(|txout| txout.script_pubkey == change_script).unwrap().value, expected_change, @@ -1016,46 +1044,108 @@ fn test_splice_in_and_out() { let htlc_limit_msat = nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat; assert!(htlc_limit_msat > initial_channel_value_sat / 2 * 1000); let _ = send_payment(&nodes[0], &[&nodes[1]], htlc_limit_msat); +} - let coinbase_tx1 = provide_anchor_reserves(&nodes); - let coinbase_tx2 = provide_anchor_reserves(&nodes); +#[test] +fn test_fails_initiating_concurrent_splices() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let config = test_default_channel_config(); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); - // Fail adding a net contribution value of zero - let added_value = Amount::from_sat(initial_channel_value_sat * 2); - let removed_value = added_value; - let change_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()); + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + let node_0_id = nodes[0].node.get_our_node_id(); + let node_1_id = nodes[1].node.get_our_node_id(); - let initiator_contribution = SpliceContribution::splice_in_and_out( - added_value, - vec![ - FundingTxInput::new_p2wpkh(coinbase_tx1, 0).unwrap(), - FundingTxInput::new_p2wpkh(coinbase_tx2, 0).unwrap(), - ], - vec![ - TxOut { - value: removed_value / 2, - script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), - }, - TxOut { - value: removed_value / 2, - script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), - }, - ], - Some(change_script), + provide_utxo_reserves(&nodes, 2, Amount::ONE_BTC); + + let outputs = vec![TxOut { + value: Amount::from_sat(initial_channel_value_sat / 4), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]; + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let funding_contribution = funding_template.splice_out_sync(outputs.clone(), &wallet).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_1_id, funding_contribution.clone(), None) + .unwrap(); + + assert_eq!( + nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate), + Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot be spliced as one is waiting to be negotiated", + channel_id + ), + }), ); + let new_funding_script = complete_splice_handshake(&nodes[0], &nodes[1]); + assert_eq!( - nodes[0].node.splice_channel( - &channel_id, - &nodes[1].node.get_our_node_id(), - initiator_contribution, - FEERATE_FLOOR_SATS_PER_KW, - None, - ), + nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate), Err(APIError::APIMisuseError { - err: format!("Channel {} cannot be spliced; contribution cannot be zero", channel_id), + err: format!( + "Channel {} cannot be spliced as one is currently being negotiated", + channel_id + ), }), ); + + // The acceptor can enqueue a quiescent action while the current splice is pending. + let added_value = Amount::from_sat(initial_channel_value_sat); + let acceptor_template = nodes[1].node.splice_channel(&channel_id, &node_0_id, feerate).unwrap(); + let acceptor_wallet = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let change_script = Some(nodes[1].wallet_source.get_change_script().unwrap()); + let acceptor_contribution = + acceptor_template.splice_in_sync(change_script, added_value, &acceptor_wallet).unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_0_id, acceptor_contribution, None) + .unwrap(); + + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + funding_contribution, + new_funding_script, + ); + + assert_eq!( + nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate), + Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot be spliced as one is currently being negotiated", + channel_id + ), + }), + ); + + let (splice_tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_1_id); + expect_splice_pending_event(&nodes[1], &node_0_id); + + // Now that the splice is pending, another splice may be initiated. + assert!(nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate).is_ok()); + + mine_transaction(&nodes[0], &splice_tx); + mine_transaction(&nodes[1], &splice_tx); + let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); + + // However, the acceptor had enqueued a quiescent action while the splice was pending, so it + // will now attempt to initiate quiescence. + assert!( + matches!(stfu, Some(MessageSendEvent::SendStfu { node_id, .. }) if node_id == node_0_id) + ); } #[cfg(test)] @@ -1091,16 +1181,19 @@ fn do_test_splice_commitment_broadcast(splice_status: SpliceStatus, claim_htlcs: let (_, _, channel_id, initial_funding_tx) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0); - let coinbase_tx = provide_anchor_reserves(&nodes); + let coinbase_tx = provide_utxo_reserves(&nodes, 1, Amount::ONE_BTC); // We want to have two HTLCs pending to make sure we can claim those sent before and after a // splice negotiation. let payment_amount = 1_000_000; let (preimage1, payment_hash1, ..) = route_payment(&nodes[0], &[&nodes[1]], payment_amount); + let splice_in_amount = initial_channel_capacity / 2; - let initiator_contribution = SpliceContribution::splice_in( + let initiator_contribution = do_initiate_splice_in( + &nodes[0], + &nodes[1], + channel_id, Amount::from_sat(splice_in_amount), - vec![FundingTxInput::new_p2wpkh(coinbase_tx.clone(), 0).unwrap()], Some(nodes[0].wallet_source.get_change_script().unwrap()), ); let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); @@ -1296,7 +1389,7 @@ fn do_test_splice_reestablish(reload: bool, async_monitor_update: bool) { route_payment(&nodes[0], &[&nodes[1]], 1_000_000); // Negotiate the splice up until the nodes exchange `tx_complete`. - let initiator_contribution = SpliceContribution::splice_out(vec![ + let outputs = vec![ TxOut { value: Amount::from_sat(initial_channel_value_sat / 4), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), @@ -1305,7 +1398,8 @@ fn do_test_splice_reestablish(reload: bool, async_monitor_update: bool) { value: Amount::from_sat(initial_channel_value_sat / 4), script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), }, - ]); + ]; + let initiator_contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs); negotiate_splice_tx(&nodes[0], &nodes[1], channel_id, initiator_contribution); // Node 0 should have a signing event to handle since they had a contribution in the splice. @@ -1582,36 +1676,35 @@ fn do_test_propose_splice_while_disconnected(reload: bool, use_0conf: bool) { nodes[1].node.peer_disconnected(node_id_0); let splice_out_sat = initial_channel_value_sat / 4; - let node_0_contribution = SpliceContribution::splice_out(vec![TxOut { + let node_0_outputs = vec![TxOut { value: Amount::from_sat(splice_out_sat), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), - }]); + }]; + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let node_0_funding_contribution = + funding_template.splice_out_sync(node_0_outputs, &wallet).unwrap(); nodes[0] .node - .splice_channel( - &channel_id, - &node_id_1, - node_0_contribution.clone(), - FEERATE_FLOOR_SATS_PER_KW, - None, - ) + .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) .unwrap(); + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); - let node_1_contribution = SpliceContribution::splice_out(vec![TxOut { + let node_1_outputs = vec![TxOut { value: Amount::from_sat(splice_out_sat), script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), - }]); + }]; + let funding_template = nodes[1].node.splice_channel(&channel_id, &node_id_0, feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let node_1_funding_contribution = + funding_template.splice_out_sync(node_1_outputs, &wallet).unwrap(); nodes[1] .node - .splice_channel( - &channel_id, - &node_id_0, - node_1_contribution.clone(), - FEERATE_FLOOR_SATS_PER_KW, - None, - ) + .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) .unwrap(); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); if reload { @@ -1644,6 +1737,7 @@ fn do_test_propose_splice_while_disconnected(reload: bool, use_0conf: bool) { } reconnect_args.send_stfu = (true, true); reconnect_nodes(reconnect_args); + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); @@ -1667,7 +1761,7 @@ fn do_test_propose_splice_while_disconnected(reload: bool, use_0conf: bool) { &nodes[0], &nodes[1], channel_id, - node_0_contribution, + node_0_funding_contribution, new_funding_script, ); let (splice_tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], use_0conf); @@ -1806,7 +1900,7 @@ fn do_test_propose_splice_while_disconnected(reload: bool, use_0conf: bool) { &nodes[1], &nodes[0], channel_id, - node_1_contribution, + node_1_funding_contribution, new_funding_script, ); let (splice_tx, splice_locked) = sign_interactive_funding_tx(&nodes[1], &nodes[0], use_0conf); @@ -1845,17 +1939,15 @@ fn disconnect_on_unexpected_interactive_tx_message() { let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0); - let coinbase_tx = provide_anchor_reserves(&nodes); + provide_utxo_reserves(&nodes, 1, Amount::ONE_BTC); + let splice_in_amount = initial_channel_capacity / 2; - let contribution = SpliceContribution::splice_in( - Amount::from_sat(splice_in_amount), - vec![FundingTxInput::new_p2wpkh(coinbase_tx, 0).unwrap()], - Some(nodes[0].wallet_source.get_change_script().unwrap()), - ); + let contribution = + initiate_splice_in(initiator, acceptor, channel_id, Amount::from_sat(splice_in_amount)); // Complete interactive-tx construction, but fail by having the acceptor send a duplicate // tx_complete instead of commitment_signed. - negotiate_splice_tx(initiator, acceptor, channel_id, contribution.clone()); + negotiate_splice_tx(initiator, acceptor, channel_id, contribution); let _ = get_event!(initiator, Event::FundingTransactionReadyForSigning); let _ = get_htlc_update_msgs(acceptor, &node_id_initiator); @@ -1883,17 +1975,15 @@ fn fail_splice_on_interactive_tx_error() { let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0); - let coinbase_tx = provide_anchor_reserves(&nodes); + provide_utxo_reserves(&nodes, 1, Amount::ONE_BTC); + let splice_in_amount = initial_channel_capacity / 2; - let contribution = SpliceContribution::splice_in( - Amount::from_sat(splice_in_amount), - vec![FundingTxInput::new_p2wpkh(coinbase_tx, 0).unwrap()], - Some(nodes[0].wallet_source.get_change_script().unwrap()), - ); // Fail during interactive-tx construction by having the acceptor echo back tx_add_input instead // of sending tx_complete. The failure occurs because the serial id will have the wrong parity. - let _ = complete_splice_handshake(initiator, acceptor, channel_id, contribution.clone()); + let funding_contribution = + initiate_splice_in(initiator, acceptor, channel_id, Amount::from_sat(splice_in_amount)); + let _ = complete_splice_handshake(initiator, acceptor); let tx_add_input = get_event_msg!(initiator, MessageSendEvent::SendTxAddInput, node_id_acceptor); @@ -1907,7 +1997,7 @@ fn fail_splice_on_interactive_tx_error() { match event { Event::SpliceFailed { contributed_inputs, .. } => { assert_eq!(contributed_inputs.len(), 1); - assert_eq!(contributed_inputs[0], contribution.inputs()[0].outpoint()); + assert_eq!(contributed_inputs[0], funding_contribution.into_tx_parts().0[0].outpoint()); }, _ => panic!("Expected Event::SpliceFailed"), } @@ -1936,17 +2026,15 @@ fn fail_splice_on_tx_abort() { let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0); - let coinbase_tx = provide_anchor_reserves(&nodes); + provide_utxo_reserves(&nodes, 1, Amount::ONE_BTC); + let splice_in_amount = initial_channel_capacity / 2; - let contribution = SpliceContribution::splice_in( - Amount::from_sat(splice_in_amount), - vec![FundingTxInput::new_p2wpkh(coinbase_tx, 0).unwrap()], - Some(nodes[0].wallet_source.get_change_script().unwrap()), - ); // Fail during interactive-tx construction by having the acceptor send tx_abort instead of // tx_complete. - let _ = complete_splice_handshake(initiator, acceptor, channel_id, contribution.clone()); + let funding_contribution = + initiate_splice_in(initiator, acceptor, channel_id, Amount::from_sat(splice_in_amount)); + let _ = complete_splice_handshake(initiator, acceptor); let tx_add_input = get_event_msg!(initiator, MessageSendEvent::SendTxAddInput, node_id_acceptor); @@ -1963,7 +2051,7 @@ fn fail_splice_on_tx_abort() { match event { Event::SpliceFailed { contributed_inputs, .. } => { assert_eq!(contributed_inputs.len(), 1); - assert_eq!(contributed_inputs[0], contribution.inputs()[0].outpoint()); + assert_eq!(contributed_inputs[0], funding_contribution.into_tx_parts().0[0].outpoint()); }, _ => panic!("Expected Event::SpliceFailed"), } @@ -1989,16 +2077,13 @@ fn fail_splice_on_channel_close() { let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0); - let coinbase_tx = provide_anchor_reserves(&nodes); + provide_utxo_reserves(&nodes, 1, Amount::ONE_BTC); + let splice_in_amount = initial_channel_capacity / 2; - let contribution = SpliceContribution::splice_in( - Amount::from_sat(splice_in_amount), - vec![FundingTxInput::new_p2wpkh(coinbase_tx, 0).unwrap()], - Some(nodes[0].wallet_source.get_change_script().unwrap()), - ); // Close the channel before completion of interactive-tx construction. - let _ = complete_splice_handshake(initiator, acceptor, channel_id, contribution.clone()); + let _ = initiate_splice_in(initiator, acceptor, channel_id, Amount::from_sat(splice_in_amount)); + let _ = complete_splice_handshake(initiator, acceptor); let _tx_add_input = get_event_msg!(initiator, MessageSendEvent::SendTxAddInput, node_id_acceptor); @@ -2039,25 +2124,12 @@ fn fail_quiescent_action_on_channel_close() { let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0); - let coinbase_tx = provide_anchor_reserves(&nodes); let splice_in_amount = initial_channel_capacity / 2; - let contribution = SpliceContribution::splice_in( - Amount::from_sat(splice_in_amount), - vec![FundingTxInput::new_p2wpkh(coinbase_tx, 0).unwrap()], - Some(nodes[0].wallet_source.get_change_script().unwrap()), - ); + + provide_utxo_reserves(&nodes, 1, Amount::ONE_BTC); // Close the channel before completion of STFU handshake. - initiator - .node - .splice_channel( - &channel_id, - &node_id_acceptor, - contribution, - FEERATE_FLOOR_SATS_PER_KW, - None, - ) - .unwrap(); + let _ = initiate_splice_in(initiator, acceptor, channel_id, Amount::from_sat(splice_in_amount)); let _stfu_init = get_event_msg!(initiator, MessageSendEvent::SendStfu, node_id_acceptor); @@ -2134,19 +2206,21 @@ fn do_test_splice_with_inflight_htlc_forward_and_resolution(expire_scid_pre_forw // Splice both channels, lock them, and connect enough blocks to trigger the legacy SCID pruning // logic while the HTLC is still pending. - let contribution = SpliceContribution::splice_out(vec![TxOut { + let outputs_0_1 = vec![TxOut { value: Amount::from_sat(1_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), - }]); + }]; + let contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id_0_1, outputs_0_1); let splice_tx_0_1 = splice_channel(&nodes[0], &nodes[1], channel_id_0_1, contribution); for node in &nodes { mine_transaction(node, &splice_tx_0_1); } - let contribution = SpliceContribution::splice_out(vec![TxOut { + let outputs_1_2 = vec![TxOut { value: Amount::from_sat(1_000), script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), - }]); + }]; + let contribution = initiate_splice_out(&nodes[1], &nodes[2], channel_id_1_2, outputs_1_2); let splice_tx_1_2 = splice_channel(&nodes[1], &nodes[2], channel_id_1_2, contribution); for node in &nodes { mine_transaction(node, &splice_tx_1_2); @@ -2250,10 +2324,11 @@ fn test_splice_buffer_commitment_signed_until_funding_tx_signed() { // Negotiate a splice-out where only the initiator (node 0) has a contribution. // This means node 1 will send their commitment_signed immediately after tx_complete. - let initiator_contribution = SpliceContribution::splice_out(vec![TxOut { + let outputs = vec![TxOut { value: Amount::from_sat(1_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), - }]); + }]; + let initiator_contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs); negotiate_splice_tx(&nodes[0], &nodes[1], channel_id, initiator_contribution); // Node 0 (initiator with contribution) should have a signing event to handle. @@ -2370,10 +2445,11 @@ fn test_splice_buffer_invalid_commitment_signed_closes_channel() { // Negotiate a splice-out where only the initiator (node 0) has a contribution. // This means node 1 will send their commitment_signed immediately after tx_complete. - let initiator_contribution = SpliceContribution::splice_out(vec![TxOut { + let outputs = vec![TxOut { value: Amount::from_sat(1_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), - }]); + }]; + let initiator_contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs); negotiate_splice_tx(&nodes[0], &nodes[1], channel_id, initiator_contribution); // Node 0 (initiator with contribution) should have a signing event to handle. diff --git a/lightning/src/ln/zero_fee_commitment_tests.rs b/lightning/src/ln/zero_fee_commitment_tests.rs index d287b6e3de1..b7221552603 100644 --- a/lightning/src/ln/zero_fee_commitment_tests.rs +++ b/lightning/src/ln/zero_fee_commitment_tests.rs @@ -129,7 +129,7 @@ fn test_htlc_claim_chunking() { let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &configs); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); - let coinbase_tx = provide_anchor_utxo_reserves(&nodes, 50, Amount::from_sat(500)); + let coinbase_tx = provide_utxo_reserves(&nodes, 50, Amount::from_sat(500)); const CHAN_CAPACITY: u64 = 10_000_000; let (_, _, chan_id, _funding_tx) = create_announced_chan_between_nodes_with_value( @@ -319,7 +319,7 @@ fn test_anchor_tx_too_big() { let node_a_id = nodes[0].node.get_our_node_id(); - let _coinbase_tx_a = provide_anchor_utxo_reserves(&nodes, 50, Amount::from_sat(500)); + let _coinbase_tx_a = provide_utxo_reserves(&nodes, 50, Amount::from_sat(500)); const CHAN_CAPACITY: u64 = 10_000_000; let (_, _, chan_id, _funding_tx) = create_announced_chan_between_nodes_with_value( diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs index 6579c0353a3..2eace55a4bf 100644 --- a/lightning/src/util/ser.rs +++ b/lightning/src/util/ser.rs @@ -41,6 +41,7 @@ use bitcoin::secp256k1::ecdsa; use bitcoin::secp256k1::schnorr; use bitcoin::secp256k1::{PublicKey, SecretKey}; use bitcoin::transaction::{OutPoint, Transaction, TxOut}; +use bitcoin::FeeRate; use bitcoin::{consensus, Sequence, TxIn, Weight, Witness}; use dnssec_prover::rr::Name; @@ -1426,6 +1427,19 @@ impl Readable for Weight { } } +impl Writeable for FeeRate { + fn write(&self, w: &mut W) -> Result<(), io::Error> { + self.to_sat_per_kwu().write(w) + } +} + +impl Readable for FeeRate { + fn read(r: &mut R) -> Result { + let sat_kwu: u64 = Readable::read(r)?; + Ok(FeeRate::from_sat_per_kwu(sat_kwu)) + } +} + impl Writeable for Txid { fn write(&self, w: &mut W) -> Result<(), io::Error> { w.write_all(&self[..]) From 96b9e6af436d1fe07db56c6946186fcb955a68c6 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 20 Jan 2026 12:08:20 -0600 Subject: [PATCH 036/627] Use CoinSelection::change_output when splicing Now that CoinSelection is used to fund a splice funding transaction, use that for determining of a change output should be used. Previously, the initiator could either provide a change script upfront or let LDK generate one using SignerProvider::get_destination_script. Since older versions may have serialized a SpliceInstruction without a change script while waiting on quiescence, LDK must still generate a change output in this case. --- fuzz/src/chanmon_consistency.rs | 8 +- fuzz/src/full_stack.rs | 8 +- .../src/upgrade_downgrade_tests.rs | 2 +- lightning/src/ln/channel.rs | 140 ++++++++++-------- lightning/src/ln/funding.rs | 101 +++++-------- lightning/src/ln/interactivetxs.rs | 1 - lightning/src/ln/splicing_tests.rs | 137 +++++++---------- 7 files changed, 186 insertions(+), 211 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 70dda138e43..ced89f5ac8c 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -2083,7 +2083,7 @@ pub fn do_test( Ok(funding_template) => { let wallet = WalletSync::new(&wallets[0], Arc::clone(&loggers[0])); if let Ok(contribution) = - funding_template.splice_in_sync(None, Amount::from_sat(10_000), &wallet) + funding_template.splice_in_sync(Amount::from_sat(10_000), &wallet) { let _ = nodes[0].funding_contributed( &chan_a_id, @@ -2109,7 +2109,7 @@ pub fn do_test( Ok(funding_template) => { let wallet = WalletSync::new(&wallets[1], Arc::clone(&loggers[1])); if let Ok(contribution) = - funding_template.splice_in_sync(None, Amount::from_sat(10_000), &wallet) + funding_template.splice_in_sync(Amount::from_sat(10_000), &wallet) { let _ = nodes[1].funding_contributed( &chan_a_id, @@ -2135,7 +2135,7 @@ pub fn do_test( Ok(funding_template) => { let wallet = WalletSync::new(&wallets[1], Arc::clone(&loggers[1])); if let Ok(contribution) = - funding_template.splice_in_sync(None, Amount::from_sat(10_000), &wallet) + funding_template.splice_in_sync(Amount::from_sat(10_000), &wallet) { let _ = nodes[1].funding_contributed( &chan_b_id, @@ -2161,7 +2161,7 @@ pub fn do_test( Ok(funding_template) => { let wallet = WalletSync::new(&wallets[2], Arc::clone(&loggers[2])); if let Ok(contribution) = - funding_template.splice_in_sync(None, Amount::from_sat(10_000), &wallet) + funding_template.splice_in_sync(Amount::from_sat(10_000), &wallet) { let _ = nodes[2].funding_contributed( &chan_b_id, diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index 2163ca0fb5f..6adb8f33c89 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -1038,11 +1038,9 @@ pub fn do_test(mut data: &[u8], logger: &Arc FeeRate::from_sat_per_kwu(253), ) { let wallet_sync = WalletSync::new(&wallet, Arc::clone(&logger)); - if let Ok(contribution) = funding_template.splice_in_sync( - None, - Amount::from_sat(splice_in_sats.min(900_000)), - &wallet_sync, - ) { + if let Ok(contribution) = funding_template + .splice_in_sync(Amount::from_sat(splice_in_sats.min(900_000)), &wallet_sync) + { let _ = channelmanager.funding_contributed( &chan_id, &counterparty, diff --git a/lightning-tests/src/upgrade_downgrade_tests.rs b/lightning-tests/src/upgrade_downgrade_tests.rs index dde194105c3..f18e0e56800 100644 --- a/lightning-tests/src/upgrade_downgrade_tests.rs +++ b/lightning-tests/src/upgrade_downgrade_tests.rs @@ -458,7 +458,7 @@ fn do_test_0_1_htlc_forward_after_splice(fail_htlc: bool) { }]; let channel_id = ChannelId(chan_id_bytes_a); let funding_contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs); - let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); for node in nodes.iter() { mine_transaction(node, &splice_tx); connect_blocks(node, ANTI_REORG_DELAY - 1); diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index e000ebc93eb..db85a26ae91 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2908,6 +2908,7 @@ impl_writeable_tlv_based!(PendingFunding, { enum FundingNegotiation { AwaitingAck { context: FundingNegotiationContext, + change_strategy: ChangeStrategy, new_holder_funding_key: PublicKey, }, ConstructingTransaction { @@ -6680,10 +6681,17 @@ pub(super) struct FundingNegotiationContext { /// The funding outputs we will be contributing to the channel. #[allow(dead_code)] // TODO(dual_funding): Remove once contribution to V2 channels is enabled. pub our_funding_outputs: Vec, +} + +/// How the funding transaction's change is determined. +#[derive(Debug)] +pub(super) enum ChangeStrategy { + /// The change output, if any, is included in the FundingContribution's outputs. + FromCoinSelection, + /// The change output script. This will be used if needed or -- if not set -- generated using /// `SignerProvider::get_destination_script`. - #[allow(dead_code)] // TODO(splicing): Remove once splicing is enabled. - pub change_script: Option, + LegacyUserProvided(Option), } impl FundingNegotiationContext { @@ -6691,7 +6699,7 @@ impl FundingNegotiationContext { /// If error occurs, it is caused by our side, not the counterparty. fn into_interactive_tx_constructor( mut self, context: &ChannelContext, funding: &FundingScope, signer_provider: &SP, - entropy_source: &ES, holder_node_id: PublicKey, + entropy_source: &ES, holder_node_id: PublicKey, change_strategy: ChangeStrategy, ) -> Result { debug_assert_eq!( self.shared_funding_input.is_some(), @@ -6712,46 +6720,15 @@ impl FundingNegotiationContext { script_pubkey: funding.get_funding_redeemscript().to_p2wsh(), }; - // Optionally add change output - let change_value_opt = if !self.our_funding_inputs.is_empty() { - match calculate_change_output_value( - &self, - self.shared_funding_input.is_some(), - &shared_funding_output.script_pubkey, - context.holder_dust_limit_satoshis, - ) { - Ok(change_value_opt) => change_value_opt, - Err(reason) => { - return Err(self.into_negotiation_error(reason)); - }, - } - } else { - None - }; - - if let Some(change_value) = change_value_opt { - let change_script = if let Some(script) = self.change_script { - script - } else { - match signer_provider.get_destination_script(context.channel_keys_id) { - Ok(script) => script, - Err(_) => { - let reason = AbortReason::InternalError("Error getting change script"); - return Err(self.into_negotiation_error(reason)); - }, - } - }; - let mut change_output = TxOut { value: change_value, script_pubkey: change_script }; - let change_output_weight = get_output_weight(&change_output.script_pubkey).to_wu(); - let change_output_fee = - fee_for_weight(self.funding_feerate_sat_per_1000_weight, change_output_weight); - let change_value_decreased_with_fee = - change_value.to_sat().saturating_sub(change_output_fee); - // Check dust limit again - if change_value_decreased_with_fee > context.holder_dust_limit_satoshis { - change_output.value = Amount::from_sat(change_value_decreased_with_fee); - self.our_funding_outputs.push(change_output); - } + match self.calculate_change_output( + context, + signer_provider, + &shared_funding_output, + change_strategy, + ) { + Ok(Some(change_output)) => self.our_funding_outputs.push(change_output), + Ok(None) => {}, + Err(reason) => return Err(self.into_negotiation_error(reason)), } let constructor_args = InteractiveTxConstructorArgs { @@ -6773,6 +6750,52 @@ impl FundingNegotiationContext { InteractiveTxConstructor::new(constructor_args) } + fn calculate_change_output( + &self, context: &ChannelContext, signer_provider: &SP, shared_funding_output: &TxOut, + change_strategy: ChangeStrategy, + ) -> Result, AbortReason> { + if self.our_funding_inputs.is_empty() { + return Ok(None); + } + + let change_script = match change_strategy { + ChangeStrategy::FromCoinSelection => return Ok(None), + ChangeStrategy::LegacyUserProvided(change_script) => change_script, + }; + + let change_value = calculate_change_output_value( + &self, + self.shared_funding_input.is_some(), + &shared_funding_output.script_pubkey, + context.holder_dust_limit_satoshis, + )?; + + if let Some(change_value) = change_value { + let change_script = match change_script { + Some(script) => script, + None => match signer_provider.get_destination_script(context.channel_keys_id) { + Ok(script) => script, + Err(_) => { + return Err(AbortReason::InternalError("Error getting change script")) + }, + }, + }; + let mut change_output = TxOut { value: change_value, script_pubkey: change_script }; + let change_output_weight = get_output_weight(&change_output.script_pubkey).to_wu(); + let change_output_fee = + fee_for_weight(self.funding_feerate_sat_per_1000_weight, change_output_weight); + let change_value_decreased_with_fee = + change_value.to_sat().saturating_sub(change_output_fee); + // Check dust limit again + if change_value_decreased_with_fee > context.holder_dust_limit_satoshis { + change_output.value = Amount::from_sat(change_value_decreased_with_fee); + return Ok(Some(change_output)); + } + } + + Ok(None) + } + fn into_negotiation_error(self, reason: AbortReason) -> NegotiationError { let (contributed_inputs, contributed_outputs) = self.into_contributed_inputs_and_outputs(); NegotiationError { reason, contributed_inputs, contributed_outputs } @@ -12235,14 +12258,13 @@ where shared_funding_input: Some(prev_funding_input), our_funding_inputs, our_funding_outputs, - change_script, }; - self.send_splice_init_internal(context) + self.send_splice_init_internal(context, ChangeStrategy::LegacyUserProvided(change_script)) } fn send_splice_init_internal( - &mut self, context: FundingNegotiationContext, + &mut self, context: FundingNegotiationContext, change_strategy: ChangeStrategy, ) -> msgs::SpliceInit { debug_assert!(self.pending_splice.is_none()); // Rotate the funding pubkey using the prev_funding_txid as a tweak @@ -12263,8 +12285,11 @@ where let funding_contribution_satoshis = context.our_funding_contribution.to_sat(); let locktime = context.funding_tx_locktime.to_consensus_u32(); - let funding_negotiation = - FundingNegotiation::AwaitingAck { context, new_holder_funding_key: funding_pubkey }; + let funding_negotiation = FundingNegotiation::AwaitingAck { + context, + change_strategy, + new_holder_funding_key: funding_pubkey, + }; self.pending_splice = Some(PendingFunding { funding_negotiation: Some(funding_negotiation), negotiated_candidates: vec![], @@ -12490,7 +12515,6 @@ where shared_funding_input: Some(prev_funding_input), our_funding_inputs: Vec::new(), our_funding_outputs: Vec::new(), - change_script: None, }; let mut interactive_tx_constructor = funding_negotiation_context @@ -12500,6 +12524,8 @@ where signer_provider, entropy_source, holder_node_id.clone(), + // ChangeStrategy doesn't matter when no inputs are contributed + ChangeStrategy::FromCoinSelection, ) .map_err(|err| { ChannelError::WarnAndDisconnect(format!( @@ -12550,11 +12576,11 @@ where let pending_splice = self.pending_splice.as_mut().expect("We should have returned an error earlier!"); // TODO: Good candidate for a let else statement once MSRV >= 1.65 - let funding_negotiation_context = - if let Some(FundingNegotiation::AwaitingAck { context, .. }) = + let (funding_negotiation_context, change_strategy) = + if let Some(FundingNegotiation::AwaitingAck { context, change_strategy, .. }) = pending_splice.funding_negotiation.take() { - context + (context, change_strategy) } else { panic!("We should have returned an error earlier!"); }; @@ -12566,6 +12592,7 @@ where signer_provider, entropy_source, holder_node_id.clone(), + change_strategy, ) .map_err(|err| { ChannelError::WarnAndDisconnect(format!( @@ -12596,7 +12623,7 @@ where let (funding_negotiation_context, new_holder_funding_key) = match &pending_splice .funding_negotiation { - Some(FundingNegotiation::AwaitingAck { context, new_holder_funding_key }) => { + Some(FundingNegotiation::AwaitingAck { context, new_holder_funding_key, .. }) => { (context, new_holder_funding_key) }, Some(FundingNegotiation::ConstructingTransaction { .. }) @@ -13523,7 +13550,7 @@ where }, }; let funding_feerate_per_kw = contribution.feerate().to_sat_per_kwu() as u32; - let (our_funding_inputs, our_funding_outputs, change_script) = contribution.into_tx_parts(); + let (our_funding_inputs, our_funding_outputs) = contribution.into_tx_parts(); let context = FundingNegotiationContext { is_initiator, @@ -13533,10 +13560,9 @@ where shared_funding_input: Some(prev_funding_input), our_funding_inputs, our_funding_outputs, - change_script, }; - let splice_init = self.send_splice_init_internal(context); + let splice_init = self.send_splice_init_internal(context, ChangeStrategy::FromCoinSelection); return Ok(Some(StfuResponse::SpliceInit(splice_init))); }, #[cfg(any(test, fuzzing))] @@ -14320,7 +14346,6 @@ impl PendingV2Channel { shared_funding_input: None, our_funding_inputs: funding_inputs, our_funding_outputs: Vec::new(), - change_script: None, }; let chan = Self { funding, @@ -14467,7 +14492,6 @@ impl PendingV2Channel { shared_funding_input: None, our_funding_inputs: our_funding_inputs.clone(), our_funding_outputs: Vec::new(), - change_script: None, }; let shared_funding_output = TxOut { value: Amount::from_sat(funding.get_value_satoshis()), diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index e369bd8a3ec..06b972d7126 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -19,7 +19,7 @@ use bitcoin::{ use core::ops::Deref; use crate::events::bump_transaction::sync::CoinSelectionSourceSync; -use crate::events::bump_transaction::{CoinSelectionSource, Input, Utxo}; +use crate::events::bump_transaction::{CoinSelection, CoinSelectionSource, Input, Utxo}; use crate::ln::chan_utils::{ make_funding_redeemscript, BASE_INPUT_WEIGHT, EMPTY_SCRIPT_SIG_WEIGHT, FUNDING_TRANSACTION_WITNESS_WEIGHT, @@ -57,18 +57,15 @@ pub struct FundingTemplate { impl FundingTemplate { /// Constructs a [`FundingTemplate`] for a splice using the provided shared input. - pub(super) fn new( - shared_input: Option, feerate: FeeRate, is_initiator: bool, - ) -> Self { + pub(super) fn new(shared_input: Option, feerate: FeeRate, is_initiator: bool) -> Self { Self { shared_input, feerate, is_initiator } } } macro_rules! build_funding_contribution { - ($value_added:expr, $outputs:expr, $change_script:expr, $shared_input:expr, $feerate:expr, $is_initiator:expr, $wallet:ident, $($await:tt)*) => {{ + ($value_added:expr, $outputs:expr, $shared_input:expr, $feerate:expr, $is_initiator:expr, $wallet:ident, $($await:tt)*) => {{ let value_added: Amount = $value_added; let outputs: Vec = $outputs; - let change_script: Option = $change_script; let shared_input: Option = $shared_input; let feerate: FeeRate = $feerate; let is_initiator: bool = $is_initiator; @@ -76,8 +73,8 @@ macro_rules! build_funding_contribution { let value_removed = outputs.iter().map(|txout| txout.value).sum(); let is_splice = shared_input.is_some(); - let inputs = if value_added == Amount::ZERO { - vec![] + let coin_selection = if value_added == Amount::ZERO { + CoinSelection { confirmed_utxos: vec![], change_output: None } } else { // Used for creating a redeem script for the new funding txo, since the funding pubkeys // are unknown at this point. Only needed when selecting which UTXOs to include in the @@ -98,18 +95,19 @@ macro_rules! build_funding_contribution { let claim_id = None; let must_spend = shared_input.map(|input| vec![input]).unwrap_or_default(); - let selection = if outputs.is_empty() { + if outputs.is_empty() { let must_pay_to = &[shared_output]; $wallet.select_confirmed_utxos(claim_id, must_spend, must_pay_to, feerate.to_sat_per_kwu() as u32, u64::MAX)$(.$await)*? } else { let must_pay_to: Vec<_> = outputs.iter().cloned().chain(core::iter::once(shared_output)).collect(); $wallet.select_confirmed_utxos(claim_id, must_spend, &must_pay_to, feerate.to_sat_per_kwu() as u32, u64::MAX)$(.$await)*? - }; - selection.confirmed_utxos + } }; // NOTE: Must NOT fail after UTXO selection + let CoinSelection { confirmed_utxos: inputs, change_output } = coin_selection; + let estimated_fee = estimate_transaction_fee(&inputs, &outputs, is_initiator, is_splice, feerate); let contribution = FundingContribution { @@ -117,7 +115,7 @@ macro_rules! build_funding_contribution { estimated_fee, inputs, outputs, - change_script, + change_output, feerate, is_initiator, is_splice, @@ -130,13 +128,8 @@ macro_rules! build_funding_contribution { impl FundingTemplate { /// Creates a [`FundingContribution`] for adding funds to a channel using `wallet` to perform /// coin selection. - /// - /// An optional `change_script` may be given to use as a change output. If `None` and change is - /// needed, one will be generated using [`SignerProvider::get_destination_script`]. - /// - /// [`SignerProvider::get_destination_script`]: crate::sign::SignerProvider::get_destination_script pub async fn splice_in( - self, change_script: Option, value_added: Amount, wallet: W, + self, value_added: Amount, wallet: W, ) -> Result where W::Target: CoinSelectionSource + MaybeSend, @@ -145,18 +138,13 @@ impl FundingTemplate { return Err(()); } let FundingTemplate { shared_input, feerate, is_initiator } = self; - build_funding_contribution!(value_added, vec![], change_script, shared_input, feerate, is_initiator, wallet, await) + build_funding_contribution!(value_added, vec![], shared_input, feerate, is_initiator, wallet, await) } /// Creates a [`FundingContribution`] for adding funds to a channel using `wallet` to perform /// coin selection. - /// - /// An optional `change_script` may be given to use as a change output. If `None` and change is - /// needed, one will be generated using [`SignerProvider::get_destination_script`]. - /// - /// [`SignerProvider::get_destination_script`]: crate::sign::SignerProvider::get_destination_script pub fn splice_in_sync( - self, change_script: Option, value_added: Amount, wallet: W, + self, value_added: Amount, wallet: W, ) -> Result where W::Target: CoinSelectionSourceSync, @@ -168,7 +156,6 @@ impl FundingTemplate { build_funding_contribution!( value_added, vec![], - change_script, shared_input, feerate, is_initiator, @@ -188,7 +175,7 @@ impl FundingTemplate { return Err(()); } let FundingTemplate { shared_input, feerate, is_initiator } = self; - build_funding_contribution!(Amount::ZERO, outputs, None, shared_input, feerate, is_initiator, wallet, await) + build_funding_contribution!(Amount::ZERO, outputs, shared_input, feerate, is_initiator, wallet, await) } /// Creates a [`FundingContribution`] for removing funds from a channel using `wallet` to @@ -206,7 +193,6 @@ impl FundingTemplate { build_funding_contribution!( Amount::ZERO, outputs, - None, shared_input, feerate, is_initiator, @@ -216,14 +202,8 @@ impl FundingTemplate { /// Creates a [`FundingContribution`] for both adding and removing funds from a channel using /// `wallet` to perform coin selection. - /// - /// An optional `change_script` may be given to use as a change output. If `None` and change is - /// needed, one will be generated using [`SignerProvider::get_destination_script`]. - /// - /// [`SignerProvider::get_destination_script`]: crate::sign::SignerProvider::get_destination_script pub async fn splice_in_and_out( - self, change_script: Option, value_added: Amount, outputs: Vec, - wallet: W, + self, value_added: Amount, outputs: Vec, wallet: W, ) -> Result where W::Target: CoinSelectionSource + MaybeSend, @@ -232,19 +212,13 @@ impl FundingTemplate { return Err(()); } let FundingTemplate { shared_input, feerate, is_initiator } = self; - build_funding_contribution!(value_added, outputs, change_script, shared_input, feerate, is_initiator, wallet, await) + build_funding_contribution!(value_added, outputs, shared_input, feerate, is_initiator, wallet, await) } /// Creates a [`FundingContribution`] for both adding and removing funds from a channel using /// `wallet` to perform coin selection. - /// - /// An optional `change_script` may be given to use as a change output. If `None` and change is - /// needed, one will be generated using [`SignerProvider::get_destination_script`]. - /// - /// [`SignerProvider::get_destination_script`]: crate::sign::SignerProvider::get_destination_script pub fn splice_in_and_out_sync( - self, change_script: Option, value_added: Amount, outputs: Vec, - wallet: W, + self, value_added: Amount, outputs: Vec, wallet: W, ) -> Result where W::Target: CoinSelectionSourceSync, @@ -256,7 +230,6 @@ impl FundingTemplate { build_funding_contribution!( value_added, outputs, - change_script, shared_input, feerate, is_initiator, @@ -334,11 +307,8 @@ pub struct FundingContribution { /// will be the amount that is removed. outputs: Vec, - /// An optional change output script. This will be used if needed or, when not set, - /// generated using [`SignerProvider::get_destination_script`]. - /// - /// [`SignerProvider::get_destination_script`]: crate::sign::SignerProvider::get_destination_script - change_script: Option, + /// The output where any change will be sent. + change_output: Option, /// The fee rate used to select `inputs`. feerate: FeeRate, @@ -356,7 +326,7 @@ impl_writeable_tlv_based!(FundingContribution, { (3, estimated_fee, required), (5, inputs, optional_vec), (7, outputs, optional_vec), - (9, change_script, option), + (9, change_output, option), (11, feerate, required), (13, is_initiator, required), (15, is_splice, required), @@ -375,13 +345,20 @@ impl FundingContribution { self.is_splice } - pub(super) fn into_tx_parts(self) -> (Vec, Vec, Option) { - let FundingContribution { inputs, outputs, change_script, .. } = self; - (inputs, outputs, change_script) + pub(super) fn into_tx_parts(self) -> (Vec, Vec) { + let FundingContribution { inputs, mut outputs, change_output, .. } = self; + + if let Some(change_output) = change_output { + outputs.push(change_output); + } + + (inputs, outputs) } pub(super) fn into_contributed_inputs_and_outputs(self) -> (Vec, Vec) { - (self.inputs.into_iter().map(|input| input.utxo.outpoint).collect(), self.outputs) + let (inputs, outputs) = self.into_tx_parts(); + + (inputs.into_iter().map(|input| input.utxo.outpoint).collect(), outputs) } /// The net value contributed to a channel by the splice. If negative, more value will be @@ -723,7 +700,7 @@ mod tests { funding_input_sats(100_000), ], outputs: vec![], - change_script: None, + change_output: None, is_initiator: true, is_splice: true, feerate: FeeRate::from_sat_per_kwu(2000), @@ -744,7 +721,7 @@ mod tests { outputs: vec![ funding_output_sats(200_000), ], - change_script: None, + change_output: None, is_initiator: true, is_splice: true, feerate: FeeRate::from_sat_per_kwu(2000), @@ -765,7 +742,7 @@ mod tests { outputs: vec![ funding_output_sats(400_000), ], - change_script: None, + change_output: None, is_initiator: true, is_splice: true, feerate: FeeRate::from_sat_per_kwu(2000), @@ -786,7 +763,7 @@ mod tests { outputs: vec![ funding_output_sats(400_000), ], - change_script: None, + change_output: None, is_initiator: true, is_splice: true, feerate: FeeRate::from_sat_per_kwu(90000), @@ -810,7 +787,7 @@ mod tests { funding_input_sats(100_000), ], outputs: vec![], - change_script: None, + change_output: None, is_initiator: true, is_splice: true, feerate: FeeRate::from_sat_per_kwu(2000), @@ -835,7 +812,7 @@ mod tests { funding_input_sats(100_000), ], outputs: vec![], - change_script: None, + change_output: None, is_initiator: true, is_splice: true, feerate: FeeRate::from_sat_per_kwu(2000), @@ -854,7 +831,7 @@ mod tests { funding_input_sats(100_000), ], outputs: vec![], - change_script: None, + change_output: None, is_initiator: true, is_splice: true, feerate: FeeRate::from_sat_per_kwu(2200), @@ -879,7 +856,7 @@ mod tests { funding_input_sats(100_000), ], outputs: vec![], - change_script: None, + change_output: None, is_initiator: false, is_splice: false, feerate: FeeRate::from_sat_per_kwu(2000), diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index 3c47658e963..c5db1bcbe8a 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -3435,7 +3435,6 @@ mod tests { shared_funding_input: None, our_funding_inputs: inputs, our_funding_outputs: outputs, - change_script: None, }; let gross_change = total_inputs - total_outputs - context.our_funding_contribution.to_unsigned().unwrap(); diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 31c13e124d4..cc422d650a7 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -29,12 +29,9 @@ use crate::util::ser::Writeable; use crate::sync::Arc; -use bitcoin::hashes::Hash; use bitcoin::secp256k1::ecdsa::Signature; use bitcoin::secp256k1::PublicKey; -use bitcoin::{ - Amount, FeeRate, OutPoint as BitcoinOutPoint, ScriptBuf, Transaction, TxOut, WPubkeyHash, -}; +use bitcoin::{Amount, FeeRate, OutPoint as BitcoinOutPoint, ScriptBuf, Transaction, TxOut}; #[test] fn test_splicing_not_supported_api_error() { @@ -109,7 +106,7 @@ fn test_v1_splice_in_negative_insufficient_inputs() { .unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - assert!(funding_template.splice_in_sync(None, splice_in_value, &wallet).is_err()); + assert!(funding_template.splice_in_sync(splice_in_value, &wallet).is_err()); } pub fn negotiate_splice_tx<'a, 'b, 'c, 'd>( @@ -131,21 +128,19 @@ pub fn initiate_splice_in<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, value_added: Amount, ) -> FundingContribution { - let change_script = Some(initiator.wallet_source.get_change_script().unwrap()); - do_initiate_splice_in(initiator, acceptor, channel_id, value_added, change_script) + do_initiate_splice_in(initiator, acceptor, channel_id, value_added) } pub fn do_initiate_splice_in<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, - value_added: Amount, change_script: Option, + value_added: Amount, ) -> FundingContribution { let node_id_acceptor = acceptor.node.get_our_node_id(); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); let funding_template = initiator.node.splice_channel(&channel_id, &node_id_acceptor, feerate).unwrap(); let wallet = WalletSync::new(Arc::clone(&initiator.wallet_source), initiator.logger); - let funding_contribution = - funding_template.splice_in_sync(change_script, value_added, &wallet).unwrap(); + let funding_contribution = funding_template.splice_in_sync(value_added, &wallet).unwrap(); initiator .node .funding_contributed(&channel_id, &node_id_acceptor, funding_contribution.clone(), None) @@ -174,29 +169,20 @@ pub fn initiate_splice_in_and_out<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, value_added: Amount, outputs: Vec, ) -> FundingContribution { - let change_script = Some(initiator.wallet_source.get_change_script().unwrap()); - do_initiate_splice_in_and_out( - initiator, - acceptor, - channel_id, - value_added, - outputs, - change_script, - ) + do_initiate_splice_in_and_out(initiator, acceptor, channel_id, value_added, outputs) } pub fn do_initiate_splice_in_and_out<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, - value_added: Amount, outputs: Vec, change_script: Option, + value_added: Amount, outputs: Vec, ) -> FundingContribution { let node_id_acceptor = acceptor.node.get_our_node_id(); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); let funding_template = initiator.node.splice_channel(&channel_id, &node_id_acceptor, feerate).unwrap(); let wallet = WalletSync::new(Arc::clone(&initiator.wallet_source), initiator.logger); - let funding_contribution = funding_template - .splice_in_and_out_sync(change_script, value_added, outputs, &wallet) - .unwrap(); + let funding_contribution = + funding_template.splice_in_and_out_sync(value_added, outputs, &wallet).unwrap(); initiator .node .funding_contributed(&channel_id, &node_id_acceptor, funding_contribution.clone(), None) @@ -245,8 +231,7 @@ pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>( }) .map(|channel| channel.funding_txo.unwrap()) .unwrap(); - let (initiator_inputs, initiator_outputs, initiator_change_script) = - initiator_contribution.into_tx_parts(); + let (initiator_inputs, initiator_outputs) = initiator_contribution.into_tx_parts(); let mut expected_initiator_inputs = initiator_inputs .iter() .map(|input| input.utxo.outpoint) @@ -256,7 +241,6 @@ pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>( .into_iter() .map(|output| output.script_pubkey) .chain(core::iter::once(new_funding_script)) - .chain(initiator_change_script.into_iter()) .collect::>(); let mut acceptor_sent_tx_complete = false; @@ -408,7 +392,7 @@ pub fn sign_interactive_funding_tx<'a, 'b, 'c, 'd>( pub fn splice_channel<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, funding_contribution: FundingContribution, -) -> Transaction { +) -> (Transaction, ScriptBuf) { let node_id_initiator = initiator.node.get_our_node_id(); let node_id_acceptor = acceptor.node.get_our_node_id(); @@ -419,7 +403,7 @@ pub fn splice_channel<'a, 'b, 'c, 'd>( acceptor, channel_id, funding_contribution, - new_funding_script, + new_funding_script.clone(), ); let (splice_tx, splice_locked) = sign_interactive_funding_tx(initiator, acceptor, false); assert!(splice_locked.is_none()); @@ -427,7 +411,7 @@ pub fn splice_channel<'a, 'b, 'c, 'd>( expect_splice_pending_event(initiator, &node_id_acceptor); expect_splice_pending_event(acceptor, &node_id_initiator); - splice_tx + (splice_tx, new_funding_script) } pub fn lock_splice_after_blocks<'a, 'b, 'c, 'd>( @@ -738,7 +722,7 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { // Attempt a splice negotiation that completes, (i.e. `tx_signatures` are exchanged). Reconnecting // should not abort the negotiation or reset the splice state. let funding_contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs); - let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); if reload { let encoded_monitor_0 = get_monitor!(nodes[0], channel_id).encode(); @@ -843,23 +827,22 @@ fn test_splice_in() { let added_value = Amount::from_sat(initial_channel_value_sat * 2); let utxo_value = added_value * 3 / 4; - let change_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()); - let fees = Amount::from_sat(321); + let fees = Amount::from_sat(322); provide_utxo_reserves(&nodes, 2, utxo_value); - let funding_contribution = do_initiate_splice_in( - &nodes[0], - &nodes[1], - channel_id, - added_value, - Some(change_script.clone()), - ); + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); - let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + let (splice_tx, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); let expected_change = utxo_value * 2 - added_value - fees; assert_eq!( - splice_tx.output.iter().find(|txout| txout.script_pubkey == change_script).unwrap().value, + splice_tx + .output + .iter() + .find(|txout| txout.script_pubkey != new_funding_script) + .unwrap() + .value, expected_change, ); @@ -904,7 +887,7 @@ fn test_splice_out() { ]; let funding_contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs); - let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); mine_transaction(&nodes[0], &splice_tx); mine_transaction(&nodes[1], &splice_tx); @@ -940,11 +923,10 @@ fn test_splice_in_and_out() { let added_value = Amount::from_sat(htlc_limit_msat / 1000); let removed_value = added_value * 2; let utxo_value = added_value * 3 / 4; - let change_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()); let fees = if cfg!(feature = "grind_signatures") { - Amount::from_sat(383) + Amount::from_sat(385) } else { - Amount::from_sat(384) + Amount::from_sat(385) }; assert!(htlc_limit_msat > initial_channel_value_sat / 2 * 1000); @@ -961,19 +943,20 @@ fn test_splice_in_and_out() { script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), }, ]; - let funding_contribution = do_initiate_splice_in_and_out( - &nodes[0], - &nodes[1], - channel_id, - added_value, - outputs, - Some(change_script.clone()), - ); + let funding_contribution = + do_initiate_splice_in_and_out(&nodes[0], &nodes[1], channel_id, added_value, outputs); - let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + let (splice_tx, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); let expected_change = utxo_value * 2 - added_value - fees; assert_eq!( - splice_tx.output.iter().find(|txout| txout.script_pubkey == change_script).unwrap().value, + splice_tx + .output + .iter() + .filter(|txout| txout.value != removed_value / 2) + .find(|txout| txout.script_pubkey != new_funding_script) + .unwrap() + .value, expected_change, ); @@ -995,11 +978,10 @@ fn test_splice_in_and_out() { let added_value = Amount::from_sat(initial_channel_value_sat * 2); let removed_value = added_value / 2; let utxo_value = added_value * 3 / 4; - let change_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()); let fees = if cfg!(feature = "grind_signatures") { - Amount::from_sat(383) + Amount::from_sat(385) } else { - Amount::from_sat(384) + Amount::from_sat(385) }; // Clear UTXOs so that the change output from the previous splice isn't considered @@ -1017,19 +999,20 @@ fn test_splice_in_and_out() { script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), }, ]; - let funding_contribution = do_initiate_splice_in_and_out( - &nodes[0], - &nodes[1], - channel_id, - added_value, - outputs, - Some(change_script.clone()), - ); + let funding_contribution = + do_initiate_splice_in_and_out(&nodes[0], &nodes[1], channel_id, added_value, outputs); - let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + let (splice_tx, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); let expected_change = utxo_value * 2 - added_value - fees; assert_eq!( - splice_tx.output.iter().find(|txout| txout.script_pubkey == change_script).unwrap().value, + splice_tx + .output + .iter() + .filter(|txout| txout.value != removed_value / 2) + .find(|txout| txout.script_pubkey != new_funding_script) + .unwrap() + .value, expected_change, ); @@ -1102,9 +1085,8 @@ fn test_fails_initiating_concurrent_splices() { let added_value = Amount::from_sat(initial_channel_value_sat); let acceptor_template = nodes[1].node.splice_channel(&channel_id, &node_0_id, feerate).unwrap(); let acceptor_wallet = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); - let change_script = Some(nodes[1].wallet_source.get_change_script().unwrap()); let acceptor_contribution = - acceptor_template.splice_in_sync(change_script, added_value, &acceptor_wallet).unwrap(); + acceptor_template.splice_in_sync(added_value, &acceptor_wallet).unwrap(); nodes[1] .node .funding_contributed(&channel_id, &node_0_id, acceptor_contribution, None) @@ -1189,14 +1171,9 @@ fn do_test_splice_commitment_broadcast(splice_status: SpliceStatus, claim_htlcs: let (preimage1, payment_hash1, ..) = route_payment(&nodes[0], &[&nodes[1]], payment_amount); let splice_in_amount = initial_channel_capacity / 2; - let initiator_contribution = do_initiate_splice_in( - &nodes[0], - &nodes[1], - channel_id, - Amount::from_sat(splice_in_amount), - Some(nodes[0].wallet_source.get_change_script().unwrap()), - ); - let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); + let initiator_contribution = + do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, Amount::from_sat(splice_in_amount)); + let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); let (preimage2, payment_hash2, ..) = route_payment(&nodes[0], &[&nodes[1]], payment_amount); let htlc_expiry = nodes[0].best_block_info().1 + TEST_FINAL_CLTV + LATENCY_GRACE_PERIOD_BLOCKS; @@ -2211,7 +2188,7 @@ fn do_test_splice_with_inflight_htlc_forward_and_resolution(expire_scid_pre_forw script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]; let contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id_0_1, outputs_0_1); - let splice_tx_0_1 = splice_channel(&nodes[0], &nodes[1], channel_id_0_1, contribution); + let (splice_tx_0_1, _) = splice_channel(&nodes[0], &nodes[1], channel_id_0_1, contribution); for node in &nodes { mine_transaction(node, &splice_tx_0_1); } @@ -2221,7 +2198,7 @@ fn do_test_splice_with_inflight_htlc_forward_and_resolution(expire_scid_pre_forw script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), }]; let contribution = initiate_splice_out(&nodes[1], &nodes[2], channel_id_1_2, outputs_1_2); - let splice_tx_1_2 = splice_channel(&nodes[1], &nodes[2], channel_id_1_2, contribution); + let (splice_tx_1_2, _) = splice_channel(&nodes[1], &nodes[2], channel_id_1_2, contribution); for node in &nodes { mine_transaction(node, &splice_tx_1_2); } From 20916b7a21b0001060c93349d07bfefc817c250c Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 2 Feb 2026 09:52:57 -0600 Subject: [PATCH 037/627] Consistently log in propose_quiescence Instead of logging both inside propose_quiescence and at the call site, only log inside it. This simplifies the return type. --- lightning/src/ln/channel.rs | 14 +++++++++----- lightning/src/ln/channelmanager.rs | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index db85a26ae91..0f1916ac59f 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12214,8 +12214,7 @@ where } self.propose_quiescence(logger, QuiescentAction::Splice { contribution, locktime }).map_err( - |(e, action)| { - log_error!(logger, "{}", e); + |action| { // FIXME: Any better way to do this? if let QuiescentAction::Splice { contribution, .. } = action { let (contributed_inputs, contributed_outputs) = @@ -13355,14 +13354,19 @@ where #[rustfmt::skip] pub fn propose_quiescence( &mut self, logger: &L, action: QuiescentAction, - ) -> Result, (&'static str, QuiescentAction)> { + ) -> Result, QuiescentAction> { log_debug!(logger, "Attempting to initiate quiescence"); if !self.context.is_usable() { - return Err(("Channel is not in a usable state to propose quiescence", action)); + log_debug!(logger, "Channel is not in a usable state to propose quiescence"); + return Err(action); } if self.quiescent_action.is_some() { - return Err(("Channel already has a pending quiescent action and cannot start another", action)); + log_debug!( + logger, + "Channel already has a pending quiescent action and cannot start another", + ); + return Err(action); } self.quiescent_action = Some(action); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index f70f4b133d0..75de6ab5d10 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -13401,7 +13401,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }); notify = NotifyOption::SkipPersistHandleEvents; }, - Err((msg, _action)) => log_trace!(logger, "{}", msg), + Err(action) => log_trace!(logger, "Failed to propose quiescence for: {:?}", action), } } else { result = Err(APIError::APIMisuseError { From 45db7c88099165460cc1cb3a1cb586384bbf2a4c Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 28 Jan 2026 16:57:45 -0600 Subject: [PATCH 038/627] Move wallet utils to dedicated module Wallet-related types were tightly coupled to bump_transaction, making them less accessible for other use cases like channel funding and splicing. Extract these utilities to a dedicated module for improved code organization and reusability across the codebase. Co-Authored-By: Claude Sonnet 4.5 --- lightning/src/events/bump_transaction/mod.rs | 522 +---------------- lightning/src/events/bump_transaction/sync.rs | 9 +- lightning/src/ln/channel.rs | 2 +- lightning/src/ln/funding.rs | 2 +- lightning/src/ln/zero_fee_commitment_tests.rs | 4 +- lightning/src/util/anchor_channel_reserves.rs | 2 +- lightning/src/util/mod.rs | 1 + lightning/src/util/test_utils.rs | 2 +- lightning/src/util/wallet_utils.rs | 546 ++++++++++++++++++ 9 files changed, 567 insertions(+), 523 deletions(-) create mode 100644 lightning/src/util/wallet_utils.rs diff --git a/lightning/src/events/bump_transaction/mod.rs b/lightning/src/events/bump_transaction/mod.rs index c783e96381a..13a5a61ebda 100644 --- a/lightning/src/events/bump_transaction/mod.rs +++ b/lightning/src/events/bump_transaction/mod.rs @@ -14,47 +14,34 @@ pub mod sync; use alloc::collections::BTreeMap; -use core::future::Future; use core::ops::Deref; use crate::chain::chaininterface::{ compute_feerate_sat_per_1000_weight, fee_for_weight, BroadcasterInterface, TransactionType, }; use crate::chain::ClaimId; -use crate::io_extras::sink; use crate::ln::chan_utils; use crate::ln::chan_utils::{ shared_anchor_script_pubkey, HTLCOutputInCommitment, ANCHOR_INPUT_WITNESS_WEIGHT, - BASE_INPUT_WEIGHT, BASE_TX_SIZE, EMPTY_SCRIPT_SIG_WEIGHT, EMPTY_WITNESS_WEIGHT, - HTLC_SUCCESS_INPUT_KEYED_ANCHOR_WITNESS_WEIGHT, HTLC_SUCCESS_INPUT_P2A_ANCHOR_WITNESS_WEIGHT, - HTLC_TIMEOUT_INPUT_KEYED_ANCHOR_WITNESS_WEIGHT, HTLC_TIMEOUT_INPUT_P2A_ANCHOR_WITNESS_WEIGHT, - P2WSH_TXOUT_WEIGHT, SEGWIT_MARKER_FLAG_WEIGHT, TRUC_CHILD_MAX_WEIGHT, TRUC_MAX_WEIGHT, + EMPTY_SCRIPT_SIG_WEIGHT, EMPTY_WITNESS_WEIGHT, HTLC_SUCCESS_INPUT_KEYED_ANCHOR_WITNESS_WEIGHT, + HTLC_SUCCESS_INPUT_P2A_ANCHOR_WITNESS_WEIGHT, HTLC_TIMEOUT_INPUT_KEYED_ANCHOR_WITNESS_WEIGHT, + HTLC_TIMEOUT_INPUT_P2A_ANCHOR_WITNESS_WEIGHT, TRUC_CHILD_MAX_WEIGHT, TRUC_MAX_WEIGHT, }; -use crate::ln::funding::FundingTxInput; use crate::ln::types::ChannelId; use crate::prelude::*; use crate::sign::ecdsa::EcdsaChannelSigner; -use crate::sign::{ - ChannelDerivationParameters, HTLCDescriptor, SignerProvider, P2TR_KEY_PATH_WITNESS_WEIGHT, - P2WPKH_WITNESS_WEIGHT, -}; -use crate::sync::Mutex; -use crate::util::async_poll::{MaybeSend, MaybeSync}; +use crate::sign::{ChannelDerivationParameters, HTLCDescriptor, SignerProvider}; use crate::util::logger::Logger; +use crate::util::wallet_utils::{CoinSelection, CoinSelectionSource, ConfirmedUtxo, Input}; use bitcoin::amount::Amount; -use bitcoin::consensus::Encodable; -use bitcoin::constants::WITNESS_SCALE_FACTOR; -use bitcoin::key::TweakedPublicKey; use bitcoin::locktime::absolute::LockTime; use bitcoin::policy::MAX_STANDARD_TX_WEIGHT; use bitcoin::secp256k1; use bitcoin::secp256k1::ecdsa::Signature; use bitcoin::secp256k1::{PublicKey, Secp256k1}; use bitcoin::transaction::Version; -use bitcoin::{ - OutPoint, Psbt, PubkeyHash, ScriptBuf, Sequence, Transaction, TxIn, TxOut, WPubkeyHash, Witness, -}; +use bitcoin::{OutPoint, Psbt, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness}; /// A descriptor used to sign for a commitment transaction's anchor output. #[derive(Clone, Debug, PartialEq, Eq)] @@ -258,499 +245,6 @@ pub enum BumpTransactionEvent { }, } -/// An input that must be included in a transaction when performing coin selection through -/// [`CoinSelectionSource::select_confirmed_utxos`]. It is guaranteed to be a SegWit input, so it -/// must have an empty [`TxIn::script_sig`] when spent. -#[derive(Clone, Debug, Hash, PartialOrd, Ord, PartialEq, Eq)] -pub struct Input { - /// The unique identifier of the input. - pub outpoint: OutPoint, - /// The UTXO being spent by the input. - pub previous_utxo: TxOut, - /// The upper-bound weight consumed by the input's full [`TxIn::script_sig`] and - /// [`TxIn::witness`], each with their lengths included, required to satisfy the output's - /// script. - pub satisfaction_weight: u64, -} - -/// An unspent transaction output that is available to spend resulting from a successful -/// [`CoinSelection`] attempt. -#[derive(Clone, Debug, Hash, PartialOrd, Ord, PartialEq, Eq)] -pub struct Utxo { - /// The unique identifier of the output. - pub outpoint: OutPoint, - /// The output to spend. - pub output: TxOut, - /// The upper-bound weight consumed by the input's full [`TxIn::script_sig`] and [`TxIn::witness`], each - /// with their lengths included, required to satisfy the output's script. The weight consumed by - /// the input's `script_sig` must account for [`WITNESS_SCALE_FACTOR`]. - pub satisfaction_weight: u64, - /// The sequence number to use in the [`TxIn`] when spending the UTXO. - pub sequence: Sequence, -} - -impl_writeable_tlv_based!(Utxo, { - (1, outpoint, required), - (3, output, required), - (5, satisfaction_weight, required), - (7, sequence, (default_value, Sequence::ENABLE_RBF_NO_LOCKTIME)), -}); - -impl Utxo { - /// Returns a `Utxo` with the `satisfaction_weight` estimate for a legacy P2PKH output. - pub fn new_p2pkh(outpoint: OutPoint, value: Amount, pubkey_hash: &PubkeyHash) -> Self { - let script_sig_size = 1 /* script_sig length */ + - 1 /* OP_PUSH73 */ + - 73 /* sig including sighash flag */ + - 1 /* OP_PUSH33 */ + - 33 /* pubkey */; - Self { - outpoint, - output: TxOut { value, script_pubkey: ScriptBuf::new_p2pkh(pubkey_hash) }, - satisfaction_weight: script_sig_size * WITNESS_SCALE_FACTOR as u64 + 1, /* empty witness */ - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - } - } - - /// Returns a `Utxo` with the `satisfaction_weight` estimate for a P2WPKH nested in P2SH output. - pub fn new_nested_p2wpkh(outpoint: OutPoint, value: Amount, pubkey_hash: &WPubkeyHash) -> Self { - let script_sig_size = 1 /* script_sig length */ + - 1 /* OP_0 */ + - 1 /* OP_PUSH20 */ + - 20 /* pubkey_hash */; - Self { - outpoint, - output: TxOut { - value, - script_pubkey: ScriptBuf::new_p2sh( - &ScriptBuf::new_p2wpkh(pubkey_hash).script_hash(), - ), - }, - satisfaction_weight: script_sig_size * WITNESS_SCALE_FACTOR as u64 - + P2WPKH_WITNESS_WEIGHT, - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - } - } - - /// Returns a `Utxo` with the `satisfaction_weight` estimate for a SegWit v0 P2WPKH output. - pub fn new_v0_p2wpkh(outpoint: OutPoint, value: Amount, pubkey_hash: &WPubkeyHash) -> Self { - Self { - outpoint, - output: TxOut { value, script_pubkey: ScriptBuf::new_p2wpkh(pubkey_hash) }, - satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + P2WPKH_WITNESS_WEIGHT, - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - } - } - - /// Returns a `Utxo` with the `satisfaction_weight` estimate for a keypath spend of a SegWit v1 P2TR output. - pub fn new_v1_p2tr( - outpoint: OutPoint, value: Amount, tweaked_public_key: TweakedPublicKey, - ) -> Self { - Self { - outpoint, - output: TxOut { value, script_pubkey: ScriptBuf::new_p2tr_tweaked(tweaked_public_key) }, - satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + P2TR_KEY_PATH_WITNESS_WEIGHT, - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - } - } -} - -/// An unspent transaction output with at least one confirmation. -pub type ConfirmedUtxo = FundingTxInput; - -/// The result of a successful coin selection attempt for a transaction requiring additional UTXOs -/// to cover its fees. -#[derive(Clone, Debug)] -pub struct CoinSelection { - /// The set of UTXOs (with at least 1 confirmation) to spend and use within a transaction - /// requiring additional fees. - pub confirmed_utxos: Vec, - /// An additional output tracking whether any change remained after coin selection. This output - /// should always have a value above dust for its given `script_pubkey`. It should not be - /// spent until the transaction it belongs to confirms to ensure mempool descendant limits are - /// not met. This implies no other party should be able to spend it except us. - pub change_output: Option, -} - -impl CoinSelection { - fn satisfaction_weight(&self) -> u64 { - self.confirmed_utxos.iter().map(|ConfirmedUtxo { utxo, .. }| utxo.satisfaction_weight).sum() - } - - fn input_amount(&self) -> Amount { - self.confirmed_utxos.iter().map(|ConfirmedUtxo { utxo, .. }| utxo.output.value).sum() - } -} - -/// An abstraction over a bitcoin wallet that can perform coin selection over a set of UTXOs and can -/// sign for them. The coin selection method aims to mimic Bitcoin Core's `fundrawtransaction` RPC, -/// which most wallets should be able to satisfy. Otherwise, consider implementing [`WalletSource`], -/// which can provide a default implementation of this trait when used with [`Wallet`]. -/// -/// For a synchronous version of this trait, see [`sync::CoinSelectionSourceSync`]. -/// -/// This is not exported to bindings users as async is only supported in Rust. -// Note that updates to documentation on this trait should be copied to the synchronous version. -pub trait CoinSelectionSource { - /// Performs coin selection of a set of UTXOs, with at least 1 confirmation each, that are - /// available to spend. Implementations are free to pick their coin selection algorithm of - /// choice, as long as the following requirements are met: - /// - /// 1. `must_spend` contains a set of [`Input`]s that must be included in the transaction - /// throughout coin selection, but must not be returned as part of the result. - /// 2. `must_pay_to` contains a set of [`TxOut`]s that must be included in the transaction - /// throughout coin selection. In some cases, like when funding an anchor transaction, this - /// set is empty. Implementations should ensure they handle this correctly on their end, - /// e.g., Bitcoin Core's `fundrawtransaction` RPC requires at least one output to be - /// provided, in which case a zero-value empty OP_RETURN output can be used instead. - /// 3. Enough inputs must be selected/contributed for the resulting transaction (including the - /// inputs and outputs noted above) to meet `target_feerate_sat_per_1000_weight`. - /// 4. The final transaction must have a weight smaller than `max_tx_weight`; if this - /// constraint can't be met, return an `Err`. In the case of counterparty-signed HTLC - /// transactions, we will remove a chunk of HTLCs and try your algorithm again. As for - /// anchor transactions, we will try your coin selection again with the same input-output - /// set when you call [`ChannelMonitor::rebroadcast_pending_claims`], as anchor transactions - /// cannot be downsized. - /// - /// Implementations must take note that [`Input::satisfaction_weight`] only tracks the weight of - /// the input's `script_sig` and `witness`. Some wallets, like Bitcoin Core's, may require - /// providing the full input weight. Failing to do so may lead to underestimating fee bumps and - /// delaying block inclusion. - /// - /// The `claim_id` must map to the set of external UTXOs assigned to the claim, such that they - /// can be re-used within new fee-bumped iterations of the original claiming transaction, - /// ensuring that claims don't double spend each other. If a specific `claim_id` has never had a - /// transaction associated with it, and all of the available UTXOs have already been assigned to - /// other claims, implementations must be willing to double spend their UTXOs. The choice of - /// which UTXOs to double spend is left to the implementation, but it must strive to keep the - /// set of other claims being double spent to a minimum. - /// - /// If `claim_id` is not set, then the selection should be treated as if it were for a unique - /// claim and must NOT be double-spent rather than being kept to a minimum. - /// - /// [`ChannelMonitor::rebroadcast_pending_claims`]: crate::chain::channelmonitor::ChannelMonitor::rebroadcast_pending_claims - fn select_confirmed_utxos<'a>( - &'a self, claim_id: Option, must_spend: Vec, must_pay_to: &'a [TxOut], - target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, - ) -> impl Future> + MaybeSend + 'a; - /// Signs and provides the full witness for all inputs within the transaction known to the - /// trait (i.e., any provided via [`CoinSelectionSource::select_confirmed_utxos`]). - /// - /// If your wallet does not support signing PSBTs you can call `psbt.extract_tx()` to get the - /// unsigned transaction and then sign it with your wallet. - fn sign_psbt<'a>( - &'a self, psbt: Psbt, - ) -> impl Future> + MaybeSend + 'a; -} - -/// An alternative to [`CoinSelectionSource`] that can be implemented and used along [`Wallet`] to -/// provide a default implementation to [`CoinSelectionSource`]. -/// -/// For a synchronous version of this trait, see [`sync::WalletSourceSync`]. -/// -/// This is not exported to bindings users as async is only supported in Rust. -// Note that updates to documentation on this trait should be copied to the synchronous version. -pub trait WalletSource { - /// Returns all UTXOs, with at least 1 confirmation each, that are available to spend. - fn list_confirmed_utxos<'a>( - &'a self, - ) -> impl Future, ()>> + MaybeSend + 'a; - - /// Returns the previous transaction containing the UTXO referenced by the outpoint. - fn get_prevtx<'a>( - &'a self, outpoint: OutPoint, - ) -> impl Future> + MaybeSend + 'a; - - /// Returns a script to use for change above dust resulting from a successful coin selection - /// attempt. - fn get_change_script<'a>( - &'a self, - ) -> impl Future> + MaybeSend + 'a; - - /// Signs and provides the full [`TxIn::script_sig`] and [`TxIn::witness`] for all inputs within - /// the transaction known to the wallet (i.e., any provided via - /// [`WalletSource::list_confirmed_utxos`]). - /// - /// If your wallet does not support signing PSBTs you can call `psbt.extract_tx()` to get the - /// unsigned transaction and then sign it with your wallet. - fn sign_psbt<'a>( - &'a self, psbt: Psbt, - ) -> impl Future> + MaybeSend + 'a; -} - -/// A wrapper over [`WalletSource`] that implements [`CoinSelectionSource`] by preferring UTXOs -/// that would avoid conflicting double spends. If not enough UTXOs are available to do so, -/// conflicting double spends may happen. -/// -/// For a synchronous version of this wrapper, see [`sync::WalletSync`]. -/// -/// This is not exported to bindings users as async is only supported in Rust. -// Note that updates to documentation on this struct should be copied to the synchronous version. -pub struct Wallet -where - W::Target: WalletSource + MaybeSend, -{ - source: W, - logger: L, - // TODO: Do we care about cleaning this up once the UTXOs have a confirmed spend? We can do so - // by checking whether any UTXOs that exist in the map are no longer returned in - // `list_confirmed_utxos`. - locked_utxos: Mutex>>, -} - -impl Wallet -where - W::Target: WalletSource + MaybeSend, -{ - /// Returns a new instance backed by the given [`WalletSource`] that serves as an implementation - /// of [`CoinSelectionSource`]. - pub fn new(source: W, logger: L) -> Self { - Self { source, logger, locked_utxos: Mutex::new(new_hash_map()) } - } - - /// Performs coin selection on the set of UTXOs obtained from - /// [`WalletSource::list_confirmed_utxos`]. Its algorithm can be described as "smallest - /// above-dust-after-spend first", with a slight twist: we may skip UTXOs that are above dust at - /// the target feerate after having spent them in a separate claim transaction if - /// `force_conflicting_utxo_spend` is unset to avoid producing conflicting transactions. If - /// `tolerate_high_network_feerates` is set, we'll attempt to spend UTXOs that contribute at - /// least 1 satoshi at the current feerate, otherwise, we'll only attempt to spend those which - /// contribute at least twice their fee. - async fn select_confirmed_utxos_internal( - &self, utxos: &[Utxo], claim_id: Option, force_conflicting_utxo_spend: bool, - tolerate_high_network_feerates: bool, target_feerate_sat_per_1000_weight: u32, - preexisting_tx_weight: u64, input_amount_sat: Amount, target_amount_sat: Amount, - max_tx_weight: u64, - ) -> Result { - debug_assert!(!(claim_id.is_none() && force_conflicting_utxo_spend)); - - // P2WSH and P2TR outputs are both the heaviest-weight standard outputs at 34 bytes - let max_coin_selection_weight = max_tx_weight - .checked_sub(preexisting_tx_weight + P2WSH_TXOUT_WEIGHT) - .ok_or_else(|| { - log_debug!( - self.logger, - "max_tx_weight is too small to accommodate the preexisting tx weight plus a P2WSH/P2TR output" - ); - })?; - - let mut selected_amount; - let mut total_fees; - let mut selected_utxos; - { - let mut locked_utxos = self.locked_utxos.lock().unwrap(); - let mut eligible_utxos = utxos - .iter() - .filter_map(|utxo| { - if let Some(utxo_claim_id) = locked_utxos.get(&utxo.outpoint) { - // TODO(splicing): For splicing (i.e., claim_id.is_none()), ideally we'd - // allow force_conflicting_utxo_spend for an RBF attempt. However, we'd need - // something similar to a ClaimId to identify a splice. - if (utxo_claim_id.is_none() || claim_id.is_none()) - || (*utxo_claim_id != claim_id && !force_conflicting_utxo_spend) - { - log_trace!( - self.logger, - "Skipping UTXO {} to prevent conflicting spend", - utxo.outpoint - ); - return None; - } - } - let fee_to_spend_utxo = Amount::from_sat(fee_for_weight( - target_feerate_sat_per_1000_weight, - BASE_INPUT_WEIGHT + utxo.satisfaction_weight, - )); - let should_spend = if tolerate_high_network_feerates { - utxo.output.value > fee_to_spend_utxo - } else { - utxo.output.value >= fee_to_spend_utxo * 2 - }; - if should_spend { - Some((utxo, fee_to_spend_utxo)) - } else { - log_trace!( - self.logger, - "Skipping UTXO {} due to dust proximity after spend", - utxo.outpoint - ); - None - } - }) - .collect::>(); - eligible_utxos.sort_unstable_by_key(|(utxo, fee_to_spend_utxo)| { - utxo.output.value - *fee_to_spend_utxo - }); - - selected_amount = input_amount_sat; - total_fees = Amount::from_sat(fee_for_weight( - target_feerate_sat_per_1000_weight, - preexisting_tx_weight, - )); - selected_utxos = VecDeque::new(); - // Invariant: `selected_utxos_weight` is never greater than `max_coin_selection_weight` - let mut selected_utxos_weight = 0; - for (utxo, fee_to_spend_utxo) in eligible_utxos { - if selected_amount >= target_amount_sat + total_fees { - break; - } - // First skip any UTXOs with prohibitive satisfaction weights - if BASE_INPUT_WEIGHT + utxo.satisfaction_weight > max_coin_selection_weight { - continue; - } - // If adding this UTXO to `selected_utxos` would push us over the - // `max_coin_selection_weight`, remove UTXOs from the front to make room - // for this new UTXO. - while selected_utxos_weight + BASE_INPUT_WEIGHT + utxo.satisfaction_weight - > max_coin_selection_weight - && !selected_utxos.is_empty() - { - let (smallest_value_after_spend_utxo, fee_to_spend_utxo): (Utxo, Amount) = - selected_utxos.pop_front().unwrap(); - selected_amount -= smallest_value_after_spend_utxo.output.value; - total_fees -= fee_to_spend_utxo; - selected_utxos_weight -= - BASE_INPUT_WEIGHT + smallest_value_after_spend_utxo.satisfaction_weight; - } - selected_amount += utxo.output.value; - total_fees += fee_to_spend_utxo; - selected_utxos_weight += BASE_INPUT_WEIGHT + utxo.satisfaction_weight; - selected_utxos.push_back((utxo.clone(), fee_to_spend_utxo)); - } - if selected_amount < target_amount_sat + total_fees { - log_debug!( - self.logger, - "Insufficient funds to meet target feerate {} sat/kW while remaining under {} WU", - target_feerate_sat_per_1000_weight, - max_coin_selection_weight, - ); - return Err(()); - } - // Once we've selected enough UTXOs to cover `target_amount_sat + total_fees`, - // we may be able to remove some small-value ones while still covering - // `target_amount_sat + total_fees`. - while !selected_utxos.is_empty() - && selected_amount - selected_utxos.front().unwrap().0.output.value - >= target_amount_sat + total_fees - selected_utxos.front().unwrap().1 - { - let (smallest_value_after_spend_utxo, fee_to_spend_utxo) = - selected_utxos.pop_front().unwrap(); - selected_amount -= smallest_value_after_spend_utxo.output.value; - total_fees -= fee_to_spend_utxo; - } - for (utxo, _) in &selected_utxos { - locked_utxos.insert(utxo.outpoint, claim_id); - } - } - - let remaining_amount = selected_amount - target_amount_sat - total_fees; - let change_script = self.source.get_change_script().await?; - let change_output_fee = fee_for_weight( - target_feerate_sat_per_1000_weight, - (8 /* value */ + change_script.consensus_encode(&mut sink()).unwrap() as u64) - * WITNESS_SCALE_FACTOR as u64, - ); - let change_output_amount = - Amount::from_sat(remaining_amount.to_sat().saturating_sub(change_output_fee)); - let change_output = if change_output_amount < change_script.minimal_non_dust() { - log_debug!(self.logger, "Coin selection attempt did not yield change output"); - None - } else { - Some(TxOut { script_pubkey: change_script, value: change_output_amount }) - }; - - let mut confirmed_utxos = Vec::with_capacity(selected_utxos.len()); - for (utxo, _) in selected_utxos { - let prevtx = self.source.get_prevtx(utxo.outpoint).await?; - let prevtx_id = prevtx.compute_txid(); - if prevtx_id != utxo.outpoint.txid - || prevtx.output.get(utxo.outpoint.vout as usize).is_none() - { - log_error!( - self.logger, - "Tx {} from wallet source doesn't contain output referenced by outpoint: {}", - prevtx_id, - utxo.outpoint, - ); - return Err(()); - } - - confirmed_utxos.push(ConfirmedUtxo { utxo, prevtx }); - } - - Ok(CoinSelection { confirmed_utxos, change_output }) - } -} - -impl CoinSelectionSource - for Wallet -where - W::Target: WalletSource + MaybeSend + MaybeSync, -{ - fn select_confirmed_utxos<'a>( - &'a self, claim_id: Option, must_spend: Vec, must_pay_to: &'a [TxOut], - target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, - ) -> impl Future> + MaybeSend + 'a { - async move { - let utxos = self.source.list_confirmed_utxos().await?; - // TODO: Use fee estimation utils when we upgrade to bitcoin v0.30.0. - let total_output_size: u64 = must_pay_to - .iter() - .map( - |output| 8 /* value */ + 1 /* script len */ + output.script_pubkey.len() as u64, - ) - .sum(); - let total_satisfaction_weight: u64 = - must_spend.iter().map(|input| input.satisfaction_weight).sum(); - let total_input_weight = - (BASE_INPUT_WEIGHT * must_spend.len() as u64) + total_satisfaction_weight; - - let preexisting_tx_weight = SEGWIT_MARKER_FLAG_WEIGHT - + total_input_weight - + ((BASE_TX_SIZE + total_output_size) * WITNESS_SCALE_FACTOR as u64); - let input_amount_sat = must_spend.iter().map(|input| input.previous_utxo.value).sum(); - let target_amount_sat = must_pay_to.iter().map(|output| output.value).sum(); - - let configs = [(false, false), (false, true), (true, false), (true, true)]; - for (force_conflicting_utxo_spend, tolerate_high_network_feerates) in configs { - if claim_id.is_none() && force_conflicting_utxo_spend { - continue; - } - log_debug!( - self.logger, - "Attempting coin selection targeting {} sat/kW (force_conflicting_utxo_spend = {}, tolerate_high_network_feerates = {})", - target_feerate_sat_per_1000_weight, - force_conflicting_utxo_spend, - tolerate_high_network_feerates - ); - let attempt = self - .select_confirmed_utxos_internal( - &utxos, - claim_id, - force_conflicting_utxo_spend, - tolerate_high_network_feerates, - target_feerate_sat_per_1000_weight, - preexisting_tx_weight, - input_amount_sat, - target_amount_sat, - max_tx_weight, - ) - .await; - if attempt.is_ok() { - return attempt; - } - } - Err(()) - } - } - - fn sign_psbt<'a>( - &'a self, psbt: Psbt, - ) -> impl Future> + MaybeSend + 'a { - self.source.sign_psbt(psbt) - } -} - /// A handler for [`Event::BumpTransaction`] events that sources confirmed UTXOs from a /// [`CoinSelectionSource`] to fee bump transactions via Child-Pays-For-Parent (CPFP) or /// Replace-By-Fee (RBF). @@ -1356,11 +850,15 @@ mod tests { use crate::ln::chan_utils::ChannelTransactionParameters; use crate::ln::channel::ANCHOR_OUTPUT_VALUE_SATOSHI; use crate::sign::KeysManager; + use crate::sync::Mutex; use crate::types::features::ChannelTypeFeatures; use crate::util::ser::Readable; use crate::util::test_utils::{TestBroadcaster, TestLogger}; + use crate::util::wallet_utils::Utxo; + use bitcoin::constants::WITNESS_SCALE_FACTOR; use bitcoin::hex::FromHex; + use bitcoin::key::TweakedPublicKey; use bitcoin::{ Network, ScriptBuf, Transaction, WitnessProgram, WitnessVersion, XOnlyPublicKey, }; diff --git a/lightning/src/events/bump_transaction/sync.rs b/lightning/src/events/bump_transaction/sync.rs index 39088bb0e97..2d88b0187fa 100644 --- a/lightning/src/events/bump_transaction/sync.rs +++ b/lightning/src/events/bump_transaction/sync.rs @@ -20,14 +20,13 @@ use crate::prelude::*; use crate::sign::SignerProvider; use crate::util::async_poll::{dummy_waker, MaybeSend, MaybeSync}; use crate::util::logger::Logger; +use crate::util::wallet_utils::{ + CoinSelection, CoinSelectionSource, Input, Utxo, Wallet, WalletSource, +}; use bitcoin::{OutPoint, Psbt, ScriptBuf, Transaction, TxOut}; -use super::BumpTransactionEvent; -use super::{ - BumpTransactionEventHandler, CoinSelection, CoinSelectionSource, Input, Utxo, Wallet, - WalletSource, -}; +use super::{BumpTransactionEvent, BumpTransactionEventHandler}; /// An alternative to [`CoinSelectionSourceSync`] that can be implemented and used along /// [`WalletSync`] to provide a default implementation to [`CoinSelectionSourceSync`]. diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 0f1916ac59f..c71ee7afc90 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -36,7 +36,6 @@ use crate::chain::channelmonitor::{ }; use crate::chain::transaction::{OutPoint, TransactionData}; use crate::chain::BestBlock; -use crate::events::bump_transaction::Input; use crate::events::{ClosureReason, FundingInfo}; use crate::ln::chan_utils; use crate::ln::chan_utils::{ @@ -84,6 +83,7 @@ use crate::util::errors::APIError; use crate::util::logger::{Logger, Record, WithContext}; use crate::util::scid_utils::{block_from_scid, scid_from_parts}; use crate::util::ser::{Readable, ReadableArgs, RequiredWrapper, Writeable, Writer}; +use crate::util::wallet_utils::Input; use crate::{impl_readable_for_vec, impl_writeable_for_vec}; use alloc::collections::{btree_map, BTreeMap}; diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 06b972d7126..c33ca8c34ee 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -19,7 +19,6 @@ use bitcoin::{ use core::ops::Deref; use crate::events::bump_transaction::sync::CoinSelectionSourceSync; -use crate::events::bump_transaction::{CoinSelection, CoinSelectionSource, Input, Utxo}; use crate::ln::chan_utils::{ make_funding_redeemscript, BASE_INPUT_WEIGHT, EMPTY_SCRIPT_SIG_WEIGHT, FUNDING_TRANSACTION_WITNESS_WEIGHT, @@ -31,6 +30,7 @@ use crate::ln::LN_MAX_MSG_LEN; use crate::prelude::*; use crate::sign::{P2TR_KEY_PATH_WITNESS_WEIGHT, P2WPKH_WITNESS_WEIGHT}; use crate::util::async_poll::MaybeSend; +use crate::util::wallet_utils::{CoinSelection, CoinSelectionSource, Input, Utxo}; /// A template for contributing to a channel's splice funding transaction. /// diff --git a/lightning/src/ln/zero_fee_commitment_tests.rs b/lightning/src/ln/zero_fee_commitment_tests.rs index b7221552603..aa4f012e73c 100644 --- a/lightning/src/ln/zero_fee_commitment_tests.rs +++ b/lightning/src/ln/zero_fee_commitment_tests.rs @@ -368,7 +368,7 @@ fn test_anchor_tx_too_big() { - EMPTY_WITNESS_WEIGHT - P2WSH_TXOUT_WEIGHT; nodes[1].logger.assert_log( - "lightning::events::bump_transaction", + "lightning::util::wallet_utils", format!( "Insufficient funds to meet target feerate {} sat/kW while remaining under {} WU", FEERATE, max_coin_selection_weight @@ -402,7 +402,7 @@ fn test_anchor_tx_too_big() { assert_eq!(txns[1].input.len(), 2); assert_eq!(txns[1].output.len(), 1); nodes[1].logger.assert_log( - "lightning::events::bump_transaction", + "lightning::util::wallet_utils", format!( "Insufficient funds to meet target feerate {} sat/kW while remaining under {} WU", FEERATE, max_coin_selection_weight diff --git a/lightning/src/util/anchor_channel_reserves.rs b/lightning/src/util/anchor_channel_reserves.rs index 25a0e7ca0ba..2c09ddd70a6 100644 --- a/lightning/src/util/anchor_channel_reserves.rs +++ b/lightning/src/util/anchor_channel_reserves.rs @@ -24,7 +24,6 @@ use crate::chain::chaininterface::FeeEstimator; use crate::chain::chainmonitor::ChainMonitor; use crate::chain::chainmonitor::Persist; use crate::chain::Filter; -use crate::events::bump_transaction::Utxo; use crate::ln::chan_utils::max_htlcs; use crate::ln::channelmanager::AChannelManager; use crate::prelude::new_hash_set; @@ -32,6 +31,7 @@ use crate::sign::ecdsa::EcdsaChannelSigner; use crate::sign::EntropySource; use crate::types::features::ChannelTypeFeatures; use crate::util::logger::Logger; +use crate::util::wallet_utils::Utxo; use bitcoin::constants::WITNESS_SCALE_FACTOR; use bitcoin::Amount; use bitcoin::FeeRate; diff --git a/lightning/src/util/mod.rs b/lightning/src/util/mod.rs index dcbea904b51..75434fdabab 100644 --- a/lightning/src/util/mod.rs +++ b/lightning/src/util/mod.rs @@ -51,6 +51,7 @@ pub(crate) mod macro_logger; // These have to come after macro_logger to build pub mod config; pub mod logger; +pub mod wallet_utils; #[cfg(any(test, feature = "_test_utils"))] pub mod test_utils; diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 02b63a61eae..48f506e62ce 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -22,7 +22,6 @@ use crate::chain::channelmonitor::{ use crate::chain::transaction::OutPoint; use crate::chain::WatchedOutput; use crate::events::bump_transaction::sync::WalletSourceSync; -use crate::events::bump_transaction::{ConfirmedUtxo, Utxo}; #[cfg(any(test, feature = "_externalize_tests"))] use crate::ln::chan_utils::CommitmentTransaction; use crate::ln::channel_state::ChannelDetails; @@ -62,6 +61,7 @@ use crate::util::persist::{KVStore, KVStoreSync, MonitorName}; use crate::util::ser::{Readable, ReadableArgs, Writeable, Writer}; use crate::util::test_channel_signer::{EnforcementState, TestChannelSigner}; use crate::util::wakers::Notifier; +use crate::util::wallet_utils::{ConfirmedUtxo, Utxo}; use bitcoin::amount::Amount; use bitcoin::block::Block; diff --git a/lightning/src/util/wallet_utils.rs b/lightning/src/util/wallet_utils.rs new file mode 100644 index 00000000000..cf754d14e3d --- /dev/null +++ b/lightning/src/util/wallet_utils.rs @@ -0,0 +1,546 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Utilities for wallet integration with LDK. + +use core::future::Future; +use core::ops::Deref; + +use crate::chain::chaininterface::fee_for_weight; +use crate::chain::ClaimId; +use crate::io_extras::sink; +use crate::ln::chan_utils::{ + BASE_INPUT_WEIGHT, BASE_TX_SIZE, EMPTY_SCRIPT_SIG_WEIGHT, P2WSH_TXOUT_WEIGHT, + SEGWIT_MARKER_FLAG_WEIGHT, +}; +use crate::ln::funding::FundingTxInput; +use crate::prelude::*; +use crate::sign::{P2TR_KEY_PATH_WITNESS_WEIGHT, P2WPKH_WITNESS_WEIGHT}; +use crate::sync::Mutex; +use crate::util::async_poll::{MaybeSend, MaybeSync}; +use crate::util::hash_tables::{new_hash_map, HashMap}; +use crate::util::logger::Logger; + +use bitcoin::amount::Amount; +use bitcoin::consensus::Encodable; +use bitcoin::constants::WITNESS_SCALE_FACTOR; +use bitcoin::key::TweakedPublicKey; +use bitcoin::{OutPoint, Psbt, PubkeyHash, ScriptBuf, Sequence, Transaction, TxOut, WPubkeyHash}; + +/// An input that must be included in a transaction when performing coin selection through +/// [`CoinSelectionSource::select_confirmed_utxos`]. It is guaranteed to be a SegWit input, so it +/// must have an empty [`TxIn::script_sig`] when spent. +/// +/// [`TxIn::script_sig`]: bitcoin::TxIn::script_sig +#[derive(Clone, Debug, Hash, PartialOrd, Ord, PartialEq, Eq)] +pub struct Input { + /// The unique identifier of the input. + pub outpoint: OutPoint, + /// The UTXO being spent by the input. + pub previous_utxo: TxOut, + /// The upper-bound weight consumed by the input's full [`TxIn::script_sig`] and + /// [`TxIn::witness`], each with their lengths included, required to satisfy the output's + /// script. + /// + /// [`TxIn::script_sig`]: bitcoin::TxIn::script_sig + /// [`TxIn::witness`]: bitcoin::TxIn::witness + pub satisfaction_weight: u64, +} + +/// An unspent transaction output that is available to spend resulting from a successful +/// [`CoinSelection`] attempt. +#[derive(Clone, Debug, Hash, PartialOrd, Ord, PartialEq, Eq)] +pub struct Utxo { + /// The unique identifier of the output. + pub outpoint: OutPoint, + /// The output to spend. + pub output: TxOut, + /// The upper-bound weight consumed by the input's full [`TxIn::script_sig`] and [`TxIn::witness`], each + /// with their lengths included, required to satisfy the output's script. The weight consumed by + /// the input's `script_sig` must account for [`WITNESS_SCALE_FACTOR`]. + /// + /// [`TxIn::script_sig`]: bitcoin::TxIn::script_sig + /// [`TxIn::witness`]: bitcoin::TxIn::witness + pub satisfaction_weight: u64, + /// The sequence number to use in the [`TxIn`] when spending the UTXO. + /// + /// [`TxIn`]: bitcoin::TxIn + pub sequence: Sequence, +} + +impl_writeable_tlv_based!(Utxo, { + (1, outpoint, required), + (3, output, required), + (5, satisfaction_weight, required), + (7, sequence, (default_value, Sequence::ENABLE_RBF_NO_LOCKTIME)), +}); + +impl Utxo { + /// Returns a `Utxo` with the `satisfaction_weight` estimate for a legacy P2PKH output. + pub fn new_p2pkh(outpoint: OutPoint, value: Amount, pubkey_hash: &PubkeyHash) -> Self { + let script_sig_size = 1 /* script_sig length */ + + 1 /* OP_PUSH73 */ + + 73 /* sig including sighash flag */ + + 1 /* OP_PUSH33 */ + + 33 /* pubkey */; + Self { + outpoint, + output: TxOut { value, script_pubkey: ScriptBuf::new_p2pkh(pubkey_hash) }, + satisfaction_weight: script_sig_size * WITNESS_SCALE_FACTOR as u64 + 1, /* empty witness */ + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + } + } + + /// Returns a `Utxo` with the `satisfaction_weight` estimate for a P2WPKH nested in P2SH output. + pub fn new_nested_p2wpkh(outpoint: OutPoint, value: Amount, pubkey_hash: &WPubkeyHash) -> Self { + let script_sig_size = 1 /* script_sig length */ + + 1 /* OP_0 */ + + 1 /* OP_PUSH20 */ + + 20 /* pubkey_hash */; + Self { + outpoint, + output: TxOut { + value, + script_pubkey: ScriptBuf::new_p2sh( + &ScriptBuf::new_p2wpkh(pubkey_hash).script_hash(), + ), + }, + satisfaction_weight: script_sig_size * WITNESS_SCALE_FACTOR as u64 + + P2WPKH_WITNESS_WEIGHT, + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + } + } + + /// Returns a `Utxo` with the `satisfaction_weight` estimate for a SegWit v0 P2WPKH output. + pub fn new_v0_p2wpkh(outpoint: OutPoint, value: Amount, pubkey_hash: &WPubkeyHash) -> Self { + Self { + outpoint, + output: TxOut { value, script_pubkey: ScriptBuf::new_p2wpkh(pubkey_hash) }, + satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + P2WPKH_WITNESS_WEIGHT, + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + } + } + + /// Returns a `Utxo` with the `satisfaction_weight` estimate for a keypath spend of a SegWit v1 P2TR output. + pub fn new_v1_p2tr( + outpoint: OutPoint, value: Amount, tweaked_public_key: TweakedPublicKey, + ) -> Self { + Self { + outpoint, + output: TxOut { value, script_pubkey: ScriptBuf::new_p2tr_tweaked(tweaked_public_key) }, + satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + P2TR_KEY_PATH_WITNESS_WEIGHT, + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + } + } +} + +/// An unspent transaction output with at least one confirmation. +pub type ConfirmedUtxo = FundingTxInput; + +/// The result of a successful coin selection attempt for a transaction requiring additional UTXOs +/// to cover its fees. +#[derive(Clone, Debug)] +pub struct CoinSelection { + /// The set of UTXOs (with at least 1 confirmation) to spend and use within a transaction + /// requiring additional fees. + pub confirmed_utxos: Vec, + /// An additional output tracking whether any change remained after coin selection. This output + /// should always have a value above dust for its given `script_pubkey`. It should not be + /// spent until the transaction it belongs to confirms to ensure mempool descendant limits are + /// not met. This implies no other party should be able to spend it except us. + pub change_output: Option, +} + +impl CoinSelection { + pub(crate) fn satisfaction_weight(&self) -> u64 { + self.confirmed_utxos.iter().map(|ConfirmedUtxo { utxo, .. }| utxo.satisfaction_weight).sum() + } + + pub(crate) fn input_amount(&self) -> Amount { + self.confirmed_utxos.iter().map(|ConfirmedUtxo { utxo, .. }| utxo.output.value).sum() + } +} + +/// An abstraction over a bitcoin wallet that can perform coin selection over a set of UTXOs and can +/// sign for them. The coin selection method aims to mimic Bitcoin Core's `fundrawtransaction` RPC, +/// which most wallets should be able to satisfy. Otherwise, consider implementing [`WalletSource`], +/// which can provide a default implementation of this trait when used with [`Wallet`]. +/// +/// For a synchronous version of this trait, see [`sync::CoinSelectionSourceSync`]. +/// +/// This is not exported to bindings users as async is only supported in Rust. +/// +/// [`sync::CoinSelectionSourceSync`]: crate::events::bump_transaction::sync::CoinSelectionSourceSync +// Note that updates to documentation on this trait should be copied to the synchronous version. +pub trait CoinSelectionSource { + /// Performs coin selection of a set of UTXOs, with at least 1 confirmation each, that are + /// available to spend. Implementations are free to pick their coin selection algorithm of + /// choice, as long as the following requirements are met: + /// + /// 1. `must_spend` contains a set of [`Input`]s that must be included in the transaction + /// throughout coin selection, but must not be returned as part of the result. + /// 2. `must_pay_to` contains a set of [`TxOut`]s that must be included in the transaction + /// throughout coin selection. In some cases, like when funding an anchor transaction, this + /// set is empty. Implementations should ensure they handle this correctly on their end, + /// e.g., Bitcoin Core's `fundrawtransaction` RPC requires at least one output to be + /// provided, in which case a zero-value empty OP_RETURN output can be used instead. + /// 3. Enough inputs must be selected/contributed for the resulting transaction (including the + /// inputs and outputs noted above) to meet `target_feerate_sat_per_1000_weight`. + /// 4. The final transaction must have a weight smaller than `max_tx_weight`; if this + /// constraint can't be met, return an `Err`. In the case of counterparty-signed HTLC + /// transactions, we will remove a chunk of HTLCs and try your algorithm again. As for + /// anchor transactions, we will try your coin selection again with the same input-output + /// set when you call [`ChannelMonitor::rebroadcast_pending_claims`], as anchor transactions + /// cannot be downsized. + /// + /// Implementations must take note that [`Input::satisfaction_weight`] only tracks the weight of + /// the input's `script_sig` and `witness`. Some wallets, like Bitcoin Core's, may require + /// providing the full input weight. Failing to do so may lead to underestimating fee bumps and + /// delaying block inclusion. + /// + /// The `claim_id` must map to the set of external UTXOs assigned to the claim, such that they + /// can be re-used within new fee-bumped iterations of the original claiming transaction, + /// ensuring that claims don't double spend each other. If a specific `claim_id` has never had a + /// transaction associated with it, and all of the available UTXOs have already been assigned to + /// other claims, implementations must be willing to double spend their UTXOs. The choice of + /// which UTXOs to double spend is left to the implementation, but it must strive to keep the + /// set of other claims being double spent to a minimum. + /// + /// If `claim_id` is not set, then the selection should be treated as if it were for a unique + /// claim and must NOT be double-spent rather than being kept to a minimum. + /// + /// [`ChannelMonitor::rebroadcast_pending_claims`]: crate::chain::channelmonitor::ChannelMonitor::rebroadcast_pending_claims + fn select_confirmed_utxos<'a>( + &'a self, claim_id: Option, must_spend: Vec, must_pay_to: &'a [TxOut], + target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, + ) -> impl Future> + MaybeSend + 'a; + /// Signs and provides the full witness for all inputs within the transaction known to the + /// trait (i.e., any provided via [`CoinSelectionSource::select_confirmed_utxos`]). + /// + /// If your wallet does not support signing PSBTs you can call `psbt.extract_tx()` to get the + /// unsigned transaction and then sign it with your wallet. + fn sign_psbt<'a>( + &'a self, psbt: Psbt, + ) -> impl Future> + MaybeSend + 'a; +} + +/// An alternative to [`CoinSelectionSource`] that can be implemented and used along [`Wallet`] to +/// provide a default implementation to [`CoinSelectionSource`]. +/// +/// For a synchronous version of this trait, see [`sync::WalletSourceSync`]. +/// +/// This is not exported to bindings users as async is only supported in Rust. +/// +/// [`sync::WalletSourceSync`]: crate::events::bump_transaction::sync::WalletSourceSync +// Note that updates to documentation on this trait should be copied to the synchronous version. +pub trait WalletSource { + /// Returns all UTXOs, with at least 1 confirmation each, that are available to spend. + fn list_confirmed_utxos<'a>( + &'a self, + ) -> impl Future, ()>> + MaybeSend + 'a; + + /// Returns the previous transaction containing the UTXO referenced by the outpoint. + fn get_prevtx<'a>( + &'a self, outpoint: OutPoint, + ) -> impl Future> + MaybeSend + 'a; + + /// Returns a script to use for change above dust resulting from a successful coin selection + /// attempt. + fn get_change_script<'a>( + &'a self, + ) -> impl Future> + MaybeSend + 'a; + + /// Signs and provides the full [`TxIn::script_sig`] and [`TxIn::witness`] for all inputs within + /// the transaction known to the wallet (i.e., any provided via + /// [`WalletSource::list_confirmed_utxos`]). + /// + /// If your wallet does not support signing PSBTs you can call `psbt.extract_tx()` to get the + /// unsigned transaction and then sign it with your wallet. + /// + /// [`TxIn::script_sig`]: bitcoin::TxIn::script_sig + /// [`TxIn::witness`]: bitcoin::TxIn::witness + fn sign_psbt<'a>( + &'a self, psbt: Psbt, + ) -> impl Future> + MaybeSend + 'a; +} + +/// A wrapper over [`WalletSource`] that implements [`CoinSelectionSource`] by preferring UTXOs +/// that would avoid conflicting double spends. If not enough UTXOs are available to do so, +/// conflicting double spends may happen. +/// +/// For a synchronous version of this wrapper, see [`sync::WalletSync`]. +/// +/// This is not exported to bindings users as async is only supported in Rust. +/// +/// [`sync::WalletSync`]: crate::events::bump_transaction::sync::WalletSync +// Note that updates to documentation on this struct should be copied to the synchronous version. +pub struct Wallet +where + W::Target: WalletSource + MaybeSend, +{ + source: W, + logger: L, + // TODO: Do we care about cleaning this up once the UTXOs have a confirmed spend? We can do so + // by checking whether any UTXOs that exist in the map are no longer returned in + // `list_confirmed_utxos`. + locked_utxos: Mutex>>, +} + +impl Wallet +where + W::Target: WalletSource + MaybeSend, +{ + /// Returns a new instance backed by the given [`WalletSource`] that serves as an implementation + /// of [`CoinSelectionSource`]. + pub fn new(source: W, logger: L) -> Self { + Self { source, logger, locked_utxos: Mutex::new(new_hash_map()) } + } + + /// Performs coin selection on the set of UTXOs obtained from + /// [`WalletSource::list_confirmed_utxos`]. Its algorithm can be described as "smallest + /// above-dust-after-spend first", with a slight twist: we may skip UTXOs that are above dust at + /// the target feerate after having spent them in a separate claim transaction if + /// `force_conflicting_utxo_spend` is unset to avoid producing conflicting transactions. If + /// `tolerate_high_network_feerates` is set, we'll attempt to spend UTXOs that contribute at + /// least 1 satoshi at the current feerate, otherwise, we'll only attempt to spend those which + /// contribute at least twice their fee. + async fn select_confirmed_utxos_internal( + &self, utxos: &[Utxo], claim_id: Option, force_conflicting_utxo_spend: bool, + tolerate_high_network_feerates: bool, target_feerate_sat_per_1000_weight: u32, + preexisting_tx_weight: u64, input_amount_sat: Amount, target_amount_sat: Amount, + max_tx_weight: u64, + ) -> Result { + debug_assert!(!(claim_id.is_none() && force_conflicting_utxo_spend)); + + // P2WSH and P2TR outputs are both the heaviest-weight standard outputs at 34 bytes + let max_coin_selection_weight = max_tx_weight + .checked_sub(preexisting_tx_weight + P2WSH_TXOUT_WEIGHT) + .ok_or_else(|| { + log_debug!( + self.logger, + "max_tx_weight is too small to accommodate the preexisting tx weight plus a P2WSH/P2TR output" + ); + })?; + + let mut selected_amount; + let mut total_fees; + let mut selected_utxos; + { + let mut locked_utxos = self.locked_utxos.lock().unwrap(); + let mut eligible_utxos = utxos + .iter() + .filter_map(|utxo| { + if let Some(utxo_claim_id) = locked_utxos.get(&utxo.outpoint) { + // TODO(splicing): For splicing (i.e., claim_id.is_none()), ideally we'd + // allow force_conflicting_utxo_spend for an RBF attempt. However, we'd need + // something similar to a ClaimId to identify a splice. + if (utxo_claim_id.is_none() || claim_id.is_none()) + || (*utxo_claim_id != claim_id && !force_conflicting_utxo_spend) + { + log_trace!( + self.logger, + "Skipping UTXO {} to prevent conflicting spend", + utxo.outpoint + ); + return None; + } + } + let fee_to_spend_utxo = Amount::from_sat(fee_for_weight( + target_feerate_sat_per_1000_weight, + BASE_INPUT_WEIGHT + utxo.satisfaction_weight, + )); + let should_spend = if tolerate_high_network_feerates { + utxo.output.value > fee_to_spend_utxo + } else { + utxo.output.value >= fee_to_spend_utxo * 2 + }; + if should_spend { + Some((utxo, fee_to_spend_utxo)) + } else { + log_trace!( + self.logger, + "Skipping UTXO {} due to dust proximity after spend", + utxo.outpoint + ); + None + } + }) + .collect::>(); + eligible_utxos.sort_unstable_by_key(|(utxo, fee_to_spend_utxo)| { + utxo.output.value - *fee_to_spend_utxo + }); + + selected_amount = input_amount_sat; + total_fees = Amount::from_sat(fee_for_weight( + target_feerate_sat_per_1000_weight, + preexisting_tx_weight, + )); + selected_utxos = VecDeque::new(); + // Invariant: `selected_utxos_weight` is never greater than `max_coin_selection_weight` + let mut selected_utxos_weight = 0; + for (utxo, fee_to_spend_utxo) in eligible_utxos { + if selected_amount >= target_amount_sat + total_fees { + break; + } + // First skip any UTXOs with prohibitive satisfaction weights + if BASE_INPUT_WEIGHT + utxo.satisfaction_weight > max_coin_selection_weight { + continue; + } + // If adding this UTXO to `selected_utxos` would push us over the + // `max_coin_selection_weight`, remove UTXOs from the front to make room + // for this new UTXO. + while selected_utxos_weight + BASE_INPUT_WEIGHT + utxo.satisfaction_weight + > max_coin_selection_weight + && !selected_utxos.is_empty() + { + let (smallest_value_after_spend_utxo, fee_to_spend_utxo): (Utxo, Amount) = + selected_utxos.pop_front().unwrap(); + selected_amount -= smallest_value_after_spend_utxo.output.value; + total_fees -= fee_to_spend_utxo; + selected_utxos_weight -= + BASE_INPUT_WEIGHT + smallest_value_after_spend_utxo.satisfaction_weight; + } + selected_amount += utxo.output.value; + total_fees += fee_to_spend_utxo; + selected_utxos_weight += BASE_INPUT_WEIGHT + utxo.satisfaction_weight; + selected_utxos.push_back((utxo.clone(), fee_to_spend_utxo)); + } + if selected_amount < target_amount_sat + total_fees { + log_debug!( + self.logger, + "Insufficient funds to meet target feerate {} sat/kW while remaining under {} WU", + target_feerate_sat_per_1000_weight, + max_coin_selection_weight, + ); + return Err(()); + } + // Once we've selected enough UTXOs to cover `target_amount_sat + total_fees`, + // we may be able to remove some small-value ones while still covering + // `target_amount_sat + total_fees`. + while !selected_utxos.is_empty() + && selected_amount - selected_utxos.front().unwrap().0.output.value + >= target_amount_sat + total_fees - selected_utxos.front().unwrap().1 + { + let (smallest_value_after_spend_utxo, fee_to_spend_utxo) = + selected_utxos.pop_front().unwrap(); + selected_amount -= smallest_value_after_spend_utxo.output.value; + total_fees -= fee_to_spend_utxo; + } + for (utxo, _) in &selected_utxos { + locked_utxos.insert(utxo.outpoint, claim_id); + } + } + + let remaining_amount = selected_amount - target_amount_sat - total_fees; + let change_script = self.source.get_change_script().await?; + let change_output_fee = fee_for_weight( + target_feerate_sat_per_1000_weight, + (8 /* value */ + change_script.consensus_encode(&mut sink()).unwrap() as u64) + * WITNESS_SCALE_FACTOR as u64, + ); + let change_output_amount = + Amount::from_sat(remaining_amount.to_sat().saturating_sub(change_output_fee)); + let change_output = if change_output_amount < change_script.minimal_non_dust() { + log_debug!(self.logger, "Coin selection attempt did not yield change output"); + None + } else { + Some(TxOut { script_pubkey: change_script, value: change_output_amount }) + }; + + let mut confirmed_utxos = Vec::with_capacity(selected_utxos.len()); + for (utxo, _) in selected_utxos { + let prevtx = self.source.get_prevtx(utxo.outpoint).await?; + let prevtx_id = prevtx.compute_txid(); + if prevtx_id != utxo.outpoint.txid + || prevtx.output.get(utxo.outpoint.vout as usize).is_none() + { + log_error!( + self.logger, + "Tx {} from wallet source doesn't contain output referenced by outpoint: {}", + prevtx_id, + utxo.outpoint, + ); + return Err(()); + } + + confirmed_utxos.push(ConfirmedUtxo { utxo, prevtx }); + } + + Ok(CoinSelection { confirmed_utxos, change_output }) + } +} + +impl CoinSelectionSource + for Wallet +where + W::Target: WalletSource + MaybeSend + MaybeSync, +{ + fn select_confirmed_utxos<'a>( + &'a self, claim_id: Option, must_spend: Vec, must_pay_to: &'a [TxOut], + target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, + ) -> impl Future> + MaybeSend + 'a { + async move { + let utxos = self.source.list_confirmed_utxos().await?; + // TODO: Use fee estimation utils when we upgrade to bitcoin v0.30.0. + let total_output_size: u64 = must_pay_to + .iter() + .map( + |output| 8 /* value */ + 1 /* script len */ + output.script_pubkey.len() as u64, + ) + .sum(); + let total_satisfaction_weight: u64 = + must_spend.iter().map(|input| input.satisfaction_weight).sum(); + let total_input_weight = + (BASE_INPUT_WEIGHT * must_spend.len() as u64) + total_satisfaction_weight; + + let preexisting_tx_weight = SEGWIT_MARKER_FLAG_WEIGHT + + total_input_weight + + ((BASE_TX_SIZE + total_output_size) * WITNESS_SCALE_FACTOR as u64); + let input_amount_sat = must_spend.iter().map(|input| input.previous_utxo.value).sum(); + let target_amount_sat = must_pay_to.iter().map(|output| output.value).sum(); + + let configs = [(false, false), (false, true), (true, false), (true, true)]; + for (force_conflicting_utxo_spend, tolerate_high_network_feerates) in configs { + if claim_id.is_none() && force_conflicting_utxo_spend { + continue; + } + log_debug!( + self.logger, + "Attempting coin selection targeting {} sat/kW (force_conflicting_utxo_spend = {}, tolerate_high_network_feerates = {})", + target_feerate_sat_per_1000_weight, + force_conflicting_utxo_spend, + tolerate_high_network_feerates + ); + let attempt = self + .select_confirmed_utxos_internal( + &utxos, + claim_id, + force_conflicting_utxo_spend, + tolerate_high_network_feerates, + target_feerate_sat_per_1000_weight, + preexisting_tx_weight, + input_amount_sat, + target_amount_sat, + max_tx_weight, + ) + .await; + if attempt.is_ok() { + return attempt; + } + } + Err(()) + } + } + + fn sign_psbt<'a>( + &'a self, psbt: Psbt, + ) -> impl Future> + MaybeSend + 'a { + self.source.sign_psbt(psbt) + } +} From 0ce6ba4026fe3dfb206c328d0d2c67d905df072f Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 28 Jan 2026 17:23:26 -0600 Subject: [PATCH 039/627] Move sync wallet utils to util::wallet_utils Synchronous wallet utilities were coupled to bump_transaction::sync, limiting their reusability for other features like channel funding and splicing which need synchronous wallet operations. Consolidate all wallet utilities in a single module for consistency and improved code organization. Co-Authored-By: Claude Sonnet 4.5 --- fuzz/src/chanmon_consistency.rs | 2 +- fuzz/src/full_stack.rs | 2 +- .../src/upgrade_downgrade_tests.rs | 2 +- lightning/src/events/bump_transaction/mod.rs | 5 +- lightning/src/events/bump_transaction/sync.rs | 249 +---------------- lightning/src/ln/async_signer_tests.rs | 2 +- lightning/src/ln/functional_test_utils.rs | 5 +- lightning/src/ln/funding.rs | 5 +- lightning/src/ln/splicing_tests.rs | 2 +- lightning/src/util/test_utils.rs | 3 +- lightning/src/util/wallet_utils.rs | 255 +++++++++++++++++- 11 files changed, 260 insertions(+), 272 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index ced89f5ac8c..0abeea1c828 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -46,7 +46,6 @@ use lightning::chain::{ chainmonitor, channelmonitor, BestBlock, ChannelMonitorUpdateStatus, Confirm, Watch, }; use lightning::events; -use lightning::events::bump_transaction::sync::{WalletSourceSync, WalletSync}; use lightning::ln::channel::{ FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE, MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS, }; @@ -81,6 +80,7 @@ use lightning::util::logger::Logger; use lightning::util::ser::{LengthReadable, ReadableArgs, Writeable, Writer}; use lightning::util::test_channel_signer::{EnforcementState, TestChannelSigner}; use lightning::util::test_utils::TestWalletSource; +use lightning::util::wallet_utils::{WalletSourceSync, WalletSync}; use lightning_invoice::RawBolt11Invoice; diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index 6adb8f33c89..085165e9e02 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -40,7 +40,6 @@ use lightning::chain::chaininterface::{ use lightning::chain::chainmonitor; use lightning::chain::transaction::OutPoint; use lightning::chain::{BestBlock, ChannelMonitorUpdateStatus, Confirm, Listen}; -use lightning::events::bump_transaction::sync::{WalletSourceSync, WalletSync}; use lightning::events::Event; use lightning::ln::channel_state::ChannelDetails; use lightning::ln::channelmanager::{ChainParameters, ChannelManager, InterceptId, PaymentId}; @@ -71,6 +70,7 @@ use lightning::util::logger::Logger; use lightning::util::ser::{Readable, Writeable}; use lightning::util::test_channel_signer::{EnforcementState, TestChannelSigner}; use lightning::util::test_utils::TestWalletSource; +use lightning::util::wallet_utils::{WalletSourceSync, WalletSync}; use lightning_invoice::RawBolt11Invoice; diff --git a/lightning-tests/src/upgrade_downgrade_tests.rs b/lightning-tests/src/upgrade_downgrade_tests.rs index f18e0e56800..93d671b176d 100644 --- a/lightning-tests/src/upgrade_downgrade_tests.rs +++ b/lightning-tests/src/upgrade_downgrade_tests.rs @@ -46,7 +46,6 @@ use lightning_0_0_125::routing::router as router_0_0_125; use lightning_0_0_125::util::ser::Writeable as _; use lightning::chain::channelmonitor::{ANTI_REORG_DELAY, HTLC_FAIL_BACK_BUFFER}; -use lightning::events::bump_transaction::sync::WalletSourceSync; use lightning::events::{ClosureReason, Event, HTLCHandlingFailureType}; use lightning::ln::functional_test_utils::*; use lightning::ln::msgs::BaseMessageHandler as _; @@ -55,6 +54,7 @@ use lightning::ln::msgs::MessageSendEvent; use lightning::ln::splicing_tests::*; use lightning::ln::types::ChannelId; use lightning::sign::OutputSpender; +use lightning::util::wallet_utils::WalletSourceSync; use lightning_types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; diff --git a/lightning/src/events/bump_transaction/mod.rs b/lightning/src/events/bump_transaction/mod.rs index 13a5a61ebda..f1ba1fcfdc8 100644 --- a/lightning/src/events/bump_transaction/mod.rs +++ b/lightning/src/events/bump_transaction/mod.rs @@ -843,9 +843,7 @@ where mod tests { use super::*; - use crate::events::bump_transaction::sync::{ - BumpTransactionEventHandlerSync, CoinSelectionSourceSync, - }; + use crate::events::bump_transaction::sync::BumpTransactionEventHandlerSync; use crate::io::Cursor; use crate::ln::chan_utils::ChannelTransactionParameters; use crate::ln::channel::ANCHOR_OUTPUT_VALUE_SATOSHI; @@ -854,6 +852,7 @@ mod tests { use crate::types::features::ChannelTypeFeatures; use crate::util::ser::Readable; use crate::util::test_utils::{TestBroadcaster, TestLogger}; + use crate::util::wallet_utils::CoinSelectionSourceSync; use crate::util::wallet_utils::Utxo; use bitcoin::constants::WITNESS_SCALE_FACTOR; diff --git a/lightning/src/events/bump_transaction/sync.rs b/lightning/src/events/bump_transaction/sync.rs index 2d88b0187fa..f2e1be1590c 100644 --- a/lightning/src/events/bump_transaction/sync.rs +++ b/lightning/src/events/bump_transaction/sync.rs @@ -15,258 +15,13 @@ use core::pin::pin; use core::task; use crate::chain::chaininterface::BroadcasterInterface; -use crate::chain::ClaimId; -use crate::prelude::*; use crate::sign::SignerProvider; -use crate::util::async_poll::{dummy_waker, MaybeSend, MaybeSync}; +use crate::util::async_poll::dummy_waker; use crate::util::logger::Logger; -use crate::util::wallet_utils::{ - CoinSelection, CoinSelectionSource, Input, Utxo, Wallet, WalletSource, -}; - -use bitcoin::{OutPoint, Psbt, ScriptBuf, Transaction, TxOut}; +use crate::util::wallet_utils::{CoinSelectionSourceSync, CoinSelectionSourceSyncWrapper}; use super::{BumpTransactionEvent, BumpTransactionEventHandler}; -/// An alternative to [`CoinSelectionSourceSync`] that can be implemented and used along -/// [`WalletSync`] to provide a default implementation to [`CoinSelectionSourceSync`]. -/// -/// For an asynchronous version of this trait, see [`WalletSource`]. -// Note that updates to documentation on this trait should be copied to the asynchronous version. -pub trait WalletSourceSync { - /// Returns all UTXOs, with at least 1 confirmation each, that are available to spend. - fn list_confirmed_utxos(&self) -> Result, ()>; - - /// Returns the previous transaction containing the UTXO referenced by the outpoint. - fn get_prevtx(&self, outpoint: OutPoint) -> Result; - - /// Returns a script to use for change above dust resulting from a successful coin selection - /// attempt. - fn get_change_script(&self) -> Result; - - /// Signs and provides the full [`TxIn::script_sig`] and [`TxIn::witness`] for all inputs within - /// the transaction known to the wallet (i.e., any provided via - /// [`WalletSource::list_confirmed_utxos`]). - /// - /// If your wallet does not support signing PSBTs you can call `psbt.extract_tx()` to get the - /// unsigned transaction and then sign it with your wallet. - /// - /// [`TxIn::script_sig`]: bitcoin::TxIn::script_sig - /// [`TxIn::witness`]: bitcoin::TxIn::witness - fn sign_psbt(&self, psbt: Psbt) -> Result; -} - -pub(crate) struct WalletSourceSyncWrapper(T) -where - T::Target: WalletSourceSync; - -// Implement `Deref` directly on WalletSourceSyncWrapper so that it can be used directly -// below, rather than via a wrapper. -impl Deref for WalletSourceSyncWrapper -where - T::Target: WalletSourceSync, -{ - type Target = Self; - fn deref(&self) -> &Self { - self - } -} - -impl WalletSource for WalletSourceSyncWrapper -where - T::Target: WalletSourceSync, -{ - fn list_confirmed_utxos<'a>( - &'a self, - ) -> impl Future, ()>> + MaybeSend + 'a { - let utxos = self.0.list_confirmed_utxos(); - async move { utxos } - } - - fn get_prevtx<'a>( - &'a self, outpoint: OutPoint, - ) -> impl Future> + MaybeSend + 'a { - let prevtx = self.0.get_prevtx(outpoint); - Box::pin(async move { prevtx }) - } - - fn get_change_script<'a>( - &'a self, - ) -> impl Future> + MaybeSend + 'a { - let script = self.0.get_change_script(); - async move { script } - } - - fn sign_psbt<'a>( - &'a self, psbt: Psbt, - ) -> impl Future> + MaybeSend + 'a { - let signed_psbt = self.0.sign_psbt(psbt); - async move { signed_psbt } - } -} - -/// A wrapper over [`WalletSourceSync`] that implements [`CoinSelectionSourceSync`] by preferring -/// UTXOs that would avoid conflicting double spends. If not enough UTXOs are available to do so, -/// conflicting double spends may happen. -/// -/// For an asynchronous version of this wrapper, see [`Wallet`]. -// Note that updates to documentation on this struct should be copied to the asynchronous version. -pub struct WalletSync -where - W::Target: WalletSourceSync + MaybeSend, -{ - wallet: Wallet, L>, -} - -impl WalletSync -where - W::Target: WalletSourceSync + MaybeSend, -{ - /// Constructs a new [`WalletSync`] instance. - pub fn new(source: W, logger: L) -> Self { - Self { wallet: Wallet::new(WalletSourceSyncWrapper(source), logger) } - } -} - -impl CoinSelectionSourceSync - for WalletSync -where - W::Target: WalletSourceSync + MaybeSend + MaybeSync, -{ - fn select_confirmed_utxos( - &self, claim_id: Option, must_spend: Vec, must_pay_to: &[TxOut], - target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, - ) -> Result { - let fut = self.wallet.select_confirmed_utxos( - claim_id, - must_spend, - must_pay_to, - target_feerate_sat_per_1000_weight, - max_tx_weight, - ); - let mut waker = dummy_waker(); - let mut ctx = task::Context::from_waker(&mut waker); - match pin!(fut).poll(&mut ctx) { - task::Poll::Ready(result) => result, - task::Poll::Pending => { - unreachable!( - "Wallet::select_confirmed_utxos should not be pending in a sync context" - ); - }, - } - } - - fn sign_psbt(&self, psbt: Psbt) -> Result { - let fut = self.wallet.sign_psbt(psbt); - let mut waker = dummy_waker(); - let mut ctx = task::Context::from_waker(&mut waker); - match pin!(fut).poll(&mut ctx) { - task::Poll::Ready(result) => result, - task::Poll::Pending => { - unreachable!("Wallet::sign_psbt should not be pending in a sync context"); - }, - } - } -} - -/// An abstraction over a bitcoin wallet that can perform coin selection over a set of UTXOs and can -/// sign for them. The coin selection method aims to mimic Bitcoin Core's `fundrawtransaction` RPC, -/// which most wallets should be able to satisfy. Otherwise, consider implementing -/// [`WalletSourceSync`], which can provide a default implementation of this trait when used with -/// [`WalletSync`]. -/// -/// For an asynchronous version of this trait, see [`CoinSelectionSource`]. -// Note that updates to documentation on this trait should be copied to the asynchronous version. -pub trait CoinSelectionSourceSync { - /// Performs coin selection of a set of UTXOs, with at least 1 confirmation each, that are - /// available to spend. Implementations are free to pick their coin selection algorithm of - /// choice, as long as the following requirements are met: - /// - /// 1. `must_spend` contains a set of [`Input`]s that must be included in the transaction - /// throughout coin selection, but must not be returned as part of the result. - /// 2. `must_pay_to` contains a set of [`TxOut`]s that must be included in the transaction - /// throughout coin selection. In some cases, like when funding an anchor transaction, this - /// set is empty. Implementations should ensure they handle this correctly on their end, - /// e.g., Bitcoin Core's `fundrawtransaction` RPC requires at least one output to be - /// provided, in which case a zero-value empty OP_RETURN output can be used instead. - /// 3. Enough inputs must be selected/contributed for the resulting transaction (including the - /// inputs and outputs noted above) to meet `target_feerate_sat_per_1000_weight`. - /// 4. The final transaction must have a weight smaller than `max_tx_weight`; if this - /// constraint can't be met, return an `Err`. In the case of counterparty-signed HTLC - /// transactions, we will remove a chunk of HTLCs and try your algorithm again. As for - /// anchor transactions, we will try your coin selection again with the same input-output - /// set when you call [`ChannelMonitor::rebroadcast_pending_claims`], as anchor transactions - /// cannot be downsized. - /// - /// Implementations must take note that [`Input::satisfaction_weight`] only tracks the weight of - /// the input's `script_sig` and `witness`. Some wallets, like Bitcoin Core's, may require - /// providing the full input weight. Failing to do so may lead to underestimating fee bumps and - /// delaying block inclusion. - /// - /// The `claim_id` must map to the set of external UTXOs assigned to the claim, such that they - /// can be re-used within new fee-bumped iterations of the original claiming transaction, - /// ensuring that claims don't double spend each other. If a specific `claim_id` has never had a - /// transaction associated with it, and all of the available UTXOs have already been assigned to - /// other claims, implementations must be willing to double spend their UTXOs. The choice of - /// which UTXOs to double spend is left to the implementation, but it must strive to keep the - /// set of other claims being double spent to a minimum. - /// - /// [`ChannelMonitor::rebroadcast_pending_claims`]: crate::chain::channelmonitor::ChannelMonitor::rebroadcast_pending_claims - fn select_confirmed_utxos( - &self, claim_id: Option, must_spend: Vec, must_pay_to: &[TxOut], - target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, - ) -> Result; - - /// Signs and provides the full witness for all inputs within the transaction known to the - /// trait (i.e., any provided via [`CoinSelectionSourceSync::select_confirmed_utxos`]). - /// - /// If your wallet does not support signing PSBTs you can call `psbt.extract_tx()` to get the - /// unsigned transaction and then sign it with your wallet. - fn sign_psbt(&self, psbt: Psbt) -> Result; -} - -struct CoinSelectionSourceSyncWrapper(T) -where - T::Target: CoinSelectionSourceSync; - -// Implement `Deref` directly on CoinSelectionSourceSyncWrapper so that it can be used directly -// below, rather than via a wrapper. -impl Deref for CoinSelectionSourceSyncWrapper -where - T::Target: CoinSelectionSourceSync, -{ - type Target = Self; - fn deref(&self) -> &Self { - self - } -} - -impl CoinSelectionSource for CoinSelectionSourceSyncWrapper -where - T::Target: CoinSelectionSourceSync, -{ - fn select_confirmed_utxos<'a>( - &'a self, claim_id: Option, must_spend: Vec, must_pay_to: &'a [TxOut], - target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, - ) -> impl Future> + MaybeSend + 'a { - let coins = self.0.select_confirmed_utxos( - claim_id, - must_spend, - must_pay_to, - target_feerate_sat_per_1000_weight, - max_tx_weight, - ); - async move { coins } - } - - fn sign_psbt<'a>( - &'a self, psbt: Psbt, - ) -> impl Future> + MaybeSend + 'a { - let psbt = self.0.sign_psbt(psbt); - async move { psbt } - } -} - /// A handler for [`Event::BumpTransaction`] events that sources confirmed UTXOs from a /// [`CoinSelectionSourceSync`] to fee bump transactions via Child-Pays-For-Parent (CPFP) or /// Replace-By-Fee (RBF). diff --git a/lightning/src/ln/async_signer_tests.rs b/lightning/src/ln/async_signer_tests.rs index f34a2b3275c..558812af55b 100644 --- a/lightning/src/ln/async_signer_tests.rs +++ b/lightning/src/ln/async_signer_tests.rs @@ -10,7 +10,6 @@ //! Tests for asynchronous signing. These tests verify that the channel state machine behaves //! properly with a signer implementation that asynchronously derives signatures. -use crate::events::bump_transaction::sync::WalletSourceSync; use crate::ln::splicing_tests::{initiate_splice_out, negotiate_splice_tx}; use crate::prelude::*; use crate::util::ser::Writeable; @@ -31,6 +30,7 @@ use crate::sign::ecdsa::EcdsaChannelSigner; use crate::sign::SignerProvider; use crate::util::logger::Logger; use crate::util::test_channel_signer::SignerOp; +use crate::util::wallet_utils::WalletSourceSync; #[test] fn test_open_channel() { diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 66a0147e131..5d5075df403 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -14,9 +14,7 @@ use crate::blinded_path::payment::DummyTlvs; use crate::chain::channelmonitor::ChannelMonitor; use crate::chain::transaction::OutPoint; use crate::chain::{BestBlock, ChannelMonitorUpdateStatus, Confirm, Listen, Watch}; -use crate::events::bump_transaction::sync::{ - BumpTransactionEventHandlerSync, WalletSourceSync, WalletSync, -}; +use crate::events::bump_transaction::sync::BumpTransactionEventHandlerSync; use crate::events::bump_transaction::BumpTransactionEvent; use crate::events::{ ClaimedHTLC, ClosureReason, Event, HTLCHandlingFailureType, PaidBolt12Invoice, PathFailure, @@ -54,6 +52,7 @@ use crate::util::test_channel_signer::SignerOp; use crate::util::test_channel_signer::TestChannelSigner; use crate::util::test_utils::{self, TestLogger}; use crate::util::test_utils::{TestChainMonitor, TestKeysInterface, TestScorer}; +use crate::util::wallet_utils::{WalletSourceSync, WalletSync}; use bitcoin::amount::Amount; use bitcoin::block::{Block, Header, Version as BlockVersion}; diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index c33ca8c34ee..65b0715768e 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -18,7 +18,6 @@ use bitcoin::{ use core::ops::Deref; -use crate::events::bump_transaction::sync::CoinSelectionSourceSync; use crate::ln::chan_utils::{ make_funding_redeemscript, BASE_INPUT_WEIGHT, EMPTY_SCRIPT_SIG_WEIGHT, FUNDING_TRANSACTION_WITNESS_WEIGHT, @@ -30,7 +29,9 @@ use crate::ln::LN_MAX_MSG_LEN; use crate::prelude::*; use crate::sign::{P2TR_KEY_PATH_WITNESS_WEIGHT, P2WPKH_WITNESS_WEIGHT}; use crate::util::async_poll::MaybeSend; -use crate::util::wallet_utils::{CoinSelection, CoinSelectionSource, Input, Utxo}; +use crate::util::wallet_utils::{ + CoinSelection, CoinSelectionSource, CoinSelectionSourceSync, Input, Utxo, +}; /// A template for contributing to a channel's splice funding transaction. /// diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index cc422d650a7..90190eeeefd 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -13,7 +13,6 @@ use crate::chain::chaininterface::{TransactionType, FEERATE_FLOOR_SATS_PER_KW}; use crate::chain::channelmonitor::{ANTI_REORG_DELAY, LATENCY_GRACE_PERIOD_BLOCKS}; use crate::chain::transaction::OutPoint; use crate::chain::ChannelMonitorUpdateStatus; -use crate::events::bump_transaction::sync::{WalletSourceSync, WalletSync}; use crate::events::{ClosureReason, Event, FundingInfo, HTLCHandlingFailureType}; use crate::ln::chan_utils; use crate::ln::channel::CHANNEL_ANNOUNCEMENT_PROPAGATION_DELAY; @@ -26,6 +25,7 @@ use crate::ln::types::ChannelId; use crate::routing::router::{PaymentParameters, RouteParameters}; use crate::util::errors::APIError; use crate::util::ser::Writeable; +use crate::util::wallet_utils::{WalletSourceSync, WalletSync}; use crate::sync::Arc; diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 48f506e62ce..22be4367c7a 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -21,7 +21,6 @@ use crate::chain::channelmonitor::{ }; use crate::chain::transaction::OutPoint; use crate::chain::WatchedOutput; -use crate::events::bump_transaction::sync::WalletSourceSync; #[cfg(any(test, feature = "_externalize_tests"))] use crate::ln::chan_utils::CommitmentTransaction; use crate::ln::channel_state::ChannelDetails; @@ -61,7 +60,7 @@ use crate::util::persist::{KVStore, KVStoreSync, MonitorName}; use crate::util::ser::{Readable, ReadableArgs, Writeable, Writer}; use crate::util::test_channel_signer::{EnforcementState, TestChannelSigner}; use crate::util::wakers::Notifier; -use crate::util::wallet_utils::{ConfirmedUtxo, Utxo}; +use crate::util::wallet_utils::{ConfirmedUtxo, Utxo, WalletSourceSync}; use bitcoin::amount::Amount; use bitcoin::block::Block; diff --git a/lightning/src/util/wallet_utils.rs b/lightning/src/util/wallet_utils.rs index cf754d14e3d..f247a8a8ece 100644 --- a/lightning/src/util/wallet_utils.rs +++ b/lightning/src/util/wallet_utils.rs @@ -11,6 +11,8 @@ use core::future::Future; use core::ops::Deref; +use core::pin::pin; +use core::task; use crate::chain::chaininterface::fee_for_weight; use crate::chain::ClaimId; @@ -23,7 +25,7 @@ use crate::ln::funding::FundingTxInput; use crate::prelude::*; use crate::sign::{P2TR_KEY_PATH_WITNESS_WEIGHT, P2WPKH_WITNESS_WEIGHT}; use crate::sync::Mutex; -use crate::util::async_poll::{MaybeSend, MaybeSync}; +use crate::util::async_poll::{dummy_waker, MaybeSend, MaybeSync}; use crate::util::hash_tables::{new_hash_map, HashMap}; use crate::util::logger::Logger; @@ -172,11 +174,9 @@ impl CoinSelection { /// which most wallets should be able to satisfy. Otherwise, consider implementing [`WalletSource`], /// which can provide a default implementation of this trait when used with [`Wallet`]. /// -/// For a synchronous version of this trait, see [`sync::CoinSelectionSourceSync`]. +/// For a synchronous version of this trait, see [`CoinSelectionSourceSync`]. /// /// This is not exported to bindings users as async is only supported in Rust. -/// -/// [`sync::CoinSelectionSourceSync`]: crate::events::bump_transaction::sync::CoinSelectionSourceSync // Note that updates to documentation on this trait should be copied to the synchronous version. pub trait CoinSelectionSource { /// Performs coin selection of a set of UTXOs, with at least 1 confirmation each, that are @@ -233,11 +233,9 @@ pub trait CoinSelectionSource { /// An alternative to [`CoinSelectionSource`] that can be implemented and used along [`Wallet`] to /// provide a default implementation to [`CoinSelectionSource`]. /// -/// For a synchronous version of this trait, see [`sync::WalletSourceSync`]. +/// For a synchronous version of this trait, see [`WalletSourceSync`]. /// /// This is not exported to bindings users as async is only supported in Rust. -/// -/// [`sync::WalletSourceSync`]: crate::events::bump_transaction::sync::WalletSourceSync // Note that updates to documentation on this trait should be copied to the synchronous version. pub trait WalletSource { /// Returns all UTXOs, with at least 1 confirmation each, that are available to spend. @@ -274,11 +272,9 @@ pub trait WalletSource { /// that would avoid conflicting double spends. If not enough UTXOs are available to do so, /// conflicting double spends may happen. /// -/// For a synchronous version of this wrapper, see [`sync::WalletSync`]. +/// For a synchronous version of this wrapper, see [`WalletSync`]. /// /// This is not exported to bindings users as async is only supported in Rust. -/// -/// [`sync::WalletSync`]: crate::events::bump_transaction::sync::WalletSync // Note that updates to documentation on this struct should be copied to the synchronous version. pub struct Wallet where @@ -544,3 +540,242 @@ where self.source.sign_psbt(psbt) } } + +/// An alternative to [`CoinSelectionSourceSync`] that can be implemented and used along +/// [`WalletSync`] to provide a default implementation to [`CoinSelectionSourceSync`]. +/// +/// For an asynchronous version of this trait, see [`WalletSource`]. +// Note that updates to documentation on this trait should be copied to the asynchronous version. +pub trait WalletSourceSync { + /// Returns all UTXOs, with at least 1 confirmation each, that are available to spend. + fn list_confirmed_utxos(&self) -> Result, ()>; + + /// Returns the previous transaction containing the UTXO referenced by the outpoint. + fn get_prevtx(&self, outpoint: OutPoint) -> Result; + + /// Returns a script to use for change above dust resulting from a successful coin selection + /// attempt. + fn get_change_script(&self) -> Result; + + /// Signs and provides the full [`TxIn::script_sig`] and [`TxIn::witness`] for all inputs within + /// the transaction known to the wallet (i.e., any provided via + /// [`WalletSource::list_confirmed_utxos`]). + /// + /// If your wallet does not support signing PSBTs you can call `psbt.extract_tx()` to get the + /// unsigned transaction and then sign it with your wallet. + /// + /// [`TxIn::script_sig`]: bitcoin::TxIn::script_sig + /// [`TxIn::witness`]: bitcoin::TxIn::witness + fn sign_psbt(&self, psbt: Psbt) -> Result; +} + +struct WalletSourceSyncWrapper(T) +where + T::Target: WalletSourceSync; + +// Implement `Deref` directly on WalletSourceSyncWrapper so that it can be used directly +// below, rather than via a wrapper. +impl Deref for WalletSourceSyncWrapper +where + T::Target: WalletSourceSync, +{ + type Target = Self; + fn deref(&self) -> &Self { + self + } +} + +impl WalletSource for WalletSourceSyncWrapper +where + T::Target: WalletSourceSync, +{ + fn list_confirmed_utxos<'a>( + &'a self, + ) -> impl Future, ()>> + MaybeSend + 'a { + let utxos = self.0.list_confirmed_utxos(); + async move { utxos } + } + + fn get_prevtx<'a>( + &'a self, outpoint: OutPoint, + ) -> impl Future> + MaybeSend + 'a { + let prevtx = self.0.get_prevtx(outpoint); + Box::pin(async move { prevtx }) + } + + fn get_change_script<'a>( + &'a self, + ) -> impl Future> + MaybeSend + 'a { + let script = self.0.get_change_script(); + async move { script } + } + + fn sign_psbt<'a>( + &'a self, psbt: Psbt, + ) -> impl Future> + MaybeSend + 'a { + let signed_psbt = self.0.sign_psbt(psbt); + async move { signed_psbt } + } +} + +/// A wrapper over [`WalletSourceSync`] that implements [`CoinSelectionSourceSync`] by preferring +/// UTXOs that would avoid conflicting double spends. If not enough UTXOs are available to do so, +/// conflicting double spends may happen. +/// +/// For an asynchronous version of this wrapper, see [`Wallet`]. +// Note that updates to documentation on this struct should be copied to the asynchronous version. +pub struct WalletSync +where + W::Target: WalletSourceSync + MaybeSend, +{ + wallet: Wallet, L>, +} + +impl WalletSync +where + W::Target: WalletSourceSync + MaybeSend, +{ + /// Constructs a new [`WalletSync`] instance. + pub fn new(source: W, logger: L) -> Self { + Self { wallet: Wallet::new(WalletSourceSyncWrapper(source), logger) } + } +} + +impl CoinSelectionSourceSync + for WalletSync +where + W::Target: WalletSourceSync + MaybeSend + MaybeSync, +{ + fn select_confirmed_utxos( + &self, claim_id: Option, must_spend: Vec, must_pay_to: &[TxOut], + target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, + ) -> Result { + let fut = self.wallet.select_confirmed_utxos( + claim_id, + must_spend, + must_pay_to, + target_feerate_sat_per_1000_weight, + max_tx_weight, + ); + let mut waker = dummy_waker(); + let mut ctx = task::Context::from_waker(&mut waker); + match pin!(fut).poll(&mut ctx) { + task::Poll::Ready(result) => result, + task::Poll::Pending => { + unreachable!( + "Wallet::select_confirmed_utxos should not be pending in a sync context" + ); + }, + } + } + + fn sign_psbt(&self, psbt: Psbt) -> Result { + let fut = self.wallet.sign_psbt(psbt); + let mut waker = dummy_waker(); + let mut ctx = task::Context::from_waker(&mut waker); + match pin!(fut).poll(&mut ctx) { + task::Poll::Ready(result) => result, + task::Poll::Pending => { + unreachable!("Wallet::sign_psbt should not be pending in a sync context"); + }, + } + } +} + +/// An abstraction over a bitcoin wallet that can perform coin selection over a set of UTXOs and can +/// sign for them. The coin selection method aims to mimic Bitcoin Core's `fundrawtransaction` RPC, +/// which most wallets should be able to satisfy. Otherwise, consider implementing +/// [`WalletSourceSync`], which can provide a default implementation of this trait when used with +/// [`WalletSync`]. +/// +/// For an asynchronous version of this trait, see [`CoinSelectionSource`]. +// Note that updates to documentation on this trait should be copied to the asynchronous version. +pub trait CoinSelectionSourceSync { + /// Performs coin selection of a set of UTXOs, with at least 1 confirmation each, that are + /// available to spend. Implementations are free to pick their coin selection algorithm of + /// choice, as long as the following requirements are met: + /// + /// 1. `must_spend` contains a set of [`Input`]s that must be included in the transaction + /// throughout coin selection, but must not be returned as part of the result. + /// 2. `must_pay_to` contains a set of [`TxOut`]s that must be included in the transaction + /// throughout coin selection. In some cases, like when funding an anchor transaction, this + /// set is empty. Implementations should ensure they handle this correctly on their end, + /// e.g., Bitcoin Core's `fundrawtransaction` RPC requires at least one output to be + /// provided, in which case a zero-value empty OP_RETURN output can be used instead. + /// 3. Enough inputs must be selected/contributed for the resulting transaction (including the + /// inputs and outputs noted above) to meet `target_feerate_sat_per_1000_weight`. + /// 4. The final transaction must have a weight smaller than `max_tx_weight`; if this + /// constraint can't be met, return an `Err`. In the case of counterparty-signed HTLC + /// transactions, we will remove a chunk of HTLCs and try your algorithm again. As for + /// anchor transactions, we will try your coin selection again with the same input-output + /// set when you call [`ChannelMonitor::rebroadcast_pending_claims`], as anchor transactions + /// cannot be downsized. + /// + /// Implementations must take note that [`Input::satisfaction_weight`] only tracks the weight of + /// the input's `script_sig` and `witness`. Some wallets, like Bitcoin Core's, may require + /// providing the full input weight. Failing to do so may lead to underestimating fee bumps and + /// delaying block inclusion. + /// + /// The `claim_id` must map to the set of external UTXOs assigned to the claim, such that they + /// can be re-used within new fee-bumped iterations of the original claiming transaction, + /// ensuring that claims don't double spend each other. If a specific `claim_id` has never had a + /// transaction associated with it, and all of the available UTXOs have already been assigned to + /// other claims, implementations must be willing to double spend their UTXOs. The choice of + /// which UTXOs to double spend is left to the implementation, but it must strive to keep the + /// set of other claims being double spent to a minimum. + /// + /// [`ChannelMonitor::rebroadcast_pending_claims`]: crate::chain::channelmonitor::ChannelMonitor::rebroadcast_pending_claims + fn select_confirmed_utxos( + &self, claim_id: Option, must_spend: Vec, must_pay_to: &[TxOut], + target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, + ) -> Result; + + /// Signs and provides the full witness for all inputs within the transaction known to the + /// trait (i.e., any provided via [`CoinSelectionSourceSync::select_confirmed_utxos`]). + /// + /// If your wallet does not support signing PSBTs you can call `psbt.extract_tx()` to get the + /// unsigned transaction and then sign it with your wallet. + fn sign_psbt(&self, psbt: Psbt) -> Result; +} + +pub(crate) struct CoinSelectionSourceSyncWrapper(pub(crate) T) +where + T::Target: CoinSelectionSourceSync; + +// Implement `Deref` directly on CoinSelectionSourceSyncWrapper so that it can be used directly +// below, rather than via a wrapper. +impl Deref for CoinSelectionSourceSyncWrapper +where + T::Target: CoinSelectionSourceSync, +{ + type Target = Self; + fn deref(&self) -> &Self { + self + } +} + +impl CoinSelectionSource for CoinSelectionSourceSyncWrapper +where + T::Target: CoinSelectionSourceSync, +{ + fn select_confirmed_utxos<'a>( + &'a self, claim_id: Option, must_spend: Vec, must_pay_to: &'a [TxOut], + target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, + ) -> impl Future> + MaybeSend + 'a { + let coins = self.0.select_confirmed_utxos( + claim_id, + must_spend, + must_pay_to, + target_feerate_sat_per_1000_weight, + max_tx_weight, + ); + async move { coins } + } + + fn sign_psbt<'a>( + &'a self, psbt: Psbt, + ) -> impl Future> + MaybeSend + 'a { + let psbt = self.0.sign_psbt(psbt); + async move { psbt } + } +} From 9a0a24976b53bbe1d64d2acc82674d9b1b1729c1 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 28 Jan 2026 17:37:40 -0600 Subject: [PATCH 040/627] Make ConfirmedUtxo the primary type FundingTxInput was originally designed for channel funding but is now used more broadly for coin selection and splicing. The name ConfirmedUtxo better reflects its general-purpose nature as a confirmed UTXO with previous transaction data. Make ConfirmedUtxo the real struct in wallet_utils and alias FundingTxInput to it for backward compatibility. Co-Authored-By: Claude Sonnet 4.5 --- lightning/src/ln/funding.rs | 175 +--------------------------- lightning/src/util/wallet_utils.rs | 176 ++++++++++++++++++++++++++++- 2 files changed, 176 insertions(+), 175 deletions(-) diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 65b0715768e..4e7cc1248cf 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -11,10 +11,7 @@ use bitcoin::hashes::Hash; use bitcoin::secp256k1::PublicKey; -use bitcoin::{ - Amount, FeeRate, OutPoint, Script, ScriptBuf, Sequence, SignedAmount, Transaction, TxOut, - WScriptHash, Weight, -}; +use bitcoin::{Amount, FeeRate, OutPoint, ScriptBuf, SignedAmount, TxOut, WScriptHash, Weight}; use core::ops::Deref; @@ -27,10 +24,9 @@ use crate::ln::msgs; use crate::ln::types::ChannelId; use crate::ln::LN_MAX_MSG_LEN; use crate::prelude::*; -use crate::sign::{P2TR_KEY_PATH_WITNESS_WEIGHT, P2WPKH_WITNESS_WEIGHT}; use crate::util::async_poll::MaybeSend; use crate::util::wallet_utils::{ - CoinSelection, CoinSelectionSource, CoinSelectionSourceSync, Input, Utxo, + CoinSelection, CoinSelectionSource, CoinSelectionSourceSync, Input, }; /// A template for contributing to a channel's splice funding transaction. @@ -443,172 +439,7 @@ impl FundingContribution { /// An input to contribute to a channel's funding transaction either when using the v2 channel /// establishment protocol or when splicing. -#[derive(Debug, Clone)] -pub struct FundingTxInput { - /// The unspent [`TxOut`] found in [`prevtx`]. - /// - /// [`TxOut`]: bitcoin::TxOut - /// [`prevtx`]: Self::prevtx - pub(crate) utxo: Utxo, - - /// The transaction containing the unspent [`TxOut`] referenced by [`utxo`]. - /// - /// [`TxOut`]: bitcoin::TxOut - /// [`utxo`]: Self::utxo - pub(crate) prevtx: Transaction, -} - -impl_writeable_tlv_based!(FundingTxInput, { - (1, utxo, required), - (3, _sequence, (legacy, Sequence, - |read_val: Option<&Sequence>| { - if let Some(sequence) = read_val { - // Utxo contains sequence now, so update it if the value read here differs since - // this indicates Utxo::sequence was read with default_value - let utxo: &mut Utxo = utxo.0.as_mut().expect("utxo is required"); - if utxo.sequence != *sequence { - utxo.sequence = *sequence; - } - } - Ok(()) - }, - |input: &FundingTxInput| Some(input.utxo.sequence))), - (5, prevtx, required), -}); - -impl FundingTxInput { - fn new bool>( - prevtx: Transaction, vout: u32, witness_weight: Weight, script_filter: F, - ) -> Result { - Ok(FundingTxInput { - utxo: Utxo { - outpoint: bitcoin::OutPoint { txid: prevtx.compute_txid(), vout }, - output: prevtx - .output - .get(vout as usize) - .filter(|output| script_filter(&output.script_pubkey)) - .ok_or(())? - .clone(), - satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + witness_weight.to_wu(), - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - }, - prevtx, - }) - } - - /// Creates an input spending a P2WPKH output from the given `prevtx` at index `vout`. - /// - /// Uses [`Sequence::ENABLE_RBF_NO_LOCKTIME`] as the [`TxIn::sequence`], which can be overridden - /// by [`set_sequence`]. - /// - /// Returns `Err` if no such output exists in `prevtx` at index `vout`. - /// - /// [`TxIn::sequence`]: bitcoin::TxIn::sequence - /// [`set_sequence`]: Self::set_sequence - pub fn new_p2wpkh(prevtx: Transaction, vout: u32) -> Result { - let witness_weight = Weight::from_wu(P2WPKH_WITNESS_WEIGHT) - - if cfg!(feature = "grind_signatures") { - // Guarantees a low R signature - Weight::from_wu(1) - } else { - Weight::ZERO - }; - FundingTxInput::new(prevtx, vout, witness_weight, Script::is_p2wpkh) - } - - /// Creates an input spending a P2WSH output from the given `prevtx` at index `vout`. - /// - /// Requires passing the weight of witness needed to satisfy the output's script. - /// - /// Uses [`Sequence::ENABLE_RBF_NO_LOCKTIME`] as the [`TxIn::sequence`], which can be overridden - /// by [`set_sequence`]. - /// - /// Returns `Err` if no such output exists in `prevtx` at index `vout`. - /// - /// [`TxIn::sequence`]: bitcoin::TxIn::sequence - /// [`set_sequence`]: Self::set_sequence - pub fn new_p2wsh(prevtx: Transaction, vout: u32, witness_weight: Weight) -> Result { - FundingTxInput::new(prevtx, vout, witness_weight, Script::is_p2wsh) - } - - /// Creates an input spending a P2TR output from the given `prevtx` at index `vout`. - /// - /// This is meant for inputs spending a taproot output using the key path. See - /// [`new_p2tr_script_spend`] for when spending using a script path. - /// - /// Uses [`Sequence::ENABLE_RBF_NO_LOCKTIME`] as the [`TxIn::sequence`], which can be overridden - /// by [`set_sequence`]. - /// - /// Returns `Err` if no such output exists in `prevtx` at index `vout`. - /// - /// [`new_p2tr_script_spend`]: Self::new_p2tr_script_spend - /// - /// [`TxIn::sequence`]: bitcoin::TxIn::sequence - /// [`set_sequence`]: Self::set_sequence - pub fn new_p2tr_key_spend(prevtx: Transaction, vout: u32) -> Result { - let witness_weight = Weight::from_wu(P2TR_KEY_PATH_WITNESS_WEIGHT); - FundingTxInput::new(prevtx, vout, witness_weight, Script::is_p2tr) - } - - /// Creates an input spending a P2TR output from the given `prevtx` at index `vout`. - /// - /// Requires passing the weight of witness needed to satisfy a script path of the taproot - /// output. See [`new_p2tr_key_spend`] for when spending using the key path. - /// - /// Uses [`Sequence::ENABLE_RBF_NO_LOCKTIME`] as the [`TxIn::sequence`], which can be overridden - /// by [`set_sequence`]. - /// - /// Returns `Err` if no such output exists in `prevtx` at index `vout`. - /// - /// [`new_p2tr_key_spend`]: Self::new_p2tr_key_spend - /// - /// [`TxIn::sequence`]: bitcoin::TxIn::sequence - /// [`set_sequence`]: Self::set_sequence - pub fn new_p2tr_script_spend( - prevtx: Transaction, vout: u32, witness_weight: Weight, - ) -> Result { - FundingTxInput::new(prevtx, vout, witness_weight, Script::is_p2tr) - } - - #[cfg(test)] - pub(crate) fn new_p2pkh(prevtx: Transaction, vout: u32) -> Result { - FundingTxInput::new(prevtx, vout, Weight::ZERO, Script::is_p2pkh) - } - - /// The outpoint of the UTXO being spent. - pub fn outpoint(&self) -> bitcoin::OutPoint { - self.utxo.outpoint - } - - /// The unspent output. - pub fn output(&self) -> &TxOut { - &self.utxo.output - } - - /// The sequence number to use in the [`TxIn`]. - /// - /// [`TxIn`]: bitcoin::TxIn - pub fn sequence(&self) -> Sequence { - self.utxo.sequence - } - - /// Sets the sequence number to use in the [`TxIn`]. - /// - /// [`TxIn`]: bitcoin::TxIn - pub fn set_sequence(&mut self, sequence: Sequence) { - self.utxo.sequence = sequence; - } - - /// Converts the [`FundingTxInput`] into a [`Utxo`]. - pub fn into_utxo(self) -> Utxo { - self.utxo - } - - /// Converts the [`FundingTxInput`] into a [`TxOut`]. - pub fn into_output(self) -> TxOut { - self.utxo.output - } -} +pub type FundingTxInput = crate::util::wallet_utils::ConfirmedUtxo; #[cfg(test)] mod tests { diff --git a/lightning/src/util/wallet_utils.rs b/lightning/src/util/wallet_utils.rs index f247a8a8ece..54c6f5428a3 100644 --- a/lightning/src/util/wallet_utils.rs +++ b/lightning/src/util/wallet_utils.rs @@ -21,7 +21,6 @@ use crate::ln::chan_utils::{ BASE_INPUT_WEIGHT, BASE_TX_SIZE, EMPTY_SCRIPT_SIG_WEIGHT, P2WSH_TXOUT_WEIGHT, SEGWIT_MARKER_FLAG_WEIGHT, }; -use crate::ln::funding::FundingTxInput; use crate::prelude::*; use crate::sign::{P2TR_KEY_PATH_WITNESS_WEIGHT, P2WPKH_WITNESS_WEIGHT}; use crate::sync::Mutex; @@ -33,7 +32,10 @@ use bitcoin::amount::Amount; use bitcoin::consensus::Encodable; use bitcoin::constants::WITNESS_SCALE_FACTOR; use bitcoin::key::TweakedPublicKey; -use bitcoin::{OutPoint, Psbt, PubkeyHash, ScriptBuf, Sequence, Transaction, TxOut, WPubkeyHash}; +use bitcoin::{ + OutPoint, Psbt, PubkeyHash, Script, ScriptBuf, Sequence, Transaction, TxOut, WPubkeyHash, + Weight, +}; /// An input that must be included in a transaction when performing coin selection through /// [`CoinSelectionSource::select_confirmed_utxos`]. It is guaranteed to be a SegWit input, so it @@ -143,7 +145,175 @@ impl Utxo { } /// An unspent transaction output with at least one confirmation. -pub type ConfirmedUtxo = FundingTxInput; +/// +/// Can be used as an input to contribute to a channel's funding transaction either when using the +/// v2 channel establishment protocol or when splicing. +#[derive(Debug, Clone)] +pub struct ConfirmedUtxo { + /// The unspent [`TxOut`] found in [`prevtx`]. + /// + /// [`TxOut`]: bitcoin::TxOut + /// [`prevtx`]: Self::prevtx + pub(crate) utxo: Utxo, + + /// The transaction containing the unspent [`TxOut`] referenced by [`utxo`]. + /// + /// [`TxOut`]: bitcoin::TxOut + /// [`utxo`]: Self::utxo + pub(crate) prevtx: Transaction, +} + +impl_writeable_tlv_based!(ConfirmedUtxo, { + (1, utxo, required), + (3, _sequence, (legacy, Sequence, + |read_val: Option<&Sequence>| { + if let Some(sequence) = read_val { + // Utxo contains sequence now, so update it if the value read here differs since + // this indicates Utxo::sequence was read with default_value + let utxo: &mut Utxo = utxo.0.as_mut().expect("utxo is required"); + if utxo.sequence != *sequence { + utxo.sequence = *sequence; + } + } + Ok(()) + }, + |utxo: &ConfirmedUtxo| Some(utxo.utxo.sequence))), + (5, prevtx, required), +}); + +impl ConfirmedUtxo { + fn new bool>( + prevtx: Transaction, vout: u32, witness_weight: Weight, script_filter: F, + ) -> Result { + Ok(ConfirmedUtxo { + utxo: Utxo { + outpoint: bitcoin::OutPoint { txid: prevtx.compute_txid(), vout }, + output: prevtx + .output + .get(vout as usize) + .filter(|output| script_filter(&output.script_pubkey)) + .ok_or(())? + .clone(), + satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + witness_weight.to_wu(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + }, + prevtx, + }) + } + + /// Creates an input spending a P2WPKH output from the given `prevtx` at index `vout`. + /// + /// Uses [`Sequence::ENABLE_RBF_NO_LOCKTIME`] as the [`TxIn::sequence`], which can be overridden + /// by [`set_sequence`]. + /// + /// Returns `Err` if no such output exists in `prevtx` at index `vout`. + /// + /// [`TxIn::sequence`]: bitcoin::TxIn::sequence + /// [`set_sequence`]: Self::set_sequence + pub fn new_p2wpkh(prevtx: Transaction, vout: u32) -> Result { + let witness_weight = Weight::from_wu(P2WPKH_WITNESS_WEIGHT) + - if cfg!(feature = "grind_signatures") { + // Guarantees a low R signature + Weight::from_wu(1) + } else { + Weight::ZERO + }; + ConfirmedUtxo::new(prevtx, vout, witness_weight, Script::is_p2wpkh) + } + + /// Creates an input spending a P2WSH output from the given `prevtx` at index `vout`. + /// + /// Requires passing the weight of witness needed to satisfy the output's script. + /// + /// Uses [`Sequence::ENABLE_RBF_NO_LOCKTIME`] as the [`TxIn::sequence`], which can be overridden + /// by [`set_sequence`]. + /// + /// Returns `Err` if no such output exists in `prevtx` at index `vout`. + /// + /// [`TxIn::sequence`]: bitcoin::TxIn::sequence + /// [`set_sequence`]: Self::set_sequence + pub fn new_p2wsh(prevtx: Transaction, vout: u32, witness_weight: Weight) -> Result { + ConfirmedUtxo::new(prevtx, vout, witness_weight, Script::is_p2wsh) + } + + /// Creates an input spending a P2TR output from the given `prevtx` at index `vout`. + /// + /// This is meant for inputs spending a taproot output using the key path. See + /// [`new_p2tr_script_spend`] for when spending using a script path. + /// + /// Uses [`Sequence::ENABLE_RBF_NO_LOCKTIME`] as the [`TxIn::sequence`], which can be overridden + /// by [`set_sequence`]. + /// + /// Returns `Err` if no such output exists in `prevtx` at index `vout`. + /// + /// [`new_p2tr_script_spend`]: Self::new_p2tr_script_spend + /// + /// [`TxIn::sequence`]: bitcoin::TxIn::sequence + /// [`set_sequence`]: Self::set_sequence + pub fn new_p2tr_key_spend(prevtx: Transaction, vout: u32) -> Result { + let witness_weight = Weight::from_wu(P2TR_KEY_PATH_WITNESS_WEIGHT); + ConfirmedUtxo::new(prevtx, vout, witness_weight, Script::is_p2tr) + } + + /// Creates an input spending a P2TR output from the given `prevtx` at index `vout`. + /// + /// Requires passing the weight of witness needed to satisfy a script path of the taproot + /// output. See [`new_p2tr_key_spend`] for when spending using the key path. + /// + /// Uses [`Sequence::ENABLE_RBF_NO_LOCKTIME`] as the [`TxIn::sequence`], which can be overridden + /// by [`set_sequence`]. + /// + /// Returns `Err` if no such output exists in `prevtx` at index `vout`. + /// + /// [`new_p2tr_key_spend`]: Self::new_p2tr_key_spend + /// + /// [`TxIn::sequence`]: bitcoin::TxIn::sequence + /// [`set_sequence`]: Self::set_sequence + pub fn new_p2tr_script_spend( + prevtx: Transaction, vout: u32, witness_weight: Weight, + ) -> Result { + ConfirmedUtxo::new(prevtx, vout, witness_weight, Script::is_p2tr) + } + + #[cfg(test)] + pub(crate) fn new_p2pkh(prevtx: Transaction, vout: u32) -> Result { + ConfirmedUtxo::new(prevtx, vout, Weight::ZERO, Script::is_p2pkh) + } + + /// The outpoint of the UTXO being spent. + pub fn outpoint(&self) -> bitcoin::OutPoint { + self.utxo.outpoint + } + + /// The unspent output. + pub fn output(&self) -> &TxOut { + &self.utxo.output + } + + /// The sequence number to use in the [`TxIn`]. + /// + /// [`TxIn`]: bitcoin::TxIn + pub fn sequence(&self) -> Sequence { + self.utxo.sequence + } + + /// Sets the sequence number to use in the [`TxIn`]. + /// + /// [`TxIn`]: bitcoin::TxIn + pub fn set_sequence(&mut self, sequence: Sequence) { + self.utxo.sequence = sequence; + } + + /// Converts the [`ConfirmedUtxo`] into a [`Utxo`]. + pub fn into_utxo(self) -> Utxo { + self.utxo + } + + /// Converts the [`ConfirmedUtxo`] into a [`TxOut`]. + pub fn into_output(self) -> TxOut { + self.utxo.output + } +} /// The result of a successful coin selection attempt for a transaction requiring additional UTXOs /// to cover its fees. From 2a238d761d4c61682363d657e9b75b4e8f164f0e Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 17 Feb 2026 14:38:10 -0600 Subject: [PATCH 041/627] Remove use of Deref with CoinSelectionSource Instead of using Deref in APIs using CoinSelectionSource, implement CoinSelectionSource for any Deref with a Target that implements CoinSelectionSource. --- lightning/src/events/bump_transaction/mod.rs | 11 ++-- lightning/src/ln/funding.rs | 44 +++++---------- lightning/src/util/wallet_utils.rs | 57 ++++++++++++++------ 3 files changed, 57 insertions(+), 55 deletions(-) diff --git a/lightning/src/events/bump_transaction/mod.rs b/lightning/src/events/bump_transaction/mod.rs index f1ba1fcfdc8..6a5e9948653 100644 --- a/lightning/src/events/bump_transaction/mod.rs +++ b/lightning/src/events/bump_transaction/mod.rs @@ -14,7 +14,6 @@ pub mod sync; use alloc::collections::BTreeMap; -use core::ops::Deref; use crate::chain::chaininterface::{ compute_feerate_sat_per_1000_weight, fee_for_weight, BroadcasterInterface, TransactionType, @@ -257,12 +256,10 @@ pub enum BumpTransactionEvent { // Note that updates to documentation on this struct should be copied to the synchronous version. pub struct BumpTransactionEventHandler< B: BroadcasterInterface, - C: Deref, + C: CoinSelectionSource, SP: SignerProvider, L: Logger, -> where - C::Target: CoinSelectionSource, -{ +> { broadcaster: B, utxo_source: C, signer_provider: SP, @@ -270,10 +267,8 @@ pub struct BumpTransactionEventHandler< secp: Secp256k1, } -impl +impl BumpTransactionEventHandler -where - C::Target: CoinSelectionSource, { /// Returns a new instance capable of handling [`Event::BumpTransaction`] events. /// diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 4e7cc1248cf..d18aca9c871 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -13,8 +13,6 @@ use bitcoin::hashes::Hash; use bitcoin::secp256k1::PublicKey; use bitcoin::{Amount, FeeRate, OutPoint, ScriptBuf, SignedAmount, TxOut, WScriptHash, Weight}; -use core::ops::Deref; - use crate::ln::chan_utils::{ make_funding_redeemscript, BASE_INPUT_WEIGHT, EMPTY_SCRIPT_SIG_WEIGHT, FUNDING_TRANSACTION_WITNESS_WEIGHT, @@ -125,12 +123,9 @@ macro_rules! build_funding_contribution { impl FundingTemplate { /// Creates a [`FundingContribution`] for adding funds to a channel using `wallet` to perform /// coin selection. - pub async fn splice_in( + pub async fn splice_in( self, value_added: Amount, wallet: W, - ) -> Result - where - W::Target: CoinSelectionSource + MaybeSend, - { + ) -> Result { if value_added == Amount::ZERO { return Err(()); } @@ -140,12 +135,9 @@ impl FundingTemplate { /// Creates a [`FundingContribution`] for adding funds to a channel using `wallet` to perform /// coin selection. - pub fn splice_in_sync( + pub fn splice_in_sync( self, value_added: Amount, wallet: W, - ) -> Result - where - W::Target: CoinSelectionSourceSync, - { + ) -> Result { if value_added == Amount::ZERO { return Err(()); } @@ -162,12 +154,9 @@ impl FundingTemplate { /// Creates a [`FundingContribution`] for removing funds from a channel using `wallet` to /// perform coin selection. - pub async fn splice_out( + pub async fn splice_out( self, outputs: Vec, wallet: W, - ) -> Result - where - W::Target: CoinSelectionSource + MaybeSend, - { + ) -> Result { if outputs.is_empty() { return Err(()); } @@ -177,12 +166,9 @@ impl FundingTemplate { /// Creates a [`FundingContribution`] for removing funds from a channel using `wallet` to /// perform coin selection. - pub fn splice_out_sync( + pub fn splice_out_sync( self, outputs: Vec, wallet: W, - ) -> Result - where - W::Target: CoinSelectionSourceSync, - { + ) -> Result { if outputs.is_empty() { return Err(()); } @@ -199,12 +185,9 @@ impl FundingTemplate { /// Creates a [`FundingContribution`] for both adding and removing funds from a channel using /// `wallet` to perform coin selection. - pub async fn splice_in_and_out( + pub async fn splice_in_and_out( self, value_added: Amount, outputs: Vec, wallet: W, - ) -> Result - where - W::Target: CoinSelectionSource + MaybeSend, - { + ) -> Result { if value_added == Amount::ZERO && outputs.is_empty() { return Err(()); } @@ -214,12 +197,9 @@ impl FundingTemplate { /// Creates a [`FundingContribution`] for both adding and removing funds from a channel using /// `wallet` to perform coin selection. - pub fn splice_in_and_out_sync( + pub fn splice_in_and_out_sync( self, value_added: Amount, outputs: Vec, wallet: W, - ) -> Result - where - W::Target: CoinSelectionSourceSync, - { + ) -> Result { if value_added == Amount::ZERO && outputs.is_empty() { return Err(()); } diff --git a/lightning/src/util/wallet_utils.rs b/lightning/src/util/wallet_utils.rs index 54c6f5428a3..b82437c03e8 100644 --- a/lightning/src/util/wallet_utils.rs +++ b/lightning/src/util/wallet_utils.rs @@ -400,6 +400,29 @@ pub trait CoinSelectionSource { ) -> impl Future> + MaybeSend + 'a; } +impl CoinSelectionSource for C +where + C::Target: CoinSelectionSource, +{ + fn select_confirmed_utxos<'a>( + &'a self, claim_id: Option, must_spend: Vec, must_pay_to: &'a [TxOut], + target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, + ) -> impl Future> + MaybeSend + 'a { + self.deref().select_confirmed_utxos( + claim_id, + must_spend, + must_pay_to, + target_feerate_sat_per_1000_weight, + max_tx_weight, + ) + } + fn sign_psbt<'a>( + &'a self, psbt: Psbt, + ) -> impl Future> + MaybeSend + 'a { + self.deref().sign_psbt(psbt) + } +} + /// An alternative to [`CoinSelectionSource`] that can be implemented and used along [`Wallet`] to /// provide a default implementation to [`CoinSelectionSource`]. /// @@ -908,26 +931,30 @@ pub trait CoinSelectionSourceSync { fn sign_psbt(&self, psbt: Psbt) -> Result; } -pub(crate) struct CoinSelectionSourceSyncWrapper(pub(crate) T) -where - T::Target: CoinSelectionSourceSync; - -// Implement `Deref` directly on CoinSelectionSourceSyncWrapper so that it can be used directly -// below, rather than via a wrapper. -impl Deref for CoinSelectionSourceSyncWrapper +impl CoinSelectionSourceSync for C where - T::Target: CoinSelectionSourceSync, + C::Target: CoinSelectionSourceSync, { - type Target = Self; - fn deref(&self) -> &Self { - self + fn select_confirmed_utxos( + &self, claim_id: Option, must_spend: Vec, must_pay_to: &[TxOut], + target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, + ) -> Result { + self.deref().select_confirmed_utxos( + claim_id, + must_spend, + must_pay_to, + target_feerate_sat_per_1000_weight, + max_tx_weight, + ) + } + fn sign_psbt(&self, psbt: Psbt) -> Result { + self.deref().sign_psbt(psbt) } } -impl CoinSelectionSource for CoinSelectionSourceSyncWrapper -where - T::Target: CoinSelectionSourceSync, -{ +pub(crate) struct CoinSelectionSourceSyncWrapper(pub(crate) T); + +impl CoinSelectionSource for CoinSelectionSourceSyncWrapper { fn select_confirmed_utxos<'a>( &'a self, claim_id: Option, must_spend: Vec, must_pay_to: &'a [TxOut], target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, From e24ff563aeb499c1492a1719bd15601ae5004b43 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 17 Feb 2026 16:46:44 -0600 Subject: [PATCH 042/627] Remove redundant is_initiator from FundingTemplate/FundingContribution FundingTemplate is only ever created by the splice initiator, so is_initiator is always true. The channel already knows who initiated the splice from who won quiescence (is_holder_quiescence_initiator), making the field on FundingTemplate and FundingContribution redundant. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 5 +-- lightning/src/ln/funding.rs | 85 ++++++++++--------------------------- 2 files changed, 24 insertions(+), 66 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index c71ee7afc90..85a23cadb22 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12187,7 +12187,7 @@ where satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + FUNDING_TRANSACTION_WITNESS_WEIGHT, }; - Ok(FundingTemplate::new(Some(shared_input), feerate, true)) + Ok(FundingTemplate::new(Some(shared_input), feerate)) } pub fn funding_contributed( @@ -13540,7 +13540,6 @@ where } let prev_funding_input = self.funding.to_splice_funding_input(); - let is_initiator = contribution.is_initiator(); let our_funding_contribution = match contribution.net_value() { Ok(net_value) => net_value, Err(e) => { @@ -13557,7 +13556,7 @@ where let (our_funding_inputs, our_funding_outputs) = contribution.into_tx_parts(); let context = FundingNegotiationContext { - is_initiator, + is_initiator: true, our_funding_contribution, funding_tx_locktime: locktime, funding_feerate_sat_per_1000_weight: funding_feerate_per_kw, diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index d18aca9c871..20319fa13bf 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -44,26 +44,21 @@ pub struct FundingTemplate { /// The fee rate to use for coin selection. feerate: FeeRate, - - /// Whether the contributor initiated the funding, and thus is responsible for fees incurred for - /// common fields and shared inputs and outputs. - is_initiator: bool, } impl FundingTemplate { /// Constructs a [`FundingTemplate`] for a splice using the provided shared input. - pub(super) fn new(shared_input: Option, feerate: FeeRate, is_initiator: bool) -> Self { - Self { shared_input, feerate, is_initiator } + pub(super) fn new(shared_input: Option, feerate: FeeRate) -> Self { + Self { shared_input, feerate } } } macro_rules! build_funding_contribution { - ($value_added:expr, $outputs:expr, $shared_input:expr, $feerate:expr, $is_initiator:expr, $wallet:ident, $($await:tt)*) => {{ + ($value_added:expr, $outputs:expr, $shared_input:expr, $feerate:expr, $wallet:ident, $($await:tt)*) => {{ let value_added: Amount = $value_added; let outputs: Vec = $outputs; let shared_input: Option = $shared_input; let feerate: FeeRate = $feerate; - let is_initiator: bool = $is_initiator; let value_removed = outputs.iter().map(|txout| txout.value).sum(); let is_splice = shared_input.is_some(); @@ -103,7 +98,10 @@ macro_rules! build_funding_contribution { let CoinSelection { confirmed_utxos: inputs, change_output } = coin_selection; - let estimated_fee = estimate_transaction_fee(&inputs, &outputs, is_initiator, is_splice, feerate); + // The caller creating a FundingContribution is always the initiator for fee estimation + // purposes — this is conservative, overestimating rather than underestimating fees if + // the node ends up as the acceptor. + let estimated_fee = estimate_transaction_fee(&inputs, &outputs, true, is_splice, feerate); let contribution = FundingContribution { value_added, @@ -112,7 +110,6 @@ macro_rules! build_funding_contribution { outputs, change_output, feerate, - is_initiator, is_splice, }; @@ -129,8 +126,8 @@ impl FundingTemplate { if value_added == Amount::ZERO { return Err(()); } - let FundingTemplate { shared_input, feerate, is_initiator } = self; - build_funding_contribution!(value_added, vec![], shared_input, feerate, is_initiator, wallet, await) + let FundingTemplate { shared_input, feerate } = self; + build_funding_contribution!(value_added, vec![], shared_input, feerate, wallet, await) } /// Creates a [`FundingContribution`] for adding funds to a channel using `wallet` to perform @@ -141,15 +138,8 @@ impl FundingTemplate { if value_added == Amount::ZERO { return Err(()); } - let FundingTemplate { shared_input, feerate, is_initiator } = self; - build_funding_contribution!( - value_added, - vec![], - shared_input, - feerate, - is_initiator, - wallet, - ) + let FundingTemplate { shared_input, feerate } = self; + build_funding_contribution!(value_added, vec![], shared_input, feerate, wallet,) } /// Creates a [`FundingContribution`] for removing funds from a channel using `wallet` to @@ -160,8 +150,8 @@ impl FundingTemplate { if outputs.is_empty() { return Err(()); } - let FundingTemplate { shared_input, feerate, is_initiator } = self; - build_funding_contribution!(Amount::ZERO, outputs, shared_input, feerate, is_initiator, wallet, await) + let FundingTemplate { shared_input, feerate } = self; + build_funding_contribution!(Amount::ZERO, outputs, shared_input, feerate, wallet, await) } /// Creates a [`FundingContribution`] for removing funds from a channel using `wallet` to @@ -172,15 +162,8 @@ impl FundingTemplate { if outputs.is_empty() { return Err(()); } - let FundingTemplate { shared_input, feerate, is_initiator } = self; - build_funding_contribution!( - Amount::ZERO, - outputs, - shared_input, - feerate, - is_initiator, - wallet, - ) + let FundingTemplate { shared_input, feerate } = self; + build_funding_contribution!(Amount::ZERO, outputs, shared_input, feerate, wallet,) } /// Creates a [`FundingContribution`] for both adding and removing funds from a channel using @@ -191,8 +174,8 @@ impl FundingTemplate { if value_added == Amount::ZERO && outputs.is_empty() { return Err(()); } - let FundingTemplate { shared_input, feerate, is_initiator } = self; - build_funding_contribution!(value_added, outputs, shared_input, feerate, is_initiator, wallet, await) + let FundingTemplate { shared_input, feerate } = self; + build_funding_contribution!(value_added, outputs, shared_input, feerate, wallet, await) } /// Creates a [`FundingContribution`] for both adding and removing funds from a channel using @@ -203,15 +186,8 @@ impl FundingTemplate { if value_added == Amount::ZERO && outputs.is_empty() { return Err(()); } - let FundingTemplate { shared_input, feerate, is_initiator } = self; - build_funding_contribution!( - value_added, - outputs, - shared_input, - feerate, - is_initiator, - wallet, - ) + let FundingTemplate { shared_input, feerate } = self; + build_funding_contribution!(value_added, outputs, shared_input, feerate, wallet,) } } @@ -290,10 +266,6 @@ pub struct FundingContribution { /// The fee rate used to select `inputs`. feerate: FeeRate, - /// Whether the contributor initiated the funding, and thus is responsible for fees incurred for - /// common fields and shared inputs and outputs. - is_initiator: bool, - /// Whether the contribution is for funding a splice. is_splice: bool, } @@ -305,8 +277,7 @@ impl_writeable_tlv_based!(FundingContribution, { (7, outputs, optional_vec), (9, change_output, option), (11, feerate, required), - (13, is_initiator, required), - (15, is_splice, required), + (13, is_splice, required), }); impl FundingContribution { @@ -314,10 +285,6 @@ impl FundingContribution { self.feerate } - pub(super) fn is_initiator(&self) -> bool { - self.is_initiator - } - pub(super) fn is_splice(&self) -> bool { self.is_splice } @@ -513,7 +480,6 @@ mod tests { ], outputs: vec![], change_output: None, - is_initiator: true, is_splice: true, feerate: FeeRate::from_sat_per_kwu(2000), }; @@ -534,7 +500,6 @@ mod tests { funding_output_sats(200_000), ], change_output: None, - is_initiator: true, is_splice: true, feerate: FeeRate::from_sat_per_kwu(2000), }; @@ -555,7 +520,6 @@ mod tests { funding_output_sats(400_000), ], change_output: None, - is_initiator: true, is_splice: true, feerate: FeeRate::from_sat_per_kwu(2000), }; @@ -576,7 +540,6 @@ mod tests { funding_output_sats(400_000), ], change_output: None, - is_initiator: true, is_splice: true, feerate: FeeRate::from_sat_per_kwu(90000), }; @@ -600,7 +563,6 @@ mod tests { ], outputs: vec![], change_output: None, - is_initiator: true, is_splice: true, feerate: FeeRate::from_sat_per_kwu(2000), }; @@ -625,7 +587,6 @@ mod tests { ], outputs: vec![], change_output: None, - is_initiator: true, is_splice: true, feerate: FeeRate::from_sat_per_kwu(2000), }; @@ -644,7 +605,6 @@ mod tests { ], outputs: vec![], change_output: None, - is_initiator: true, is_splice: true, feerate: FeeRate::from_sat_per_kwu(2200), }; @@ -657,9 +617,9 @@ mod tests { ); } - // barely covers, less fees (no extra weight, not initiator) + // barely covers, less fees (not a splice) { - let expected_fee = if cfg!(feature = "grind_signatures") { 1084 } else { 1088 }; + let expected_fee = if cfg!(feature = "grind_signatures") { 1512 } else { 1516 }; let contribution = FundingContribution { value_added: Amount::from_sat(300_000 - expected_fee - 20), estimated_fee: Amount::from_sat(expected_fee), @@ -669,7 +629,6 @@ mod tests { ], outputs: vec![], change_output: None, - is_initiator: false, is_splice: false, feerate: FeeRate::from_sat_per_kwu(2000), }; From ac0f53fa1c2b8de4b0dc4803b872db83979eadde Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Thu, 12 Feb 2026 10:49:38 -0800 Subject: [PATCH 043/627] Free holding cell upon handling an invalid interactive-tx message After cad88af, a few code paths that also lead to a quiescence exit were not accounted for. This commit addresses the path where we exit quiescence due to a processing error on a counterparty's `tx_add_input/output`, `tx_remove_input/output`, or `tx_complete` message. --- lightning/src/ln/channel.rs | 84 ++++++++++++++-------- lightning/src/ln/channelmanager.rs | 71 ++++++++++++++---- lightning/src/ln/splicing_tests.rs | 111 ++++++++++++++++++++++++++++- 3 files changed, 221 insertions(+), 45 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 0f1916ac59f..d1adbf7e700 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -1204,6 +1204,18 @@ pub enum UpdateFulfillCommitFetch { DuplicateClaim {}, } +/// Error returned when processing an invalid interactive-tx message from our counterparty. +pub(super) struct InteractiveTxMsgError { + /// The underlying error. + pub(super) err: ChannelError, + /// If a splice was in progress when processing the message, this contains the splice funding + /// information for emitting a `SpliceFailed` event. + pub(super) splice_funding_failed: Option, + /// Whether we were quiescent when we received the message, and are no longer due to aborting + /// the session. + pub(super) exited_quiescence: bool, +} + /// The return value of `monitor_updating_restored` pub(super) struct MonitorRestoreUpdates { pub raa: Option, @@ -1846,104 +1858,118 @@ where fn fail_interactive_tx_negotiation( &mut self, reason: AbortReason, logger: &L, - ) -> (ChannelError, Option) { + ) -> InteractiveTxMsgError { let logger = WithChannelContext::from(logger, &self.context(), None); log_info!(logger, "Failed interactive transaction negotiation: {reason}"); - let splice_funding_failed = match &mut self.phase { + let (splice_funding_failed, exited_quiescence) = match &mut self.phase { ChannelPhase::Undefined => unreachable!(), - ChannelPhase::UnfundedOutboundV1(_) | ChannelPhase::UnfundedInboundV1(_) => None, + ChannelPhase::UnfundedOutboundV1(_) | ChannelPhase::UnfundedInboundV1(_) => { + (None, false) + }, ChannelPhase::UnfundedV2(pending_v2_channel) => { pending_v2_channel.interactive_tx_constructor.take(); - None + (None, false) }, ChannelPhase::Funded(funded_channel) => { if funded_channel.should_reset_pending_splice_state(false) { - funded_channel.reset_pending_splice_state() + (funded_channel.reset_pending_splice_state(), true) } else { debug_assert!(false, "We should never fail an interactive funding negotiation once we're exchanging tx_signatures"); - None + (None, false) } }, }; - (ChannelError::Abort(reason), splice_funding_failed) + InteractiveTxMsgError { + err: ChannelError::Abort(reason), + splice_funding_failed, + exited_quiescence, + } } pub fn tx_add_input( &mut self, msg: &msgs::TxAddInput, logger: &L, - ) -> Result)> { + ) -> Result { match self.interactive_tx_constructor_mut() { Some(interactive_tx_constructor) => interactive_tx_constructor .handle_tx_add_input(msg) .map_err(|reason| self.fail_interactive_tx_negotiation(reason, logger)), - None => Err(( - ChannelError::WarnAndDisconnect( + None => Err(InteractiveTxMsgError { + err: ChannelError::WarnAndDisconnect( "Received unexpected interactive transaction negotiation message".to_owned(), ), - None, - )), + splice_funding_failed: None, + exited_quiescence: false, + }), } } pub fn tx_add_output( &mut self, msg: &msgs::TxAddOutput, logger: &L, - ) -> Result)> { + ) -> Result { match self.interactive_tx_constructor_mut() { Some(interactive_tx_constructor) => interactive_tx_constructor .handle_tx_add_output(msg) .map_err(|reason| self.fail_interactive_tx_negotiation(reason, logger)), - None => Err(( - ChannelError::WarnAndDisconnect( + None => Err(InteractiveTxMsgError { + err: ChannelError::WarnAndDisconnect( "Received unexpected interactive transaction negotiation message".to_owned(), ), - None, - )), + splice_funding_failed: None, + exited_quiescence: false, + }), } } pub fn tx_remove_input( &mut self, msg: &msgs::TxRemoveInput, logger: &L, - ) -> Result)> { + ) -> Result { match self.interactive_tx_constructor_mut() { Some(interactive_tx_constructor) => interactive_tx_constructor .handle_tx_remove_input(msg) .map_err(|reason| self.fail_interactive_tx_negotiation(reason, logger)), - None => Err(( - ChannelError::WarnAndDisconnect( + None => Err(InteractiveTxMsgError { + err: ChannelError::WarnAndDisconnect( "Received unexpected interactive transaction negotiation message".to_owned(), ), - None, - )), + splice_funding_failed: None, + exited_quiescence: false, + }), } } pub fn tx_remove_output( &mut self, msg: &msgs::TxRemoveOutput, logger: &L, - ) -> Result)> { + ) -> Result { match self.interactive_tx_constructor_mut() { Some(interactive_tx_constructor) => interactive_tx_constructor .handle_tx_remove_output(msg) .map_err(|reason| self.fail_interactive_tx_negotiation(reason, logger)), - None => Err(( - ChannelError::WarnAndDisconnect( + None => Err(InteractiveTxMsgError { + err: ChannelError::WarnAndDisconnect( "Received unexpected interactive transaction negotiation message".to_owned(), ), - None, - )), + splice_funding_failed: None, + exited_quiescence: false, + }), } } pub fn tx_complete( &mut self, msg: &msgs::TxComplete, fee_estimator: &LowerBoundedFeeEstimator, logger: &L, - ) -> Result)> { + ) -> Result { let tx_complete_action = match self.interactive_tx_constructor_mut() { Some(interactive_tx_constructor) => interactive_tx_constructor .handle_tx_complete(msg) .map_err(|reason| self.fail_interactive_tx_negotiation(reason, logger))?, None => { let err = "Received unexpected interactive transaction negotiation message"; - return Err((ChannelError::WarnAndDisconnect(err.to_owned()), None)); + return Err(InteractiveTxMsgError { + err: ChannelError::WarnAndDisconnect(err.to_owned()), + splice_funding_failed: None, + exited_quiescence: false, + }); }, }; diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 75de6ab5d10..67b2dc8ec38 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -59,8 +59,8 @@ use crate::ln::chan_utils::selected_commitment_sat_per_1000_weight; use crate::ln::channel::QuiescentAction; use crate::ln::channel::{ self, hold_time_since, Channel, ChannelError, ChannelUpdateStatus, DisconnectResult, - FundedChannel, FundingTxSigned, InboundV1Channel, OutboundHop, OutboundV1Channel, - PendingV2Channel, ReconnectionMsg, ShutdownResult, SpliceFundingFailed, StfuResponse, + FundedChannel, FundingTxSigned, InboundV1Channel, InteractiveTxMsgError, OutboundHop, + OutboundV1Channel, PendingV2Channel, ReconnectionMsg, ShutdownResult, StfuResponse, UpdateFulfillCommitFetch, WithChannelContext, }; use crate::ln::channel_state::ChannelDetails; @@ -938,6 +938,7 @@ struct MsgHandleErrInternal { closes_channel: bool, shutdown_finish: Option<(ShutdownResult, Option<(msgs::ChannelUpdate, NodeId, NodeId)>)>, tx_abort: Option, + exited_quiescence: bool, } impl MsgHandleErrInternal { @@ -952,6 +953,7 @@ impl MsgHandleErrInternal { closes_channel: false, shutdown_finish: None, tx_abort: None, + exited_quiescence: false, } } @@ -970,7 +972,13 @@ impl MsgHandleErrInternal { } fn from_no_close(err: msgs::LightningError) -> Self { - Self { err, closes_channel: false, shutdown_finish: None, tx_abort: None } + Self { + err, + closes_channel: false, + shutdown_finish: None, + tx_abort: None, + exited_quiescence: false, + } } fn from_finish_shutdown( @@ -991,6 +999,7 @@ impl MsgHandleErrInternal { closes_channel: true, shutdown_finish: Some((shutdown_res, channel_update)), tx_abort: None, + exited_quiescence: false, } } @@ -1026,7 +1035,13 @@ impl MsgHandleErrInternal { }, }, }; - Self { err, closes_channel: false, shutdown_finish: None, tx_abort } + Self { + err, + closes_channel: false, + shutdown_finish: None, + tx_abort, + exited_quiescence: false, + } } fn dont_send_error_message(&mut self) { @@ -1042,6 +1057,11 @@ impl MsgHandleErrInternal { fn closes_channel(&self) -> bool { self.closes_channel } + + fn with_exited_quiescence(mut self, exited_quiescence: bool) -> Self { + self.exited_quiescence = exited_quiescence; + self + } } /// For events which result in both a RevokeAndACK and a CommitmentUpdate, by default they should @@ -4350,15 +4370,26 @@ impl< }); } - if let Some(msg_event) = msg_event { + let mut holding_cell_res = None; + if msg_event.is_some() || err_internal.exited_quiescence { let per_peer_state = self.per_peer_state.read().unwrap(); if let Some(peer_state_mutex) = per_peer_state.get(&counterparty_node_id) { let mut peer_state = peer_state_mutex.lock().unwrap(); - if peer_state.is_connected { - peer_state.pending_msg_events.push(msg_event); + if let Some(msg_event) = msg_event { + if peer_state.is_connected { + peer_state.pending_msg_events.push(msg_event); + } } + // We need to enqueue the `tx_abort` in `pending_msg_events` above before we + // enqueue any commitment updates generated by freeing holding cell HTLCs. + holding_cell_res = err_internal + .exited_quiescence + .then(|| self.check_free_peer_holding_cells(&mut peer_state)); } } + if let Some(res) = holding_cell_res { + self.handle_holding_cell_free_result(res); + } // Return error in case higher-API need one err_internal.err @@ -11301,9 +11332,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } fn internal_tx_msg< - HandleTxMsgFn: Fn( - &mut Channel, - ) -> Result)>, + HandleTxMsgFn: Fn(&mut Channel) -> Result, >( &self, counterparty_node_id: &PublicKey, channel_id: ChannelId, tx_msg_handler: HandleTxMsgFn, @@ -11324,7 +11353,11 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ peer_state.pending_msg_events.push(msg_send_event); Ok(NotifyOption::SkipPersistHandleEvents) }, - Err((error, splice_funding_failed)) => { + Err(InteractiveTxMsgError { + err, + splice_funding_failed, + exited_quiescence, + }) => { if let Some(splice_funding_failed) = splice_funding_failed { let pending_events = &mut self.pending_events.lock().unwrap(); pending_events.push_back(( @@ -11340,7 +11373,10 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ None, )); } - Err(MsgHandleErrInternal::from_chan_no_close(error, channel_id)) + debug_assert!(!exited_quiescence || matches!(err, ChannelError::Abort(_))); + + Err(MsgHandleErrInternal::from_chan_no_close(err, channel_id) + .with_exited_quiescence(exited_quiescence)) }, } }, @@ -11470,7 +11506,11 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ Ok(persist) }, - Err((error, splice_funding_failed)) => { + Err(InteractiveTxMsgError { + err, + splice_funding_failed, + exited_quiescence, + }) => { if let Some(splice_funding_failed) = splice_funding_failed { let pending_events = &mut self.pending_events.lock().unwrap(); pending_events.push_back(( @@ -11486,7 +11526,10 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ None, )); } - Err(MsgHandleErrInternal::from_chan_no_close(error, msg.channel_id)) + debug_assert!(!exited_quiescence || matches!(err, ChannelError::Abort(_))); + + Err(MsgHandleErrInternal::from_chan_no_close(err, msg.channel_id) + .with_exited_quiescence(exited_quiescence)) }, } }, diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index cc422d650a7..c051f2994f5 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -1962,6 +1962,13 @@ fn fail_splice_on_interactive_tx_error() { initiate_splice_in(initiator, acceptor, channel_id, Amount::from_sat(splice_in_amount)); let _ = complete_splice_handshake(initiator, acceptor); + // Queue an outgoing HTLC to the holding cell. It should be freed once we exit quiescence. + let (route, payment_hash, _payment_preimage, payment_secret) = + get_route_and_payment_hash!(initiator, acceptor, 1_000_000); + let onion = RecipientOnionFields::secret_only(payment_secret); + let payment_id = PaymentId(payment_hash.0); + initiator.node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); + let tx_add_input = get_event_msg!(initiator, MessageSendEvent::SendTxAddInput, node_id_acceptor); acceptor.node.handle_tx_add_input(node_id_initiator, &tx_add_input); @@ -1979,11 +1986,28 @@ fn fail_splice_on_interactive_tx_error() { _ => panic!("Expected Event::SpliceFailed"), } - let tx_abort = get_event_msg!(initiator, MessageSendEvent::SendTxAbort, node_id_acceptor); - acceptor.node.handle_tx_abort(node_id_initiator, &tx_abort); + // We exit quiescence upon sending `tx_abort`, so we should see the holding cell be immediately + // freed. + let msg_events = initiator.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + let tx_abort = if let MessageSendEvent::SendTxAbort { msg, .. } = &msg_events[0] { + msg + } else { + panic!("Unexpected event {:?}", msg_events[0]); + }; + let update = if let MessageSendEvent::UpdateHTLCs { updates, .. } = &msg_events[1] { + updates + } else { + panic!("Unexpected event {:?}", msg_events[1]); + }; + check_added_monitors(initiator, 1); + acceptor.node.handle_tx_abort(node_id_initiator, tx_abort); let tx_abort = get_event_msg!(acceptor, MessageSendEvent::SendTxAbort, node_id_initiator); initiator.node.handle_tx_abort(node_id_acceptor, &tx_abort); + + acceptor.node.handle_update_add_htlc(node_id_initiator, &update.update_add_htlcs[0]); + do_commitment_signed_dance(acceptor, initiator, &update.commitment_signed, false, false); } #[test] @@ -2037,6 +2061,89 @@ fn fail_splice_on_tx_abort() { acceptor.node.handle_tx_abort(node_id_initiator, &tx_abort); } +#[test] +fn fail_splice_on_tx_complete_error() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let config = test_default_channel_config(); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let initiator = &nodes[1]; + let acceptor = &nodes[0]; + + let node_id_initiator = initiator.node.get_our_node_id(); + let node_id_acceptor = acceptor.node.get_our_node_id(); + + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 50_000_000); + + let outputs = vec![TxOut { + value: Amount::from_sat(1_000), + script_pubkey: acceptor.wallet_source.get_change_script().unwrap(), + }]; + let _ = initiate_splice_out(initiator, acceptor, channel_id, outputs); + let _ = complete_splice_handshake(initiator, acceptor); + + // Queue an outgoing HTLC to the holding cell. It should be freed once we exit quiescence. + let (route, payment_hash, _payment_preimage, payment_secret) = + get_route_and_payment_hash!(initiator, acceptor, 1_000_000); + let onion = RecipientOnionFields::secret_only(payment_secret); + let payment_id = PaymentId(payment_hash.0); + acceptor.node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); + + let tx_add_input = + get_event_msg!(initiator, MessageSendEvent::SendTxAddInput, node_id_acceptor); + acceptor.node.handle_tx_add_input(node_id_initiator, &tx_add_input); + let tx_complete = get_event_msg!(acceptor, MessageSendEvent::SendTxComplete, node_id_initiator); + initiator.node.handle_tx_complete(node_id_acceptor, &tx_complete); + + // Tamper the shared funding output such that the acceptor fails upon `tx_complete`. + let mut tx_add_output = + get_event_msg!(initiator, MessageSendEvent::SendTxAddOutput, node_id_acceptor); + if tx_add_output.script.is_p2wsh() { + tx_add_output.sats *= 2; + } + acceptor.node.handle_tx_add_output(node_id_initiator, &tx_add_output); + let tx_complete = get_event_msg!(acceptor, MessageSendEvent::SendTxComplete, node_id_initiator); + initiator.node.handle_tx_complete(node_id_acceptor, &tx_complete); + + let mut tx_add_output = + get_event_msg!(initiator, MessageSendEvent::SendTxAddOutput, node_id_acceptor); + if tx_add_output.script.is_p2wsh() { + tx_add_output.sats *= 2; + } + acceptor.node.handle_tx_add_output(node_id_initiator, &tx_add_output); + let tx_complete = get_event_msg!(acceptor, MessageSendEvent::SendTxComplete, node_id_initiator); + initiator.node.handle_tx_complete(node_id_acceptor, &tx_complete); + + let _ = get_event!(initiator, Event::FundingTransactionReadyForSigning); + let tx_complete = get_event_msg!(initiator, MessageSendEvent::SendTxComplete, node_id_acceptor); + acceptor.node.handle_tx_complete(node_id_initiator, &tx_complete); + + let msg_events = acceptor.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + check_added_monitors(acceptor, 1); + let tx_abort = if let MessageSendEvent::SendTxAbort { msg, .. } = &msg_events[0] { + msg + } else { + panic!("Unexpected event {:?}", msg_events[0]); + }; + let update = if let MessageSendEvent::UpdateHTLCs { updates, .. } = &msg_events[1] { + updates + } else { + panic!("Unexpected event {:?}", msg_events[1]); + }; + + initiator.node.handle_tx_abort(node_id_acceptor, tx_abort); + let _ = get_event!(initiator, Event::SpliceFailed); + let tx_abort = get_event_msg!(initiator, MessageSendEvent::SendTxAbort, node_id_acceptor); + acceptor.node.handle_tx_abort(node_id_initiator, &tx_abort); + + initiator.node.handle_update_add_htlc(node_id_acceptor, &update.update_add_htlcs[0]); + do_commitment_signed_dance(initiator, acceptor, &update.commitment_signed, false, false); +} + #[test] fn fail_splice_on_channel_close() { let chanmon_cfgs = create_chanmon_cfgs(2); From 04130c10d74610bc3cbf8e5c8a3eeaa62c26a5be Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 17 Feb 2026 17:00:40 -0600 Subject: [PATCH 044/627] Remove PersistenceNotifierGuard from splice_channel Now that ChannelManager::splice_channel only reads, there is no need to notify listeners about event handling nor persistence. --- lightning/src/ln/channel.rs | 2 +- lightning/src/ln/channelmanager.rs | 18 ++---------------- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 85a23cadb22..5bea1c97fe4 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12136,7 +12136,7 @@ where } /// Initiate splicing. - pub fn splice_channel(&mut self, feerate: FeeRate) -> Result { + pub fn splice_channel(&self, feerate: FeeRate) -> Result { if self.holder_commitment_point.current_point().is_none() { return Err(APIError::APIMisuseError { err: format!( diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 75de6ab5d10..667b7de12ef 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -4576,20 +4576,6 @@ impl< #[rustfmt::skip] pub fn splice_channel( &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, feerate: FeeRate, - ) -> Result { - let mut res = Err(APIError::APIMisuseError { err: String::new() }); - PersistenceNotifierGuard::optionally_notify(self, || { - let result = self.internal_splice_channel( - channel_id, counterparty_node_id, feerate, - ); - res = result; - NotifyOption::SkipPersistNoEvents - }); - res - } - - fn internal_splice_channel( - &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, feerate: FeeRate, ) -> Result { let per_peer_state = self.per_peer_state.read().unwrap(); @@ -4615,8 +4601,8 @@ impl< // Look for the channel match peer_state.channel_by_id.entry(*channel_id) { - hash_map::Entry::Occupied(mut chan_phase_entry) => { - if let Some(chan) = chan_phase_entry.get_mut().as_funded_mut() { + hash_map::Entry::Occupied(chan_phase_entry) => { + if let Some(chan) = chan_phase_entry.get().as_funded() { chan.splice_channel(feerate) } else { Err(APIError::ChannelUnavailable { From 2665465b458c2096f355b608b89c5b8d74cdb932 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Thu, 12 Feb 2026 10:52:36 -0800 Subject: [PATCH 045/627] Free holding cell upon handling a counterparty tx_abort After cad88af, a few code paths that also lead to a quiescence exit were not accounted for. This commit addresses the path where we exit quiescence due to processing a counterparty's `tx_abort` message. --- lightning/src/ln/channel.rs | 12 ++-- lightning/src/ln/channelmanager.rs | 103 ++++++++++++++++------------- lightning/src/ln/splicing_tests.rs | 25 ++++++- 3 files changed, 87 insertions(+), 53 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index d1adbf7e700..4c31b690647 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2029,13 +2029,13 @@ where pub fn tx_abort( &mut self, msg: &msgs::TxAbort, logger: &L, - ) -> Result<(Option, Option), ChannelError> { + ) -> Result<(Option, Option, bool), ChannelError> { // If we have not sent a `tx_abort` message for this negotiation previously, we need to echo // back a tx_abort message according to the spec: // https://github.com/lightning/bolts/blob/247e83d/02-peer-protocol.md?plain=1#L560-L561 // For rationale why we echo back `tx_abort`: // https://github.com/lightning/bolts/blob/247e83d/02-peer-protocol.md?plain=1#L578-L580 - let (should_ack, splice_funding_failed) = match &mut self.phase { + let (should_ack, splice_funding_failed, exited_quiescence) = match &mut self.phase { ChannelPhase::Undefined => unreachable!(), ChannelPhase::UnfundedOutboundV1(_) | ChannelPhase::UnfundedInboundV1(_) => { let err = "Got an unexpected tx_abort message: This is an unfunded channel created with V1 channel establishment"; @@ -2044,7 +2044,7 @@ where ChannelPhase::UnfundedV2(pending_v2_channel) => { let had_constructor = pending_v2_channel.interactive_tx_constructor.take().is_some(); - (had_constructor, None) + (had_constructor, None, false) }, ChannelPhase::Funded(funded_channel) => { if funded_channel.has_pending_splice_awaiting_signatures() @@ -2072,11 +2072,11 @@ where .unwrap_or(false); debug_assert!(has_funding_negotiation); let splice_funding_failed = funded_channel.reset_pending_splice_state(); - (true, splice_funding_failed) + (true, splice_funding_failed, true) } else { // We were not tracking the pending funding negotiation state anymore, likely // due to a disconnection or already having sent our own `tx_abort`. - (false, None) + (false, None, false) } }, }; @@ -2092,7 +2092,7 @@ where } }); - Ok((tx_abort, splice_funding_failed)) + Ok((tx_abort, splice_funding_failed, exited_quiescence)) } #[rustfmt::skip] diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 67b2dc8ec38..36fbd32c1c6 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -11632,55 +11632,68 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ fn internal_tx_abort( &self, counterparty_node_id: &PublicKey, msg: &msgs::TxAbort, ) -> Result { - let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) - })?; - let mut peer_state_lock = peer_state_mutex.lock().unwrap(); - let peer_state = &mut *peer_state_lock; - match peer_state.channel_by_id.entry(msg.channel_id) { - hash_map::Entry::Occupied(mut chan_entry) => { - let res = chan_entry.get_mut().tx_abort(msg, &self.logger); - let (tx_abort, splice_failed) = - try_channel_entry!(self, peer_state, res, chan_entry); + let (result, holding_cell_res) = { + let per_peer_state = self.per_peer_state.read().unwrap(); + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + debug_assert!(false); + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) + })?; + let mut peer_state_lock = peer_state_mutex.lock().unwrap(); + let peer_state = &mut *peer_state_lock; + match peer_state.channel_by_id.entry(msg.channel_id) { + hash_map::Entry::Occupied(mut chan_entry) => { + let res = chan_entry.get_mut().tx_abort(msg, &self.logger); + let (tx_abort, splice_failed, exited_quiescence) = + try_channel_entry!(self, peer_state, res, chan_entry); - let persist = if tx_abort.is_some() || splice_failed.is_some() { - NotifyOption::DoPersist - } else { - NotifyOption::SkipPersistNoEvents - }; + let persist = if tx_abort.is_some() || splice_failed.is_some() { + NotifyOption::DoPersist + } else { + NotifyOption::SkipPersistNoEvents + }; - if let Some(tx_abort_msg) = tx_abort { - peer_state.pending_msg_events.push(MessageSendEvent::SendTxAbort { - node_id: *counterparty_node_id, - msg: tx_abort_msg, - }); - } + if let Some(tx_abort_msg) = tx_abort { + peer_state.pending_msg_events.push(MessageSendEvent::SendTxAbort { + node_id: *counterparty_node_id, + msg: tx_abort_msg, + }); + } - if let Some(splice_funding_failed) = splice_failed { - let pending_events = &mut self.pending_events.lock().unwrap(); - pending_events.push_back(( - events::Event::SpliceFailed { - channel_id: msg.channel_id, - counterparty_node_id: *counterparty_node_id, - user_channel_id: chan_entry.get().context().get_user_id(), - abandoned_funding_txo: splice_funding_failed.funding_txo, - channel_type: splice_funding_failed.channel_type, - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, - }, - None, - )); - } + if let Some(splice_funding_failed) = splice_failed { + let pending_events = &mut self.pending_events.lock().unwrap(); + pending_events.push_back(( + events::Event::SpliceFailed { + channel_id: msg.channel_id, + counterparty_node_id: *counterparty_node_id, + user_channel_id: chan_entry.get().context().get_user_id(), + abandoned_funding_txo: splice_funding_failed.funding_txo, + channel_type: splice_funding_failed.channel_type, + contributed_inputs: splice_funding_failed.contributed_inputs, + contributed_outputs: splice_funding_failed.contributed_outputs, + }, + None, + )); + } - Ok(persist) - }, - hash_map::Entry::Vacant(_) => Err(MsgHandleErrInternal::no_such_channel_for_peer( - counterparty_node_id, - msg.channel_id, - )), - } + let holding_cell_res = if exited_quiescence { + self.check_free_peer_holding_cells(peer_state) + } else { + Vec::new() + }; + (Ok(persist), holding_cell_res) + }, + hash_map::Entry::Vacant(_) => ( + Err(MsgHandleErrInternal::no_such_channel_for_peer( + counterparty_node_id, + msg.channel_id, + )), + Vec::new(), + ), + } + }; + + self.handle_holding_cell_free_result(holding_cell_res); + result } #[rustfmt::skip] diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index c051f2994f5..fc5c18164e9 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -2037,6 +2037,13 @@ fn fail_splice_on_tx_abort() { initiate_splice_in(initiator, acceptor, channel_id, Amount::from_sat(splice_in_amount)); let _ = complete_splice_handshake(initiator, acceptor); + // Queue an outgoing HTLC to the holding cell. It should be freed once we exit quiescence. + let (route, payment_hash, _payment_preimage, payment_secret) = + get_route_and_payment_hash!(initiator, acceptor, 1_000_000); + let onion = RecipientOnionFields::secret_only(payment_secret); + let payment_id = PaymentId(payment_hash.0); + initiator.node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); + let tx_add_input = get_event_msg!(initiator, MessageSendEvent::SendTxAddInput, node_id_acceptor); acceptor.node.handle_tx_add_input(node_id_initiator, &tx_add_input); @@ -2057,8 +2064,22 @@ fn fail_splice_on_tx_abort() { _ => panic!("Expected Event::SpliceFailed"), } - let tx_abort = get_event_msg!(initiator, MessageSendEvent::SendTxAbort, node_id_acceptor); - acceptor.node.handle_tx_abort(node_id_initiator, &tx_abort); + // We exit quiescence upon receiving `tx_abort`, so we should see our `tx_abort` echo and the + // holding cell be immediately freed. + let msg_events = initiator.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + check_added_monitors(initiator, 1); + if let MessageSendEvent::SendTxAbort { msg, .. } = &msg_events[0] { + acceptor.node.handle_tx_abort(node_id_initiator, msg); + } else { + panic!("Unexpected event {:?}", msg_events[0]); + }; + if let MessageSendEvent::UpdateHTLCs { updates, .. } = &msg_events[1] { + acceptor.node.handle_update_add_htlc(node_id_initiator, &updates.update_add_htlcs[0]); + do_commitment_signed_dance(acceptor, initiator, &updates.commitment_signed, false, false); + } else { + panic!("Unexpected event {:?}", msg_events[1]); + }; } #[test] From 3e8b060432c83ff6efa88abf3e815c9a49625e6a Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Thu, 12 Feb 2026 10:53:49 -0800 Subject: [PATCH 046/627] Free holding cell upon tx_signatures exchange After cad88af, a few code paths that also lead to a quiescence exit were not accounted for. This commit addresses the last remaining path where we exit quiescence when we exchange `tx_signatures` with the counterparty. --- lightning/src/ln/channelmanager.rs | 180 ++++++++++++++++------------- lightning/src/ln/splicing_tests.rs | 90 +++++++++++++++ 2 files changed, 188 insertions(+), 82 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 36fbd32c1c6..930f5fe4298 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -11543,90 +11543,106 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ fn internal_tx_signatures( &self, counterparty_node_id: &PublicKey, msg: &msgs::TxSignatures, ) -> Result<(), MsgHandleErrInternal> { - let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) - })?; - let mut peer_state_lock = peer_state_mutex.lock().unwrap(); - let peer_state = &mut *peer_state_lock; - match peer_state.channel_by_id.entry(msg.channel_id) { - hash_map::Entry::Occupied(mut chan_entry) => { - match chan_entry.get_mut().as_funded_mut() { - Some(chan) => { - let best_block_height = self.best_block.read().unwrap().height; - let FundingTxSigned { - commitment_signed, - counterparty_initial_commitment_signed_result, - tx_signatures, - funding_tx, - splice_negotiated, - splice_locked, - } = try_channel_entry!( - self, - peer_state, - chan.tx_signatures(msg, best_block_height, &self.logger), - chan_entry - ); - - // We should never be sending a `commitment_signed` in response to their - // `tx_signatures`. - debug_assert!(commitment_signed.is_none()); - debug_assert!(counterparty_initial_commitment_signed_result.is_none()); - - if let Some(tx_signatures) = tx_signatures { - peer_state.pending_msg_events.push( - MessageSendEvent::SendTxSignatures { - node_id: *counterparty_node_id, - msg: tx_signatures, - }, - ); - } - if let Some(splice_locked) = splice_locked { - peer_state.pending_msg_events.push( - MessageSendEvent::SendSpliceLocked { - node_id: *counterparty_node_id, - msg: splice_locked, - }, - ); - } - if let Some((ref funding_tx, ref tx_type)) = funding_tx { - self.broadcast_interactive_funding( - chan, + let (result, holding_cell_res) = { + let per_peer_state = self.per_peer_state.read().unwrap(); + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + debug_assert!(false); + MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) + })?; + let mut peer_state_lock = peer_state_mutex.lock().unwrap(); + let peer_state = &mut *peer_state_lock; + match peer_state.channel_by_id.entry(msg.channel_id) { + hash_map::Entry::Occupied(mut chan_entry) => { + match chan_entry.get_mut().as_funded_mut() { + Some(chan) => { + let best_block_height = self.best_block.read().unwrap().height; + let FundingTxSigned { + commitment_signed, + counterparty_initial_commitment_signed_result, + tx_signatures, funding_tx, - Some(tx_type.clone()), - &self.logger, + splice_negotiated, + splice_locked, + } = try_channel_entry!( + self, + peer_state, + chan.tx_signatures(msg, best_block_height, &self.logger), + chan_entry ); - } - if let Some(splice_negotiated) = splice_negotiated { - self.pending_events.lock().unwrap().push_back(( - events::Event::SplicePending { - channel_id: msg.channel_id, - counterparty_node_id: *counterparty_node_id, - user_channel_id: chan.context.get_user_id(), - new_funding_txo: splice_negotiated.funding_txo, - channel_type: splice_negotiated.channel_type, - new_funding_redeem_script: splice_negotiated - .funding_redeem_script, - }, - None, - )); - } - }, - None => { - let msg = "Got an unexpected tx_signatures message"; - let reason = ClosureReason::ProcessingError { err: msg.to_owned() }; - let err = ChannelError::Close((msg.to_owned(), reason)); - try_channel_entry!(self, peer_state, Err(err), chan_entry) - }, - } - Ok(()) - }, - hash_map::Entry::Vacant(_) => Err(MsgHandleErrInternal::no_such_channel_for_peer( - counterparty_node_id, - msg.channel_id, - )), - } + + // We should never be sending a `commitment_signed` in response to their + // `tx_signatures`. + debug_assert!(commitment_signed.is_none()); + debug_assert!(counterparty_initial_commitment_signed_result.is_none()); + + if let Some(tx_signatures) = tx_signatures { + peer_state.pending_msg_events.push( + MessageSendEvent::SendTxSignatures { + node_id: *counterparty_node_id, + msg: tx_signatures, + }, + ); + } + if let Some(splice_locked) = splice_locked { + peer_state.pending_msg_events.push( + MessageSendEvent::SendSpliceLocked { + node_id: *counterparty_node_id, + msg: splice_locked, + }, + ); + } + if let Some((ref funding_tx, ref tx_type)) = funding_tx { + self.broadcast_interactive_funding( + chan, + funding_tx, + Some(tx_type.clone()), + &self.logger, + ); + } + // We consider a splice negotiated when we exchange `tx_signatures`, + // which also terminates quiescence. + let exited_quiescence = splice_negotiated.is_some(); + if let Some(splice_negotiated) = splice_negotiated { + self.pending_events.lock().unwrap().push_back(( + events::Event::SplicePending { + channel_id: msg.channel_id, + counterparty_node_id: *counterparty_node_id, + user_channel_id: chan.context.get_user_id(), + new_funding_txo: splice_negotiated.funding_txo, + channel_type: splice_negotiated.channel_type, + new_funding_redeem_script: splice_negotiated + .funding_redeem_script, + }, + None, + )); + } + let holding_cell_res = if exited_quiescence { + self.check_free_peer_holding_cells(peer_state) + } else { + Vec::new() + }; + (Ok(()), holding_cell_res) + }, + None => { + let msg = "Got an unexpected tx_signatures message"; + let reason = ClosureReason::ProcessingError { err: msg.to_owned() }; + let err = ChannelError::Close((msg.to_owned(), reason)); + try_channel_entry!(self, peer_state, Err(err), chan_entry) + }, + } + }, + hash_map::Entry::Vacant(_) => ( + Err(MsgHandleErrInternal::no_such_channel_for_peer( + counterparty_node_id, + msg.channel_id, + )), + Vec::new(), + ), + } + }; + + self.handle_holding_cell_free_result(holding_cell_res); + result } fn internal_tx_abort( diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index fc5c18164e9..6727437a38a 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -2165,6 +2165,96 @@ fn fail_splice_on_tx_complete_error() { do_commitment_signed_dance(initiator, acceptor, &update.commitment_signed, false, false); } +#[test] +fn free_holding_cell_on_tx_signatures_quiescence_exit() { + // Test that if there's an update in the holding cell while we're quiescent, that it gets freed + // upon exiting quiescence via the `tx_signatures` exchange. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let config = test_default_channel_config(); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let initiator = &nodes[0]; + let acceptor = &nodes[1]; + let node_id_initiator = initiator.node.get_our_node_id(); + let node_id_acceptor = acceptor.node.get_our_node_id(); + + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); + + let outputs = vec![TxOut { + value: Amount::from_sat(1_000), + script_pubkey: initiator.wallet_source.get_change_script().unwrap(), + }]; + let contribution = initiate_splice_out(initiator, acceptor, channel_id, outputs); + negotiate_splice_tx(initiator, acceptor, channel_id, contribution); + + // Queue an outgoing HTLC to the holding cell. It should be freed once we exit quiescence. + let (route, payment_hash, _payment_preimage, payment_secret) = + get_route_and_payment_hash!(initiator, acceptor, 1_000_000); + let onion = RecipientOnionFields::secret_only(payment_secret); + let payment_id = PaymentId(payment_hash.0); + initiator.node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); + assert!(initiator.node.get_and_clear_pending_msg_events().is_empty()); + + let event = get_event!(initiator, Event::FundingTransactionReadyForSigning); + if let Event::FundingTransactionReadyForSigning { + channel_id, + counterparty_node_id, + unsigned_transaction, + .. + } = event + { + let partially_signed_tx = initiator.wallet_source.sign_tx(unsigned_transaction).unwrap(); + initiator + .node + .funding_transaction_signed(&channel_id, &counterparty_node_id, partially_signed_tx) + .unwrap(); + } else { + unreachable!(); + } + + let update = get_htlc_update_msgs(initiator, &node_id_acceptor); + acceptor.node.handle_commitment_signed(node_id_initiator, &update.commitment_signed[0]); + check_added_monitors(&acceptor, 1); + + let msg_events = acceptor.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + if let MessageSendEvent::UpdateHTLCs { ref updates, .. } = &msg_events[0] { + let commitment_signed = &updates.commitment_signed[0]; + initiator.node.handle_commitment_signed(node_id_acceptor, commitment_signed); + check_added_monitors(&initiator, 1); + } else { + panic!("Unexpected event {:?}", &msg_events[0]); + } + if let MessageSendEvent::SendTxSignatures { ref msg, .. } = &msg_events[1] { + initiator.node.handle_tx_signatures(node_id_acceptor, msg); + } else { + panic!("Unexpected event {:?}", &msg_events[1]); + } + + // With `tx_signatures` exchanged, we've exited quiescence and should now see the outgoing HTLC + // update be sent. + let msg_events = initiator.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + check_added_monitors(initiator, 1); // Outgoing HTLC monitor update + if let MessageSendEvent::SendTxSignatures { ref msg, .. } = &msg_events[0] { + acceptor.node.handle_tx_signatures(node_id_initiator, msg); + } else { + panic!("Unexpected event {:?}", &msg_events[0]); + } + if let MessageSendEvent::UpdateHTLCs { updates, .. } = &msg_events[1] { + acceptor.node.handle_update_add_htlc(node_id_initiator, &updates.update_add_htlcs[0]); + do_commitment_signed_dance(acceptor, initiator, &updates.commitment_signed, false, false); + } else { + panic!("Unexpected event {:?}", &msg_events[1]); + } + + expect_splice_pending_event(initiator, &node_id_acceptor); + expect_splice_pending_event(acceptor, &node_id_initiator); +} + #[test] fn fail_splice_on_channel_close() { let chanmon_cfgs = create_chanmon_cfgs(2); From 9f6c6785cf2ff2dbbf85438c7abf6fd6d307572c Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 17 Feb 2026 17:54:19 -0600 Subject: [PATCH 047/627] Make FundingContribution::net_value() infallible Split FundingContribution::net_value() (which returned Result) into a separate validate() method and an infallible net_value() that returns SignedAmount directly. The validate() method checks prevtx sizes and input sufficiency, while net_value() computes the net contribution amount. To make net_value() safe to call without error handling, add MAX_MONEY bounds checks in the build_funding_contribution! macro before coin selection. This ensures FundingContribution is valid by construction: value_added and the sum of outputs are each bounded by MAX_MONEY (~2.1e15 sat), so the worst-case net_value() computation (-2 * MAX_MONEY ~= -4.2e15) is well within i64 range (~-9.2e18). Update callers in channel.rs to use the new separate methods, simplifying error handling at call sites where net_value() previously required unwrapping a Result. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 16 +---- lightning/src/ln/funding.rs | 138 +++++++++++++++++++++++++++++------- 2 files changed, 116 insertions(+), 38 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 5bea1c97fe4..be0244a33f1 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12195,9 +12195,10 @@ where ) -> Result, SpliceFundingFailed> { debug_assert!(contribution.is_splice()); - if let Err(e) = contribution.net_value().and_then(|our_funding_contribution| { + if let Err(e) = contribution.validate().and_then(|()| { // For splice-out, our_funding_contribution is adjusted to cover fees if there // aren't any inputs. + let our_funding_contribution = contribution.net_value(); self.validate_splice_contributions(our_funding_contribution, SignedAmount::ZERO) }) { log_error!(logger, "Channel {} cannot be funded: {}", self.context.channel_id(), e); @@ -13540,18 +13541,7 @@ where } let prev_funding_input = self.funding.to_splice_funding_input(); - let our_funding_contribution = match contribution.net_value() { - Ok(net_value) => net_value, - Err(e) => { - debug_assert!(false); - return Err(ChannelError::WarnAndDisconnect( - format!( - "Internal Error: Insufficient funding contribution: {}", - e, - ) - )); - }, - }; + let our_funding_contribution = contribution.net_value(); let funding_feerate_per_kw = contribution.feerate().to_sat_per_kwu() as u32; let (our_funding_inputs, our_funding_outputs) = contribution.into_tx_parts(); diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 20319fa13bf..935703ce817 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -60,7 +60,22 @@ macro_rules! build_funding_contribution { let shared_input: Option = $shared_input; let feerate: FeeRate = $feerate; - let value_removed = outputs.iter().map(|txout| txout.value).sum(); + // Validate user-provided amounts are within MAX_MONEY before coin selection to + // ensure FundingContribution::net_value() arithmetic cannot overflow. With all + // amounts bounded by MAX_MONEY (~2.1e15 sat), the worst-case net_value() + // computation is -2 * MAX_MONEY (~-4.2e15), well within i64::MIN (~-9.2e18). + if value_added > Amount::MAX_MONEY { + return Err(()); + } + + let mut value_removed = Amount::ZERO; + for txout in outputs.iter() { + value_removed = match value_removed.checked_add(txout.value) { + Some(sum) if sum <= Amount::MAX_MONEY => sum, + _ => return Err(()), + }; + } + let is_splice = shared_input.is_some(); let coin_selection = if value_added == Amount::ZERO { @@ -102,6 +117,7 @@ macro_rules! build_funding_contribution { // purposes — this is conservative, overestimating rather than underestimating fees if // the node ends up as the acceptor. let estimated_fee = estimate_transaction_fee(&inputs, &outputs, true, is_splice, feerate); + debug_assert!(estimated_fee <= Amount::MAX_MONEY); let contribution = FundingContribution { value_added, @@ -305,10 +321,9 @@ impl FundingContribution { (inputs.into_iter().map(|input| input.utxo.outpoint).collect(), outputs) } - /// The net value contributed to a channel by the splice. If negative, more value will be - /// spliced out than spliced in. Fees will be deducted from the expected splice-out amount - /// if no inputs were included. - pub fn net_value(&self) -> Result { + /// Validates that the funding inputs are suitable for use in the interactive transaction + /// protocol, checking prevtx sizes and input sufficiency. + pub fn validate(&self) -> Result<(), String> { for FundingTxInput { utxo, prevtx, .. } in self.inputs.iter() { use crate::util::ser::Writeable; const MESSAGE_TEMPLATE: msgs::TxAddInput = msgs::TxAddInput { @@ -361,26 +376,32 @@ impl FundingContribution { } } + Ok(()) + } + + /// The net value contributed to a channel by the splice. If negative, more value will be + /// spliced out than spliced in. Fees will be deducted from the expected splice-out amount + /// if no inputs were included. + pub fn net_value(&self) -> SignedAmount { let unpaid_fees = if self.inputs.is_empty() { self.estimated_fee } else { Amount::ZERO } .to_signed() - .expect("fees should never exceed Amount::MAX_MONEY"); - let value_added = self.value_added.to_signed().map_err(|_| "Value added too large")?; + .expect("estimated_fee is validated to not exceed Amount::MAX_MONEY"); + let value_added = self + .value_added + .to_signed() + .expect("value_added is validated to not exceed Amount::MAX_MONEY"); let value_removed = self .outputs .iter() .map(|txout| txout.value) .sum::() .to_signed() - .map_err(|_| "Value removed too large")?; + .expect("value_removed is validated to not exceed Amount::MAX_MONEY"); let contribution_amount = value_added - value_removed; - let adjusted_contribution = contribution_amount.checked_sub(unpaid_fees).ok_or(format!( - "{} splice-out amount plus {} fee estimate exceeds the total bitcoin supply", - contribution_amount.unsigned_abs(), - self.estimated_fee, - ))?; - - Ok(adjusted_contribution) + contribution_amount + .checked_sub(unpaid_fees) + .expect("all amounts are validated to not exceed Amount::MAX_MONEY") } } @@ -390,10 +411,12 @@ pub type FundingTxInput = crate::util::wallet_utils::ConfirmedUtxo; #[cfg(test)] mod tests { - use super::{estimate_transaction_fee, FundingContribution, FundingTxInput}; + use super::{estimate_transaction_fee, FundingContribution, FundingTemplate, FundingTxInput}; + use crate::chain::ClaimId; + use crate::util::wallet_utils::{CoinSelection, CoinSelectionSourceSync, Input}; use bitcoin::hashes::Hash; use bitcoin::transaction::{Transaction, TxOut, Version}; - use bitcoin::{Amount, FeeRate, ScriptBuf, SignedAmount, WPubkeyHash}; + use bitcoin::{Amount, FeeRate, Psbt, ScriptBuf, SignedAmount, WPubkeyHash}; #[test] #[rustfmt::skip] @@ -483,7 +506,8 @@ mod tests { is_splice: true, feerate: FeeRate::from_sat_per_kwu(2000), }; - assert_eq!(contribution.net_value(), Ok(contribution.value_added.to_signed().unwrap())); + assert!(contribution.validate().is_ok()); + assert_eq!(contribution.net_value(), contribution.value_added.to_signed().unwrap()); } // Net splice-in @@ -503,7 +527,8 @@ mod tests { is_splice: true, feerate: FeeRate::from_sat_per_kwu(2000), }; - assert_eq!(contribution.net_value(), Ok(SignedAmount::from_sat(220_000 - 200_000))); + assert!(contribution.validate().is_ok()); + assert_eq!(contribution.net_value(), SignedAmount::from_sat(220_000 - 200_000)); } // Net splice-out @@ -523,7 +548,8 @@ mod tests { is_splice: true, feerate: FeeRate::from_sat_per_kwu(2000), }; - assert_eq!(contribution.net_value(), Ok(SignedAmount::from_sat(220_000 - 400_000))); + assert!(contribution.validate().is_ok()); + assert_eq!(contribution.net_value(), SignedAmount::from_sat(220_000 - 400_000)); } // Net splice-out, inputs insufficient to cover fees @@ -544,7 +570,7 @@ mod tests { feerate: FeeRate::from_sat_per_kwu(90000), }; assert_eq!( - contribution.net_value(), + contribution.validate(), Err(format!( "Total input amount 0.00300000 BTC is lower than needed for splice-in contribution 0.00220000 BTC, considering fees of {}. Need more inputs.", Amount::from_sat(expected_fee), @@ -567,7 +593,7 @@ mod tests { feerate: FeeRate::from_sat_per_kwu(2000), }; assert_eq!( - contribution.net_value(), + contribution.validate(), Err(format!( "Total input amount 0.00100000 BTC is lower than needed for splice-in contribution 0.00220000 BTC, considering fees of {}. Need more inputs.", Amount::from_sat(expected_fee), @@ -590,7 +616,8 @@ mod tests { is_splice: true, feerate: FeeRate::from_sat_per_kwu(2000), }; - assert_eq!(contribution.net_value(), Ok(contribution.value_added.to_signed().unwrap())); + assert!(contribution.validate().is_ok()); + assert_eq!(contribution.net_value(), contribution.value_added.to_signed().unwrap()); } // higher fee rate, does not cover @@ -609,7 +636,7 @@ mod tests { feerate: FeeRate::from_sat_per_kwu(2200), }; assert_eq!( - contribution.net_value(), + contribution.validate(), Err(format!( "Total input amount 0.00300000 BTC is lower than needed for splice-in contribution 0.00298032 BTC, considering fees of {}. Need more inputs.", Amount::from_sat(expected_fee), @@ -632,7 +659,68 @@ mod tests { is_splice: false, feerate: FeeRate::from_sat_per_kwu(2000), }; - assert_eq!(contribution.net_value(), Ok(contribution.value_added.to_signed().unwrap())); + assert!(contribution.validate().is_ok()); + assert_eq!(contribution.net_value(), contribution.value_added.to_signed().unwrap()); + } + } + + struct UnreachableWallet; + + impl CoinSelectionSourceSync for UnreachableWallet { + fn select_confirmed_utxos( + &self, _claim_id: Option, _must_spend: Vec, _must_pay_to: &[TxOut], + _target_feerate_sat_per_1000_weight: u32, _max_tx_weight: u64, + ) -> Result { + unreachable!("should not reach coin selection") + } + fn sign_psbt(&self, _psbt: Psbt) -> Result { + unreachable!("should not reach signing") + } + } + + #[test] + fn test_build_funding_contribution_validates_max_money() { + let over_max = Amount::MAX_MONEY + Amount::from_sat(1); + let feerate = FeeRate::from_sat_per_kwu(2000); + + // splice_in_sync with value_added > MAX_MONEY + { + let template = FundingTemplate::new(None, feerate); + assert!(template.splice_in_sync(over_max, UnreachableWallet).is_err()); + } + + // splice_out_sync with single output value > MAX_MONEY + { + let template = FundingTemplate::new(None, feerate); + let outputs = vec![funding_output_sats(over_max.to_sat())]; + assert!(template.splice_out_sync(outputs, UnreachableWallet).is_err()); + } + + // splice_out_sync with multiple outputs summing > MAX_MONEY + { + let template = FundingTemplate::new(None, feerate); + let half_over = Amount::MAX_MONEY / 2 + Amount::from_sat(1); + let outputs = vec![ + funding_output_sats(half_over.to_sat()), + funding_output_sats(half_over.to_sat()), + ]; + assert!(template.splice_out_sync(outputs, UnreachableWallet).is_err()); + } + + // splice_in_and_out_sync with value_added > MAX_MONEY + { + let template = FundingTemplate::new(None, feerate); + let outputs = vec![funding_output_sats(1_000)]; + assert!(template.splice_in_and_out_sync(over_max, outputs, UnreachableWallet).is_err()); + } + + // splice_in_and_out_sync with output sum > MAX_MONEY + { + let template = FundingTemplate::new(None, feerate); + let outputs = vec![funding_output_sats(over_max.to_sat())]; + assert!(template + .splice_in_and_out_sync(Amount::from_sat(1_000), outputs, UnreachableWallet) + .is_err()); } } } From 113f9cbdce0c2fbf0e98558ad3aab90acc904f4a Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 22 Jan 2026 20:14:09 +0000 Subject: [PATCH 048/627] Pass constructed `PendingAddHTLCInfo` to chanman `forward_htlcs` We jump through some hoops in order to pass a small list of objects to `forward_htlcs` on a per-channel basis rather than per-HTLC. Then, `forward_htlcs` builds a `PendingAddHTLCInfo` for each HTLC for insertion. Worse, in some `forward_htlcs` callsites we're actually starting with a `PendingAddHTLCInfo`, converting it to a tuple, then back inside `forward_htlcs`. Instead, here we just pass a list of built `PendingAddHTLCInfo`s to `forward_htlcs`, cleaning up a good bit of code and even avoiding an allocation of the HTLCs vec in many cases. --- lightning/src/ln/channelmanager.rs | 153 +++++++++++------------------ 1 file changed, 57 insertions(+), 96 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index eae26cc2d91..9d32d4f3575 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -449,7 +449,7 @@ pub(super) enum PendingHTLCStatus { pub(super) struct PendingAddHTLCInfo { pub(super) forward_info: PendingHTLCInfo, - // These fields are produced in `forward_htlcs()` and consumed in + // These fields are set before calling `forward_htlcs()` and consumed in // `process_pending_htlc_forwards()` for constructing the // `HTLCSource::PreviousHopData` for failed and forwarded // HTLCs. @@ -766,10 +766,6 @@ impl_writeable_tlv_based_enum!(SentHTLCId, }, ); -// (src_outbound_scid_alias, src_counterparty_node_id, src_funding_outpoint, src_chan_id, src_user_chan_id) -type PerSourcePendingForward = - (u64, PublicKey, OutPoint, ChannelId, u128, Vec<(PendingHTLCInfo, u64)>); - type FailedHTLCForward = (HTLCSource, PaymentHash, HTLCFailReason, HTLCHandlingFailureType); mod fuzzy_channelmanager { @@ -1421,7 +1417,7 @@ enum PostMonitorUpdateChanResume { user_channel_id: u128, unbroadcasted_batch_funding_txid: Option, update_actions: Vec, - htlc_forwards: Option, + htlc_forwards: Vec, decode_update_add_htlcs: Option<(u64, Vec)>, finalized_claimed_htlcs: Vec<(HTLCSource, Option)>, failed_htlcs: Vec<(HTLCSource, PaymentHash, HTLCFailReason)>, @@ -6798,15 +6794,16 @@ impl< ..payment.forward_info }; - let mut per_source_pending_forward = [( - payment.prev_outbound_scid_alias, - payment.prev_counterparty_node_id, - payment.prev_funding_outpoint, - payment.prev_channel_id, - payment.prev_user_channel_id, - vec![(pending_htlc_info, payment.prev_htlc_id)], - )]; - self.forward_htlcs(&mut per_source_pending_forward); + let forward = [PendingAddHTLCInfo { + prev_outbound_scid_alias: payment.prev_outbound_scid_alias, + prev_htlc_id: payment.prev_htlc_id, + prev_counterparty_node_id: payment.prev_counterparty_node_id, + prev_channel_id: payment.prev_channel_id, + prev_funding_outpoint: payment.prev_funding_outpoint, + prev_user_channel_id: payment.prev_user_channel_id, + forward_info: pending_htlc_info, + }]; + self.forward_htlcs(forward); Ok(()) } @@ -7037,7 +7034,7 @@ impl< next_packet_details_opt.map(|d| d.next_packet_pubkey), ) { Ok(info) => { - let to_pending_add = |info| PendingAddHTLCInfo { + let pending_add = PendingAddHTLCInfo { prev_outbound_scid_alias: incoming_scid_alias, prev_counterparty_node_id: incoming_counterparty_node_id, prev_funding_outpoint: incoming_funding_txo, @@ -7059,7 +7056,7 @@ impl< Some(incoming_channel_id), Some(update_add_htlc.payment_hash), ); - if info.routing.should_hold_htlc() { + if pending_add.forward_info.routing.should_hold_htlc() { let mut held_htlcs = self.pending_intercepted_htlcs.lock().unwrap(); let intercept_id = intercept_id(); match held_htlcs.entry(intercept_id) { @@ -7068,7 +7065,6 @@ impl< logger, "Intercepted held HTLC with id {intercept_id}, holding until the recipient is online" ); - let pending_add = to_pending_add(info); entry.insert(pending_add); }, hash_map::Entry::Occupied(_) => { @@ -7085,7 +7081,6 @@ impl< self.pending_intercepted_htlcs.lock().unwrap(); match pending_intercepts.entry(intercept_id) { hash_map::Entry::Vacant(entry) => { - let pending_add = to_pending_add(info); if let Ok(intercept_ev) = create_htlc_intercepted_event(intercept_id, &pending_add) { @@ -7125,7 +7120,7 @@ impl< }, } } else { - htlc_forwards.push((info, update_add_htlc.htlc_id)) + htlc_forwards.push(pending_add); } }, Err(inbound_err) => { @@ -7145,15 +7140,7 @@ impl< // Process all of the forwards and failures for the channel in which the HTLCs were // proposed to as a batch. - let pending_forwards = ( - incoming_scid_alias, - incoming_counterparty_node_id, - incoming_funding_txo, - incoming_channel_id, - incoming_user_channel_id, - htlc_forwards, - ); - self.forward_htlcs(&mut [pending_forwards]); + self.forward_htlcs(htlc_forwards); for (htlc_fail, failure_type, failure_reason) in htlc_fails.drain(..) { let failure = match htlc_fail { HTLCFailureMsg::Relay(fail_htlc) => HTLCForwardInfo::FailHTLC { @@ -7247,7 +7234,7 @@ impl< let mut new_events = VecDeque::new(); let mut failed_forwards = Vec::new(); - let mut phantom_receives: Vec = Vec::new(); + let mut phantom_receives: Vec = Vec::new(); let mut forward_htlcs = new_hash_map(); mem::swap(&mut forward_htlcs, &mut self.forward_htlcs.lock().unwrap()); @@ -7294,7 +7281,7 @@ impl< None, ); } - self.forward_htlcs(&mut phantom_receives); + self.forward_htlcs(phantom_receives); if self.check_free_holding_cells() { should_persist = NotifyOption::DoPersist; @@ -7314,7 +7301,7 @@ impl< fn forwarding_channel_not_found( &self, forward_infos: impl Iterator, short_chan_id: u64, forwarding_counterparty: Option, failed_forwards: &mut Vec, - phantom_receives: &mut Vec, + phantom_receives: &mut Vec, ) { for forward_info in forward_infos { match forward_info { @@ -7436,14 +7423,15 @@ impl< current_height, ); match create_res { - Ok(info) => phantom_receives.push(( + Ok(info) => phantom_receives.push(PendingAddHTLCInfo { + forward_info: info, prev_outbound_scid_alias, + prev_htlc_id, prev_counterparty_node_id, - prev_funding_outpoint, prev_channel_id, + prev_funding_outpoint, prev_user_channel_id, - vec![(info, prev_htlc_id)], - )), + }), Err(InboundHTLCErr { reason, err_data, msg }) => { failure_handler( msg, @@ -7495,7 +7483,7 @@ impl< fn process_forward_htlcs( &self, short_chan_id: u64, pending_forwards: &mut Vec, failed_forwards: &mut Vec, - phantom_receives: &mut Vec, + phantom_receives: &mut Vec, ) { let mut forwarding_counterparty = None; @@ -9572,8 +9560,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ fn post_monitor_update_unlock( &self, channel_id: ChannelId, counterparty_node_id: PublicKey, funding_txo: OutPoint, user_channel_id: u128, unbroadcasted_batch_funding_txid: Option, - update_actions: Vec, - htlc_forwards: Option, + update_actions: Vec, htlc_forwards: Vec, decode_update_add_htlcs: Option<(u64, Vec)>, finalized_claimed_htlcs: Vec<(HTLCSource, Option)>, failed_htlcs: Vec<(HTLCSource, PaymentHash, HTLCFailReason)>, @@ -9634,9 +9621,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ self.handle_monitor_update_completion_actions(update_actions); - if let Some(forwards) = htlc_forwards { - self.forward_htlcs(&mut [forwards][..]); - } + self.forward_htlcs(htlc_forwards); if let Some(decode) = decode_update_add_htlcs { self.push_decode_update_add_htlcs(decode); } @@ -10263,7 +10248,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ channel_ready: Option, announcement_sigs: Option, tx_signatures: Option, tx_abort: Option, channel_ready_order: ChannelReadyOrder, - ) -> (Option<(u64, PublicKey, OutPoint, ChannelId, u128, Vec<(PendingHTLCInfo, u64)>)>, Option<(u64, Vec)>) { + ) -> (Vec, Option<(u64, Vec)>) { let logger = WithChannelContext::from(&self.logger, &channel.context, None); log_trace!(logger, "Handling channel resumption with {} RAA, {} commitment update, {} pending forwards, {} pending update_add_htlcs, {}broadcasting funding, {} channel ready, {} announcement, {} tx_signatures, {} tx_abort", if raa.is_some() { "an" } else { "no" }, @@ -10279,13 +10264,19 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let counterparty_node_id = channel.context.get_counterparty_node_id(); let outbound_scid_alias = channel.context.outbound_scid_alias(); - let mut htlc_forwards = None; + let mut htlc_forwards = Vec::new(); if !pending_forwards.is_empty() { - htlc_forwards = Some(( - outbound_scid_alias, channel.context.get_counterparty_node_id(), - channel.funding.get_funding_txo().unwrap(), channel.context.channel_id(), - channel.context.get_user_id(), pending_forwards - )); + htlc_forwards = pending_forwards.into_iter().map(|(forward_info, prev_htlc_id)| { + PendingAddHTLCInfo { + forward_info, + prev_outbound_scid_alias: outbound_scid_alias, + prev_htlc_id, + prev_counterparty_node_id: counterparty_node_id, + prev_channel_id: channel.context.channel_id(), + prev_funding_outpoint: channel.funding.get_funding_txo().unwrap(), + prev_user_channel_id: channel.context.get_user_id(), + } + }).collect(); } let mut decode_update_add_htlcs = None; if !pending_update_adds.is_empty() { @@ -12130,44 +12121,22 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } #[inline] - fn forward_htlcs(&self, per_source_pending_forwards: &mut [PerSourcePendingForward]) { - for &mut ( - prev_outbound_scid_alias, - prev_counterparty_node_id, - prev_funding_outpoint, - prev_channel_id, - prev_user_channel_id, - ref mut pending_forwards, - ) in per_source_pending_forwards - { - if !pending_forwards.is_empty() { - for (forward_info, prev_htlc_id) in pending_forwards.drain(..) { - let scid = match forward_info.routing { - PendingHTLCRouting::Forward { short_channel_id, .. } => short_channel_id, - PendingHTLCRouting::TrampolineForward { .. } - | PendingHTLCRouting::Receive { .. } - | PendingHTLCRouting::ReceiveKeysend { .. } => 0, - }; - - let pending_add = PendingAddHTLCInfo { - prev_outbound_scid_alias, - prev_counterparty_node_id, - prev_funding_outpoint, - prev_channel_id, - prev_htlc_id, - prev_user_channel_id, - forward_info, - }; + fn forward_htlcs>(&self, pending_forwards: I) { + for htlc in pending_forwards.into_iter() { + let scid = match htlc.forward_info.routing { + PendingHTLCRouting::Forward { short_channel_id, .. } => short_channel_id, + PendingHTLCRouting::TrampolineForward { .. } + | PendingHTLCRouting::Receive { .. } + | PendingHTLCRouting::ReceiveKeysend { .. } => 0, + }; - match self.forward_htlcs.lock().unwrap().entry(scid) { - hash_map::Entry::Occupied(mut entry) => { - entry.get_mut().push(HTLCForwardInfo::AddHTLC(pending_add)); - }, - hash_map::Entry::Vacant(entry) => { - entry.insert(vec![HTLCForwardInfo::AddHTLC(pending_add)]); - }, - } - } + match self.forward_htlcs.lock().unwrap().entry(scid) { + hash_map::Entry::Occupied(mut entry) => { + entry.get_mut().push(HTLCForwardInfo::AddHTLC(htlc)); + }, + hash_map::Entry::Vacant(entry) => { + entry.insert(vec![HTLCForwardInfo::AddHTLC(htlc)]); + }, } } } @@ -12502,7 +12471,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ Vec::new(), Vec::new(), None, responses.channel_ready, responses.announcement_sigs, responses.tx_signatures, responses.tx_abort, responses.channel_ready_order, ); - debug_assert!(htlc_forwards.is_none()); + debug_assert!(htlc_forwards.is_empty()); debug_assert!(decode_update_add_htlcs.is_none()); if let Some(upd) = channel_update { peer_state.pending_msg_events.push(upd); @@ -16563,15 +16532,7 @@ impl< }, } } else { - let mut per_source_pending_forward = [( - htlc.prev_outbound_scid_alias, - htlc.prev_counterparty_node_id, - htlc.prev_funding_outpoint, - htlc.prev_channel_id, - htlc.prev_user_channel_id, - vec![(htlc.forward_info, htlc.prev_htlc_id)], - )]; - self.forward_htlcs(&mut per_source_pending_forward); + self.forward_htlcs([htlc]); } }, _ => return, From 9a9531f3ce71cb05c37ebf93a6953196c25a676d Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 22 Jan 2026 21:15:22 +0000 Subject: [PATCH 049/627] Allow intercepting HTLCs based on the source channel It may be useful in some situations to select HTLCs for interception based on the source channel in addition to the sink. Here we add the ability to do so by adding new flags to `HTLCInterceptionFlags`. --- lightning/src/ln/channelmanager.rs | 92 ++++++++++++------ lightning/src/ln/interception_tests.rs | 128 +++++++++++++++++++------ lightning/src/util/config.rs | 49 +++++++++- 3 files changed, 212 insertions(+), 57 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 9d32d4f3575..b417e023f4b 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -4729,7 +4729,9 @@ impl< } } - fn forward_needs_intercept_to_known_chan(&self, outbound_chan: &FundedChannel) -> bool { + fn forward_needs_intercept_to_known_chan( + &self, prev_chan_public: bool, outbound_chan: &FundedChannel, + ) -> bool { let intercept_flags = self.config.read().unwrap().htlc_interception_flags; if !outbound_chan.context.should_announce() { if outbound_chan.context.is_connected() { @@ -4746,6 +4748,23 @@ impl< return true; } } + if prev_chan_public { + if outbound_chan.context.should_announce() { + if intercept_flags & (HTLCInterceptionFlags::FromPublicToPublicChannels as u8) != 0 + { + return true; + } + } else { + if intercept_flags & (HTLCInterceptionFlags::FromPublicToPrivateChannels as u8) != 0 + { + return true; + } + } + } else { + if intercept_flags & (HTLCInterceptionFlags::FromPrivateChannels as u8) != 0 { + return true; + } + } false } @@ -4839,7 +4858,7 @@ impl< } fn can_forward_htlc_should_intercept( - &self, msg: &msgs::UpdateAddHTLC, next_hop: &NextPacketDetails, + &self, msg: &msgs::UpdateAddHTLC, prev_chan_public: bool, next_hop: &NextPacketDetails, ) -> Result { let outgoing_scid = match next_hop.outgoing_connector { HopConnector::ShortChannelId(scid) => scid, @@ -4858,7 +4877,7 @@ impl< // times we do it. let intercept = match self.do_funded_channel_callback(outgoing_scid, |chan: &mut FundedChannel| { - let intercept = self.forward_needs_intercept_to_known_chan(chan); + let intercept = self.forward_needs_intercept_to_known_chan(prev_chan_public, chan); self.can_forward_htlc_to_outgoing_channel(chan, msg, next_hop, intercept)?; Ok(intercept) }) { @@ -6869,34 +6888,29 @@ impl< 'outer_loop: for (incoming_scid_alias, update_add_htlcs) in decode_update_add_htlcs { // If any decoded update_add_htlcs were processed, we need to persist. should_persist = true; - let incoming_channel_details_opt = self.do_funded_channel_callback( - incoming_scid_alias, - |chan: &mut FundedChannel| { - let counterparty_node_id = chan.context.get_counterparty_node_id(); - let channel_id = chan.context.channel_id(); - let funding_txo = chan.funding.get_funding_txo().unwrap(); - let user_channel_id = chan.context.get_user_id(); - let accept_underpaying_htlcs = chan.context.config().accept_underpaying_htlcs; - ( - counterparty_node_id, - channel_id, - funding_txo, - user_channel_id, - accept_underpaying_htlcs, - ) - }, - ); let ( incoming_counterparty_node_id, incoming_channel_id, incoming_funding_txo, incoming_user_channel_id, incoming_accept_underpaying_htlcs, - ) = if let Some(incoming_channel_details) = incoming_channel_details_opt { - incoming_channel_details - } else { + incoming_chan_is_public, + ) = match self.do_funded_channel_callback( + incoming_scid_alias, + |chan: &mut FundedChannel| { + ( + chan.context.get_counterparty_node_id(), + chan.context.channel_id(), + chan.funding.get_funding_txo().unwrap(), + chan.context.get_user_id(), + chan.context.config().accept_underpaying_htlcs, + chan.context.should_announce(), + ) + }, + ) { + Some(incoming_channel_details) => incoming_channel_details, // The incoming channel no longer exists, HTLCs should be resolved onchain instead. - continue; + None => continue, }; let mut htlc_forwards = Vec::new(); @@ -7016,9 +7030,11 @@ impl< // Now process the HTLC on the outgoing channel if it's a forward. let mut intercept_forward = false; if let Some(next_packet_details) = next_packet_details_opt.as_ref() { - match self - .can_forward_htlc_should_intercept(&update_add_htlc, next_packet_details) - { + match self.can_forward_htlc_should_intercept( + &update_add_htlc, + incoming_chan_is_public, + next_packet_details, + ) { Err(reason) => { fail_htlc_continue_to_next!(reason); }, @@ -16492,9 +16508,29 @@ impl< ); log_trace!(logger, "Releasing held htlc with intercept_id {}", intercept_id); + let prev_chan_public = { + let per_peer_state = self.per_peer_state.read().unwrap(); + let peer_state = per_peer_state + .get(&htlc.prev_counterparty_node_id) + .map(|mtx| mtx.lock().unwrap()); + let chan_state = peer_state + .as_ref() + .map(|state| state.channel_by_id.get(&htlc.prev_channel_id)) + .flatten(); + if let Some(chan_state) = chan_state { + chan_state.context().should_announce() + } else { + // If the inbound channel has closed since the HTLC was held, we really + // shouldn't forward it - forwarding it now would result in, at best, + // having to claim the HTLC on chain. Instead, drop the HTLC and let the + // counterparty claim their money on chain. + return; + } + }; + let should_intercept = self .do_funded_channel_callback(next_hop_scid, |chan| { - self.forward_needs_intercept_to_known_chan(chan) + self.forward_needs_intercept_to_known_chan(prev_chan_public, chan) }) .unwrap_or_else(|| self.forward_needs_intercept_to_unknown_chan(next_hop_scid)); diff --git a/lightning/src/ln/interception_tests.rs b/lightning/src/ln/interception_tests.rs index c83ef177628..c3cd52a0e2e 100644 --- a/lightning/src/ln/interception_tests.rs +++ b/lightning/src/ln/interception_tests.rs @@ -51,7 +51,16 @@ fn do_test_htlc_interception_flags( let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, Some(intercept_config), None]); let nodes = create_network(3, &node_cfgs, &node_chanmgrs); - create_announced_chan_between_nodes(&nodes, 0, 1); + let inbound_private = match flag { + Flag::FromPrivateChannels => { + create_unannounced_chan_between_nodes_with_value(&nodes, 0, 1, 100000, 0); + true + }, + _ => { + create_announced_chan_between_nodes(&nodes, 0, 1); + false + }, + }; let node_0_id = nodes[0].node.get_our_node_id(); let node_1_id = nodes[1].node.get_our_node_id(); @@ -59,29 +68,31 @@ fn do_test_htlc_interception_flags( // First open the right type of channel (and get it in the right state) for the bit we're // testing. - let (target_scid, target_chan_id) = match flag { - Flag::ToOfflinePrivateChannels | Flag::ToOnlinePrivateChannels => { + let (target_scid, target_chan_id, outbound_private_for_known_scids) = match flag { + Flag::ToOfflinePrivateChannels + | Flag::ToOnlinePrivateChannels + | Flag::FromPublicToPrivateChannels => { create_unannounced_chan_between_nodes_with_value(&nodes, 1, 2, 100000, 0); let chan_id = nodes[2].node.list_channels()[0].channel_id; let scid = nodes[2].node.list_channels()[0].short_channel_id.unwrap(); if flag == Flag::ToOfflinePrivateChannels { nodes[1].node.peer_disconnected(node_2_id); nodes[2].node.peer_disconnected(node_1_id); - } else { - assert_eq!(flag, Flag::ToOnlinePrivateChannels); } - (scid, chan_id) + (scid, chan_id, Some(true)) }, - Flag::ToInterceptSCIDs | Flag::ToPublicChannels | Flag::ToUnknownSCIDs => { + Flag::ToInterceptSCIDs + | Flag::ToPublicChannels + | Flag::FromPrivateChannels + | Flag::FromPublicToPublicChannels + | Flag::ToUnknownSCIDs => { let (chan_upd, _, chan_id, _) = create_announced_chan_between_nodes(&nodes, 1, 2); if flag == Flag::ToInterceptSCIDs { - (nodes[1].node.get_intercept_scid(), chan_id) - } else if flag == Flag::ToPublicChannels { - (chan_upd.contents.short_channel_id, chan_id) + (nodes[1].node.get_intercept_scid(), chan_id, None) } else if flag == Flag::ToUnknownSCIDs { - (42424242, chan_id) + (42424242, chan_id, None) } else { - panic!(); + (chan_upd.contents.short_channel_id, chan_id, Some(false)) } }, _ => panic!("Combined flags aren't allowed"), @@ -101,21 +112,50 @@ fn do_test_htlc_interception_flags( get_route_and_payment_hash!(nodes[0], nodes[2], pay_params, amt_msat); route.paths[0].hops[1].short_channel_id = target_scid; - let interception_bit_match = (flags_bitmask & (flag as u8)) != 0; + let mut should_intercept = false; + for a_flag in ALL_FLAGS { + if flags_bitmask & (a_flag as u8) != 0 { + match a_flag { + Flag::ToInterceptSCIDs => { + should_intercept |= flag == Flag::ToInterceptSCIDs; + }, + Flag::ToOfflinePrivateChannels => { + should_intercept |= flag == Flag::ToOfflinePrivateChannels; + }, + Flag::ToOnlinePrivateChannels => { + should_intercept |= flag != Flag::ToOfflinePrivateChannels + && outbound_private_for_known_scids == Some(true); + }, + Flag::ToPublicChannels => { + should_intercept |= outbound_private_for_known_scids == Some(false); + }, + Flag::ToUnknownSCIDs => { + should_intercept |= flag == Flag::ToUnknownSCIDs; + }, + Flag::FromPrivateChannels => { + should_intercept |= inbound_private; + }, + Flag::FromPublicToPrivateChannels => { + should_intercept |= + !inbound_private && outbound_private_for_known_scids == Some(true); + }, + Flag::FromPublicToPublicChannels => { + should_intercept |= + !inbound_private && outbound_private_for_known_scids == Some(false); + }, + _ => panic!("Combined flags aren't allowed"), + } + } + } + match modification { Some(ForwardingMod::FeeTooLow) => { - assert!( - interception_bit_match, - "No reason to test failing if we aren't trying to intercept", - ); + assert!(should_intercept, "No reason to test failing if we aren't trying to intercept"); route.paths[0].hops[0].fee_msat = 500; }, Some(ForwardingMod::CLTVBelowConfig) => { route.paths[0].hops[0].cltv_expiry_delta = 6 * 12; - assert!( - interception_bit_match, - "No reason to test failing if we aren't trying to intercept", - ); + assert!(should_intercept, "No reason to test failing if we aren't trying to intercept"); }, Some(ForwardingMod::CLTVBelowMin) => { route.paths[0].hops[0].cltv_expiry_delta = 6; @@ -133,7 +173,7 @@ fn do_test_htlc_interception_flags( do_commitment_signed_dance(&nodes[1], &nodes[0], &payment_event.commitment_msg, false, true); expect_and_process_pending_htlcs(&nodes[1], false); - if interception_bit_match && modification.is_none() { + if should_intercept && modification.is_none() { // If we were set to intercept, check that we got an interception event then // forward the HTLC on to nodes[2] and claim the payment. let intercept_id; @@ -172,7 +212,14 @@ fn do_test_htlc_interception_flags( // If we were not set to intercept, check that the HTLC either failed or was // automatically forwarded as appropriate. match (modification, flag) { - (None, Flag::ToOnlinePrivateChannels | Flag::ToPublicChannels) => { + ( + None, + Flag::ToOnlinePrivateChannels + | Flag::ToPublicChannels + | Flag::FromPrivateChannels + | Flag::FromPublicToPrivateChannels + | Flag::FromPublicToPublicChannels, + ) => { check_added_monitors(&nodes[1], 1); let forward_ev = SendEvent::from_node(&nodes[1]); @@ -241,31 +288,55 @@ fn do_test_htlc_interception_flags( } const MAX_BITMASK: u8 = HTLCInterceptionFlags::AllValidHTLCs as u8; -const ALL_FLAGS: [HTLCInterceptionFlags; 5] = [ +const ALL_FLAGS: [HTLCInterceptionFlags; 8] = [ HTLCInterceptionFlags::ToInterceptSCIDs, HTLCInterceptionFlags::ToOfflinePrivateChannels, HTLCInterceptionFlags::ToOnlinePrivateChannels, HTLCInterceptionFlags::ToPublicChannels, HTLCInterceptionFlags::ToUnknownSCIDs, + HTLCInterceptionFlags::FromPrivateChannels, + HTLCInterceptionFlags::FromPublicToPrivateChannels, + HTLCInterceptionFlags::FromPublicToPublicChannels, ]; - #[test] -fn test_htlc_interception_flags() { +fn check_all_flags() { let mut all_flag_bits = 0; for flag in ALL_FLAGS { all_flag_bits |= flag as isize; } assert_eq!(all_flag_bits, MAX_BITMASK as isize, "all flags must test all bits"); +} +fn test_htlc_interception_flags_subrange>(r: I) { // Test all 2^5 = 32 combinations of the HTLCInterceptionFlags bitmask // For each combination, test 5 different HTLC forwards and verify correct interception behavior - for flags_bitmask in 0..=MAX_BITMASK { + for flags_bitmask in r { for flag in ALL_FLAGS { do_test_htlc_interception_flags(flags_bitmask, flag, None); } } } +#[test] +fn test_htlc_interception_flags_a() { + test_htlc_interception_flags_subrange(0..MAX_BITMASK / 4); +} + +#[test] +fn test_htlc_interception_flags_b() { + test_htlc_interception_flags_subrange(MAX_BITMASK / 4..MAX_BITMASK / 2); +} + +#[test] +fn test_htlc_interception_flags_c() { + test_htlc_interception_flags_subrange(MAX_BITMASK / 2..MAX_BITMASK / 4 * 3); +} + +#[test] +fn test_htlc_interception_flags_d() { + test_htlc_interception_flags_subrange(MAX_BITMASK / 4 * 3..=MAX_BITMASK); +} + #[test] fn test_htlc_bad_for_chan_config() { // Test that interception won't be done if an HTLC fails to meet the target channel's channel @@ -274,6 +345,9 @@ fn test_htlc_bad_for_chan_config() { HTLCInterceptionFlags::ToOfflinePrivateChannels, HTLCInterceptionFlags::ToOnlinePrivateChannels, HTLCInterceptionFlags::ToPublicChannels, + HTLCInterceptionFlags::FromPrivateChannels, + HTLCInterceptionFlags::FromPublicToPrivateChannels, + HTLCInterceptionFlags::FromPublicToPublicChannels, ]; for flag in have_chan_flags { do_test_htlc_interception_flags(flag as u8, flag, Some(ForwardingMod::FeeTooLow)); diff --git a/lightning/src/util/config.rs b/lightning/src/util/config.rs index dd55d5c2130..e4158910b9a 100644 --- a/lightning/src/util/config.rs +++ b/lightning/src/util/config.rs @@ -920,6 +920,51 @@ pub enum HTLCInterceptionFlags { | Self::ToOfflinePrivateChannels as isize | Self::ToOnlinePrivateChannels as isize | Self::ToPublicChannels as isize, + /// If this flag is set, any attempts to forward a payment from a private channel (to anywhere) + /// will instead generate an [`Event::HTLCIntercepted`] which must be handled the same as any + /// other intercepted HTLC. + /// + /// This is useful for an LSP that may wish to apply a higher fee policy on their channels when + /// the HTLC comes from a private channel client. Note that HTLCs which do not pay the + /// configured fee rate or do not meet the [`ChannelConfig::cltv_expiry_delta`] will fail. + /// Thus, this cannot be used to allow forwarding for less than the public fees. + /// + /// Note that no HTLCs to unknown channels will be intercepted by this flag. For that, use + /// [`Self::ToUnknownSCIDs`]. + /// + /// [`Event::HTLCIntercepted`]: crate::events::Event::HTLCIntercepted + FromPrivateChannels = 1 << 4, + /// If this flag is set, any attempts to forward a payment from a public channel to a private + /// channel will instead generate an [`Event::HTLCIntercepted`] which must be handled the same + /// as any other intercepted HTLC. + /// + /// This is useful for an LSP that may wish to take an additional fee on any HTLCs which are + /// forwarded to a private channel client but wishes to avoid taking that fee when forwarding + /// an HTLC from a private channel client to another private channel client. + /// + /// Note that HTLCs which do not pay the configured fee rate or do not meet the + /// [`ChannelConfig::cltv_expiry_delta`] will fail and not be intercepted. + /// + /// Note that no HTLCs to unknown channels will be intercepted by this flag. For that, use + /// [`Self::ToUnknownSCIDs`]. + /// + /// [`Event::HTLCIntercepted`]: crate::events::Event::HTLCIntercepted + FromPublicToPrivateChannels = 1 << 5, + /// If this flag is set, any attempts to forward a payment from a public channel to another + /// public channel will instead generate an [`Event::HTLCIntercepted`] which must be handled + /// the same as any other intercepted HTLC. + /// + /// This primarily exists for completeness, and generally interception of HTLCs between public + /// channels is *strongly* discouraged. + /// + /// Note that HTLCs which do not pay the configured fee rate or do not meet the + /// [`ChannelConfig::cltv_expiry_delta`] will fail and not be intercepted. + /// + /// Note that no HTLCs to unknown channels will be intercepted by this flag. For that, use + /// [`Self::ToUnknownSCIDs`]. + /// + /// [`Event::HTLCIntercepted`]: crate::events::Event::HTLCIntercepted + FromPublicToPublicChannels = 1 << 6, /// If this flag is set, any attempts to forward a payment to an unknown short channel id will /// instead generate an [`Event::HTLCIntercepted`] which must be handled the same as any other /// intercepted HTLC. @@ -931,7 +976,7 @@ pub enum HTLCInterceptionFlags { /// delta meets your requirements before forwarding the HTLC. /// /// [`Event::HTLCIntercepted`]: crate::events::Event::HTLCIntercepted - ToUnknownSCIDs = 1 << 4, + ToUnknownSCIDs = 1 << 7, /// If these flags are set, all HTLCs being forwarded over this node will instead generate an /// [`Event::HTLCIntercepted`] which must be handled the same as any other intercepted HTLC. /// @@ -941,7 +986,7 @@ pub enum HTLCInterceptionFlags { /// validate the fee and CLTV delta meets your requirements before forwarding the HTLC. /// /// [`Event::HTLCIntercepted`]: crate::events::Event::HTLCIntercepted - AllValidHTLCs = Self::ToAllKnownSCIDs as isize | Self::ToUnknownSCIDs as isize, + AllValidHTLCs = 0xff, } impl Into for HTLCInterceptionFlags { From a026acef4f52da63265d1d2bcbfee59af2a0394d Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Tue, 17 Feb 2026 15:39:30 +0100 Subject: [PATCH 050/627] Split CI test script into parallel jobs Split the monolithic ci-tests.sh into six focused scripts that run as separate parallel jobs via a reusable workflow (ci-build.yml): - ci-tests-workspace.sh: workspace checks, tests, docs, and downstream compat - ci-tests-features.sh: crate feature/flag combinations (dnssec, tokio, backtrace, test vectors, serde) - ci-tests-bindings.sh: c_bindings builds and tests - ci-tests-nostd.sh: no_std builds and compatibility checks - ci-tests-cfg-flags.sh: experimental gated cfg flags (taproot, simple_close, lsps1_service, peer_storage) - ci-tests-sync.sh: block sync and transaction sync clients Shared setup (MSRV pins, backtrace) is extracted into ci-tests-common.sh. The ci-tests.sh script now delegates to the sub-scripts for local use. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build.yml | 98 +++++++-------------- .github/workflows/ci-build.yml | 80 +++++++++++++++++ ci/ci-tests-bindings.sh | 20 +++++ ci/ci-tests-cfg-flags.sh | 14 +++ ci/ci-tests-common.sh | 23 +++++ ci/ci-tests-features.sh | 25 ++++++ ci/ci-tests-nostd.sh | 27 ++++++ ci/ci-tests-sync.sh | 33 +++++++ ci/ci-tests-workspace.sh | 36 ++++++++ ci/ci-tests.sh | 151 ++------------------------------- 10 files changed, 298 insertions(+), 209 deletions(-) create mode 100644 .github/workflows/ci-build.yml create mode 100755 ci/ci-tests-bindings.sh create mode 100755 ci/ci-tests-cfg-flags.sh create mode 100755 ci/ci-tests-common.sh create mode 100755 ci/ci-tests-features.sh create mode 100755 ci/ci-tests-nostd.sh create mode 100755 ci/ci-tests-sync.sh create mode 100755 ci/ci-tests-workspace.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index abc580baf13..7d0a81ee6fc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,72 +26,36 @@ jobs: cd ext-functional-test-demo cargo test --verbose --color always cargo test --verbose --color always --features test-broken - build: - strategy: - fail-fast: false - matrix: - platform: >- - ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' - && fromJSON('["self-hosted","windows-latest","macos-latest"]') - || fromJSON('["self-hosted"]') }} - toolchain: >- - ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' - && fromJSON('["stable","beta","1.75.0"]') - || fromJSON('["1.75.0"]') }} - exclude: - - platform: windows-latest - toolchain: 1.75.0 - - platform: windows-latest - toolchain: beta - - platform: macos-latest - toolchain: beta - runs-on: ${{ matrix.platform }} - steps: - - name: Checkout source code - uses: actions/checkout@v4 - - name: Install Rust ${{ matrix.toolchain }} toolchain - run: | - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ matrix.toolchain }} - - name: Use rust-lld linker on Windows - if: matrix.platform == 'windows-latest' - shell: bash - run: echo "RUSTFLAGS=-C linker=rust-lld" >> "$GITHUB_ENV" - - name: Install no-std-check dependencies for ARM Embedded - if: "matrix.platform == 'self-hosted'" - run: | - rustup target add thumbv7m-none-eabi - - name: Set RUSTFLAGS to deny warnings - if: "matrix.toolchain == '1.75.0'" - run: echo "RUSTFLAGS=-D warnings" >> "$GITHUB_ENV" - - name: Enable caching for bitcoind - if: matrix.platform != 'windows-latest' - id: cache-bitcoind - uses: actions/cache@v4 - with: - path: bin/bitcoind-${{ runner.os }}-${{ runner.arch }} - key: bitcoind-${{ runner.os }}-${{ runner.arch }} - - name: Enable caching for electrs - if: matrix.platform != 'windows-latest' - id: cache-electrs - uses: actions/cache@v4 - with: - path: bin/electrs-${{ runner.os }}-${{ runner.arch }} - key: electrs-${{ runner.os }}-${{ runner.arch }} - - name: Download bitcoind/electrs - if: "matrix.platform != 'windows-latest' && (steps.cache-bitcoind.outputs.cache-hit != 'true' || steps.cache-electrs.outputs.cache-hit != 'true')" - run: | - source ./contrib/download_bitcoind_electrs.sh - mkdir bin - mv "$BITCOIND_EXE" bin/bitcoind-${{ runner.os }}-${{ runner.arch }} - mv "$ELECTRS_EXE" bin/electrs-${{ runner.os }}-${{ runner.arch }} - - name: Set bitcoind/electrs environment variables - if: matrix.platform != 'windows-latest' - run: | - echo "BITCOIND_EXE=$( pwd )/bin/bitcoind-${{ runner.os }}-${{ runner.arch }}" >> "$GITHUB_ENV" - echo "ELECTRS_EXE=$( pwd )/bin/electrs-${{ runner.os }}-${{ runner.arch }}" >> "$GITHUB_ENV" - - name: Run CI script - shell: bash # Default on Winblows is powershell - run: CI_ENV=1 CI_MINIMIZE_DISK_USAGE=1 ./ci/ci-tests.sh + + build-workspace: + uses: ./.github/workflows/ci-build.yml + with: + script: ci/ci-tests-workspace.sh + + build-features: + uses: ./.github/workflows/ci-build.yml + with: + script: ci/ci-tests-features.sh + + build-bindings: + uses: ./.github/workflows/ci-build.yml + with: + script: ci/ci-tests-bindings.sh + + build-nostd: + uses: ./.github/workflows/ci-build.yml + with: + script: ci/ci-tests-nostd.sh + + build-cfg-flags: + uses: ./.github/workflows/ci-build.yml + with: + script: ci/ci-tests-cfg-flags.sh + + build-sync: + uses: ./.github/workflows/ci-build.yml + with: + script: ci/ci-tests-sync.sh coverage: needs: fuzz @@ -343,7 +307,7 @@ jobs: TOR_PROXY="127.0.0.1:9050" RUSTFLAGS="--cfg=tor" cargo test --verbose --color always -p lightning-net-tokio notify-failure: - needs: [build, fuzz, linting, rustfmt, check_release, check_docs, benchmark, ext-test, tor-connect, coverage] + needs: [build-workspace, build-features, build-bindings, build-nostd, build-cfg-flags, build-sync, fuzz, linting, rustfmt, check_release, check_docs, benchmark, ext-test, tor-connect, coverage] if: failure() && github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml new file mode 100644 index 00000000000..4c56619d3ad --- /dev/null +++ b/.github/workflows/ci-build.yml @@ -0,0 +1,80 @@ +name: CI Build Job + +on: + workflow_call: + inputs: + script: + description: CI script to run (relative to repo root) + required: true + type: string + +jobs: + build: + strategy: + fail-fast: false + matrix: + platform: >- + ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' + && fromJSON('["self-hosted","windows-latest","macos-latest"]') + || fromJSON('["self-hosted"]') }} + toolchain: >- + ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' + && fromJSON('["stable","beta","1.75.0"]') + || fromJSON('["1.75.0"]') }} + exclude: + - platform: windows-latest + toolchain: 1.75.0 + - platform: windows-latest + toolchain: beta + - platform: macos-latest + toolchain: beta + runs-on: ${{ matrix.platform }} + steps: + - name: Checkout source code + uses: actions/checkout@v4 + - name: Install Rust ${{ matrix.toolchain }} toolchain + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ matrix.toolchain }} + - name: Use rust-lld linker on Windows + if: matrix.platform == 'windows-latest' + shell: bash + run: echo "RUSTFLAGS=-C linker=rust-lld" >> "$GITHUB_ENV" + - name: Set RUSTFLAGS to deny warnings + if: "matrix.toolchain == '1.75.0'" + run: echo "RUSTFLAGS=-D warnings" >> "$GITHUB_ENV" + - name: Install no-std-check dependencies for ARM Embedded + if: matrix.platform == 'self-hosted' + run: | + rustup target add thumbv7m-none-eabi + - name: Enable caching for bitcoind + if: matrix.platform != 'windows-latest' + id: cache-bitcoind + uses: actions/cache@v4 + with: + path: bin/bitcoind-${{ runner.os }}-${{ runner.arch }} + key: bitcoind-${{ runner.os }}-${{ runner.arch }} + - name: Enable caching for electrs + if: matrix.platform != 'windows-latest' + id: cache-electrs + uses: actions/cache@v4 + with: + path: bin/electrs-${{ runner.os }}-${{ runner.arch }} + key: electrs-${{ runner.os }}-${{ runner.arch }} + - name: Download bitcoind/electrs + if: >- + matrix.platform != 'windows-latest' + && (steps.cache-bitcoind.outputs.cache-hit != 'true' + || steps.cache-electrs.outputs.cache-hit != 'true') + run: | + source ./contrib/download_bitcoind_electrs.sh + mkdir bin + mv "$BITCOIND_EXE" bin/bitcoind-${{ runner.os }}-${{ runner.arch }} + mv "$ELECTRS_EXE" bin/electrs-${{ runner.os }}-${{ runner.arch }} + - name: Set bitcoind/electrs environment variables + if: matrix.platform != 'windows-latest' + run: | + echo "BITCOIND_EXE=$( pwd )/bin/bitcoind-${{ runner.os }}-${{ runner.arch }}" >> "$GITHUB_ENV" + echo "ELECTRS_EXE=$( pwd )/bin/electrs-${{ runner.os }}-${{ runner.arch }}" >> "$GITHUB_ENV" + - name: Run CI script + shell: bash + run: CI_ENV=1 CI_MINIMIZE_DISK_USAGE=1 ./${{ inputs.script }} diff --git a/ci/ci-tests-bindings.sh b/ci/ci-tests-bindings.sh new file mode 100755 index 00000000000..74b471a391b --- /dev/null +++ b/ci/ci-tests-bindings.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -eox pipefail + +# shellcheck source=ci/ci-tests-common.sh +source "$(dirname "$0")/ci-tests-common.sh" + +echo -e "\n\nTesting c_bindings builds" +# Note that because `$RUSTFLAGS` is not passed through to doctest builds we cannot selectively +# disable doctests in `c_bindings` so we skip doctests entirely here. +RUSTFLAGS="$RUSTFLAGS --cfg=c_bindings" cargo test --quiet --color always --lib --bins --tests + +for DIR in lightning-invoice lightning-rapid-gossip-sync; do + # check if there is a conflict between no_std and the c_bindings cfg + RUSTFLAGS="$RUSTFLAGS --cfg=c_bindings" cargo test -p $DIR --quiet --color always --no-default-features +done + +# Note that because `$RUSTFLAGS` is not passed through to doctest builds we cannot selectively +# disable doctests in `c_bindings` so we skip doctests entirely here. +RUSTFLAGS="$RUSTFLAGS --cfg=c_bindings" cargo test -p lightning-background-processor --quiet --color always --no-default-features --lib --bins --tests +RUSTFLAGS="$RUSTFLAGS --cfg=c_bindings" cargo test -p lightning --quiet --color always --no-default-features --lib --bins --tests diff --git a/ci/ci-tests-cfg-flags.sh b/ci/ci-tests-cfg-flags.sh new file mode 100755 index 00000000000..5380c986f3f --- /dev/null +++ b/ci/ci-tests-cfg-flags.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -eox pipefail + +# shellcheck source=ci/ci-tests-common.sh +source "$(dirname "$0")/ci-tests-common.sh" + +echo -e "\n\nTest cfg-flag builds" +RUSTFLAGS="--cfg=taproot" cargo test --quiet --color always -p lightning +[ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean +RUSTFLAGS="--cfg=simple_close" cargo test --quiet --color always -p lightning +[ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean +RUSTFLAGS="--cfg=lsps1_service" cargo test --quiet --color always -p lightning-liquidity +[ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean +RUSTFLAGS="--cfg=peer_storage" cargo test --quiet --color always -p lightning diff --git a/ci/ci-tests-common.sh b/ci/ci-tests-common.sh new file mode 100755 index 00000000000..d60c9d07df0 --- /dev/null +++ b/ci/ci-tests-common.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# ci/ci-tests-common.sh - Shared helpers for CI test scripts. +# Source this file; do not execute it directly. +# shellcheck disable=SC2002,SC2207 + +RUSTC_MINOR_VERSION=$(rustc --version | awk '{ split($2,a,"."); print a[2] }') + +# Some crates require pinning to meet our MSRV even for our downstream users, +# which we do here. +# Further crates which appear only as dev-dependencies are pinned further down. +function PIN_RELEASE_DEPS { + return 0 # Don't fail the script if our rustc is higher than the last check +} + +PIN_RELEASE_DEPS # pin the release dependencies in our main workspace + +# The backtrace v0.3.75 crate relies on rustc 1.82 +[ "$RUSTC_MINOR_VERSION" -lt 82 ] && cargo update -p backtrace --precise "0.3.74" --quiet + +# Starting with version 1.2.0, the `idna_adapter` crate has an MSRV of rustc 1.81.0. +[ "$RUSTC_MINOR_VERSION" -lt 81 ] && cargo update -p idna_adapter --precise "1.1.0" --quiet + +export RUST_BACKTRACE=1 diff --git a/ci/ci-tests-features.sh b/ci/ci-tests-features.sh new file mode 100755 index 00000000000..f01e7fd8fec --- /dev/null +++ b/ci/ci-tests-features.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -eox pipefail + +# shellcheck source=ci/ci-tests-common.sh +source "$(dirname "$0")/ci-tests-common.sh" + +echo -e "\n\nChecking and testing lightning with features" +cargo test -p lightning --quiet --color always --features dnssec +cargo check -p lightning --quiet --color always --features dnssec +cargo doc -p lightning --quiet --document-private-items --features dnssec + +echo -e "\n\nChecking and testing lightning-persister with features" +cargo test -p lightning-persister --quiet --color always --features tokio +cargo check -p lightning-persister --quiet --color always --features tokio +cargo doc -p lightning-persister --quiet --document-private-items --features tokio + +echo -e "\n\nTest backtrace-debug builds" +cargo test -p lightning --quiet --color always --features backtrace + +echo -e "\n\nTesting other crate-specific builds" +# Note that outbound_commitment_test only runs in this mode because of hardcoded signature values +RUSTFLAGS="$RUSTFLAGS --cfg=ldk_test_vectors" cargo test -p lightning --quiet --color always --no-default-features --features=std +# This one only works for lightning-invoice +# check that compile with no_std and serde works in lightning-invoice +cargo test -p lightning-invoice --quiet --color always --no-default-features --features serde diff --git a/ci/ci-tests-nostd.sh b/ci/ci-tests-nostd.sh new file mode 100755 index 00000000000..7d3acb15e06 --- /dev/null +++ b/ci/ci-tests-nostd.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -eox pipefail + +# shellcheck source=ci/ci-tests-common.sh +source "$(dirname "$0")/ci-tests-common.sh" + +echo -e "\n\nTesting no_std builds" +for DIR in lightning-invoice lightning-rapid-gossip-sync lightning-liquidity; do + cargo test -p $DIR --quiet --color always --no-default-features +done + +cargo test -p lightning --quiet --color always --no-default-features +cargo test -p lightning-background-processor --quiet --color always --no-default-features + +echo -e "\n\nTesting no_std build on a downstream no-std crate" +# check no-std compatibility across dependencies +pushd no-std-check +cargo check --quiet --color always +[ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean +popd + +if [ -f "$(which arm-none-eabi-gcc)" ]; then + pushd no-std-check + cargo build --quiet --target=thumbv7m-none-eabi + [ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean + popd +fi diff --git a/ci/ci-tests-sync.sh b/ci/ci-tests-sync.sh new file mode 100755 index 00000000000..ef836a60413 --- /dev/null +++ b/ci/ci-tests-sync.sh @@ -0,0 +1,33 @@ +#!/bin/bash +set -eox pipefail + +# shellcheck source=ci/ci-tests-common.sh +source "$(dirname "$0")/ci-tests-common.sh" + +echo -e "\n\nChecking and testing Block Sync Clients with features" + +cargo test -p lightning-block-sync --quiet --color always --features rest-client +cargo check -p lightning-block-sync --quiet --color always --features rest-client +cargo test -p lightning-block-sync --quiet --color always --features rpc-client +cargo check -p lightning-block-sync --quiet --color always --features rpc-client +cargo test -p lightning-block-sync --quiet --color always --features rpc-client,rest-client +cargo check -p lightning-block-sync --quiet --color always --features rpc-client,rest-client +cargo test -p lightning-block-sync --quiet --color always --features rpc-client,rest-client,tokio +cargo check -p lightning-block-sync --quiet --color always --features rpc-client,rest-client,tokio + +echo -e "\n\nChecking Transaction Sync Clients with features." +cargo check -p lightning-transaction-sync --quiet --color always --features esplora-blocking +cargo check -p lightning-transaction-sync --quiet --color always --features esplora-async +cargo check -p lightning-transaction-sync --quiet --color always --features esplora-async-https +cargo check -p lightning-transaction-sync --quiet --color always --features electrum + +if [ -z "$CI_ENV" ] && [[ -z "$BITCOIND_EXE" || -z "$ELECTRS_EXE" ]]; then + echo -e "\n\nSkipping testing Transaction Sync Clients due to BITCOIND_EXE or ELECTRS_EXE being unset." + cargo check -p lightning-transaction-sync --tests +else + echo -e "\n\nTesting Transaction Sync Clients with features." + cargo test -p lightning-transaction-sync --quiet --color always --features esplora-blocking + cargo test -p lightning-transaction-sync --quiet --color always --features esplora-async + cargo test -p lightning-transaction-sync --quiet --color always --features esplora-async-https + cargo test -p lightning-transaction-sync --quiet --color always --features electrum +fi diff --git a/ci/ci-tests-workspace.sh b/ci/ci-tests-workspace.sh new file mode 100755 index 00000000000..3302f075394 --- /dev/null +++ b/ci/ci-tests-workspace.sh @@ -0,0 +1,36 @@ +#!/bin/bash +#shellcheck disable=SC2002,SC2207 +set -eox pipefail + +# shellcheck source=ci/ci-tests-common.sh +source "$(dirname "$0")/ci-tests-common.sh" + +echo -e "\n\nChecking the workspace, except lightning-transaction-sync." +cargo check --quiet --color always + +WORKSPACE_MEMBERS=( $(cat Cargo.toml | tr '\n' '\r' | sed 's/\r //g' | tr '\r' '\n' | grep '^members =' | sed 's/members.*=.*\[//' | tr -d '"' | tr ',' ' ') ) + +echo -e "\n\nTesting the workspace, except lightning-transaction-sync." +cargo test --quiet --color always + +echo -e "\n\nTesting upgrade from prior versions of LDK" +pushd lightning-tests +cargo test --quiet +popd + +echo -e "\n\nChecking and building docs for all workspace members individually..." +for DIR in "${WORKSPACE_MEMBERS[@]}"; do + cargo check -p "$DIR" --quiet --color always + cargo doc -p "$DIR" --quiet --document-private-items +done + +echo -e "\n\nTest Custom Message Macros" +cargo test -p lightning-custom-message --quiet --color always +[ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean + +# Test that we can build downstream code with only the "release pins". +pushd msrv-no-dev-deps-check +PIN_RELEASE_DEPS +cargo check --quiet +[ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean +popd diff --git a/ci/ci-tests.sh b/ci/ci-tests.sh index 83b2af277f5..57691ad9d27 100755 --- a/ci/ci-tests.sh +++ b/ci/ci-tests.sh @@ -1,146 +1,13 @@ #!/bin/bash -#shellcheck disable=SC2002,SC2207 set -eox pipefail -RUSTC_MINOR_VERSION=$(rustc --version | awk '{ split($2,a,"."); print a[2] }') +# Run all CI test groups sequentially for local testing. +# In GitHub Actions, these run as separate parallel jobs. -# Some crates require pinning to meet our MSRV even for our downstream users, -# which we do here. -# Further crates which appear only as dev-dependencies are pinned further down. -function PIN_RELEASE_DEPS { - return 0 # Don't fail the script if our rustc is higher than the last check -} - -PIN_RELEASE_DEPS # pin the release dependencies in our main workspace - -# The backtrace v0.3.75 crate relies on rustc 1.82 -[ "$RUSTC_MINOR_VERSION" -lt 82 ] && cargo update -p backtrace --precise "0.3.74" --quiet - -# Starting with version 1.2.0, the `idna_adapter` crate has an MSRV of rustc 1.81.0. -[ "$RUSTC_MINOR_VERSION" -lt 81 ] && cargo update -p idna_adapter --precise "1.1.0" --quiet - -export RUST_BACKTRACE=1 - -echo -e "\n\nChecking the workspace, except lightning-transaction-sync." -cargo check --quiet --color always - -WORKSPACE_MEMBERS=( $(cat Cargo.toml | tr '\n' '\r' | sed 's/\r //g' | tr '\r' '\n' | grep '^members =' | sed 's/members.*=.*\[//' | tr -d '"' | tr ',' ' ') ) - -echo -e "\n\nTesting the workspace, except lightning-transaction-sync." -cargo test --quiet --color always - -echo -e "\n\nTesting upgrade from prior versions of LDK" -pushd lightning-tests -cargo test --quiet -popd - -echo -e "\n\nChecking and building docs for all workspace members individually..." -for DIR in "${WORKSPACE_MEMBERS[@]}"; do - cargo check -p "$DIR" --quiet --color always - cargo doc -p "$DIR" --quiet --document-private-items -done - -echo -e "\n\nChecking and testing lightning with features" -cargo test -p lightning --quiet --color always --features dnssec -cargo check -p lightning --quiet --color always --features dnssec -cargo doc -p lightning --quiet --document-private-items --features dnssec - -echo -e "\n\nChecking and testing Block Sync Clients with features" - -cargo test -p lightning-block-sync --quiet --color always --features rest-client -cargo check -p lightning-block-sync --quiet --color always --features rest-client -cargo test -p lightning-block-sync --quiet --color always --features rpc-client -cargo check -p lightning-block-sync --quiet --color always --features rpc-client -cargo test -p lightning-block-sync --quiet --color always --features rpc-client,rest-client -cargo check -p lightning-block-sync --quiet --color always --features rpc-client,rest-client -cargo test -p lightning-block-sync --quiet --color always --features rpc-client,rest-client,tokio -cargo check -p lightning-block-sync --quiet --color always --features rpc-client,rest-client,tokio - -echo -e "\n\nChecking Transaction Sync Clients with features." -cargo check -p lightning-transaction-sync --quiet --color always --features esplora-blocking -cargo check -p lightning-transaction-sync --quiet --color always --features esplora-async -cargo check -p lightning-transaction-sync --quiet --color always --features esplora-async-https -cargo check -p lightning-transaction-sync --quiet --color always --features electrum - -if [ -z "$CI_ENV" ] && [[ -z "$BITCOIND_EXE" || -z "$ELECTRS_EXE" ]]; then - echo -e "\n\nSkipping testing Transaction Sync Clients due to BITCOIND_EXE or ELECTRS_EXE being unset." - cargo check -p lightning-transaction-sync --tests -else - echo -e "\n\nTesting Transaction Sync Clients with features." - cargo test -p lightning-transaction-sync --quiet --color always --features esplora-blocking - cargo test -p lightning-transaction-sync --quiet --color always --features esplora-async - cargo test -p lightning-transaction-sync --quiet --color always --features esplora-async-https - cargo test -p lightning-transaction-sync --quiet --color always --features electrum -fi - -echo -e "\n\nChecking and testing lightning-persister with features" -cargo test -p lightning-persister --quiet --color always --features tokio -cargo check -p lightning-persister --quiet --color always --features tokio -cargo doc -p lightning-persister --quiet --document-private-items --features tokio - -echo -e "\n\nTest Custom Message Macros" -cargo test -p lightning-custom-message --quiet --color always -[ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean - -echo -e "\n\nTest backtrace-debug builds" -cargo test -p lightning --quiet --color always --features backtrace - -echo -e "\n\nTesting no_std builds" -for DIR in lightning-invoice lightning-rapid-gossip-sync lightning-liquidity; do - cargo test -p $DIR --quiet --color always --no-default-features -done - -cargo test -p lightning --quiet --color always --no-default-features -cargo test -p lightning-background-processor --quiet --color always --no-default-features - -echo -e "\n\nTesting c_bindings builds" -# Note that because `$RUSTFLAGS` is not passed through to doctest builds we cannot selectively -# disable doctests in `c_bindings` so we skip doctests entirely here. -RUSTFLAGS="$RUSTFLAGS --cfg=c_bindings" cargo test --quiet --color always --lib --bins --tests - -for DIR in lightning-invoice lightning-rapid-gossip-sync; do - # check if there is a conflict between no_std and the c_bindings cfg - RUSTFLAGS="$RUSTFLAGS --cfg=c_bindings" cargo test -p $DIR --quiet --color always --no-default-features -done - -# Note that because `$RUSTFLAGS` is not passed through to doctest builds we cannot selectively -# disable doctests in `c_bindings` so we skip doctests entirely here. -RUSTFLAGS="$RUSTFLAGS --cfg=c_bindings" cargo test -p lightning-background-processor --quiet --color always --no-default-features --lib --bins --tests -RUSTFLAGS="$RUSTFLAGS --cfg=c_bindings" cargo test -p lightning --quiet --color always --no-default-features --lib --bins --tests - -echo -e "\n\nTesting other crate-specific builds" -# Note that outbound_commitment_test only runs in this mode because of hardcoded signature values -RUSTFLAGS="$RUSTFLAGS --cfg=ldk_test_vectors" cargo test -p lightning --quiet --color always --no-default-features --features=std -# This one only works for lightning-invoice -# check that compile with no_std and serde works in lightning-invoice -cargo test -p lightning-invoice --quiet --color always --no-default-features --features serde - -echo -e "\n\nTesting no_std build on a downstream no-std crate" -# check no-std compatibility across dependencies -pushd no-std-check -cargo check --quiet --color always -[ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean -popd - -# Test that we can build downstream code with only the "release pins". -pushd msrv-no-dev-deps-check -PIN_RELEASE_DEPS -cargo check --quiet -[ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean -popd - -if [ -f "$(which arm-none-eabi-gcc)" ]; then - pushd no-std-check - cargo build --quiet --target=thumbv7m-none-eabi - [ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean - popd -fi - -echo -e "\n\nTest cfg-flag builds" -RUSTFLAGS="--cfg=taproot" cargo test --quiet --color always -p lightning -[ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean -RUSTFLAGS="--cfg=simple_close" cargo test --quiet --color always -p lightning -[ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean -RUSTFLAGS="--cfg=lsps1_service" cargo test --quiet --color always -p lightning-liquidity -[ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean -RUSTFLAGS="--cfg=peer_storage" cargo test --quiet --color always -p lightning +DIR="$(dirname "$0")" +"$DIR/ci-tests-workspace.sh" +"$DIR/ci-tests-features.sh" +"$DIR/ci-tests-bindings.sh" +"$DIR/ci-tests-nostd.sh" +"$DIR/ci-tests-cfg-flags.sh" +"$DIR/ci-tests-sync.sh" From ed520ae982985fb9ded58331be5b9a78d146984e Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Mon, 2 Feb 2026 11:02:19 -0800 Subject: [PATCH 051/627] Support async signing in chanmon_consistency This commit adds new opcodes to enable/disable signer operations one by one. Note that this only covers signer operations post-funding. --- fuzz/src/chanmon_consistency.rs | 75 ++++++++++++++++++++++- lightning/src/util/test_channel_signer.rs | 28 ++++----- 2 files changed, 85 insertions(+), 18 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index ced89f5ac8c..0d4fe882e7f 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -79,7 +79,7 @@ use lightning::util::errors::APIError; use lightning::util::hash_tables::*; use lightning::util::logger::Logger; use lightning::util::ser::{LengthReadable, ReadableArgs, Writeable, Writer}; -use lightning::util::test_channel_signer::{EnforcementState, TestChannelSigner}; +use lightning::util::test_channel_signer::{EnforcementState, SignerOp, TestChannelSigner}; use lightning::util::test_utils::TestWalletSource; use lightning_invoice::RawBolt11Invoice; @@ -448,6 +448,14 @@ impl SignerProvider for KeyProvider { } } +// Since this fuzzer is only concerned with live-channel operations, we don't need to worry about +// any signer operations that come after a force close. +const SUPPORTED_SIGNER_OPS: [SignerOp; 3] = [ + SignerOp::SignCounterpartyCommitment, + SignerOp::GetPerCommitmentPoint, + SignerOp::ReleaseCommitmentSecret, +]; + impl KeyProvider { fn make_enforcement_state_cell( &self, commitment_seed: [u8; 32], @@ -460,6 +468,22 @@ impl KeyProvider { let cell = revoked_commitments.get(&commitment_seed).unwrap(); Arc::clone(cell) } + + fn disable_supported_ops_for_all_signers(&self) { + let enforcement_states = self.enforcement_states.lock().unwrap(); + for (_, state) in enforcement_states.iter() { + for signer_op in SUPPORTED_SIGNER_OPS { + state.lock().unwrap().disabled_signer_ops.insert(signer_op); + } + } + } + + fn enable_op_for_all_signers(&self, signer_op: SignerOp) { + let enforcement_states = self.enforcement_states.lock().unwrap(); + for (_, state) in enforcement_states.iter() { + state.lock().unwrap().disabled_signer_ops.remove(&signer_op); + } + } } // Returns a bool indicating whether the payment failed. @@ -2404,6 +2428,46 @@ pub fn do_test( monitor_c = new_monitor_c; }, + 0xc0 => keys_manager_a.disable_supported_ops_for_all_signers(), + 0xc1 => keys_manager_b.disable_supported_ops_for_all_signers(), + 0xc2 => keys_manager_c.disable_supported_ops_for_all_signers(), + 0xc3 => { + keys_manager_a.enable_op_for_all_signers(SignerOp::SignCounterpartyCommitment); + nodes[0].signer_unblocked(None); + }, + 0xc4 => { + keys_manager_b.enable_op_for_all_signers(SignerOp::SignCounterpartyCommitment); + nodes[1].signer_unblocked(None); + }, + 0xc5 => { + keys_manager_c.enable_op_for_all_signers(SignerOp::SignCounterpartyCommitment); + nodes[2].signer_unblocked(None); + }, + 0xc6 => { + keys_manager_a.enable_op_for_all_signers(SignerOp::GetPerCommitmentPoint); + nodes[0].signer_unblocked(None); + }, + 0xc7 => { + keys_manager_b.enable_op_for_all_signers(SignerOp::GetPerCommitmentPoint); + nodes[1].signer_unblocked(None); + }, + 0xc8 => { + keys_manager_c.enable_op_for_all_signers(SignerOp::GetPerCommitmentPoint); + nodes[2].signer_unblocked(None); + }, + 0xc9 => { + keys_manager_a.enable_op_for_all_signers(SignerOp::ReleaseCommitmentSecret); + nodes[0].signer_unblocked(None); + }, + 0xca => { + keys_manager_b.enable_op_for_all_signers(SignerOp::ReleaseCommitmentSecret); + nodes[1].signer_unblocked(None); + }, + 0xcb => { + keys_manager_c.enable_op_for_all_signers(SignerOp::ReleaseCommitmentSecret); + nodes[2].signer_unblocked(None); + }, + 0xf0 => { for id in &chan_ab_ids { complete_monitor_update(&monitor_a, id, &complete_first); @@ -2504,6 +2568,15 @@ pub fn do_test( peers_bc_disconnected = false; } + for op in SUPPORTED_SIGNER_OPS { + keys_manager_a.enable_op_for_all_signers(op); + keys_manager_b.enable_op_for_all_signers(op); + keys_manager_c.enable_op_for_all_signers(op); + } + nodes[0].signer_unblocked(None); + nodes[1].signer_unblocked(None); + nodes[2].signer_unblocked(None); + macro_rules! process_all_events { () => { { let mut last_pass_no_updates = false; diff --git a/lightning/src/util/test_channel_signer.rs b/lightning/src/util/test_channel_signer.rs index 3bacd76a610..70eb3223bc4 100644 --- a/lightning/src/util/test_channel_signer.rs +++ b/lightning/src/util/test_channel_signer.rs @@ -103,7 +103,6 @@ pub enum SignerOp { ReleaseCommitmentSecret, ValidateHolderCommitment, SignCounterpartyCommitment, - ValidateCounterpartyRevocation, SignHolderCommitment, SignJusticeRevokedOutput, SignJusticeRevokedHtlc, @@ -121,7 +120,6 @@ impl SignerOp { SignerOp::ReleaseCommitmentSecret, SignerOp::ValidateHolderCommitment, SignerOp::SignCounterpartyCommitment, - SignerOp::ValidateCounterpartyRevocation, SignerOp::SignHolderCommitment, SignerOp::SignJusticeRevokedOutput, SignerOp::SignJusticeRevokedHtlc, @@ -186,7 +184,7 @@ impl TestChannelSigner { self.get_enforcement_state().disabled_signer_ops.insert(signer_op); } - #[cfg(test)] + #[cfg(any(test, feature = "_test_utils"))] fn is_signer_available(&self, signer_op: SignerOp) -> bool { !self.get_enforcement_state().disabled_signer_ops.contains(&signer_op) } @@ -196,7 +194,7 @@ impl ChannelSigner for TestChannelSigner { fn get_per_commitment_point( &self, idx: u64, secp_ctx: &Secp256k1, ) -> Result { - #[cfg(test)] + #[cfg(any(test, feature = "_test_utils"))] if !self.is_signer_available(SignerOp::GetPerCommitmentPoint) { return Err(()); } @@ -204,7 +202,7 @@ impl ChannelSigner for TestChannelSigner { } fn release_commitment_secret(&self, idx: u64) -> Result<[u8; 32], ()> { - #[cfg(test)] + #[cfg(any(test, feature = "_test_utils"))] if !self.is_signer_available(SignerOp::ReleaseCommitmentSecret) { return Err(()); } @@ -236,10 +234,6 @@ impl ChannelSigner for TestChannelSigner { } fn validate_counterparty_revocation(&self, idx: u64, _secret: &SecretKey) -> Result<(), ()> { - #[cfg(test)] - if !self.is_signer_available(SignerOp::ValidateCounterpartyRevocation) { - return Err(()); - } let mut state = self.state.lock().unwrap(); if !self.disable_all_state_policy_checks { assert!(idx == state.last_counterparty_revoked_commitment || idx == state.last_counterparty_revoked_commitment - 1, "expecting to validate the current or next counterparty revocation - trying {}, current {}", idx, state.last_counterparty_revoked_commitment); @@ -272,7 +266,7 @@ impl EcdsaChannelSigner for TestChannelSigner { ) -> Result<(Signature, Vec), ()> { self.verify_counterparty_commitment_tx(channel_parameters, commitment_tx, secp_ctx); - #[cfg(test)] + #[cfg(any(test, feature = "_test_utils"))] if !self.is_signer_available(SignerOp::SignCounterpartyCommitment) { return Err(()); } @@ -317,7 +311,7 @@ impl EcdsaChannelSigner for TestChannelSigner { &self, channel_parameters: &ChannelTransactionParameters, commitment_tx: &HolderCommitmentTransaction, secp_ctx: &Secp256k1, ) -> Result { - #[cfg(test)] + #[cfg(any(test, feature = "_test_utils"))] if !self.is_signer_available(SignerOp::SignHolderCommitment) { return Err(()); } @@ -354,7 +348,7 @@ impl EcdsaChannelSigner for TestChannelSigner { input: usize, amount: u64, per_commitment_key: &SecretKey, secp_ctx: &Secp256k1, ) -> Result { - #[cfg(test)] + #[cfg(any(test, feature = "_test_utils"))] if !self.is_signer_available(SignerOp::SignJusticeRevokedOutput) { return Err(()); } @@ -375,7 +369,7 @@ impl EcdsaChannelSigner for TestChannelSigner { input: usize, amount: u64, per_commitment_key: &SecretKey, htlc: &HTLCOutputInCommitment, secp_ctx: &Secp256k1, ) -> Result { - #[cfg(test)] + #[cfg(any(test, feature = "_test_utils"))] if !self.is_signer_available(SignerOp::SignJusticeRevokedHtlc) { return Err(()); } @@ -396,7 +390,7 @@ impl EcdsaChannelSigner for TestChannelSigner { &self, htlc_tx: &Transaction, input: usize, htlc_descriptor: &HTLCDescriptor, secp_ctx: &Secp256k1, ) -> Result { - #[cfg(test)] + #[cfg(any(test, feature = "_test_utils"))] if !self.is_signer_available(SignerOp::SignHolderHtlcTransaction) { return Err(()); } @@ -462,7 +456,7 @@ impl EcdsaChannelSigner for TestChannelSigner { input: usize, amount: u64, per_commitment_point: &PublicKey, htlc: &HTLCOutputInCommitment, secp_ctx: &Secp256k1, ) -> Result { - #[cfg(test)] + #[cfg(any(test, feature = "_test_utils"))] if !self.is_signer_available(SignerOp::SignCounterpartyHtlcTransaction) { return Err(()); } @@ -483,7 +477,7 @@ impl EcdsaChannelSigner for TestChannelSigner { &self, channel_parameters: &ChannelTransactionParameters, closing_tx: &ClosingTransaction, secp_ctx: &Secp256k1, ) -> Result { - #[cfg(test)] + #[cfg(any(test, feature = "_test_utils"))] if !self.is_signer_available(SignerOp::SignClosingTransaction) { return Err(()); } @@ -504,7 +498,7 @@ impl EcdsaChannelSigner for TestChannelSigner { anchor_tx.input[input].previous_output.vout == 0 || anchor_tx.input[input].previous_output.vout == 1 ); - #[cfg(test)] + #[cfg(any(test, feature = "_test_utils"))] if !self.is_signer_available(SignerOp::SignHolderAnchorInput) { return Err(()); } From 3c09513b71708375737b33a9d79630cd65a7e386 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Mon, 2 Feb 2026 11:03:55 -0800 Subject: [PATCH 052/627] Drive splices to completion in chanmon_consistency This commit adds support for locking splices. This required confirming transactions, which this target previously didn't consider. It also fixes a `serial_id` collision, due to its generation using the first 4 bytes of `get_secure_random_bytes`, that was preventing splices from negotiating up to the `tx_signatures` exchange. --- fuzz/src/chanmon_consistency.rs | 199 +++++++++++++++++++++++++------- 1 file changed, 157 insertions(+), 42 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 0d4fe882e7f..4bc0f3533b2 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -28,7 +28,8 @@ use bitcoin::transaction::Version; use bitcoin::transaction::{Transaction, TxOut}; use bitcoin::FeeRate; -use bitcoin::hash_types::BlockHash; +use bitcoin::block::Header; +use bitcoin::hash_types::{BlockHash, Txid}; use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::sha256d::Hash as Sha256dHash; use bitcoin::hashes::Hash as TraitImport; @@ -96,6 +97,7 @@ use lightning::util::dyn_signer::DynSigner; use std::cell::RefCell; use std::cmp; +use std::collections::HashSet; use std::mem; use std::sync::atomic; use std::sync::{Arc, Mutex}; @@ -171,6 +173,46 @@ impl BroadcasterInterface for TestBroadcaster { } } +struct ChainState { + blocks: Vec<(Header, Vec)>, + confirmed_txids: HashSet, +} + +impl ChainState { + fn new() -> Self { + let genesis_hash = genesis_block(Network::Bitcoin).block_hash(); + let genesis_header = create_dummy_header(genesis_hash, 42); + Self { blocks: vec![(genesis_header, Vec::new())], confirmed_txids: HashSet::new() } + } + + fn tip_height(&self) -> u32 { + (self.blocks.len() - 1) as u32 + } + + fn confirm_tx(&mut self, tx: Transaction) -> bool { + let txid = tx.compute_txid(); + if self.confirmed_txids.contains(&txid) { + return false; + } + self.confirmed_txids.insert(txid); + + let prev_hash = self.blocks.last().unwrap().0.block_hash(); + let header = create_dummy_header(prev_hash, 42); + self.blocks.push((header, vec![tx])); + + for _ in 0..5 { + let prev_hash = self.blocks.last().unwrap().0.block_hash(); + let header = create_dummy_header(prev_hash, 42); + self.blocks.push((header, Vec::new())); + } + true + } + + fn block_at(&self, height: u32) -> &(Header, Vec) { + &self.blocks[height as usize] + } +} + pub struct VecWriter(pub Vec); impl Writer for VecWriter { fn write_all(&mut self, buf: &[u8]) -> Result<(), ::lightning::io::Error> { @@ -326,7 +368,8 @@ impl EntropySource for KeyProvider { fn get_secure_random_bytes(&self) -> [u8; 32] { let id = self.rand_bytes_id.fetch_add(1, atomic::Ordering::Relaxed); #[rustfmt::skip] - let mut res = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 11, self.node_secret[31]]; + let mut res = [self.node_secret[31], 11, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 11, self.node_secret[31]]; + res[2..6].copy_from_slice(&id.to_le_bytes()); res[30 - 4..30].copy_from_slice(&id.to_le_bytes()); res } @@ -752,7 +795,9 @@ pub fn do_test( data: &[u8], underlying_out: Out, anchors: bool, ) { let out = SearchingOutput::new(underlying_out); - let broadcast = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); + let broadcast_a = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); + let broadcast_b = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); + let broadcast_c = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); let router = FuzzRouter {}; // Read initial monitor styles from fuzz input (1 byte: 2 bits per node) @@ -775,8 +820,13 @@ pub fn do_test( }), ]; + let mut chain_state = ChainState::new(); + let mut node_height_a: u32 = 0; + let mut node_height_b: u32 = 0; + let mut node_height_c: u32 = 0; + macro_rules! make_node { - ($node_id: expr, $fee_estimator: expr) => {{ + ($node_id: expr, $fee_estimator: expr, $broadcaster: expr) => {{ let logger: Arc = Arc::new(test_logger::TestLogger::new($node_id.to_string(), out.clone())); let node_secret = SecretKey::from_slice(&[ @@ -790,7 +840,7 @@ pub fn do_test( enforcement_states: Mutex::new(new_hash_map()), }); let monitor = Arc::new(TestChainMonitor::new( - broadcast.clone(), + $broadcaster.clone(), logger.clone(), $fee_estimator.clone(), Arc::new(TestPersister { @@ -813,7 +863,7 @@ pub fn do_test( ChannelManager::new( $fee_estimator.clone(), monitor.clone(), - broadcast.clone(), + $broadcaster.clone(), &router, &router, Arc::clone(&logger), @@ -836,12 +886,13 @@ pub fn do_test( old_monitors: &TestChainMonitor, mut use_old_mons, keys, - fee_estimator| { + fee_estimator, + broadcaster: Arc| { let keys_manager = Arc::clone(keys); let logger: Arc = Arc::new(test_logger::TestLogger::new(node_id.to_string(), out.clone())); let chain_monitor = Arc::new(TestChainMonitor::new( - broadcast.clone(), + broadcaster.clone(), logger.clone(), Arc::clone(fee_estimator), Arc::new(TestPersister { @@ -903,7 +954,7 @@ pub fn do_test( signer_provider: keys_manager, fee_estimator: Arc::clone(fee_estimator), chain_monitor: chain_monitor.clone(), - tx_broadcaster: broadcast.clone(), + tx_broadcaster: broadcaster, router: &router, message_router: &router, logger, @@ -924,7 +975,6 @@ pub fn do_test( res }; - let mut channel_txn = Vec::new(); macro_rules! complete_all_pending_monitor_updates { ($monitor: expr) => {{ for (channel_id, state) in $monitor.latest_monitors.lock().unwrap().iter_mut() { @@ -1028,7 +1078,7 @@ pub fn do_test( tx.clone(), ) .unwrap(); - channel_txn.push(tx); + chain_state.confirm_tx(tx); } else { panic!("Wrong event type"); } @@ -1086,20 +1136,6 @@ pub fn do_test( }}; } - macro_rules! confirm_txn { - ($node: expr) => {{ - let chain_hash = genesis_block(Network::Bitcoin).block_hash(); - let mut header = create_dummy_header(chain_hash, 42); - let txdata: Vec<_> = - channel_txn.iter().enumerate().map(|(i, tx)| (i + 1, tx)).collect(); - $node.transactions_confirmed(&header, &txdata, 1); - for _ in 2..100 { - header = create_dummy_header(header.block_hash(), 42); - } - $node.best_block_updated(&header, 99); - }}; - } - macro_rules! lock_fundings { ($nodes: expr) => {{ let mut node_events = Vec::new(); @@ -1161,9 +1197,9 @@ pub fn do_test( // 3 nodes is enough to hit all the possible cases, notably unknown-source-unknown-dest // forwarding. - let (node_a, mut monitor_a, keys_manager_a, logger_a) = make_node!(0, fee_est_a); - let (node_b, mut monitor_b, keys_manager_b, logger_b) = make_node!(1, fee_est_b); - let (node_c, mut monitor_c, keys_manager_c, logger_c) = make_node!(2, fee_est_c); + let (node_a, mut monitor_a, keys_manager_a, logger_a) = make_node!(0, fee_est_a, broadcast_a); + let (node_b, mut monitor_b, keys_manager_b, logger_b) = make_node!(1, fee_est_b, broadcast_b); + let (node_c, mut monitor_c, keys_manager_c, logger_c) = make_node!(2, fee_est_c, broadcast_c); let mut nodes = [node_a, node_b, node_c]; let loggers = [logger_a, logger_b, logger_c]; @@ -1192,11 +1228,35 @@ pub fn do_test( // Wipe the transactions-broadcasted set to make sure we don't broadcast any transactions // during normal operation in `test_return`. - broadcast.txn_broadcasted.borrow_mut().clear(); + broadcast_a.txn_broadcasted.borrow_mut().clear(); + broadcast_b.txn_broadcasted.borrow_mut().clear(); + broadcast_c.txn_broadcasted.borrow_mut().clear(); + + let sync_with_chain_state = |chain_state: &ChainState, + node: &ChannelManager<_, _, _, _, _, _, _, _, _>, + node_height: &mut u32, + num_blocks: Option| { + let target_height = if let Some(num_blocks) = num_blocks { + std::cmp::min(*node_height + num_blocks, chain_state.tip_height()) + } else { + chain_state.tip_height() + }; - for node in nodes.iter() { - confirm_txn!(node); - } + while *node_height < target_height { + *node_height += 1; + let (header, txn) = chain_state.block_at(*node_height); + let txdata: Vec<_> = txn.iter().enumerate().map(|(i, tx)| (i + 1, tx)).collect(); + if !txdata.is_empty() { + node.transactions_confirmed(header, &txdata, *node_height); + } + node.best_block_updated(header, *node_height); + } + }; + + // Sync all nodes to tip to lock the funding. + sync_with_chain_state(&mut chain_state, &nodes[0], &mut node_height_a, None); + sync_with_chain_state(&mut chain_state, &nodes[1], &mut node_height_b, None); + sync_with_chain_state(&mut chain_state, &nodes[2], &mut node_height_c, None); lock_fundings!(nodes); @@ -1246,9 +1306,11 @@ pub fn do_test( assert_eq!(nodes[1].list_channels().len(), 6); assert_eq!(nodes[2].list_channels().len(), 3); - // At no point should we have broadcasted any transactions after the initial channel - // opens. - assert!(broadcast.txn_broadcasted.borrow().is_empty()); + // All broadcasters should be empty (all broadcast transactions should be handled + // explicitly). + assert!(broadcast_a.txn_broadcasted.borrow().is_empty()); + assert!(broadcast_b.txn_broadcasted.borrow().is_empty()); + assert!(broadcast_c.txn_broadcasted.borrow().is_empty()); return; }}; @@ -1319,6 +1381,10 @@ pub fn do_test( if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } *node_id == a_id }, + MessageSendEvent::SendTxSignatures { ref node_id, .. } => { + if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } + *node_id == a_id + }, MessageSendEvent::SendChannelReady { .. } => continue, MessageSendEvent::SendAnnouncementSignatures { .. } => continue, MessageSendEvent::SendChannelUpdate { ref node_id, ref msg } => { @@ -1503,6 +1569,14 @@ pub fn do_test( } } }, + MessageSendEvent::SendTxSignatures { ref node_id, ref msg } => { + for (idx, dest) in nodes.iter().enumerate() { + if dest.get_our_node_id() == *node_id { + out.locked_write(format!("Delivering tx_signatures from node {} to node {}.\n", $node, idx).as_bytes()); + dest.handle_tx_signatures(nodes[$node].get_our_node_id(), msg); + } + } + }, MessageSendEvent::SendSpliceInit { ref node_id, ref msg } => { for (idx, dest) in nodes.iter().enumerate() { if dest.get_our_node_id() == *node_id { @@ -1704,7 +1778,18 @@ pub fn do_test( ) .unwrap(); }, - events::Event::SplicePending { .. } => {}, + events::Event::SplicePending { new_funding_txo, .. } => { + let broadcaster = match $node { + 0 => &broadcast_a, + 1 => &broadcast_b, + _ => &broadcast_c, + }; + let mut txs = broadcaster.txn_broadcasted.borrow_mut(); + assert!(txs.len() >= 1); + let splice_tx = txs.remove(0); + assert_eq!(new_funding_txo.txid, splice_tx.compute_txid()); + chain_state.confirm_tx(splice_tx); + }, events::Event::SpliceFailed { .. } => {}, _ => { @@ -2369,6 +2454,15 @@ pub fn do_test( } }, + // Sync node by 1 block to cover confirmation of a transaction. + 0xa8 => sync_with_chain_state(&mut chain_state, &nodes[0], &mut node_height_a, Some(1)), + 0xa9 => sync_with_chain_state(&mut chain_state, &nodes[1], &mut node_height_b, Some(1)), + 0xaa => sync_with_chain_state(&mut chain_state, &nodes[2], &mut node_height_c, Some(1)), + // Sync node to chain tip to cover confirmation of a transaction post-reorg-risk. + 0xab => sync_with_chain_state(&mut chain_state, &nodes[0], &mut node_height_a, None), + 0xac => sync_with_chain_state(&mut chain_state, &nodes[1], &mut node_height_b, None), + 0xad => sync_with_chain_state(&mut chain_state, &nodes[2], &mut node_height_c, None), + 0xb0 | 0xb1 | 0xb2 => { // Restart node A, picking among the in-flight `ChannelMonitor`s to use based on // the value of `v` we're matching. @@ -2382,8 +2476,15 @@ pub fn do_test( ab_events.clear(); ba_events.clear(); } - let (new_node_a, new_monitor_a) = - reload_node(&node_a_ser, 0, &monitor_a, v, &keys_manager_a, &fee_est_a); + let (new_node_a, new_monitor_a) = reload_node( + &node_a_ser, + 0, + &monitor_a, + v, + &keys_manager_a, + &fee_est_a, + broadcast_a.clone(), + ); nodes[0] = new_node_a; monitor_a = new_monitor_a; }, @@ -2404,8 +2505,15 @@ pub fn do_test( bc_events.clear(); cb_events.clear(); } - let (new_node_b, new_monitor_b) = - reload_node(&node_b_ser, 1, &monitor_b, v, &keys_manager_b, &fee_est_b); + let (new_node_b, new_monitor_b) = reload_node( + &node_b_ser, + 1, + &monitor_b, + v, + &keys_manager_b, + &fee_est_b, + broadcast_b.clone(), + ); nodes[1] = new_node_b; monitor_b = new_monitor_b; }, @@ -2422,8 +2530,15 @@ pub fn do_test( bc_events.clear(); cb_events.clear(); } - let (new_node_c, new_monitor_c) = - reload_node(&node_c_ser, 2, &monitor_c, v, &keys_manager_c, &fee_est_c); + let (new_node_c, new_monitor_c) = reload_node( + &node_c_ser, + 2, + &monitor_c, + v, + &keys_manager_c, + &fee_est_c, + broadcast_c.clone(), + ); nodes[2] = new_node_c; monitor_c = new_monitor_c; }, From c0c09c7dd10d20d35c478a88ffea87fd508cec15 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Thu, 5 Feb 2026 09:26:08 -0800 Subject: [PATCH 053/627] Use channel_id over short_channel_id for payments in chanmon_consistency The `short_channel_id` is no longer guaranteed to be stable with splicing now that the fuzzer can actually lock splices. --- fuzz/src/chanmon_consistency.rs | 299 ++++++++++++++++++-------------- 1 file changed, 169 insertions(+), 130 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 4bc0f3533b2..69af660aa96 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -570,15 +570,21 @@ fn get_payment_secret_hash(dest: &ChanMan, payment_ctr: &mut u64) -> (PaymentSec #[inline] fn send_payment( - source: &ChanMan, dest: &ChanMan, dest_chan_id: u64, amt: u64, payment_secret: PaymentSecret, - payment_hash: PaymentHash, payment_id: PaymentId, + source: &ChanMan, dest: &ChanMan, dest_chan_id: ChannelId, amt: u64, + payment_secret: PaymentSecret, payment_hash: PaymentHash, payment_id: PaymentId, ) -> bool { - let (min_value_sendable, max_value_sendable) = source + let (min_value_sendable, max_value_sendable, dest_scid) = source .list_usable_channels() .iter() - .find(|chan| chan.short_channel_id == Some(dest_chan_id)) - .map(|chan| (chan.next_outbound_htlc_minimum_msat, chan.next_outbound_htlc_limit_msat)) - .unwrap_or((0, 0)); + .find(|chan| chan.channel_id == dest_chan_id) + .map(|chan| { + ( + chan.next_outbound_htlc_minimum_msat, + chan.next_outbound_htlc_limit_msat, + chan.short_channel_id.unwrap_or(0), + ) + }) + .unwrap_or((0, 0, 0)); let route_params = RouteParameters::from_payment_params_and_value( PaymentParameters::from_node_id(source.get_our_node_id(), TEST_FINAL_CLTV), amt, @@ -588,7 +594,7 @@ fn send_payment( hops: vec![RouteHop { pubkey: dest.get_our_node_id(), node_features: dest.node_features(), - short_channel_id: dest_chan_id, + short_channel_id: dest_scid, channel_features: dest.channel_features(), fee_msat: amt, cltv_expiry_delta: 200, @@ -615,15 +621,28 @@ fn send_payment( #[inline] fn send_hop_payment( - source: &ChanMan, middle: &ChanMan, middle_scid: u64, dest: &ChanMan, dest_scid: u64, amt: u64, - payment_secret: PaymentSecret, payment_hash: PaymentHash, payment_id: PaymentId, + source: &ChanMan, middle: &ChanMan, middle_chan_id: ChannelId, dest: &ChanMan, + dest_chan_id: ChannelId, amt: u64, payment_secret: PaymentSecret, payment_hash: PaymentHash, + payment_id: PaymentId, ) -> bool { - let (min_value_sendable, max_value_sendable) = source + let (min_value_sendable, max_value_sendable, middle_scid) = source .list_usable_channels() .iter() - .find(|chan| chan.short_channel_id == Some(middle_scid)) - .map(|chan| (chan.next_outbound_htlc_minimum_msat, chan.next_outbound_htlc_limit_msat)) - .unwrap_or((0, 0)); + .find(|chan| chan.channel_id == middle_chan_id) + .map(|chan| { + ( + chan.next_outbound_htlc_minimum_msat, + chan.next_outbound_htlc_limit_msat, + chan.short_channel_id.unwrap_or(0), + ) + }) + .unwrap_or((0, 0, 0)); + let dest_scid = dest + .list_channels() + .iter() + .find(|chan| chan.channel_id == dest_chan_id) + .and_then(|chan| chan.short_channel_id) + .unwrap_or(0); let first_hop_fee = 50_000; let route_params = RouteParameters::from_payment_params_and_value( PaymentParameters::from_node_id(source.get_our_node_id(), TEST_FINAL_CLTV), @@ -674,10 +693,10 @@ fn send_hop_payment( /// Send an MPP payment directly from source to dest using multiple channels. #[inline] fn send_mpp_payment( - source: &ChanMan, dest: &ChanMan, dest_scids: &[u64], amt: u64, payment_secret: PaymentSecret, - payment_hash: PaymentHash, payment_id: PaymentId, + source: &ChanMan, dest: &ChanMan, dest_chan_ids: &[ChannelId], amt: u64, + payment_secret: PaymentSecret, payment_hash: PaymentHash, payment_id: PaymentId, ) -> bool { - let num_paths = dest_scids.len(); + let num_paths = dest_chan_ids.len(); if num_paths == 0 { return false; } @@ -685,7 +704,16 @@ fn send_mpp_payment( let amt_per_path = amt / num_paths as u64; let mut paths = Vec::with_capacity(num_paths); - for (i, &dest_scid) in dest_scids.iter().enumerate() { + let dest_chans = dest.list_channels(); + let dest_scids = dest_chan_ids.iter().map(|chan_id| { + dest_chans + .iter() + .find(|chan| chan.channel_id == *chan_id) + .and_then(|chan| chan.short_channel_id) + .unwrap() + }); + + for (i, dest_scid) in dest_scids.enumerate() { let path_amt = if i == num_paths - 1 { amt - amt_per_path * (num_paths as u64 - 1) } else { @@ -723,11 +751,12 @@ fn send_mpp_payment( /// Supports multiple channels on either or both hops. #[inline] fn send_mpp_hop_payment( - source: &ChanMan, middle: &ChanMan, middle_scids: &[u64], dest: &ChanMan, dest_scids: &[u64], - amt: u64, payment_secret: PaymentSecret, payment_hash: PaymentHash, payment_id: PaymentId, + source: &ChanMan, middle: &ChanMan, middle_chan_ids: &[ChannelId], dest: &ChanMan, + dest_chan_ids: &[ChannelId], amt: u64, payment_secret: PaymentSecret, + payment_hash: PaymentHash, payment_id: PaymentId, ) -> bool { // Create paths by pairing middle_scids with dest_scids - let num_paths = middle_scids.len().max(dest_scids.len()); + let num_paths = middle_chan_ids.len().max(dest_chan_ids.len()); if num_paths == 0 { return false; } @@ -737,6 +766,30 @@ fn send_mpp_hop_payment( let fee_per_path = first_hop_fee / num_paths as u64; let mut paths = Vec::with_capacity(num_paths); + let middle_chans = middle.list_channels(); + let middle_scids: Vec<_> = middle_chan_ids + .iter() + .map(|chan_id| { + middle_chans + .iter() + .find(|chan| chan.channel_id == *chan_id) + .and_then(|chan| chan.short_channel_id) + .unwrap() + }) + .collect(); + + let dest_chans = dest.list_channels(); + let dest_scids: Vec<_> = dest_chan_ids + .iter() + .map(|chan_id| { + dest_chans + .iter() + .find(|chan| chan.channel_id == *chan_id) + .and_then(|chan| chan.short_channel_id) + .unwrap() + }) + .collect(); + for i in 0..num_paths { let middle_scid = middle_scids[i % middle_scids.len()]; let dest_scid = dest_scids[i % dest_scids.len()]; @@ -1131,8 +1184,6 @@ pub fn do_test( } else { panic!("Wrong event type"); } - - channel_id }}; } @@ -1215,16 +1266,12 @@ pub fn do_test( // Fuzz mode uses XOR-based hashing (all bytes XOR to one byte), and // versions 0-5 cause collisions between A-B and B-C channel pairs // (e.g., A-B with Version(1) collides with B-C with Version(3)). - let chan_ab_ids = [ - make_channel!(nodes[0], nodes[1], monitor_a, monitor_b, keys_manager_b, 1), - make_channel!(nodes[0], nodes[1], monitor_a, monitor_b, keys_manager_b, 2), - make_channel!(nodes[0], nodes[1], monitor_a, monitor_b, keys_manager_b, 3), - ]; - let chan_bc_ids = [ - make_channel!(nodes[1], nodes[2], monitor_b, monitor_c, keys_manager_c, 4), - make_channel!(nodes[1], nodes[2], monitor_b, monitor_c, keys_manager_c, 5), - make_channel!(nodes[1], nodes[2], monitor_b, monitor_c, keys_manager_c, 6), - ]; + make_channel!(nodes[0], nodes[1], monitor_a, monitor_b, keys_manager_b, 1); + make_channel!(nodes[0], nodes[1], monitor_a, monitor_b, keys_manager_b, 2); + make_channel!(nodes[0], nodes[1], monitor_a, monitor_b, keys_manager_b, 3); + make_channel!(nodes[1], nodes[2], monitor_b, monitor_c, keys_manager_c, 4); + make_channel!(nodes[1], nodes[2], monitor_b, monitor_c, keys_manager_c, 5); + make_channel!(nodes[1], nodes[2], monitor_b, monitor_c, keys_manager_c, 6); // Wipe the transactions-broadcasted set to make sure we don't broadcast any transactions // during normal operation in `test_return`. @@ -1260,29 +1307,19 @@ pub fn do_test( lock_fundings!(nodes); - // Get SCIDs for all A-B channels (from node A's perspective) - let node_a_chans: Vec<_> = nodes[0].list_usable_channels(); - let chan_ab_scids: [u64; 3] = [ - node_a_chans[0].short_channel_id.unwrap(), - node_a_chans[1].short_channel_id.unwrap(), - node_a_chans[2].short_channel_id.unwrap(), - ]; - let chan_ab_chan_ids: [ChannelId; 3] = - [node_a_chans[0].channel_id, node_a_chans[1].channel_id, node_a_chans[2].channel_id]; - // Get SCIDs for all B-C channels (from node C's perspective) - let node_c_chans: Vec<_> = nodes[2].list_usable_channels(); - let chan_bc_scids: [u64; 3] = [ - node_c_chans[0].short_channel_id.unwrap(), - node_c_chans[1].short_channel_id.unwrap(), - node_c_chans[2].short_channel_id.unwrap(), - ]; - let chan_bc_chan_ids: [ChannelId; 3] = - [node_c_chans[0].channel_id, node_c_chans[1].channel_id, node_c_chans[2].channel_id]; + // Get channel IDs for all A-B channels (from node A's perspective) + let chan_ab_ids = { + let node_a_chans = nodes[0].list_usable_channels(); + [node_a_chans[0].channel_id, node_a_chans[1].channel_id, node_a_chans[2].channel_id] + }; + // Get channel IDs for all B-C channels (from node C's perspective) + let chan_bc_ids = { + let node_c_chans = nodes[2].list_usable_channels(); + [node_c_chans[0].channel_id, node_c_chans[1].channel_id, node_c_chans[2].channel_id] + }; // Keep old names for backward compatibility in existing code - let chan_a = chan_ab_scids[0]; - let chan_a_id = chan_ab_chan_ids[0]; - let chan_b = chan_bc_scids[0]; - let chan_b_id = chan_bc_chan_ids[0]; + let chan_a_id = chan_ab_ids[0]; + let chan_b_id = chan_bc_ids[0]; let mut p_ctr: u64 = 0; @@ -1870,9 +1907,9 @@ pub fn do_test( let send_hop_noret = |source_idx: usize, middle_idx: usize, - middle_scid: u64, + middle_chan_id: ChannelId, dest_idx: usize, - dest_scid: u64, + dest_chan_id: ChannelId, amt: u64, payment_ctr: &mut u64| { let source = &nodes[source_idx]; @@ -1884,9 +1921,9 @@ pub fn do_test( let succeeded = send_hop_payment( source, middle, - middle_scid, + middle_chan_id, dest, - dest_scid, + dest_chan_id, amt, secret, hash, @@ -1900,7 +1937,7 @@ pub fn do_test( // Direct MPP payment (no hop) let send_mpp_direct = |source_idx: usize, dest_idx: usize, - dest_scids: &[u64], + dest_chan_ids: &[ChannelId], amt: u64, payment_ctr: &mut u64| { let source = &nodes[source_idx]; @@ -1908,7 +1945,7 @@ pub fn do_test( let (secret, hash) = get_payment_secret_hash(dest, payment_ctr); let mut id = PaymentId([0; 32]); id.0[0..8].copy_from_slice(&payment_ctr.to_ne_bytes()); - let succeeded = send_mpp_payment(source, dest, dest_scids, amt, secret, hash, id); + let succeeded = send_mpp_payment(source, dest, dest_chan_ids, amt, secret, hash, id); if succeeded { pending_payments.borrow_mut()[source_idx].push(id); } @@ -1917,9 +1954,9 @@ pub fn do_test( // MPP payment via hop - splits payment across multiple channels on either or both hops let send_mpp_hop = |source_idx: usize, middle_idx: usize, - middle_scids: &[u64], + middle_chan_ids: &[ChannelId], dest_idx: usize, - dest_scids: &[u64], + dest_chan_ids: &[ChannelId], amt: u64, payment_ctr: &mut u64| { let source = &nodes[source_idx]; @@ -1931,9 +1968,9 @@ pub fn do_test( let succeeded = send_mpp_hop_payment( source, middle, - middle_scids, + middle_chan_ids, dest, - dest_scids, + dest_chan_ids, amt, secret, hash, @@ -2072,73 +2109,75 @@ pub fn do_test( 0x27 => process_ev_noret!(2, false), // 1/10th the channel size: - 0x30 => send_noret(0, 1, chan_a, 10_000_000, &mut p_ctr), - 0x31 => send_noret(1, 0, chan_a, 10_000_000, &mut p_ctr), - 0x32 => send_noret(1, 2, chan_b, 10_000_000, &mut p_ctr), - 0x33 => send_noret(2, 1, chan_b, 10_000_000, &mut p_ctr), - 0x34 => send_hop_noret(0, 1, chan_a, 2, chan_b, 10_000_000, &mut p_ctr), - 0x35 => send_hop_noret(2, 1, chan_b, 0, chan_a, 10_000_000, &mut p_ctr), - - 0x38 => send_noret(0, 1, chan_a, 1_000_000, &mut p_ctr), - 0x39 => send_noret(1, 0, chan_a, 1_000_000, &mut p_ctr), - 0x3a => send_noret(1, 2, chan_b, 1_000_000, &mut p_ctr), - 0x3b => send_noret(2, 1, chan_b, 1_000_000, &mut p_ctr), - 0x3c => send_hop_noret(0, 1, chan_a, 2, chan_b, 1_000_000, &mut p_ctr), - 0x3d => send_hop_noret(2, 1, chan_b, 0, chan_a, 1_000_000, &mut p_ctr), - - 0x40 => send_noret(0, 1, chan_a, 100_000, &mut p_ctr), - 0x41 => send_noret(1, 0, chan_a, 100_000, &mut p_ctr), - 0x42 => send_noret(1, 2, chan_b, 100_000, &mut p_ctr), - 0x43 => send_noret(2, 1, chan_b, 100_000, &mut p_ctr), - 0x44 => send_hop_noret(0, 1, chan_a, 2, chan_b, 100_000, &mut p_ctr), - 0x45 => send_hop_noret(2, 1, chan_b, 0, chan_a, 100_000, &mut p_ctr), - - 0x48 => send_noret(0, 1, chan_a, 10_000, &mut p_ctr), - 0x49 => send_noret(1, 0, chan_a, 10_000, &mut p_ctr), - 0x4a => send_noret(1, 2, chan_b, 10_000, &mut p_ctr), - 0x4b => send_noret(2, 1, chan_b, 10_000, &mut p_ctr), - 0x4c => send_hop_noret(0, 1, chan_a, 2, chan_b, 10_000, &mut p_ctr), - 0x4d => send_hop_noret(2, 1, chan_b, 0, chan_a, 10_000, &mut p_ctr), - - 0x50 => send_noret(0, 1, chan_a, 1_000, &mut p_ctr), - 0x51 => send_noret(1, 0, chan_a, 1_000, &mut p_ctr), - 0x52 => send_noret(1, 2, chan_b, 1_000, &mut p_ctr), - 0x53 => send_noret(2, 1, chan_b, 1_000, &mut p_ctr), - 0x54 => send_hop_noret(0, 1, chan_a, 2, chan_b, 1_000, &mut p_ctr), - 0x55 => send_hop_noret(2, 1, chan_b, 0, chan_a, 1_000, &mut p_ctr), - - 0x58 => send_noret(0, 1, chan_a, 100, &mut p_ctr), - 0x59 => send_noret(1, 0, chan_a, 100, &mut p_ctr), - 0x5a => send_noret(1, 2, chan_b, 100, &mut p_ctr), - 0x5b => send_noret(2, 1, chan_b, 100, &mut p_ctr), - 0x5c => send_hop_noret(0, 1, chan_a, 2, chan_b, 100, &mut p_ctr), - 0x5d => send_hop_noret(2, 1, chan_b, 0, chan_a, 100, &mut p_ctr), - - 0x60 => send_noret(0, 1, chan_a, 10, &mut p_ctr), - 0x61 => send_noret(1, 0, chan_a, 10, &mut p_ctr), - 0x62 => send_noret(1, 2, chan_b, 10, &mut p_ctr), - 0x63 => send_noret(2, 1, chan_b, 10, &mut p_ctr), - 0x64 => send_hop_noret(0, 1, chan_a, 2, chan_b, 10, &mut p_ctr), - 0x65 => send_hop_noret(2, 1, chan_b, 0, chan_a, 10, &mut p_ctr), - - 0x68 => send_noret(0, 1, chan_a, 1, &mut p_ctr), - 0x69 => send_noret(1, 0, chan_a, 1, &mut p_ctr), - 0x6a => send_noret(1, 2, chan_b, 1, &mut p_ctr), - 0x6b => send_noret(2, 1, chan_b, 1, &mut p_ctr), - 0x6c => send_hop_noret(0, 1, chan_a, 2, chan_b, 1, &mut p_ctr), - 0x6d => send_hop_noret(2, 1, chan_b, 0, chan_a, 1, &mut p_ctr), + 0x30 => send_noret(0, 1, chan_a_id, 10_000_000, &mut p_ctr), + 0x31 => send_noret(1, 0, chan_a_id, 10_000_000, &mut p_ctr), + 0x32 => send_noret(1, 2, chan_b_id, 10_000_000, &mut p_ctr), + 0x33 => send_noret(2, 1, chan_b_id, 10_000_000, &mut p_ctr), + 0x34 => send_hop_noret(0, 1, chan_a_id, 2, chan_b_id, 10_000_000, &mut p_ctr), + 0x35 => send_hop_noret(2, 1, chan_b_id, 0, chan_a_id, 10_000_000, &mut p_ctr), + + 0x38 => send_noret(0, 1, chan_a_id, 1_000_000, &mut p_ctr), + 0x39 => send_noret(1, 0, chan_a_id, 1_000_000, &mut p_ctr), + 0x3a => send_noret(1, 2, chan_b_id, 1_000_000, &mut p_ctr), + 0x3b => send_noret(2, 1, chan_b_id, 1_000_000, &mut p_ctr), + 0x3c => send_hop_noret(0, 1, chan_a_id, 2, chan_b_id, 1_000_000, &mut p_ctr), + 0x3d => send_hop_noret(2, 1, chan_b_id, 0, chan_a_id, 1_000_000, &mut p_ctr), + + 0x40 => send_noret(0, 1, chan_a_id, 100_000, &mut p_ctr), + 0x41 => send_noret(1, 0, chan_a_id, 100_000, &mut p_ctr), + 0x42 => send_noret(1, 2, chan_b_id, 100_000, &mut p_ctr), + 0x43 => send_noret(2, 1, chan_b_id, 100_000, &mut p_ctr), + 0x44 => send_hop_noret(0, 1, chan_a_id, 2, chan_b_id, 100_000, &mut p_ctr), + 0x45 => send_hop_noret(2, 1, chan_b_id, 0, chan_a_id, 100_000, &mut p_ctr), + + 0x48 => send_noret(0, 1, chan_a_id, 10_000, &mut p_ctr), + 0x49 => send_noret(1, 0, chan_a_id, 10_000, &mut p_ctr), + 0x4a => send_noret(1, 2, chan_b_id, 10_000, &mut p_ctr), + 0x4b => send_noret(2, 1, chan_b_id, 10_000, &mut p_ctr), + 0x4c => send_hop_noret(0, 1, chan_a_id, 2, chan_b_id, 10_000, &mut p_ctr), + 0x4d => send_hop_noret(2, 1, chan_b_id, 0, chan_a_id, 10_000, &mut p_ctr), + + 0x50 => send_noret(0, 1, chan_a_id, 1_000, &mut p_ctr), + 0x51 => send_noret(1, 0, chan_a_id, 1_000, &mut p_ctr), + 0x52 => send_noret(1, 2, chan_b_id, 1_000, &mut p_ctr), + 0x53 => send_noret(2, 1, chan_b_id, 1_000, &mut p_ctr), + 0x54 => send_hop_noret(0, 1, chan_a_id, 2, chan_b_id, 1_000, &mut p_ctr), + 0x55 => send_hop_noret(2, 1, chan_b_id, 0, chan_a_id, 1_000, &mut p_ctr), + + 0x58 => send_noret(0, 1, chan_a_id, 100, &mut p_ctr), + 0x59 => send_noret(1, 0, chan_a_id, 100, &mut p_ctr), + 0x5a => send_noret(1, 2, chan_b_id, 100, &mut p_ctr), + 0x5b => send_noret(2, 1, chan_b_id, 100, &mut p_ctr), + 0x5c => send_hop_noret(0, 1, chan_a_id, 2, chan_b_id, 100, &mut p_ctr), + 0x5d => send_hop_noret(2, 1, chan_b_id, 0, chan_a_id, 100, &mut p_ctr), + + 0x60 => send_noret(0, 1, chan_a_id, 10, &mut p_ctr), + 0x61 => send_noret(1, 0, chan_a_id, 10, &mut p_ctr), + 0x62 => send_noret(1, 2, chan_b_id, 10, &mut p_ctr), + 0x63 => send_noret(2, 1, chan_b_id, 10, &mut p_ctr), + 0x64 => send_hop_noret(0, 1, chan_a_id, 2, chan_b_id, 10, &mut p_ctr), + 0x65 => send_hop_noret(2, 1, chan_b_id, 0, chan_a_id, 10, &mut p_ctr), + + 0x68 => send_noret(0, 1, chan_a_id, 1, &mut p_ctr), + 0x69 => send_noret(1, 0, chan_a_id, 1, &mut p_ctr), + 0x6a => send_noret(1, 2, chan_b_id, 1, &mut p_ctr), + 0x6b => send_noret(2, 1, chan_b_id, 1, &mut p_ctr), + 0x6c => send_hop_noret(0, 1, chan_a_id, 2, chan_b_id, 1, &mut p_ctr), + 0x6d => send_hop_noret(2, 1, chan_b_id, 0, chan_a_id, 1, &mut p_ctr), // MPP payments // 0x70: direct MPP from 0 to 1 (multi A-B channels) - 0x70 => send_mpp_direct(0, 1, &chan_ab_scids, 1_000_000, &mut p_ctr), + 0x70 => send_mpp_direct(0, 1, &chan_ab_ids, 1_000_000, &mut p_ctr), // 0x71: MPP 0->1->2, multi channels on first hop (A-B) - 0x71 => send_mpp_hop(0, 1, &chan_ab_scids, 2, &[chan_b], 1_000_000, &mut p_ctr), + 0x71 => send_mpp_hop(0, 1, &chan_ab_ids, 2, &[chan_b_id], 1_000_000, &mut p_ctr), // 0x72: MPP 0->1->2, multi channels on both hops (A-B and B-C) - 0x72 => send_mpp_hop(0, 1, &chan_ab_scids, 2, &chan_bc_scids, 1_000_000, &mut p_ctr), + 0x72 => send_mpp_hop(0, 1, &chan_ab_ids, 2, &chan_bc_ids, 1_000_000, &mut p_ctr), // 0x73: MPP 0->1->2, multi channels on second hop (B-C) - 0x73 => send_mpp_hop(0, 1, &[chan_a], 2, &chan_bc_scids, 1_000_000, &mut p_ctr), + 0x73 => send_mpp_hop(0, 1, &[chan_a_id], 2, &chan_bc_ids, 1_000_000, &mut p_ctr), // 0x74: direct MPP from 0 to 1, multi parts over single channel - 0x74 => send_mpp_direct(0, 1, &[chan_a, chan_a, chan_a], 1_000_000, &mut p_ctr), + 0x74 => { + send_mpp_direct(0, 1, &[chan_a_id, chan_a_id, chan_a_id], 1_000_000, &mut p_ctr) + }, 0x80 => { let mut max_feerate = last_htlc_clear_fee_a; @@ -2762,16 +2801,16 @@ pub fn do_test( } // Finally, make sure that at least one end of each channel can make a substantial payment - for &scid in &chan_ab_scids { + for &chan_id in &chan_ab_ids { assert!( - send(0, 1, scid, 10_000_000, &mut p_ctr) - || send(1, 0, scid, 10_000_000, &mut p_ctr) + send(0, 1, chan_id, 10_000_000, &mut p_ctr) + || send(1, 0, chan_id, 10_000_000, &mut p_ctr) ); } - for &scid in &chan_bc_scids { + for &chan_id in &chan_bc_ids { assert!( - send(1, 2, scid, 10_000_000, &mut p_ctr) - || send(2, 1, scid, 10_000_000, &mut p_ctr) + send(1, 2, chan_id, 10_000_000, &mut p_ctr) + || send(2, 1, chan_id, 10_000_000, &mut p_ctr) ); } From 28ef7a8d739917250bddfd0a07576108fdba30c0 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Tue, 10 Feb 2026 01:18:19 -0800 Subject: [PATCH 054/627] Consider probe events for stuck payments check in chanmon_consistency Even though we don't explicitly send probes, because probes are detected based on hashing the payment hash+preimage, it's rather trivial for the fuzzer to build payments that accidentally end up looking like probes. --- fuzz/src/chanmon_consistency.rs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 69af660aa96..abaa92d3b1e 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1774,7 +1774,22 @@ pub fn do_test( assert!(resolved_payments[$node].contains(&sent_id)); } }, - events::Event::PaymentFailed { payment_id, .. } => { + // Even though we don't explicitly send probes, because probes are + // detected based on hashing the payment hash+preimage, its rather + // trivial for the fuzzer to build payments that accidentally end up + // looking like probes. + events::Event::ProbeSuccessful { payment_id, .. } => { + let idx_opt = + pending_payments[$node].iter().position(|id| *id == payment_id); + if let Some(idx) = idx_opt { + pending_payments[$node].remove(idx); + resolved_payments[$node].push(payment_id); + } else { + assert!(resolved_payments[$node].contains(&payment_id)); + } + }, + events::Event::PaymentFailed { payment_id, .. } + | events::Event::ProbeFailed { payment_id, .. } => { let idx_opt = pending_payments[$node].iter().position(|id| *id == payment_id); if let Some(idx) = idx_opt { @@ -1789,13 +1804,6 @@ pub fn do_test( events::Event::PaymentClaimed { .. } => {}, events::Event::PaymentPathSuccessful { .. } => {}, events::Event::PaymentPathFailed { .. } => {}, - events::Event::ProbeSuccessful { .. } - | events::Event::ProbeFailed { .. } => { - // Even though we don't explicitly send probes, because probes are - // detected based on hashing the payment hash+preimage, its rather - // trivial for the fuzzer to build payments that accidentally end up - // looking like probes. - }, events::Event::PaymentForwarded { .. } if $node == 1 => {}, events::Event::ChannelReady { .. } => {}, events::Event::HTLCHandlingFailed { .. } => {}, From 520dcbb250f24a10abfa049a755839cbb405732c Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Tue, 10 Feb 2026 11:19:47 -0800 Subject: [PATCH 055/627] Time out incomplete MPP payments in chanmon_consistency This requires calling `timer_tick_occurred`. As a result, when `timer_tick_occurred` is called, disabled/enabled updates and `WarnAndDisconnect` events may be triggered. --- fuzz/src/chanmon_consistency.rs | 68 +++++++++++++++++++--------- lightning/src/ln/channel.rs | 12 ++--- lightning/src/ln/channelmanager.rs | 42 +++-------------- lightning/src/ln/update_fee_tests.rs | 18 ++++++-- 4 files changed, 73 insertions(+), 67 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index abaa92d3b1e..d1f6094dbbf 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -58,7 +58,7 @@ use lightning::ln::channelmanager::{ use lightning::ln::functional_test_utils::*; use lightning::ln::inbound_payment::ExpandedKey; use lightning::ln::msgs::{ - BaseMessageHandler, ChannelMessageHandler, CommitmentUpdate, Init, MessageSendEvent, + self, BaseMessageHandler, ChannelMessageHandler, CommitmentUpdate, Init, MessageSendEvent, UpdateAddHTLC, }; use lightning::ln::outbound_payment::RecipientOnionFields; @@ -843,6 +843,17 @@ fn send_mpp_hop_payment( } } +#[inline] +fn assert_action_timeout_awaiting_response(action: &msgs::ErrorAction) { + // Since sending/receiving messages may be delayed, `timer_tick_occurred` may cause a node to + // disconnect their counterparty if they're expecting a timely response. + assert!(matches!( + action, + msgs::ErrorAction::DisconnectPeerWithWarning { msg } + if msg.data.contains("Disconnecting due to timeout awaiting response") + )); +} + #[inline] pub fn do_test( data: &[u8], underlying_out: Out, anchors: bool, @@ -1424,8 +1435,12 @@ pub fn do_test( }, MessageSendEvent::SendChannelReady { .. } => continue, MessageSendEvent::SendAnnouncementSignatures { .. } => continue, - MessageSendEvent::SendChannelUpdate { ref node_id, ref msg } => { - assert_eq!(msg.contents.channel_flags & 2, 0); // The disable bit must never be set! + MessageSendEvent::SendChannelUpdate { ref node_id, .. } => { + if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } + *node_id == a_id + }, + MessageSendEvent::HandleError { ref action, ref node_id } => { + assert_action_timeout_awaiting_response(action); if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } *node_id == a_id }, @@ -1638,20 +1653,21 @@ pub fn do_test( } } }, + MessageSendEvent::HandleError { ref action, .. } => { + assert_action_timeout_awaiting_response(action); + }, MessageSendEvent::SendChannelReady { .. } => { // Can be generated as a reestablish response }, MessageSendEvent::SendAnnouncementSignatures { .. } => { // Can be generated as a reestablish response }, - MessageSendEvent::SendChannelUpdate { ref msg, .. } => { - // When we reconnect we will resend a channel_update to make sure our - // counterparty has the latest parameters for receiving payments - // through us. We do, however, check that the message does not include - // the "disabled" bit, as we should never ever have a channel which is - // disabled when we send such an update (or it may indicate channel - // force-close which we should detect as an error). - assert_eq!(msg.contents.channel_flags & 2, 0); + MessageSendEvent::SendChannelUpdate { .. } => { + // Can be generated as a reestablish response + }, + MessageSendEvent::BroadcastChannelUpdate { .. } => { + // Can be generated as a result of calling `timer_tick_occurred` enough + // times while peers are disconnected }, _ => if out.may_fail.load(atomic::Ordering::Acquire) { return; @@ -1693,8 +1709,9 @@ pub fn do_test( MessageSendEvent::SendStfu { .. } => {}, MessageSendEvent::SendChannelReady { .. } => {}, MessageSendEvent::SendAnnouncementSignatures { .. } => {}, - MessageSendEvent::SendChannelUpdate { ref msg, .. } => { - assert_eq!(msg.contents.channel_flags & 2, 0); // The disable bit must never be set! + MessageSendEvent::SendChannelUpdate { .. } => {}, + MessageSendEvent::HandleError { ref action, .. } => { + assert_action_timeout_awaiting_response(action); }, _ => { if out.may_fail.load(atomic::Ordering::Acquire) { @@ -1720,8 +1737,9 @@ pub fn do_test( MessageSendEvent::SendStfu { .. } => {}, MessageSendEvent::SendChannelReady { .. } => {}, MessageSendEvent::SendAnnouncementSignatures { .. } => {}, - MessageSendEvent::SendChannelUpdate { ref msg, .. } => { - assert_eq!(msg.contents.channel_flags & 2, 0); // The disable bit must never be set! + MessageSendEvent::SendChannelUpdate { .. } => {}, + MessageSendEvent::HandleError { ref action, .. } => { + assert_action_timeout_awaiting_response(action); }, _ => { if out.may_fail.load(atomic::Ordering::Acquire) { @@ -2195,11 +2213,11 @@ pub fn do_test( if fee_est_a.ret_val.fetch_add(250, atomic::Ordering::AcqRel) + 250 > max_feerate { fee_est_a.ret_val.store(max_feerate, atomic::Ordering::Release); } - nodes[0].maybe_update_chan_fees(); + nodes[0].timer_tick_occurred(); }, 0x81 => { fee_est_a.ret_val.store(253, atomic::Ordering::Release); - nodes[0].maybe_update_chan_fees(); + nodes[0].timer_tick_occurred(); }, 0x84 => { @@ -2210,11 +2228,11 @@ pub fn do_test( if fee_est_b.ret_val.fetch_add(250, atomic::Ordering::AcqRel) + 250 > max_feerate { fee_est_b.ret_val.store(max_feerate, atomic::Ordering::Release); } - nodes[1].maybe_update_chan_fees(); + nodes[1].timer_tick_occurred(); }, 0x85 => { fee_est_b.ret_val.store(253, atomic::Ordering::Release); - nodes[1].maybe_update_chan_fees(); + nodes[1].timer_tick_occurred(); }, 0x88 => { @@ -2225,11 +2243,11 @@ pub fn do_test( if fee_est_c.ret_val.fetch_add(250, atomic::Ordering::AcqRel) + 250 > max_feerate { fee_est_c.ret_val.store(max_feerate, atomic::Ordering::Release); } - nodes[2].maybe_update_chan_fees(); + nodes[2].timer_tick_occurred(); }, 0x89 => { fee_est_c.ret_val.store(253, atomic::Ordering::Release); - nodes[2].maybe_update_chan_fees(); + nodes[2].timer_tick_occurred(); }, 0xa0 => { @@ -2798,6 +2816,14 @@ pub fn do_test( process_all_events!(); + // Since MPP payments are supported, we wait until we fully settle the state of all + // channels to see if we have any committed HTLC parts of an MPP payment that need + // to be failed back. + for node in &nodes { + node.timer_tick_occurred(); + } + process_all_events!(); + // Verify no payments are stuck - all should have resolved for (idx, pending) in pending_payments.borrow().iter().enumerate() { assert!( diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 0f1916ac59f..dd3837499df 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -3030,7 +3030,7 @@ pub(crate) enum QuiescentAction { contribution: FundingContribution, locktime: LockTime, }, - #[cfg(any(test, fuzzing))] + #[cfg(any(test, fuzzing, feature = "_test_utils"))] DoNothing, } @@ -3039,7 +3039,7 @@ pub(crate) enum StfuResponse { SpliceInit(msgs::SpliceInit), } -#[cfg(any(test, fuzzing))] +#[cfg(any(test, fuzzing, feature = "_test_utils"))] impl_writeable_tlv_based_enum_upgradable!(QuiescentAction, (0, DoNothing) => {}, (2, Splice) => { @@ -3048,7 +3048,7 @@ impl_writeable_tlv_based_enum_upgradable!(QuiescentAction, }, {1, LegacySplice} => (), ); -#[cfg(not(any(test, fuzzing)))] +#[cfg(not(any(test, fuzzing, feature = "_test_utils")))] impl_writeable_tlv_based_enum_upgradable!(QuiescentAction, (2, Splice) => { (0, contribution, required), @@ -7066,7 +7066,7 @@ where contributed_outputs: outputs, }) }, - #[cfg(any(test, fuzzing))] + #[cfg(any(test, fuzzing, feature = "_test_utils"))] Some(quiescent_action) => { self.quiescent_action = Some(quiescent_action); None @@ -13569,7 +13569,7 @@ where let splice_init = self.send_splice_init_internal(context, ChangeStrategy::FromCoinSelection); return Ok(Some(StfuResponse::SpliceInit(splice_init))); }, - #[cfg(any(test, fuzzing))] + #[cfg(any(test, fuzzing, feature = "_test_utils"))] Some(QuiescentAction::DoNothing) => { // In quiescence test we want to just hang out here, letting the test manually // leave quiescence. @@ -13612,7 +13612,7 @@ where Ok(None) } - #[cfg(any(test, fuzzing))] + #[cfg(any(test, fuzzing, feature = "_test_utils"))] #[rustfmt::skip] pub fn exit_quiescence(&mut self) -> bool { // Make sure we either finished the quiescence handshake and are quiescent, or we never diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 869a431e757..051bda3793c 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -55,7 +55,7 @@ use crate::events::{ }; use crate::events::{FundingInfo, PaidBolt12Invoice}; use crate::ln::chan_utils::selected_commitment_sat_per_1000_weight; -#[cfg(any(test, fuzzing))] +#[cfg(any(test, fuzzing, feature = "_test_utils"))] use crate::ln::channel::QuiescentAction; use crate::ln::channel::{ self, hold_time_since, Channel, ChannelError, ChannelUpdateStatus, DisconnectResult, @@ -3047,7 +3047,10 @@ const _CHECK_CLTV_EXPIRY_OFFCHAIN: () = assert!( ); /// The number of ticks of [`ChannelManager::timer_tick_occurred`] until expiry of incomplete MPPs +#[cfg(not(any(fuzzing, test, feature = "_test_utils")))] pub(crate) const MPP_TIMEOUT_TICKS: u8 = 3; +#[cfg(any(fuzzing, test, feature = "_test_utils"))] +pub(crate) const MPP_TIMEOUT_TICKS: u8 = 1; /// The number of ticks of [`ChannelManager::timer_tick_occurred`] where a peer is disconnected /// until we mark the channel disabled and gossip the update. @@ -8313,39 +8316,6 @@ impl< NotifyOption::DoPersist } - #[cfg(any(test, fuzzing, feature = "_externalize_tests"))] - /// In chanmon_consistency we want to sometimes do the channel fee updates done in - /// timer_tick_occurred, but we can't generate the disabled channel updates as it considers - /// these a fuzz failure (as they usually indicate a channel force-close, which is exactly what - /// it wants to detect). Thus, we have a variant exposed here for its benefit. - #[rustfmt::skip] - pub fn maybe_update_chan_fees(&self) { - PersistenceNotifierGuard::optionally_notify(self, || { - let mut should_persist = NotifyOption::SkipPersistNoEvents; - let mut feerate_cache = new_hash_map(); - - let per_peer_state = self.per_peer_state.read().unwrap(); - for (_cp_id, peer_state_mutex) in per_peer_state.iter() { - let mut peer_state_lock = peer_state_mutex.lock().unwrap(); - let peer_state = &mut *peer_state_lock; - for (chan_id, chan) in peer_state.channel_by_id.iter_mut() - .filter_map(|(chan_id, chan)| chan.as_funded_mut().map(|chan| (chan_id, chan))) - { - let channel_type = chan.funding.get_channel_type(); - let new_feerate = feerate_cache.get(channel_type).copied().or_else(|| { - let feerate = selected_commitment_sat_per_1000_weight(&self.fee_estimator, &channel_type); - feerate_cache.insert(channel_type.clone(), feerate); - Some(feerate) - }).unwrap(); - let chan_needs_persist = self.update_channel_fee(chan_id, chan, new_feerate); - if chan_needs_persist == NotifyOption::DoPersist { should_persist = NotifyOption::DoPersist; } - } - } - - should_persist - }); - } - /// Performs actions which should happen on startup and roughly once per minute thereafter. /// /// This currently includes: @@ -13351,7 +13321,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } } - #[cfg(any(test, fuzzing))] + #[cfg(any(test, fuzzing, feature = "_test_utils"))] #[rustfmt::skip] pub fn maybe_propose_quiescence(&self, counterparty_node_id: &PublicKey, channel_id: &ChannelId) -> Result<(), APIError> { let mut result = Ok(()); @@ -13408,7 +13378,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ result } - #[cfg(any(test, fuzzing))] + #[cfg(any(test, fuzzing, feature = "_test_utils"))] #[rustfmt::skip] pub fn exit_quiescence(&self, counterparty_node_id: &PublicKey, channel_id: &ChannelId) -> Result { let _read_guard = self.total_consistency_lock.read().unwrap(); diff --git a/lightning/src/ln/update_fee_tests.rs b/lightning/src/ln/update_fee_tests.rs index 24ae8525450..423d27b611c 100644 --- a/lightning/src/ln/update_fee_tests.rs +++ b/lightning/src/ln/update_fee_tests.rs @@ -1089,9 +1089,13 @@ pub fn do_cannot_afford_on_holding_cell_release( *feerate_lock = target_feerate; } - // Put the update fee into the holding cell of node 0 - - nodes[0].node.maybe_update_chan_fees(); + // Put the update fee into the holding cell of node 0. We use quiescence as an easy way to force + // the update into the holding cell. + nodes[0].node.maybe_propose_quiescence(&node_b_id, &chan_id).unwrap(); + let stfu = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_b_id); + nodes[0].node.timer_tick_occurred(); + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + check_added_monitors(&nodes[0], 0); // While the update_fee is in the holding cell, add an inbound HTLC @@ -1132,11 +1136,17 @@ pub fn do_cannot_afford_on_holding_cell_release( panic!(); } - // Release the update_fee from its holding cell + // Release the update_fee from its holding cell by completing the quiescence handshake. + nodes[1].node.handle_stfu(node_a_id, &stfu); + let stfu = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_a_id); + nodes[0].node.handle_stfu(node_b_id, &stfu); + let _ = nodes[0].node.exit_quiescence(&node_b_id, &chan_id); + let _ = nodes[1].node.exit_quiescence(&node_a_id, &chan_id); let mut events = nodes[0].node.get_and_clear_pending_msg_events(); if can_afford { // We could afford the update_fee, sanity check everything assert_eq!(events.len(), 1); + check_added_monitors(&nodes[0], 1); if let MessageSendEvent::UpdateHTLCs { node_id, channel_id, updates } = events.pop().unwrap() { From 7b8c68c9aa6641395979ffb7919d4a59f66a8ffd Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Tue, 10 Feb 2026 11:20:35 -0800 Subject: [PATCH 056/627] Add newline to fuzz log statements This regressed at some point, making the logs harder to parse on a failed test run. --- fuzz/src/utils/test_logger.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fuzz/src/utils/test_logger.rs b/fuzz/src/utils/test_logger.rs index f8369879447..8f38d08a035 100644 --- a/fuzz/src/utils/test_logger.rs +++ b/fuzz/src/utils/test_logger.rs @@ -59,6 +59,6 @@ impl<'a, Out: Output> Write for LockedWriteAdapter<'a, Out> { impl Logger for TestLogger { fn log(&self, record: Record) { - write!(LockedWriteAdapter(&self.out), "{:<6} {}", self.id, record).unwrap(); + writeln!(LockedWriteAdapter(&self.out), "{:<6} {}", self.id, record).unwrap(); } } From ff39dff942d95e527940558ff36e1363ae356f5b Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Tue, 17 Feb 2026 09:08:25 -0800 Subject: [PATCH 057/627] Refactor channel splice operations into helper in chanmon_consistency --- fuzz/src/chanmon_consistency.rs | 373 +++++++++++--------------------- 1 file changed, 122 insertions(+), 251 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index d1f6094dbbf..b647ee3e33e 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -56,6 +56,7 @@ use lightning::ln::channelmanager::{ ChainParameters, ChannelManager, ChannelManagerReadArgs, PaymentId, RecentPaymentDetails, }; use lightning::ln::functional_test_utils::*; +use lightning::ln::funding::{FundingContribution, FundingTemplate}; use lightning::ln::inbound_payment::ExpandedKey; use lightning::ln::msgs::{ self, BaseMessageHandler, ChannelMessageHandler, CommitmentUpdate, Init, MessageSendEvent, @@ -106,6 +107,7 @@ const MAX_FEE: u32 = 10_000; struct FuzzEstimator { ret_val: atomic::AtomicU32, } + impl FeeEstimator for FuzzEstimator { fn get_est_sat_per_1000_weight(&self, conf_target: ConfirmationTarget) -> u32 { // We force-close channels if our counterparty sends us a feerate which is a small multiple @@ -128,6 +130,13 @@ impl FeeEstimator for FuzzEstimator { } } +impl FuzzEstimator { + fn feerate_sat_per_kw(&self) -> FeeRate { + let feerate = self.ret_val.load(atomic::Ordering::Acquire); + FeeRate::from_sat_per_kwu(feerate as u64) + } +} + struct FuzzRouter {} impl Router for FuzzRouter { @@ -1233,6 +1242,7 @@ pub fn do_test( let wallet_a = TestWalletSource::new(SecretKey::from_slice(&[1; 32]).unwrap()); let wallet_b = TestWalletSource::new(SecretKey::from_slice(&[2; 32]).unwrap()); let wallet_c = TestWalletSource::new(SecretKey::from_slice(&[3; 32]).unwrap()); + let wallets = vec![wallet_a, wallet_b, wallet_c]; let coinbase_tx = bitcoin::Transaction { version: bitcoin::transaction::Version::TWO, @@ -1376,6 +1386,82 @@ pub fn do_test( }}; } + let splice_channel = |node: &ChanMan, + counterparty_node_id: &PublicKey, + channel_id: &ChannelId, + f: &dyn Fn(FundingTemplate) -> Result, + funding_feerate_sat_per_kw: FeeRate| { + match node.splice_channel(channel_id, counterparty_node_id, funding_feerate_sat_per_kw) { + Ok(funding_template) => { + if let Ok(contribution) = f(funding_template) { + let _ = node.funding_contributed( + channel_id, + counterparty_node_id, + contribution, + None, + ); + } + }, + Err(e) => { + assert!( + matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), + "{:?}", + e + ); + }, + } + }; + + let splice_in = + |node: &ChanMan, + counterparty_node_id: &PublicKey, + channel_id: &ChannelId, + wallet: &WalletSync<&TestWalletSource, Arc>, + funding_feerate_sat_per_kw: FeeRate| { + splice_channel( + node, + counterparty_node_id, + channel_id, + &move |funding_template: FundingTemplate| { + funding_template.splice_in_sync(Amount::from_sat(10_000), wallet) + }, + funding_feerate_sat_per_kw, + ); + }; + + let splice_out = |node: &ChanMan, + counterparty_node_id: &PublicKey, + channel_id: &ChannelId, + wallet: &TestWalletSource, + logger: Arc, + funding_feerate_sat_per_kw: FeeRate| { + // We conditionally splice out `MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS` only when the node + // has double the balance required to send a payment upon a `0xff` byte. We do this to + // ensure there's always liquidity available for a payment to succeed then. + let outbound_capacity_msat = node + .list_channels() + .iter() + .find(|chan| chan.channel_id == *channel_id) + .map(|chan| chan.outbound_capacity_msat) + .unwrap(); + if outbound_capacity_msat < 20_000_000 { + return; + } + splice_channel( + node, + counterparty_node_id, + channel_id, + &move |funding_template| { + let outputs = vec![TxOut { + value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), + script_pubkey: wallet.get_change_script().unwrap(), + }]; + funding_template.splice_out_sync(outputs, &WalletSync::new(wallet, logger.clone())) + }, + funding_feerate_sat_per_kw, + ); + }; + loop { // Push any events from Node B onto ba_events and bc_events macro_rules! push_excess_b_events { @@ -2251,272 +2337,57 @@ pub fn do_test( }, 0xa0 => { - let feerate_sat_per_kw = fee_estimators[0].ret_val.load(atomic::Ordering::Acquire); - let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); - match nodes[0].splice_channel(&chan_a_id, &nodes[1].get_our_node_id(), feerate) { - Ok(funding_template) => { - let wallet = WalletSync::new(&wallets[0], Arc::clone(&loggers[0])); - if let Ok(contribution) = - funding_template.splice_in_sync(Amount::from_sat(10_000), &wallet) - { - let _ = nodes[0].funding_contributed( - &chan_a_id, - &nodes[1].get_our_node_id(), - contribution, - None, - ); - } - }, - Err(e) => { - assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), - "{:?}", - e - ); - }, - } + let cp_node_id = nodes[1].get_our_node_id(); + let wallet = WalletSync::new(&wallets[0], Arc::clone(&loggers[0])); + let feerate_sat_per_kw = fee_estimators[0].feerate_sat_per_kw(); + splice_in(&nodes[0], &cp_node_id, &chan_a_id, &wallet, feerate_sat_per_kw); }, 0xa1 => { - let feerate_sat_per_kw = fee_estimators[1].ret_val.load(atomic::Ordering::Acquire); - let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); - match nodes[1].splice_channel(&chan_a_id, &nodes[0].get_our_node_id(), feerate) { - Ok(funding_template) => { - let wallet = WalletSync::new(&wallets[1], Arc::clone(&loggers[1])); - if let Ok(contribution) = - funding_template.splice_in_sync(Amount::from_sat(10_000), &wallet) - { - let _ = nodes[1].funding_contributed( - &chan_a_id, - &nodes[0].get_our_node_id(), - contribution, - None, - ); - } - }, - Err(e) => { - assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), - "{:?}", - e - ); - }, - } + let cp_node_id = nodes[0].get_our_node_id(); + let wallet = WalletSync::new(&wallets[1], Arc::clone(&loggers[1])); + let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw(); + splice_in(&nodes[1], &cp_node_id, &chan_a_id, &wallet, feerate_sat_per_kw); }, 0xa2 => { - let feerate_sat_per_kw = fee_estimators[1].ret_val.load(atomic::Ordering::Acquire); - let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); - match nodes[1].splice_channel(&chan_b_id, &nodes[2].get_our_node_id(), feerate) { - Ok(funding_template) => { - let wallet = WalletSync::new(&wallets[1], Arc::clone(&loggers[1])); - if let Ok(contribution) = - funding_template.splice_in_sync(Amount::from_sat(10_000), &wallet) - { - let _ = nodes[1].funding_contributed( - &chan_b_id, - &nodes[2].get_our_node_id(), - contribution, - None, - ); - } - }, - Err(e) => { - assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), - "{:?}", - e - ); - }, - } + let cp_node_id = nodes[2].get_our_node_id(); + let wallet = WalletSync::new(&wallets[1], Arc::clone(&loggers[1])); + let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw(); + splice_in(&nodes[1], &cp_node_id, &chan_b_id, &wallet, feerate_sat_per_kw); }, 0xa3 => { - let feerate_sat_per_kw = fee_estimators[2].ret_val.load(atomic::Ordering::Acquire); - let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); - match nodes[2].splice_channel(&chan_b_id, &nodes[1].get_our_node_id(), feerate) { - Ok(funding_template) => { - let wallet = WalletSync::new(&wallets[2], Arc::clone(&loggers[2])); - if let Ok(contribution) = - funding_template.splice_in_sync(Amount::from_sat(10_000), &wallet) - { - let _ = nodes[2].funding_contributed( - &chan_b_id, - &nodes[1].get_our_node_id(), - contribution, - None, - ); - } - }, - Err(e) => { - assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), - "{:?}", - e - ); - }, - } + let cp_node_id = nodes[1].get_our_node_id(); + let wallet = WalletSync::new(&wallets[2], Arc::clone(&loggers[2])); + let feerate_sat_per_kw = fee_estimators[2].feerate_sat_per_kw(); + splice_in(&nodes[2], &cp_node_id, &chan_b_id, &wallet, feerate_sat_per_kw); }, - // We conditionally splice out `MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS` only when the node - // has double the balance required to send a payment upon a `0xff` byte. We do this to - // ensure there's always liquidity available for a payment to succeed then. 0xa4 => { - let outbound_capacity_msat = nodes[0] - .list_channels() - .iter() - .find(|chan| chan.channel_id == chan_a_id) - .map(|chan| chan.outbound_capacity_msat) - .unwrap(); - if outbound_capacity_msat >= 20_000_000 { - let feerate_sat_per_kw = - fee_estimators[0].ret_val.load(atomic::Ordering::Acquire); - let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); - match nodes[0].splice_channel(&chan_a_id, &nodes[1].get_our_node_id(), feerate) - { - Ok(funding_template) => { - let outputs = vec![TxOut { - value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), - script_pubkey: coinbase_tx.output[0].script_pubkey.clone(), - }]; - let wallet = WalletSync::new(&wallets[0], Arc::clone(&loggers[0])); - if let Ok(contribution) = - funding_template.splice_out_sync(outputs, &wallet) - { - let _ = nodes[0].funding_contributed( - &chan_a_id, - &nodes[1].get_our_node_id(), - contribution, - None, - ); - } - }, - Err(e) => { - assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), - "{:?}", - e - ); - }, - } - } + let cp_node_id = nodes[1].get_our_node_id(); + let wallet = &wallets[0]; + let logger = Arc::clone(&loggers[0]); + let feerate_sat_per_kw = fee_estimators[0].feerate_sat_per_kw(); + splice_out(&nodes[0], &cp_node_id, &chan_a_id, wallet, logger, feerate_sat_per_kw); }, 0xa5 => { - let outbound_capacity_msat = nodes[1] - .list_channels() - .iter() - .find(|chan| chan.channel_id == chan_a_id) - .map(|chan| chan.outbound_capacity_msat) - .unwrap(); - if outbound_capacity_msat >= 20_000_000 { - let feerate_sat_per_kw = - fee_estimators[1].ret_val.load(atomic::Ordering::Acquire); - let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); - match nodes[1].splice_channel(&chan_a_id, &nodes[0].get_our_node_id(), feerate) - { - Ok(funding_template) => { - let outputs = vec![TxOut { - value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), - script_pubkey: coinbase_tx.output[1].script_pubkey.clone(), - }]; - let wallet = WalletSync::new(&wallets[1], Arc::clone(&loggers[1])); - if let Ok(contribution) = - funding_template.splice_out_sync(outputs, &wallet) - { - let _ = nodes[1].funding_contributed( - &chan_a_id, - &nodes[0].get_our_node_id(), - contribution, - None, - ); - } - }, - Err(e) => { - assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), - "{:?}", - e - ); - }, - } - } + let cp_node_id = nodes[0].get_our_node_id(); + let wallet = &wallets[1]; + let logger = Arc::clone(&loggers[1]); + let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw(); + splice_out(&nodes[1], &cp_node_id, &chan_a_id, wallet, logger, feerate_sat_per_kw); }, 0xa6 => { - let outbound_capacity_msat = nodes[1] - .list_channels() - .iter() - .find(|chan| chan.channel_id == chan_b_id) - .map(|chan| chan.outbound_capacity_msat) - .unwrap(); - if outbound_capacity_msat >= 20_000_000 { - let feerate_sat_per_kw = - fee_estimators[1].ret_val.load(atomic::Ordering::Acquire); - let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); - match nodes[1].splice_channel(&chan_b_id, &nodes[2].get_our_node_id(), feerate) - { - Ok(funding_template) => { - let outputs = vec![TxOut { - value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), - script_pubkey: coinbase_tx.output[1].script_pubkey.clone(), - }]; - let wallet = WalletSync::new(&wallets[1], Arc::clone(&loggers[1])); - if let Ok(contribution) = - funding_template.splice_out_sync(outputs, &wallet) - { - let _ = nodes[1].funding_contributed( - &chan_b_id, - &nodes[2].get_our_node_id(), - contribution, - None, - ); - } - }, - Err(e) => { - assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), - "{:?}", - e - ); - }, - } - } + let cp_node_id = nodes[2].get_our_node_id(); + let wallet = &wallets[1]; + let logger = Arc::clone(&loggers[1]); + let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw(); + splice_out(&nodes[1], &cp_node_id, &chan_b_id, wallet, logger, feerate_sat_per_kw); }, 0xa7 => { - let outbound_capacity_msat = nodes[2] - .list_channels() - .iter() - .find(|chan| chan.channel_id == chan_b_id) - .map(|chan| chan.outbound_capacity_msat) - .unwrap(); - if outbound_capacity_msat >= 20_000_000 { - let feerate_sat_per_kw = - fee_estimators[2].ret_val.load(atomic::Ordering::Acquire); - let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); - match nodes[2].splice_channel(&chan_b_id, &nodes[1].get_our_node_id(), feerate) - { - Ok(funding_template) => { - let outputs = vec![TxOut { - value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), - script_pubkey: coinbase_tx.output[2].script_pubkey.clone(), - }]; - let wallet = WalletSync::new(&wallets[2], Arc::clone(&loggers[2])); - if let Ok(contribution) = - funding_template.splice_out_sync(outputs, &wallet) - { - let _ = nodes[2].funding_contributed( - &chan_b_id, - &nodes[1].get_our_node_id(), - contribution, - None, - ); - } - }, - Err(e) => { - assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), - "{:?}", - e - ); - }, - } - } + let cp_node_id = nodes[1].get_our_node_id(); + let wallet = &wallets[2]; + let logger = Arc::clone(&loggers[2]); + let feerate_sat_per_kw = fee_estimators[2].feerate_sat_per_kw(); + splice_out(&nodes[2], &cp_node_id, &chan_b_id, wallet, logger, feerate_sat_per_kw); }, // Sync node by 1 block to cover confirmation of a transaction. From c9827fc012eb8352e53e859ed9c5ef88a115a0e4 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Thu, 12 Feb 2026 10:02:41 -0800 Subject: [PATCH 058/627] Avoid persisting ChannelManager on handle_tx_* errors These errors will only ever affect our in-memory state, so there's no need to persist the ChannelManager when we come across one. Note that `tx_abort` is not included here because there is a possibility we force close the channel, which we should persist. --- lightning/src/ln/channelmanager.rs | 87 +++++++++++++++--------------- 1 file changed, 44 insertions(+), 43 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 930f5fe4298..c9fbdc725fd 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -2935,6 +2935,23 @@ impl<'a> PersistenceNotifierGuard<'a, fn() -> NotifyOption> { Self::optionally_notify(cm, || -> NotifyOption { NotifyOption::DoPersist }) } + fn manually_notify( + cm: &'a C, f: F, + ) -> PersistenceNotifierGuard<'a, impl FnOnce() -> NotifyOption> { + let read_guard = cm.get_cm().total_consistency_lock.read().unwrap(); + let force_notify = cm.get_cm().process_background_events(); + + PersistenceNotifierGuard { + event_persist_notifier: &cm.get_cm().event_persist_notifier, + needs_persist_flag: &cm.get_cm().needs_persist_flag, + should_persist: Some(move || { + f(); + force_notify + }), + _read_guard: read_guard, + } + } + fn optionally_notify NotifyOption, C: AChannelManager>( cm: &'a C, persist_check: F, ) -> PersistenceNotifierGuard<'a, impl FnOnce() -> NotifyOption> { @@ -11336,7 +11353,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ >( &self, counterparty_node_id: &PublicKey, channel_id: ChannelId, tx_msg_handler: HandleTxMsgFn, - ) -> Result { + ) -> Result<(), MsgHandleErrInternal> { let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { debug_assert!(false); @@ -11351,7 +11368,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ Ok(msg_send) => { let msg_send_event = msg_send.into_msg_send_event(*counterparty_node_id); peer_state.pending_msg_events.push(msg_send_event); - Ok(NotifyOption::SkipPersistHandleEvents) + Ok(()) }, Err(InteractiveTxMsgError { err, @@ -11389,7 +11406,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ fn internal_tx_add_input( &self, counterparty_node_id: PublicKey, msg: &msgs::TxAddInput, - ) -> Result { + ) -> Result<(), MsgHandleErrInternal> { self.internal_tx_msg(&counterparty_node_id, msg.channel_id, |channel: &mut Channel| { channel.tx_add_input(msg, &self.logger) }) @@ -11397,7 +11414,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ fn internal_tx_add_output( &self, counterparty_node_id: PublicKey, msg: &msgs::TxAddOutput, - ) -> Result { + ) -> Result<(), MsgHandleErrInternal> { self.internal_tx_msg(&counterparty_node_id, msg.channel_id, |channel: &mut Channel| { channel.tx_add_output(msg, &self.logger) }) @@ -11405,7 +11422,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ fn internal_tx_remove_input( &self, counterparty_node_id: PublicKey, msg: &msgs::TxRemoveInput, - ) -> Result { + ) -> Result<(), MsgHandleErrInternal> { self.internal_tx_msg(&counterparty_node_id, msg.channel_id, |channel: &mut Channel| { channel.tx_remove_input(msg, &self.logger) }) @@ -11413,7 +11430,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ fn internal_tx_remove_output( &self, counterparty_node_id: PublicKey, msg: &msgs::TxRemoveOutput, - ) -> Result { + ) -> Result<(), MsgHandleErrInternal> { self.internal_tx_msg(&counterparty_node_id, msg.channel_id, |channel: &mut Channel| { channel.tx_remove_output(msg, &self.logger) }) @@ -11421,7 +11438,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ fn internal_tx_complete( &self, counterparty_node_id: PublicKey, msg: &msgs::TxComplete, - ) -> Result { + ) -> Result<(), MsgHandleErrInternal> { let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(&counterparty_node_id).ok_or_else(|| { debug_assert!(false); @@ -11434,15 +11451,12 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let chan = chan_entry.get_mut(); match chan.tx_complete(msg, &self.fee_estimator, &self.logger) { Ok(tx_complete_result) => { - let mut persist = NotifyOption::SkipPersistNoEvents; - if let Some(interactive_tx_msg_send) = tx_complete_result.interactive_tx_msg_send { let msg_send_event = interactive_tx_msg_send.into_msg_send_event(counterparty_node_id); peer_state.pending_msg_events.push(msg_send_event); - persist = NotifyOption::SkipPersistHandleEvents; }; if let Some(unsigned_transaction) = tx_complete_result.event_unsigned_tx { @@ -11456,7 +11470,8 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ None, )); // We have a successful signing session that we need to persist. - persist = NotifyOption::DoPersist; + self.needs_persist_flag.store(true, Ordering::Release); + self.event_persist_notifier.notify() } if let Some(FundingTxSigned { @@ -11501,10 +11516,11 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } // We have a successful signing session that we need to persist. - persist = NotifyOption::DoPersist; + self.needs_persist_flag.store(true, Ordering::Release); + self.event_persist_notifier.notify() } - Ok(persist) + Ok(()) }, Err(InteractiveTxMsgError { err, @@ -16182,62 +16198,47 @@ impl< } fn handle_tx_add_input(&self, counterparty_node_id: PublicKey, msg: &msgs::TxAddInput) { - let _persistence_guard = PersistenceNotifierGuard::optionally_notify(self, || { + let _persistence_guard = PersistenceNotifierGuard::manually_notify(self, || { let res = self.internal_tx_add_input(counterparty_node_id, msg); - let persist = match &res { - Err(_) => NotifyOption::DoPersist, - Ok(persist) => *persist, - }; + debug_assert!(res.as_ref().err().map_or(true, |err| !err.closes_channel())); let _ = self.handle_error(res, counterparty_node_id); - persist + self.event_persist_notifier.notify(); }); } fn handle_tx_add_output(&self, counterparty_node_id: PublicKey, msg: &msgs::TxAddOutput) { - let _persistence_guard = PersistenceNotifierGuard::optionally_notify(self, || { + let _persistence_guard = PersistenceNotifierGuard::manually_notify(self, || { let res = self.internal_tx_add_output(counterparty_node_id, msg); - let persist = match &res { - Err(_) => NotifyOption::DoPersist, - Ok(persist) => *persist, - }; + debug_assert!(res.as_ref().err().map_or(true, |err| !err.closes_channel())); let _ = self.handle_error(res, counterparty_node_id); - persist + self.event_persist_notifier.notify(); }); } fn handle_tx_remove_input(&self, counterparty_node_id: PublicKey, msg: &msgs::TxRemoveInput) { - let _persistence_guard = PersistenceNotifierGuard::optionally_notify(self, || { + let _persistence_guard = PersistenceNotifierGuard::manually_notify(self, || { let res = self.internal_tx_remove_input(counterparty_node_id, msg); - let persist = match &res { - Err(_) => NotifyOption::DoPersist, - Ok(persist) => *persist, - }; + debug_assert!(res.as_ref().err().map_or(true, |err| !err.closes_channel())); let _ = self.handle_error(res, counterparty_node_id); - persist + self.event_persist_notifier.notify(); }); } fn handle_tx_remove_output(&self, counterparty_node_id: PublicKey, msg: &msgs::TxRemoveOutput) { - let _persistence_guard = PersistenceNotifierGuard::optionally_notify(self, || { + let _persistence_guard = PersistenceNotifierGuard::manually_notify(self, || { let res = self.internal_tx_remove_output(counterparty_node_id, msg); - let persist = match &res { - Err(_) => NotifyOption::DoPersist, - Ok(persist) => *persist, - }; + debug_assert!(res.as_ref().err().map_or(true, |err| !err.closes_channel())); let _ = self.handle_error(res, counterparty_node_id); - persist + self.event_persist_notifier.notify(); }); } fn handle_tx_complete(&self, counterparty_node_id: PublicKey, msg: &msgs::TxComplete) { - let _persistence_guard = PersistenceNotifierGuard::optionally_notify(self, || { + let _persistence_guard = PersistenceNotifierGuard::manually_notify(self, || { let res = self.internal_tx_complete(counterparty_node_id, msg); - let persist = match &res { - Err(_) => NotifyOption::DoPersist, - Ok(persist) => *persist, - }; + debug_assert!(res.as_ref().err().map_or(true, |err| !err.closes_channel())); let _ = self.handle_error(res, counterparty_node_id); - persist + self.event_persist_notifier.notify(); }); } From f6ea33e9f40c8065e5a6f241b40d5afb4f7c913d Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Thu, 12 Feb 2026 15:11:06 -0800 Subject: [PATCH 059/627] Inline unreachable debug assertion in MsgHandlErrInternal::no_such_peer --- lightning/src/ln/channelmanager.rs | 149 +++++++++++------------------ 1 file changed, 58 insertions(+), 91 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index c9fbdc725fd..1a4fe170408 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -957,7 +957,8 @@ impl MsgHandleErrInternal { } } - fn no_such_peer(counterparty_node_id: &PublicKey, channel_id: ChannelId) -> Self { + fn unreachable_no_such_peer(counterparty_node_id: &PublicKey, channel_id: ChannelId) -> Self { + debug_assert!(false); let err = format!("No such peer for the passed counterparty_node_id {counterparty_node_id}"); Self::send_err_msg_no_close(err, channel_id) @@ -10950,8 +10951,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer( + MsgHandleErrInternal::unreachable_no_such_peer( counterparty_node_id, common_fields.temporary_channel_id, ) @@ -11021,11 +11021,12 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ // likely to be lost on restart! let (value, output_script, user_id) = { let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = per_peer_state.get(counterparty_node_id) - .ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.common_fields.temporary_channel_id) - })?; + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + MsgHandleErrInternal::unreachable_no_such_peer( + counterparty_node_id, + msg.common_fields.temporary_channel_id, + ) + })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; match peer_state.channel_by_id.entry(msg.common_fields.temporary_channel_id) { @@ -11066,8 +11067,10 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.temporary_channel_id) + MsgHandleErrInternal::unreachable_no_such_peer( + counterparty_node_id, + msg.temporary_channel_id, + ) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); @@ -11261,11 +11264,9 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ #[rustfmt::skip] fn internal_peer_storage(&self, counterparty_node_id: PublicKey, msg: msgs::PeerStorage) -> Result<(), MsgHandleErrInternal> { let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = per_peer_state.get(&counterparty_node_id) - .ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(&counterparty_node_id, ChannelId([0; 32])) - })?; + let peer_state_mutex = per_peer_state.get(&counterparty_node_id).ok_or_else(|| { + MsgHandleErrInternal::unreachable_no_such_peer(&counterparty_node_id, ChannelId([0; 32])) + })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -11299,11 +11300,9 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ fn internal_funding_signed(&self, counterparty_node_id: &PublicKey, msg: &msgs::FundingSigned) -> Result<(), MsgHandleErrInternal> { let best_block = *self.best_block.read().unwrap(); let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = per_peer_state.get(counterparty_node_id) - .ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) - })?; + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) + })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -11356,8 +11355,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ ) -> Result<(), MsgHandleErrInternal> { let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, channel_id) + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -11441,8 +11439,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ ) -> Result<(), MsgHandleErrInternal> { let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(&counterparty_node_id).ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(&counterparty_node_id, msg.channel_id) + MsgHandleErrInternal::unreachable_no_such_peer(&counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -11562,8 +11559,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let (result, holding_cell_res) = { let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -11667,8 +11663,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let (result, holding_cell_res) = { let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -11733,11 +11728,9 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ // Note that the ChannelManager is NOT re-persisted on disk after this (unless we error // closing a channel), so any changes are likely to be lost on restart! let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = per_peer_state.get(counterparty_node_id) - .ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) - })?; + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) + })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; match peer_state.channel_by_id.entry(msg.channel_id) { @@ -11799,8 +11792,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ { let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -11903,8 +11895,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ ) -> Result<(), MsgHandleErrInternal> { let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) })?; let logger; let tx_err: Option<(_, Result)> = { @@ -12006,11 +11997,9 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ // closing a channel), so any changes are likely to be lost on restart! let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = per_peer_state.get(counterparty_node_id) - .ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) - })?; + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) + })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; match peer_state.channel_by_id.entry(msg.channel_id) { @@ -12035,8 +12024,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let (htlc_source, forwarded_htlc_value, skimmed_fee_msat, send_timestamp) = { let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -12117,11 +12105,9 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ // Note that the ChannelManager is NOT re-persisted on disk after this (unless we error // closing a channel), so any changes are likely to be lost on restart! let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = per_peer_state.get(counterparty_node_id) - .ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) - })?; + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) + })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; match peer_state.channel_by_id.entry(msg.channel_id) { @@ -12143,11 +12129,9 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ // Note that the ChannelManager is NOT re-persisted on disk after this (unless we error // closing a channel), so any changes are likely to be lost on restart! let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = per_peer_state.get(counterparty_node_id) - .ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) - })?; + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) + })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; match peer_state.channel_by_id.entry(msg.channel_id) { @@ -12174,8 +12158,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let best_block = *self.best_block.read().unwrap(); let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -12251,11 +12234,9 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ #[rustfmt::skip] fn internal_commitment_signed_batch(&self, counterparty_node_id: &PublicKey, channel_id: ChannelId, batch: Vec) -> Result<(), MsgHandleErrInternal> { let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = per_peer_state.get(counterparty_node_id) - .ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, channel_id) - })?; + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, channel_id) + })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; match peer_state.channel_by_id.entry(channel_id) { @@ -12393,11 +12374,9 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ fn internal_revoke_and_ack(&self, counterparty_node_id: &PublicKey, msg: &msgs::RevokeAndACK) -> Result<(), MsgHandleErrInternal> { let (htlcs_to_fail, static_invoices) = { let per_peer_state = self.per_peer_state.read().unwrap(); - let mut peer_state_lock = per_peer_state.get(counterparty_node_id) - .ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) - }).map(|mtx| mtx.lock().unwrap())?; + let mut peer_state_lock = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) + }).map(|mtx| mtx.lock().unwrap())?; let peer_state = &mut *peer_state_lock; match peer_state.channel_by_id.entry(msg.channel_id) { hash_map::Entry::Occupied(mut chan_entry) => { @@ -12446,11 +12425,9 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ #[rustfmt::skip] fn internal_update_fee(&self, counterparty_node_id: &PublicKey, msg: &msgs::UpdateFee) -> Result<(), MsgHandleErrInternal> { let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = per_peer_state.get(counterparty_node_id) - .ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) - })?; + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) + })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; match peer_state.channel_by_id.entry(msg.channel_id) { @@ -12472,9 +12449,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ fn internal_stfu(&self, counterparty_node_id: &PublicKey, msg: &msgs::Stfu) -> Result { let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id - ) + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -12527,11 +12502,9 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ #[rustfmt::skip] fn internal_announcement_signatures(&self, counterparty_node_id: &PublicKey, msg: &msgs::AnnouncementSignatures) -> Result<(), MsgHandleErrInternal> { let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = per_peer_state.get(counterparty_node_id) - .ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) - })?; + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) + })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; match peer_state.channel_by_id.entry(msg.channel_id) { @@ -12626,12 +12599,9 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let (inferred_splice_locked, need_lnd_workaround, holding_cell_res) = { let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = per_peer_state.get(counterparty_node_id) - .ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id - ) - })?; + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) + })?; let logger = WithContext::from(&self.logger, Some(*counterparty_node_id), Some(msg.channel_id), None); let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -12743,8 +12713,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ ) -> Result<(), MsgHandleErrInternal> { let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -12801,8 +12770,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ ) -> Result<(), MsgHandleErrInternal> { let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -12847,8 +12815,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ ) -> Result<(), MsgHandleErrInternal> { let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::no_such_peer(counterparty_node_id, msg.channel_id) + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; From 1b8617c381df916c3dffd74d24612bcf2a081a01 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Thu, 12 Feb 2026 15:12:48 -0800 Subject: [PATCH 060/627] Bind Channel::commitment_signed result in internal_commitment_signed --- lightning/src/ln/channelmanager.rs | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 1a4fe170408..4fc79adeaeb 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -12167,18 +12167,15 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let chan = chan_entry.get_mut(); let logger = WithChannelContext::from(&self.logger, &chan.context(), None); let funding_txo = chan.funding().get_funding_txo(); - let (monitor_opt, monitor_update_opt) = try_channel_entry!( - self, - peer_state, - chan.commitment_signed( - msg, - best_block, - &self.signer_provider, - &self.fee_estimator, - &&logger - ), - chan_entry + let res = chan.commitment_signed( + msg, + best_block, + &self.signer_provider, + &self.fee_estimator, + &&logger, ); + let (monitor_opt, monitor_update_opt) = + try_channel_entry!(self, peer_state, res, chan_entry); if let Some(chan) = chan.as_funded_mut() { if let Some(monitor) = monitor_opt { From 52c76abfe2bf8e34dfa3bc2e9752efd998e314a9 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Mon, 9 Feb 2026 09:44:05 -0800 Subject: [PATCH 061/627] Account for missing balance in channel reserve assertions for splices When we create the post-splice `FundingScope`, the monotonicity debug assertion trackers were initialized to the post-splice balance without accounting for pending HTLCs or anchor costs. Since splices can have in-flight HTLCs (unlike fresh channel opens), the first commitment transaction's actual balance was lower than the initialized max, causing the debug assertion in `ChannelContext::build_commitment_transaction` to fire. Note that we don't need to recompute the full post-splice balance here. We can rely on the pre-splice `FundingScope`'s `holder/counterparty_max_commitment_tx_output` instead since they're already accounted for there. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 27ccd1c12c0..096a10102fc 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2847,15 +2847,21 @@ impl FundingScope { counterparty_selected_channel_reserve_satoshis, holder_selected_channel_reserve_satoshis, #[cfg(debug_assertions)] - holder_max_commitment_tx_output: Mutex::new(( - post_value_to_self_msat, - (post_channel_value * 1000).saturating_sub(post_value_to_self_msat), - )), + holder_max_commitment_tx_output: { + let prev = *prev_funding.holder_max_commitment_tx_output.lock().unwrap(); + Mutex::new(( + prev.0.saturating_add_signed(our_funding_contribution.to_sat() * 1000), + prev.1.saturating_add_signed(their_funding_contribution.to_sat() * 1000), + )) + }, #[cfg(debug_assertions)] - counterparty_max_commitment_tx_output: Mutex::new(( - post_value_to_self_msat, - (post_channel_value * 1000).saturating_sub(post_value_to_self_msat), - )), + counterparty_max_commitment_tx_output: { + let prev = *prev_funding.counterparty_max_commitment_tx_output.lock().unwrap(); + Mutex::new(( + prev.0.saturating_add_signed(our_funding_contribution.to_sat() * 1000), + prev.1.saturating_add_signed(their_funding_contribution.to_sat() * 1000), + )) + }, #[cfg(any(test, fuzzing))] next_local_fee: Mutex::new(PredictedNextFee::default()), #[cfg(any(test, fuzzing))] From 034892b531d336dde47298a597ff195d8a2d0b25 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Wed, 18 Feb 2026 16:08:55 -0800 Subject: [PATCH 062/627] Rework max commitment transaction balance debug assertions These assertions made sure that our balance would never dip below the reserve, and if they ever were, that the balance must only move towards meeting the reserve. With splicing, this doesn't always work, as a node that is not interested in contributing could end up below the reserve of the post-splice channel. Therefore, we rework these assertions such that we only keep track of the previous commitment transaction balance, and compare against the current, ensuring that our balance only increases when below the reserve. --- lightning/src/ln/channel.rs | 57 +++++++++++++++++------------- lightning/src/ln/splicing_tests.rs | 51 ++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 24 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 096a10102fc..3dee02978e7 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2583,10 +2583,10 @@ pub(super) struct FundingScope { #[cfg(debug_assertions)] /// Max to_local and to_remote outputs in a locally-generated commitment transaction - holder_max_commitment_tx_output: Mutex<(u64, u64)>, + holder_prev_commitment_tx_balance: Mutex<(u64, u64)>, #[cfg(debug_assertions)] /// Max to_local and to_remote outputs in a remote-generated commitment transaction - counterparty_max_commitment_tx_output: Mutex<(u64, u64)>, + counterparty_prev_commitment_tx_balance: Mutex<(u64, u64)>, // We save these values so we can make sure validation of channel updates properly predicts // what the next commitment transaction fee will be, by comparing the cached values to the @@ -2658,9 +2658,9 @@ impl Readable for FundingScope { counterparty_selected_channel_reserve_satoshis, holder_selected_channel_reserve_satoshis: holder_selected_channel_reserve_satoshis.0.unwrap(), #[cfg(debug_assertions)] - holder_max_commitment_tx_output: Mutex::new((0, 0)), + holder_prev_commitment_tx_balance: Mutex::new((0, 0)), #[cfg(debug_assertions)] - counterparty_max_commitment_tx_output: Mutex::new((0, 0)), + counterparty_prev_commitment_tx_balance: Mutex::new((0, 0)), channel_transaction_parameters: channel_transaction_parameters.0.unwrap(), funding_transaction, funding_tx_confirmed_in, @@ -2847,16 +2847,16 @@ impl FundingScope { counterparty_selected_channel_reserve_satoshis, holder_selected_channel_reserve_satoshis, #[cfg(debug_assertions)] - holder_max_commitment_tx_output: { - let prev = *prev_funding.holder_max_commitment_tx_output.lock().unwrap(); + holder_prev_commitment_tx_balance: { + let prev = *prev_funding.holder_prev_commitment_tx_balance.lock().unwrap(); Mutex::new(( prev.0.saturating_add_signed(our_funding_contribution.to_sat() * 1000), prev.1.saturating_add_signed(their_funding_contribution.to_sat() * 1000), )) }, #[cfg(debug_assertions)] - counterparty_max_commitment_tx_output: { - let prev = *prev_funding.counterparty_max_commitment_tx_output.lock().unwrap(); + counterparty_prev_commitment_tx_balance: { + let prev = *prev_funding.counterparty_prev_commitment_tx_balance.lock().unwrap(); Mutex::new(( prev.0.saturating_add_signed(our_funding_contribution.to_sat() * 1000), prev.1.saturating_add_signed(their_funding_contribution.to_sat() * 1000), @@ -3805,9 +3805,9 @@ impl ChannelContext { holder_selected_channel_reserve_satoshis, #[cfg(debug_assertions)] - holder_max_commitment_tx_output: Mutex::new((value_to_self_msat, (channel_value_satoshis * 1000 - msg_push_msat).saturating_sub(value_to_self_msat))), + holder_prev_commitment_tx_balance: Mutex::new((value_to_self_msat, (channel_value_satoshis * 1000 - msg_push_msat).saturating_sub(value_to_self_msat))), #[cfg(debug_assertions)] - counterparty_max_commitment_tx_output: Mutex::new((value_to_self_msat, (channel_value_satoshis * 1000 - msg_push_msat).saturating_sub(value_to_self_msat))), + counterparty_prev_commitment_tx_balance: Mutex::new((value_to_self_msat, (channel_value_satoshis * 1000 - msg_push_msat).saturating_sub(value_to_self_msat))), #[cfg(any(test, fuzzing))] next_local_fee: Mutex::new(PredictedNextFee::default()), @@ -4043,9 +4043,9 @@ impl ChannelContext { // We'll add our counterparty's `funding_satoshis` to these max commitment output assertions // when we receive `accept_channel2`. #[cfg(debug_assertions)] - holder_max_commitment_tx_output: Mutex::new((channel_value_satoshis * 1000 - push_msat, push_msat)), + holder_prev_commitment_tx_balance: Mutex::new((channel_value_satoshis * 1000 - push_msat, push_msat)), #[cfg(debug_assertions)] - counterparty_max_commitment_tx_output: Mutex::new((channel_value_satoshis * 1000 - push_msat, push_msat)), + counterparty_prev_commitment_tx_balance: Mutex::new((channel_value_satoshis * 1000 - push_msat, push_msat)), #[cfg(any(test, fuzzing))] next_local_fee: Mutex::new(PredictedNextFee::default()), @@ -5594,17 +5594,26 @@ impl ChannelContext { { // Make sure that the to_self/to_remote is always either past the appropriate // channel_reserve *or* it is making progress towards it. - let mut broadcaster_max_commitment_tx_output = if generated_by_local { - funding.holder_max_commitment_tx_output.lock().unwrap() + let mut broadcaster_prev_commitment_balance = if generated_by_local { + funding.holder_prev_commitment_tx_balance.lock().unwrap() } else { - funding.counterparty_max_commitment_tx_output.lock().unwrap() + funding.counterparty_prev_commitment_tx_balance.lock().unwrap() }; - debug_assert!(broadcaster_max_commitment_tx_output.0 <= stats.local_balance_before_fee_msat || stats.local_balance_before_fee_msat / 1000 >= funding.counterparty_selected_channel_reserve_satoshis.unwrap()); - broadcaster_max_commitment_tx_output.0 = cmp::max(broadcaster_max_commitment_tx_output.0, stats.local_balance_before_fee_msat); - debug_assert!(broadcaster_max_commitment_tx_output.1 <= stats.remote_balance_before_fee_msat || stats.remote_balance_before_fee_msat / 1000 >= funding.holder_selected_channel_reserve_satoshis); - broadcaster_max_commitment_tx_output.1 = cmp::max(broadcaster_max_commitment_tx_output.1, stats.remote_balance_before_fee_msat); - } + if stats.local_balance_before_fee_msat / 1000 < funding.counterparty_selected_channel_reserve_satoshis.unwrap() { + // If the local balance is below the reserve on this new commitment, it MUST be + // greater than or equal to the one on the previous commitment. + debug_assert!(broadcaster_prev_commitment_balance.0 <= stats.local_balance_before_fee_msat); + } + broadcaster_prev_commitment_balance.0 = stats.local_balance_before_fee_msat; + + if stats.remote_balance_before_fee_msat / 1000 < funding.holder_selected_channel_reserve_satoshis { + // If the remote balance is below the reserve on this new commitment, it MUST be + // greater than or equal to the one on the previous commitment. + debug_assert!(broadcaster_prev_commitment_balance.1 <= stats.remote_balance_before_fee_msat); + } + broadcaster_prev_commitment_balance.1 = stats.remote_balance_before_fee_msat; + } // This populates the HTLC-source table with the indices from the HTLCs in the commitment // transaction. @@ -15983,9 +15992,9 @@ impl<'a, 'b, 'c, ES: EntropySource, SP: SignerProvider> .unwrap(), #[cfg(debug_assertions)] - holder_max_commitment_tx_output: Mutex::new((0, 0)), + holder_prev_commitment_tx_balance: Mutex::new((0, 0)), #[cfg(debug_assertions)] - counterparty_max_commitment_tx_output: Mutex::new((0, 0)), + counterparty_prev_commitment_tx_balance: Mutex::new((0, 0)), #[cfg(any(test, fuzzing))] next_local_fee: Mutex::new(PredictedNextFee::default()), @@ -18548,9 +18557,9 @@ mod tests { holder_selected_channel_reserve_satoshis: 0, #[cfg(debug_assertions)] - holder_max_commitment_tx_output: Mutex::new((0, 0)), + holder_prev_commitment_tx_balance: Mutex::new((0, 0)), #[cfg(debug_assertions)] - counterparty_max_commitment_tx_output: Mutex::new((0, 0)), + counterparty_prev_commitment_tx_balance: Mutex::new((0, 0)), #[cfg(any(test, fuzzing))] next_local_fee: Mutex::new(PredictedNextFee::default()), diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 6727437a38a..086454dcba5 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -2730,3 +2730,54 @@ fn test_splice_buffer_invalid_commitment_signed_closes_channel() { ); check_added_monitors(&nodes[0], 1); } + +#[test] +fn test_splice_balance_falls_below_reserve() { + // Test that we're able to proceed with a splice where the acceptor does not contribute + // anything, but the initiator does, resulting in an increased channel reserve that the + // counterparty does not meet but is still valid. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut config = test_default_channel_config(); + config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let initial_channel_value_sat = 100_000; + // Push 10k sat to node 1 so it has balance to send HTLCs back. + let push_msat = 10_000_000; + let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value( + &nodes, + 0, + 1, + initial_channel_value_sat, + push_msat, + ); + + let _ = provide_anchor_reserves(&nodes); + + // Create bidirectional pending HTLCs (routed but not claimed). + // Outbound HTLC from node 0 to node 1. + let (preimage_0_to_1, _hash_0_to_1, ..) = route_payment(&nodes[0], &[&nodes[1]], 1_000_000); + // Large inbound HTLC from node 1 to node 0, bringing node 1's remaining balance down to + // 2000 sat. The old reserve (1% of 100k) is 1000 sat so this is still above reserve. + let (preimage_1_to_0, _hash_1_to_0, ..) = route_payment(&nodes[1], &[&nodes[0]], 8_000_000); + + // Splice-in 200k sat. The new channel value becomes 300k sat, raising the reserve to 3000 + // sat. Node 1's remaining 2000 sat is now below the new reserve. + let initiator_contribution = + initiate_splice_in(&nodes[0], &nodes[1], channel_id, Amount::from_sat(200_000)); + let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); + + // Confirm and lock the splice. + mine_transaction(&nodes[0], &splice_tx); + mine_transaction(&nodes[1], &splice_tx); + lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); + + // Claim both pending HTLCs to verify the channel is fully functional after the splice. + claim_payment(&nodes[0], &[&nodes[1]], preimage_0_to_1); + claim_payment(&nodes[1], &[&nodes[0]], preimage_1_to_0); + + // Final sanity check: send a payment using the new spliced capacity. + let _ = send_payment(&nodes[0], &[&nodes[1]], 1_000_000); +} From 15b04b5e5ece974ee7693a8ac449a7577f1e7514 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Wed, 18 Feb 2026 15:44:32 -0800 Subject: [PATCH 063/627] Avoid sending stfu for quiescent splice action while pending splice We can't splice while one is already pending, so there's no point in attempting to become quiescent. --- lightning/src/ln/channel.rs | 11 +++++++++++ lightning/src/ln/splicing_tests.rs | 21 ++++++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 27ccd1c12c0..5faa784614b 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -13619,6 +13619,17 @@ where return Ok(None); } + if let Some(action) = self.quiescent_action.as_ref() { + // We can't initiate another splice while ours is pending, so don't bother becoming + // quiescent yet. + // TODO(splicing): Allow the splice as an RBF once supported. + let has_splice_action = matches!(action, QuiescentAction::Splice { .. }) + || matches!(action, QuiescentAction::LegacySplice(_)); + if has_splice_action && self.pending_splice.is_some() { + return Ok(None); + } + } + // We need to send our `stfu`, either because we're trying to initiate quiescence, or the // counterparty is and we've yet to send ours. if self.context.channel_state.is_awaiting_quiescence() diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 6727437a38a..96b9b13f3a1 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -1031,6 +1031,12 @@ fn test_splice_in_and_out() { #[test] fn test_fails_initiating_concurrent_splices() { + fails_initiating_concurrent_splices(true); + fails_initiating_concurrent_splices(false); +} + +#[cfg(test)] +fn fails_initiating_concurrent_splices(reconnect: bool) { let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let config = test_default_channel_config(); @@ -1043,6 +1049,7 @@ fn test_fails_initiating_concurrent_splices() { let node_0_id = nodes[0].node.get_our_node_id(); let node_1_id = nodes[1].node.get_our_node_id(); + send_payment(&nodes[0], &[&nodes[1]], 1_000); provide_utxo_reserves(&nodes, 2, Amount::ONE_BTC); let outputs = vec![TxOut { @@ -1116,15 +1123,23 @@ fn test_fails_initiating_concurrent_splices() { expect_splice_pending_event(&nodes[0], &node_1_id); expect_splice_pending_event(&nodes[1], &node_0_id); - // Now that the splice is pending, another splice may be initiated. + // Now that the splice is pending, another splice may be initiated, but we must wait until + // the `splice_locked` exchange to send the initiator `stfu`. assert!(nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate).is_ok()); + if reconnect { + nodes[0].node.peer_disconnected(node_1_id); + nodes[1].node.peer_disconnected(node_0_id); + reconnect_nodes(ReconnectArgs::new(&nodes[0], &nodes[1])); + } + + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + mine_transaction(&nodes[0], &splice_tx); mine_transaction(&nodes[1], &splice_tx); let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); - // However, the acceptor had enqueued a quiescent action while the splice was pending, so it - // will now attempt to initiate quiescence. assert!( matches!(stfu, Some(MessageSendEvent::SendStfu { node_id, .. }) if node_id == node_0_id) ); From 98535fab45b33d5b098ab6c582edd08f45f4ac72 Mon Sep 17 00:00:00 2001 From: Apostlex0 Date: Tue, 27 Jan 2026 13:40:14 +0530 Subject: [PATCH 064/627] changes: switch to bitreq from chunked_transfer we changed the http layer from the manual tcpstream client implementation to bitreq, this change lets us rely on bitreq for http request formatting, sockets, HTTP parsing, chunked bodies, pooling, and async support instead of having to manually implement them. cargo.toml: we added bitreq 0.3 and updates the rest-client and rpc-client feature wiring to depend on bitreq. A tokio feature is also enabled to allow bitreq async support and pipelining. http.rs: The old HttpEndpoint builder and all manual TCP/socket timeout logic are dropped along with the manual GET/POST construction and response parsing. The client API now uses base_url and get/post return Result with a typed HttpClientError instead of std::io::Result. HttpClientError splits transport failures (bitreq::Error), non-2xx HTTP responses (HttpError), and response decoding issues (std::io::Error). rest.rs and rpc.rs: HttpEndpoint and the Mutex> caching pattern are removed and both clients now own an HttpClient directly using base_url. rpc.rs also adds RpcClientError so we can represent HTTP failures, JSON-RPC errors from the server, and malformed responses instead of just giving out std::io::Error. convert.rs: it maps HttpClientError and RpcClientError into BlockSourceError with this retry classification: transport errors and HTTP 5xx are transient, HTTP 4xx and invalid data are persistent, and RPC errors are treated as transient. --- lightning-block-sync/Cargo.toml | 7 +- lightning-block-sync/src/convert.rs | 62 +- lightning-block-sync/src/http.rs | 870 ++++++---------------------- lightning-block-sync/src/rest.rs | 33 +- lightning-block-sync/src/rpc.rs | 135 +++-- 5 files changed, 345 insertions(+), 762 deletions(-) diff --git a/lightning-block-sync/Cargo.toml b/lightning-block-sync/Cargo.toml index 97f199963ac..d8d71da3fae 100644 --- a/lightning-block-sync/Cargo.toml +++ b/lightning-block-sync/Cargo.toml @@ -16,15 +16,16 @@ all-features = true rustdoc-args = ["--cfg", "docsrs"] [features] -rest-client = [ "serde_json", "chunked_transfer" ] -rpc-client = [ "serde_json", "chunked_transfer" ] +rest-client = [ "serde_json", "dep:bitreq" ] +rpc-client = [ "serde_json", "dep:bitreq" ] +tokio = [ "dep:tokio", "bitreq?/async" ] [dependencies] bitcoin = "0.32.2" lightning = { version = "0.3.0", path = "../lightning" } tokio = { version = "1.35", features = [ "io-util", "net", "time", "rt" ], optional = true } serde_json = { version = "1.0", optional = true } -chunked_transfer = { version = "1.4", optional = true } +bitreq = { version = "0.3", default-features = false, features = ["std"], optional = true } [dev-dependencies] lightning = { version = "0.3.0", path = "../lightning", features = ["_test_utils"] } diff --git a/lightning-block-sync/src/convert.rs b/lightning-block-sync/src/convert.rs index a31b329a5af..47c7586a2c4 100644 --- a/lightning-block-sync/src/convert.rs +++ b/lightning-block-sync/src/convert.rs @@ -1,4 +1,6 @@ -use crate::http::{BinaryResponse, JsonResponse}; +use crate::http::{BinaryResponse, HttpClientError, JsonResponse}; +#[cfg(feature = "rpc-client")] +use crate::rpc::RpcClientError; use crate::utils::hex_to_work; use crate::{BlockHeaderData, BlockSourceError}; @@ -35,6 +37,64 @@ impl From for BlockSourceError { } } +/// Conversion from `HttpClientError` into `BlockSourceError`. +impl From for BlockSourceError { + fn from(e: HttpClientError) -> BlockSourceError { + match e { + // Transport errors (connection, timeout, etc.) are transient + HttpClientError::Transport(err) => { + BlockSourceError::transient(HttpClientError::Transport(err)) + }, + // 5xx errors are transient (server issues), others are persistent (client errors) + HttpClientError::Http(http_err) => { + if (500..600).contains(&http_err.status_code) { + BlockSourceError::transient(HttpClientError::Http(http_err)) + } else { + BlockSourceError::persistent(HttpClientError::Http(http_err)) + } + }, + // Delegate to existing From implementation + HttpClientError::Io(io_err) => BlockSourceError::from(io_err), + } + } +} + +/// Conversion from `RpcClientError` into `BlockSourceError`. +#[cfg(feature = "rpc-client")] +impl From for BlockSourceError { + fn from(e: RpcClientError) -> BlockSourceError { + match e { + RpcClientError::Http(http_err) => match http_err { + // Transport errors (connection, timeout, etc.) are transient + HttpClientError::Transport(err) => BlockSourceError::transient( + RpcClientError::Http(HttpClientError::Transport(err)), + ), + // 5xx errors are transient (server issues), others are persistent (client errors) + HttpClientError::Http(http) => { + if (500..600).contains(&http.status_code) { + BlockSourceError::transient(RpcClientError::Http(HttpClientError::Http( + http, + ))) + } else { + BlockSourceError::persistent(RpcClientError::Http(HttpClientError::Http( + http, + ))) + } + }, + HttpClientError::Io(io_err) => BlockSourceError::from(io_err), + }, + // RPC errors (e.g. "block not found") are transient + RpcClientError::Rpc(rpc_err) => { + BlockSourceError::transient(RpcClientError::Rpc(rpc_err)) + }, + // Malformed response data is persistent + RpcClientError::InvalidData(msg) => { + BlockSourceError::persistent(RpcClientError::InvalidData(msg)) + }, + } + } +} + /// Parses binary data as a block. impl TryInto for BinaryResponse { type Error = io::Error; diff --git a/lightning-block-sync/src/http.rs b/lightning-block-sync/src/http.rs index 0fb82b4acde..29cc4256437 100644 --- a/lightning-block-sync/src/http.rs +++ b/lightning-block-sync/src/http.rs @@ -1,399 +1,164 @@ //! Simple HTTP implementation which supports both async and traditional execution environments //! with minimal dependencies. This is used as the basis for REST and RPC clients. -use chunked_transfer; use serde_json; -use std::convert::TryFrom; -use std::fmt; -#[cfg(not(feature = "tokio"))] -use std::io::Write; -use std::net::{SocketAddr, ToSocketAddrs}; -use std::time::Duration; - -#[cfg(feature = "tokio")] -use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt}; #[cfg(feature = "tokio")] -use tokio::net::TcpStream; +use bitreq::RequestExt; -#[cfg(not(feature = "tokio"))] -use std::io::BufRead; -use std::io::Read; -#[cfg(not(feature = "tokio"))] -use std::net::TcpStream; - -/// Timeout for operations on TCP streams. -const TCP_STREAM_TIMEOUT: Duration = Duration::from_secs(5); - -/// Timeout for reading the first byte of a response. This is separate from the general read -/// timeout as it is not uncommon for Bitcoin Core to be blocked waiting on UTXO cache flushes for -/// upwards of 10 minutes on slow devices (e.g. RPis with SSDs over USB). Note that we always retry -/// once when we time out, so the maximum time we allow Bitcoin Core to block for is twice this -/// value. -const TCP_STREAM_RESPONSE_TIMEOUT: Duration = Duration::from_secs(300); +use std::convert::TryFrom; +use std::fmt; -/// Maximum HTTP message header size in bytes. -const MAX_HTTP_MESSAGE_HEADER_SIZE: usize = 8192; +/// Timeout for requests in seconds. This is set to a high value as it is not uncommon for Bitcoin +/// Core to be blocked waiting on UTXO cache flushes for upwards of 10 minutes on slow devices +/// (e.g. RPis with SSDs over USB). +const TCP_STREAM_RESPONSE_TIMEOUT: u64 = 300; /// Maximum HTTP message body size in bytes. Enough for a hex-encoded block in JSON format and any /// overhead for HTTP chunked transfer encoding. const MAX_HTTP_MESSAGE_BODY_SIZE: usize = 2 * 4_000_000 + 32_000; -/// Endpoint for interacting with an HTTP-based API. +/// Error type for HTTP client operations. #[derive(Debug)] -pub struct HttpEndpoint { - host: String, - port: Option, - path: String, +pub enum HttpClientError { + /// transport-level error (connection, timeout, protocol parsing, etc.) + Transport(bitreq::Error), + /// HTTP error response (non-2xx status code) + Http(HttpError), + /// Response parsing/conversion error + Io(std::io::Error), } -impl HttpEndpoint { - /// Creates an endpoint for the given host and default HTTP port. - pub fn for_host(host: String) -> Self { - Self { host, port: None, path: String::from("/") } - } - - /// Specifies a port to use with the endpoint. - pub fn with_port(mut self, port: u16) -> Self { - self.port = Some(port); - self - } - - /// Specifies a path to use with the endpoint. - pub fn with_path(mut self, path: String) -> Self { - self.path = path; - self - } - - /// Returns the endpoint host. - pub fn host(&self) -> &str { - &self.host +impl std::error::Error for HttpClientError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + HttpClientError::Transport(e) => Some(e), + HttpClientError::Http(e) => Some(e), + HttpClientError::Io(e) => Some(e), + } } +} - /// Returns the endpoint port. - pub fn port(&self) -> u16 { - match self.port { - None => 80, - Some(port) => port, +impl fmt::Display for HttpClientError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + HttpClientError::Transport(e) => write!(f, "transport error: {}", e), + HttpClientError::Http(e) => write!(f, "HTTP error: {}", e), + HttpClientError::Io(e) => write!(f, "Response parsing/conversion error: {}", e), } } +} - /// Returns the endpoint path. - pub fn path(&self) -> &str { - &self.path +impl From for HttpClientError { + fn from(e: std::io::Error) -> Self { + HttpClientError::Io(e) } } -impl<'a> std::net::ToSocketAddrs for &'a HttpEndpoint { - type Iter = <(&'a str, u16) as std::net::ToSocketAddrs>::Iter; +impl From for HttpClientError { + fn from(e: bitreq::Error) -> Self { + HttpClientError::Transport(e) + } +} - fn to_socket_addrs(&self) -> std::io::Result { - (self.host(), self.port()).to_socket_addrs() +impl From for HttpClientError { + fn from(e: HttpError) -> Self { + HttpClientError::Http(e) } } +/// Maximum number of cached connections in the connection pool. +#[cfg(feature = "tokio")] +const MAX_CONNECTIONS: usize = 10; + /// Client for making HTTP requests. pub(crate) struct HttpClient { - address: SocketAddr, - stream: TcpStream, + base_url: String, + #[cfg(feature = "tokio")] + client: bitreq::Client, } impl HttpClient { - /// Opens a connection to an HTTP endpoint. - pub fn connect(endpoint: E) -> std::io::Result { - let address = match endpoint.to_socket_addrs()?.next() { - None => { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "could not resolve to any addresses", - )); - }, - Some(address) => address, - }; - let stream = std::net::TcpStream::connect_timeout(&address, TCP_STREAM_TIMEOUT)?; - stream.set_read_timeout(Some(TCP_STREAM_TIMEOUT))?; - stream.set_write_timeout(Some(TCP_STREAM_TIMEOUT))?; - - #[cfg(feature = "tokio")] - let stream = { - stream.set_nonblocking(true)?; - TcpStream::from_std(stream)? - }; - - Ok(Self { address, stream }) + /// Creates a new HTTP client for the given base URL. + /// + /// The base URL should include the scheme, host, and port (e.g., "http://127.0.0.1:8332"). + /// DNS resolution is deferred until the first request is made. + pub fn new(base_url: String) -> Self { + Self { + base_url, + #[cfg(feature = "tokio")] + client: bitreq::Client::new(MAX_CONNECTIONS), + } } - /// Sends a `GET` request for a resource identified by `uri` at the `host`. + /// Sends a `GET` request for a resource identified by `uri`. /// /// Returns the response body in `F` format. #[allow(dead_code)] - pub async fn get(&mut self, uri: &str, host: &str) -> std::io::Result + pub async fn get(&self, uri: &str) -> Result where F: TryFrom, Error = std::io::Error>, { - let request = format!( - "GET {} HTTP/1.1\r\n\ - Host: {}\r\n\ - Connection: keep-alive\r\n\ - \r\n", - uri, host - ); - let response_body = self.send_request_with_retry(&request).await?; - F::try_from(response_body) + let url = format!("{}{}", self.base_url, uri); + let request = bitreq::get(url) + .with_timeout(TCP_STREAM_RESPONSE_TIMEOUT) + .with_max_body_size(Some(MAX_HTTP_MESSAGE_BODY_SIZE)); + #[cfg(feature = "tokio")] + let request = request.with_pipelining(); + let response_body = self.send_request(request).await?; + F::try_from(response_body).map_err(HttpClientError::Io) } - /// Sends a `POST` request for a resource identified by `uri` at the `host` using the given HTTP + /// Sends a `POST` request for a resource identified by `uri` using the given HTTP /// authentication credentials. /// /// The request body consists of the provided JSON `content`. Returns the response body in `F` /// format. #[allow(dead_code)] pub async fn post( - &mut self, uri: &str, host: &str, auth: &str, content: serde_json::Value, - ) -> std::io::Result + &self, uri: &str, auth: &str, content: serde_json::Value, + ) -> Result where F: TryFrom, Error = std::io::Error>, { - let content = content.to_string(); - let request = format!( - "POST {} HTTP/1.1\r\n\ - Host: {}\r\n\ - Authorization: {}\r\n\ - Connection: keep-alive\r\n\ - Content-Type: application/json\r\n\ - Content-Length: {}\r\n\ - \r\n\ - {}", - uri, - host, - auth, - content.len(), - content - ); - let response_body = self.send_request_with_retry(&request).await?; - F::try_from(response_body) - } - - /// Sends an HTTP request message and reads the response, returning its body. Attempts to - /// reconnect and retry if the connection has been closed. - async fn send_request_with_retry(&mut self, request: &str) -> std::io::Result> { - match self.send_request(request).await { - Ok(bytes) => Ok(bytes), - Err(_) => { - // Reconnect and retry on fail. This can happen if the connection was closed after - // the keep-alive limits are reached, or generally if the request timed out due to - // Bitcoin Core being stuck on a long-running operation or its RPC queue being - // full. - // Block 100ms before retrying the request as in many cases the source of the error - // may be persistent for some time. - #[cfg(feature = "tokio")] - tokio::time::sleep(Duration::from_millis(100)).await; - #[cfg(not(feature = "tokio"))] - std::thread::sleep(Duration::from_millis(100)); - *self = Self::connect(self.address)?; - self.send_request(request).await - }, - } - } - - /// Sends an HTTP request message and reads the response, returning its body. - async fn send_request(&mut self, request: &str) -> std::io::Result> { - self.write_request(request).await?; - self.read_response().await - } - - /// Writes an HTTP request message. - async fn write_request(&mut self, request: &str) -> std::io::Result<()> { + let url = format!("{}{}", self.base_url, uri); + let request = bitreq::post(url) + .with_header("Authorization", auth) + .with_header("Content-Type", "application/json") + .with_timeout(TCP_STREAM_RESPONSE_TIMEOUT) + .with_max_body_size(Some(MAX_HTTP_MESSAGE_BODY_SIZE)) + .with_body(content.to_string()); #[cfg(feature = "tokio")] - { - self.stream.write_all(request.as_bytes()).await?; - self.stream.flush().await - } - #[cfg(not(feature = "tokio"))] - { - self.stream.write_all(request.as_bytes())?; - self.stream.flush() - } + let request = request.with_pipelining(); + let response_body = self.send_request(request).await?; + F::try_from(response_body).map_err(HttpClientError::Io) } - /// Reads an HTTP response message. - async fn read_response(&mut self) -> std::io::Result> { - #[cfg(feature = "tokio")] - let stream = self.stream.split().0; - #[cfg(not(feature = "tokio"))] - let stream = std::io::Read::by_ref(&mut self.stream); - - let limited_stream = stream.take(MAX_HTTP_MESSAGE_HEADER_SIZE as u64); - + /// Sends an HTTP request message and reads the response, returning its body. + async fn send_request(&self, request: bitreq::Request) -> Result, HttpClientError> { #[cfg(feature = "tokio")] - let mut reader = tokio::io::BufReader::new(limited_stream); + let response = request.send_async_with_client(&self.client).await?; #[cfg(not(feature = "tokio"))] - let mut reader = std::io::BufReader::new(limited_stream); - - macro_rules! read_line { - () => { - read_line!(0) - }; - ($retry_count: expr) => {{ - let mut line = String::new(); - let mut timeout_count: u64 = 0; - let bytes_read = loop { - #[cfg(feature = "tokio")] - let read_res = reader.read_line(&mut line).await; - #[cfg(not(feature = "tokio"))] - let read_res = reader.read_line(&mut line); - match read_res { - Ok(bytes_read) => break bytes_read, - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { - timeout_count += 1; - if timeout_count > $retry_count { - return Err(e); - } else { - continue; - } - }, - Err(e) => return Err(e), - } - }; - - match bytes_read { - 0 => None, - _ => { - // Remove trailing CRLF - if line.ends_with('\n') { - line.pop(); - if line.ends_with('\r') { - line.pop(); - } - } - Some(line) - }, - } - }}; - } + let response = request.send()?; - // Read and parse status line - // Note that we allow retrying a few times to reach TCP_STREAM_RESPONSE_TIMEOUT. - let status_line = - read_line!(TCP_STREAM_RESPONSE_TIMEOUT.as_secs() / TCP_STREAM_TIMEOUT.as_secs()) - .ok_or(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "no status line"))?; - let status = HttpStatus::parse(&status_line)?; - - // Read and parse relevant headers - let mut message_length = HttpMessageLength::Empty; - loop { - let line = read_line!() - .ok_or(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "no headers"))?; - if line.is_empty() { - break; - } + let status_code = response.status_code; + let body = response.into_bytes(); - let header = HttpHeader::parse(&line)?; - if header.has_name("Content-Length") { - let length = header - .value - .parse() - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - if let HttpMessageLength::Empty = message_length { - message_length = HttpMessageLength::ContentLength(length); - } - continue; - } - - if header.has_name("Transfer-Encoding") { - message_length = HttpMessageLength::TransferEncoding(header.value.into()); - continue; - } + if !(200..300).contains(&status_code) { + return Err(HttpError { status_code, contents: body }.into()); } - // Read message body - let read_limit = MAX_HTTP_MESSAGE_BODY_SIZE - reader.buffer().len(); - reader.get_mut().set_limit(read_limit as u64); - let contents = match message_length { - HttpMessageLength::Empty => Vec::new(), - HttpMessageLength::ContentLength(length) => { - if length == 0 || length > MAX_HTTP_MESSAGE_BODY_SIZE { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("invalid response length: {} bytes", length), - )); - } else { - let mut content = vec![0; length]; - #[cfg(feature = "tokio")] - reader.read_exact(&mut content[..]).await?; - #[cfg(not(feature = "tokio"))] - reader.read_exact(&mut content[..])?; - content - } - }, - HttpMessageLength::TransferEncoding(coding) => { - if !coding.eq_ignore_ascii_case("chunked") { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "unsupported transfer coding", - )); - } else { - let mut content = Vec::new(); - #[cfg(feature = "tokio")] - { - // Since chunked_transfer doesn't have an async interface, only use it to - // determine the size of each chunk to read. - // - // TODO: Replace with an async interface when available. - // https://github.com/frewsxcv/rust-chunked-transfer/issues/7 - loop { - // Read the chunk header which contains the chunk size. - let mut chunk_header = String::new(); - reader.read_line(&mut chunk_header).await?; - if chunk_header == "0\r\n" { - // Read the terminator chunk since the decoder consumes the CRLF - // immediately when this chunk is encountered. - reader.read_line(&mut chunk_header).await?; - } - - // Decode the chunk header to obtain the chunk size. - let mut buffer = Vec::new(); - let mut decoder = - chunked_transfer::Decoder::new(chunk_header.as_bytes()); - decoder.read_to_end(&mut buffer)?; - - // Read the chunk body. - let chunk_size = match decoder.remaining_chunks_size() { - None => break, - Some(chunk_size) => chunk_size, - }; - let chunk_offset = content.len(); - content.resize(chunk_offset + chunk_size + "\r\n".len(), 0); - reader.read_exact(&mut content[chunk_offset..]).await?; - content.resize(chunk_offset + chunk_size, 0); - } - content - } - #[cfg(not(feature = "tokio"))] - { - let mut decoder = chunked_transfer::Decoder::new(reader); - decoder.read_to_end(&mut content)?; - content - } - } - }, - }; - - if !status.is_ok() { - // TODO: Handle 3xx redirection responses. - let error = HttpError { status_code: status.code.to_string(), contents }; - return Err(std::io::Error::new(std::io::ErrorKind::Other, error)); - } - - Ok(contents) + Ok(body) } } /// HTTP error consisting of a status code and body contents. #[derive(Debug)] -pub(crate) struct HttpError { - pub(crate) status_code: String, - pub(crate) contents: Vec, +pub struct HttpError { + /// The HTTP status code. + pub status_code: i32, + /// The response body contents. + pub contents: Vec, } impl std::error::Error for HttpError {} @@ -405,94 +170,6 @@ impl fmt::Display for HttpError { } } -/// HTTP response status code as defined by [RFC 7231]. -/// -/// [RFC 7231]: https://tools.ietf.org/html/rfc7231#section-6 -struct HttpStatus<'a> { - code: &'a str, -} - -impl<'a> HttpStatus<'a> { - /// Parses an HTTP status line as defined by [RFC 7230]. - /// - /// [RFC 7230]: https://tools.ietf.org/html/rfc7230#section-3.1.2 - fn parse(line: &'a String) -> std::io::Result> { - let mut tokens = line.splitn(3, ' '); - - let http_version = tokens - .next() - .ok_or(std::io::Error::new(std::io::ErrorKind::InvalidData, "no HTTP-Version"))?; - if !http_version.eq_ignore_ascii_case("HTTP/1.1") - && !http_version.eq_ignore_ascii_case("HTTP/1.0") - { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "invalid HTTP-Version", - )); - } - - let code = tokens - .next() - .ok_or(std::io::Error::new(std::io::ErrorKind::InvalidData, "no Status-Code"))?; - if code.len() != 3 || !code.chars().all(|c| c.is_ascii_digit()) { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "invalid Status-Code", - )); - } - - let _reason = tokens - .next() - .ok_or(std::io::Error::new(std::io::ErrorKind::InvalidData, "no Reason-Phrase"))?; - - Ok(Self { code }) - } - - /// Returns whether the status is successful (i.e., 2xx status class). - fn is_ok(&self) -> bool { - self.code.starts_with('2') - } -} - -/// HTTP response header as defined by [RFC 7231]. -/// -/// [RFC 7231]: https://tools.ietf.org/html/rfc7231#section-7 -struct HttpHeader<'a> { - name: &'a str, - value: &'a str, -} - -impl<'a> HttpHeader<'a> { - /// Parses an HTTP header field as defined by [RFC 7230]. - /// - /// [RFC 7230]: https://tools.ietf.org/html/rfc7230#section-3.2 - fn parse(line: &'a String) -> std::io::Result> { - let mut tokens = line.splitn(2, ':'); - let name = tokens - .next() - .ok_or(std::io::Error::new(std::io::ErrorKind::InvalidData, "no header name"))?; - let value = tokens - .next() - .ok_or(std::io::Error::new(std::io::ErrorKind::InvalidData, "no header value"))? - .trim_start(); - Ok(Self { name, value }) - } - - /// Returns whether the header field has the given name. - fn has_name(&self, name: &str) -> bool { - self.name.eq_ignore_ascii_case(name) - } -} - -/// HTTP message body length as defined by [RFC 7230]. -/// -/// [RFC 7230]: https://tools.ietf.org/html/rfc7230#section-3.3.3 -enum HttpMessageLength { - Empty, - ContentLength(usize), - TransferEncoding(String), -} - /// An HTTP response body in binary format. pub struct BinaryResponse(pub Vec); @@ -517,82 +194,45 @@ impl TryFrom> for JsonResponse { } } -#[cfg(test)] -mod endpoint_tests { - use super::HttpEndpoint; - - #[test] - fn with_default_port() { - let endpoint = HttpEndpoint::for_host("foo.com".into()); - assert_eq!(endpoint.host(), "foo.com"); - assert_eq!(endpoint.port(), 80); - } - - #[test] - fn with_custom_port() { - let endpoint = HttpEndpoint::for_host("foo.com".into()).with_port(8080); - assert_eq!(endpoint.host(), "foo.com"); - assert_eq!(endpoint.port(), 8080); - } - - #[test] - fn with_uri_path() { - let endpoint = HttpEndpoint::for_host("foo.com".into()).with_path("/path".into()); - assert_eq!(endpoint.host(), "foo.com"); - assert_eq!(endpoint.path(), "/path"); - } - - #[test] - fn without_uri_path() { - let endpoint = HttpEndpoint::for_host("foo.com".into()); - assert_eq!(endpoint.host(), "foo.com"); - assert_eq!(endpoint.path(), "/"); - } - - #[test] - fn convert_to_socket_addrs() { - let endpoint = HttpEndpoint::for_host("localhost".into()); - let host = endpoint.host(); - let port = endpoint.port(); - - use std::net::ToSocketAddrs; - match (&endpoint).to_socket_addrs() { - Err(e) => panic!("Unexpected error: {:?}", e), - Ok(socket_addrs) => { - let mut std_addrs = (host, port).to_socket_addrs().unwrap(); - for addr in socket_addrs { - assert_eq!(addr, std_addrs.next().unwrap()); - } - assert!(std_addrs.next().is_none()); - }, - } - } -} - #[cfg(test)] pub(crate) mod client_tests { use super::*; - use std::io::BufRead; - use std::io::Write; + use std::io::{BufRead, Read, Write}; + use std::time::Duration; /// Server for handling HTTP client requests with a stock response. pub struct HttpServer { address: std::net::SocketAddr, - handler: std::thread::JoinHandle<()>, + handler: Option>, shutdown: std::sync::Arc, } + impl Drop for HttpServer { + fn drop(&mut self) { + self.shutdown.store(true, std::sync::atomic::Ordering::SeqCst); + // Make a connection to unblock the listener's accept() call + let _ = std::net::TcpStream::connect(self.address); + if let Some(handler) = self.handler.take() { + let _ = handler.join(); + } + } + } + /// Body of HTTP response messages. pub enum MessageBody { Empty, Content(T), - ChunkedContent(T), } impl HttpServer { fn responding_with_body(status: &str, body: MessageBody) -> Self { let response = match body { - MessageBody::Empty => format!("{}\r\n\r\n", status), + MessageBody::Empty => format!( + "{}\r\n\ + Content-Length: 0\r\n\ + \r\n", + status + ), MessageBody::Content(body) => { let body = body.to_string(); format!( @@ -605,22 +245,6 @@ pub(crate) mod client_tests { body ) }, - MessageBody::ChunkedContent(body) => { - let mut chuncked_body = Vec::new(); - { - use chunked_transfer::Encoder; - let mut encoder = Encoder::with_chunks_size(&mut chuncked_body, 8); - encoder.write_all(body.to_string().as_bytes()).unwrap(); - } - format!( - "{}\r\n\ - Transfer-Encoding: chunked\r\n\ - \r\n\ - {}", - status, - String::from_utf8(chuncked_body).unwrap() - ) - }, }; HttpServer::responding_with(response) } @@ -645,179 +269,90 @@ pub(crate) mod client_tests { let shutdown = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let shutdown_signaled = std::sync::Arc::clone(&shutdown); let handler = std::thread::spawn(move || { + let timeout = Duration::from_secs(5); for stream in listener.incoming() { - let mut stream = stream.unwrap(); - stream.set_write_timeout(Some(TCP_STREAM_TIMEOUT)).unwrap(); - - let lines_read = std::io::BufReader::new(&stream) - .lines() - .take_while(|line| !line.as_ref().unwrap().is_empty()) - .count(); - if lines_read == 0 { - continue; + if shutdown_signaled.load(std::sync::atomic::Ordering::SeqCst) { + return; } - for chunk in response.as_bytes().chunks(16) { + let stream = stream.unwrap(); + stream.set_write_timeout(Some(timeout)).unwrap(); + stream.set_read_timeout(Some(timeout)).unwrap(); + + let mut reader = std::io::BufReader::new(stream); + + // Handle multiple requests on the same connection (keep-alive) + loop { if shutdown_signaled.load(std::sync::atomic::Ordering::SeqCst) { return; - } else { - if let Err(_) = stream.write(chunk) { + } + + // Read request headers + let mut lines_read = 0; + let mut content_length: usize = 0; + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) => break, // eof + Ok(_) => { + if line == "\r\n" || line == "\n" { + break; // end of headers + } + // Parse content_length for POST body handling + if let Some(value) = line.strip_prefix("Content-Length:") { + content_length = value.trim().parse().unwrap_or(0); + } + lines_read += 1; + }, + Err(_) => break, // Read error or timeout + } + } + + if lines_read == 0 { + break; // No request received, connection closed + } + + // Consume request body if present (needed for POST keep-alive) + if content_length > 0 { + let mut body = vec![0u8; content_length]; + if reader.read_exact(&mut body).is_err() { break; } - if let Err(_) = stream.flush() { + } + + // Send response + let stream = reader.get_mut(); + let mut write_error = false; + for chunk in response.as_bytes().chunks(16) { + if shutdown_signaled.load(std::sync::atomic::Ordering::SeqCst) { + return; + } + if stream.write(chunk).is_err() || stream.flush().is_err() { + write_error = true; break; } } + if write_error { + break; + } } } }); - Self { address, handler, shutdown } - } - - fn shutdown(self) { - self.shutdown.store(true, std::sync::atomic::Ordering::SeqCst); - self.handler.join().unwrap(); - } - - pub fn endpoint(&self) -> HttpEndpoint { - HttpEndpoint::for_host(self.address.ip().to_string()).with_port(self.address.port()) - } - } - - #[test] - fn connect_to_unresolvable_host() { - match HttpClient::connect(("example.invalid", 80)) { - Err(e) => { - assert!( - e.to_string().contains("failed to lookup address information") - || e.to_string().contains("No such host"), - "{:?}", - e - ); - }, - Ok(_) => panic!("Expected error"), - } - } - - #[test] - fn connect_with_no_socket_address() { - match HttpClient::connect(&vec![][..]) { - Err(e) => assert_eq!(e.kind(), std::io::ErrorKind::InvalidInput), - Ok(_) => panic!("Expected error"), - } - } - - #[test] - fn connect_with_unknown_server() { - // get an unused port by binding to port 0 - let port = { - let t = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); - t.local_addr().unwrap().port() - }; - - match HttpClient::connect(("::", port)) { - #[cfg(target_os = "windows")] - Err(e) => assert_eq!(e.kind(), std::io::ErrorKind::AddrNotAvailable), - #[cfg(not(target_os = "windows"))] - Err(e) => assert_eq!(e.kind(), std::io::ErrorKind::ConnectionRefused), - Ok(_) => panic!("Expected error"), + Self { address, handler: Some(handler), shutdown } } - } - - #[tokio::test] - async fn connect_with_valid_endpoint() { - let server = HttpServer::responding_with_ok::(MessageBody::Empty); - match HttpClient::connect(&server.endpoint()) { - Err(e) => panic!("Unexpected error: {:?}", e), - Ok(_) => {}, + pub fn endpoint(&self) -> String { + format!("http://{}:{}", self.address.ip(), self.address.port()) } } #[tokio::test] - async fn read_empty_message() { - let server = HttpServer::responding_with("".to_string()); - - let mut client = HttpClient::connect(&server.endpoint()).unwrap(); - match client.get::("/foo", "foo.com").await { - Err(e) => { - assert_eq!(e.kind(), std::io::ErrorKind::UnexpectedEof); - assert_eq!(e.get_ref().unwrap().to_string(), "no status line"); - }, - Ok(_) => panic!("Expected error"), - } - } - - #[tokio::test] - async fn read_incomplete_message() { - let server = HttpServer::responding_with("HTTP/1.1 200 OK".to_string()); - - let mut client = HttpClient::connect(&server.endpoint()).unwrap(); - match client.get::("/foo", "foo.com").await { - Err(e) => { - assert_eq!(e.kind(), std::io::ErrorKind::UnexpectedEof); - assert_eq!(e.get_ref().unwrap().to_string(), "no headers"); - }, - Ok(_) => panic!("Expected error"), - } - } - - #[tokio::test] - async fn read_too_large_message_headers() { - let response = format!( - "HTTP/1.1 302 Found\r\n\ - Location: {}\r\n\ - \r\n", - "Z".repeat(MAX_HTTP_MESSAGE_HEADER_SIZE) - ); - let server = HttpServer::responding_with(response); - - let mut client = HttpClient::connect(&server.endpoint()).unwrap(); - match client.get::("/foo", "foo.com").await { - Err(e) => { - assert_eq!(e.kind(), std::io::ErrorKind::UnexpectedEof); - assert_eq!(e.get_ref().unwrap().to_string(), "no headers"); - }, - Ok(_) => panic!("Expected error"), - } - } - - #[tokio::test] - async fn read_too_large_message_body() { - let body = "Z".repeat(MAX_HTTP_MESSAGE_BODY_SIZE + 1); - let server = HttpServer::responding_with_ok::(MessageBody::Content(body)); - - let mut client = HttpClient::connect(&server.endpoint()).unwrap(); - match client.get::("/foo", "foo.com").await { - Err(e) => { - assert_eq!(e.kind(), std::io::ErrorKind::InvalidData); - assert_eq!( - e.get_ref().unwrap().to_string(), - "invalid response length: 8032001 bytes" - ); - }, - Ok(_) => panic!("Expected error"), - } - server.shutdown(); - } - - #[tokio::test] - async fn read_message_with_unsupported_transfer_coding() { - let response = String::from( - "HTTP/1.1 200 OK\r\n\ - Transfer-Encoding: gzip\r\n\ - \r\n\ - foobar", - ); - let server = HttpServer::responding_with(response); - - let mut client = HttpClient::connect(&server.endpoint()).unwrap(); - match client.get::("/foo", "foo.com").await { - Err(e) => { - assert_eq!(e.kind(), std::io::ErrorKind::InvalidInput); - assert_eq!(e.get_ref().unwrap().to_string(), "unsupported transfer coding"); - }, + async fn connect_with_invalid_host() { + let client = HttpClient::new("http://invalid.host.example:80".to_string()); + match client.get::("/foo").await { + Err(HttpClientError::Transport(_)) => {}, + Err(e) => panic!("Unexpected error type: {:?}", e), Ok(_) => panic!("Expected error"), } } @@ -826,50 +361,25 @@ pub(crate) mod client_tests { async fn read_error() { let server = HttpServer::responding_with_server_error("foo"); - let mut client = HttpClient::connect(&server.endpoint()).unwrap(); - match client.get::("/foo", "foo.com").await { - Err(e) => { - assert_eq!(e.kind(), std::io::ErrorKind::Other); - let http_error = e.into_inner().unwrap().downcast::().unwrap(); - assert_eq!(http_error.status_code, "500"); + let client = HttpClient::new(server.endpoint()); + match client.get::("/foo").await { + Err(HttpClientError::Http(http_error)) => { + assert_eq!(http_error.status_code, 500); assert_eq!(http_error.contents, "foo".as_bytes()); }, + Err(e) => panic!("Unexpected error type: {:?}", e), Ok(_) => panic!("Expected error"), } } #[tokio::test] - async fn read_empty_message_body() { - let server = HttpServer::responding_with_ok::(MessageBody::Empty); - - let mut client = HttpClient::connect(&server.endpoint()).unwrap(); - match client.get::("/foo", "foo.com").await { - Err(e) => panic!("Unexpected error: {:?}", e), - Ok(bytes) => assert_eq!(bytes.0, Vec::::new()), - } - } - - #[tokio::test] - async fn read_message_body_with_length() { + async fn read_message_body() { let body = "foo bar baz qux".repeat(32); let content = MessageBody::Content(body.clone()); let server = HttpServer::responding_with_ok::(content); - let mut client = HttpClient::connect(&server.endpoint()).unwrap(); - match client.get::("/foo", "foo.com").await { - Err(e) => panic!("Unexpected error: {:?}", e), - Ok(bytes) => assert_eq!(bytes.0, body.as_bytes()), - } - } - - #[tokio::test] - async fn read_chunked_message_body() { - let body = "foo bar baz qux".repeat(32); - let chunked_content = MessageBody::ChunkedContent(body.clone()); - let server = HttpServer::responding_with_ok::(chunked_content); - - let mut client = HttpClient::connect(&server.endpoint()).unwrap(); - match client.get::("/foo", "foo.com").await { + let client = HttpClient::new(server.endpoint()); + match client.get::("/foo").await { Err(e) => panic!("Unexpected error: {:?}", e), Ok(bytes) => assert_eq!(bytes.0, body.as_bytes()), } @@ -879,9 +389,9 @@ pub(crate) mod client_tests { async fn reconnect_closed_connection() { let server = HttpServer::responding_with_ok::(MessageBody::Empty); - let mut client = HttpClient::connect(&server.endpoint()).unwrap(); - assert!(client.get::("/foo", "foo.com").await.is_ok()); - match client.get::("/foo", "foo.com").await { + let client = HttpClient::new(server.endpoint()); + assert!(client.get::("/foo").await.is_ok()); + match client.get::("/foo").await { Err(e) => panic!("Unexpected error: {:?}", e), Ok(bytes) => assert_eq!(bytes.0, Vec::::new()), } diff --git a/lightning-block-sync/src/rest.rs b/lightning-block-sync/src/rest.rs index 619981bb4d0..0ea93895bcd 100644 --- a/lightning-block-sync/src/rest.rs +++ b/lightning-block-sync/src/rest.rs @@ -3,7 +3,7 @@ use crate::convert::GetUtxosResponse; use crate::gossip::UtxoSource; -use crate::http::{BinaryResponse, HttpClient, HttpEndpoint, JsonResponse}; +use crate::http::{BinaryResponse, HttpClient, HttpClientError, JsonResponse}; use crate::{BlockData, BlockHeaderData, BlockSource, BlockSourceResult}; use bitcoin::hash_types::BlockHash; @@ -12,38 +12,27 @@ use bitcoin::OutPoint; use std::convert::TryFrom; use std::convert::TryInto; use std::future::Future; -use std::sync::Mutex; /// A simple REST client for requesting resources using HTTP `GET`. pub struct RestClient { - endpoint: HttpEndpoint, - client: Mutex>, + client: HttpClient, } impl RestClient { /// Creates a new REST client connected to the given endpoint. /// - /// The endpoint should contain the REST path component (e.g., http://127.0.0.1:8332/rest). - pub fn new(endpoint: HttpEndpoint) -> Self { - Self { endpoint, client: Mutex::new(None) } + /// The base URL should include the REST path component (e.g., "http://127.0.0.1:8332/rest"). + pub fn new(base_url: String) -> Self { + Self { client: HttpClient::new(base_url) } } /// Requests a resource encoded in `F` format and interpreted as type `T`. - pub async fn request_resource(&self, resource_path: &str) -> std::io::Result + pub async fn request_resource(&self, resource_path: &str) -> Result where F: TryFrom, Error = std::io::Error> + TryInto, { - let host = format!("{}:{}", self.endpoint.host(), self.endpoint.port()); - let uri = format!("{}/{}", self.endpoint.path().trim_end_matches("/"), resource_path); - let reserved_client = self.client.lock().unwrap().take(); - let mut client = if let Some(client) = reserved_client { - client - } else { - HttpClient::connect(&self.endpoint)? - }; - let res = client.get::(&uri, &host).await?.try_into(); - *self.client.lock().unwrap() = Some(client); - res + let uri = format!("/{}", resource_path); + self.client.get::(&uri).await?.try_into().map_err(HttpClientError::Io) } } @@ -126,7 +115,8 @@ mod tests { let client = RestClient::new(server.endpoint()); match client.request_resource::("/").await { - Err(e) => assert_eq!(e.kind(), std::io::ErrorKind::Other), + Err(HttpClientError::Http(e)) => assert_eq!(e.status_code, 404), + Err(e) => panic!("Unexpected error type: {:?}", e), Ok(_) => panic!("Expected error"), } } @@ -137,7 +127,8 @@ mod tests { let client = RestClient::new(server.endpoint()); match client.request_resource::("/").await { - Err(e) => assert_eq!(e.kind(), std::io::ErrorKind::InvalidData), + Err(HttpClientError::Io(_)) => {}, + Err(e) => panic!("Unexpected error type: {:?}", e), Ok(_) => panic!("Expected error"), } } diff --git a/lightning-block-sync/src/rpc.rs b/lightning-block-sync/src/rpc.rs index d851ba2ccf0..c81d7f23da9 100644 --- a/lightning-block-sync/src/rpc.rs +++ b/lightning-block-sync/src/rpc.rs @@ -2,14 +2,12 @@ //! endpoint. use crate::gossip::UtxoSource; -use crate::http::{HttpClient, HttpEndpoint, HttpError, JsonResponse}; +use crate::http::{HttpClient, HttpClientError, JsonResponse}; use crate::{BlockData, BlockHeaderData, BlockSource, BlockSourceResult}; use bitcoin::hash_types::BlockHash; use bitcoin::OutPoint; -use std::sync::Mutex; - use serde_json; use std::convert::TryFrom; @@ -36,14 +34,56 @@ impl fmt::Display for RpcError { impl Error for RpcError {} +/// Error type for RPC client operations. +#[derive(Debug)] +pub enum RpcClientError { + /// An HTTP client error (transport or HTTP error). + Http(HttpClientError), + /// An RPC error returned by the server. + Rpc(RpcError), + /// Invalid data in the response. + InvalidData(String), +} + +impl std::error::Error for RpcClientError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + RpcClientError::Http(e) => Some(e), + RpcClientError::Rpc(e) => Some(e), + RpcClientError::InvalidData(_) => None, + } + } +} + +impl fmt::Display for RpcClientError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + RpcClientError::Http(e) => write!(f, "HTTP error: {}", e), + RpcClientError::Rpc(e) => write!(f, "{}", e), + RpcClientError::InvalidData(msg) => write!(f, "invalid data: {}", msg), + } + } +} + +impl From for RpcClientError { + fn from(e: HttpClientError) -> Self { + RpcClientError::Http(e) + } +} + +impl From for RpcClientError { + fn from(e: RpcError) -> Self { + RpcClientError::Rpc(e) + } +} + /// A simple RPC client for calling methods using HTTP `POST`. /// /// Implements [`BlockSource`] and may return an `Err` containing [`RpcError`]. See /// [`RpcClient::call_method`] for details. pub struct RpcClient { basic_auth: String, - endpoint: HttpEndpoint, - client: Mutex>, + client: HttpClient, id: AtomicUsize, } @@ -51,85 +91,64 @@ impl RpcClient { /// Creates a new RPC client connected to the given endpoint with the provided credentials. The /// credentials should be a base64 encoding of a user name and password joined by a colon, as is /// required for HTTP basic access authentication. - pub fn new(credentials: &str, endpoint: HttpEndpoint) -> Self { + /// + /// The base URL should include the scheme, host, and port (e.g., "http://127.0.0.1:8332"). + pub fn new(credentials: &str, base_url: String) -> Self { Self { basic_auth: "Basic ".to_string() + credentials, - endpoint, - client: Mutex::new(None), + client: HttpClient::new(base_url), id: AtomicUsize::new(0), } } /// Calls a method with the response encoded in JSON format and interpreted as type `T`. - /// - /// When an `Err` is returned, [`std::io::Error::into_inner`] may contain an [`RpcError`] if - /// [`std::io::Error::kind`] is [`std::io::ErrorKind::Other`]. pub async fn call_method( &self, method: &str, params: &[serde_json::Value], - ) -> std::io::Result + ) -> Result where JsonResponse: TryFrom, Error = std::io::Error> + TryInto, { - let host = format!("{}:{}", self.endpoint.host(), self.endpoint.port()); - let uri = self.endpoint.path(); let content = serde_json::json!({ "method": method, "params": params, "id": &self.id.fetch_add(1, Ordering::AcqRel).to_string() }); - let reserved_client = self.client.lock().unwrap().take(); - let mut client = if let Some(client) = reserved_client { - client - } else { - HttpClient::connect(&self.endpoint)? - }; - let http_response = - client.post::(&uri, &host, &self.basic_auth, content).await; - *self.client.lock().unwrap() = Some(client); + let http_response = self.client.post::("/", &self.basic_auth, content).await; let mut response = match http_response { Ok(JsonResponse(response)) => response, - Err(e) if e.kind() == std::io::ErrorKind::Other => { - match e.get_ref().unwrap().downcast_ref::() { - Some(http_error) => match JsonResponse::try_from(http_error.contents.clone()) { - Ok(JsonResponse(response)) => response, - Err(_) => Err(e)?, - }, - None => Err(e)?, + Err(HttpClientError::Http(http_error)) => { + // Try to parse the error body as JSON-RPC response + match JsonResponse::try_from(http_error.contents.clone()) { + Ok(JsonResponse(response)) => response, + Err(_) => return Err(HttpClientError::Http(http_error).into()), } }, - Err(e) => Err(e)?, + Err(e) => return Err(e.into()), }; if !response.is_object() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "expected JSON object", - )); + return Err(RpcClientError::InvalidData("expected JSON object".to_string())); } let error = &response["error"]; if !error.is_null() { - // TODO: Examine error code for a more precise std::io::ErrorKind. let rpc_error = RpcError { code: error["code"].as_i64().unwrap_or(-1), message: error["message"].as_str().unwrap_or("unknown error").to_string(), }; - return Err(std::io::Error::new(std::io::ErrorKind::Other, rpc_error)); + return Err(rpc_error.into()); } let result = match response.get_mut("result") { Some(result) => result.take(), - None => { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "expected JSON result", - )) - }, + None => return Err(RpcClientError::InvalidData("expected JSON result".to_string())), }; - JsonResponse(result).try_into() + JsonResponse(result) + .try_into() + .map_err(|e: std::io::Error| RpcClientError::InvalidData(e.to_string())) } } @@ -212,7 +231,10 @@ mod tests { let client = RpcClient::new(CREDENTIALS, server.endpoint()); match client.call_method::("getblockcount", &[]).await { - Err(e) => assert_eq!(e.kind(), std::io::ErrorKind::Other), + Err(RpcClientError::Http(HttpClientError::Http(e))) => { + assert_eq!(e.status_code, 404); + }, + Err(e) => panic!("Unexpected error type: {:?}", e), Ok(_) => panic!("Expected error"), } } @@ -224,10 +246,10 @@ mod tests { let client = RpcClient::new(CREDENTIALS, server.endpoint()); match client.call_method::("getblockcount", &[]).await { - Err(e) => { - assert_eq!(e.kind(), std::io::ErrorKind::InvalidData); - assert_eq!(e.get_ref().unwrap().to_string(), "expected JSON object"); + Err(RpcClientError::InvalidData(msg)) => { + assert_eq!(msg, "expected JSON object"); }, + Err(e) => panic!("Unexpected error type: {:?}", e), Ok(_) => panic!("Expected error"), } } @@ -242,12 +264,11 @@ mod tests { let invalid_block_hash = serde_json::json!("foo"); match client.call_method::("getblock", &[invalid_block_hash]).await { - Err(e) => { - assert_eq!(e.kind(), std::io::ErrorKind::Other); - let rpc_error: Box = e.into_inner().unwrap().downcast().unwrap(); + Err(RpcClientError::Rpc(rpc_error)) => { assert_eq!(rpc_error.code, -8); assert_eq!(rpc_error.message, "invalid parameter"); }, + Err(e) => panic!("Unexpected error type: {:?}", e), Ok(_) => panic!("Expected error"), } } @@ -259,10 +280,10 @@ mod tests { let client = RpcClient::new(CREDENTIALS, server.endpoint()); match client.call_method::("getblockcount", &[]).await { - Err(e) => { - assert_eq!(e.kind(), std::io::ErrorKind::InvalidData); - assert_eq!(e.get_ref().unwrap().to_string(), "expected JSON result"); + Err(RpcClientError::InvalidData(msg)) => { + assert_eq!(msg, "expected JSON result"); }, + Err(e) => panic!("Unexpected error type: {:?}", e), Ok(_) => panic!("Expected error"), } } @@ -274,10 +295,10 @@ mod tests { let client = RpcClient::new(CREDENTIALS, server.endpoint()); match client.call_method::("getblockcount", &[]).await { - Err(e) => { - assert_eq!(e.kind(), std::io::ErrorKind::InvalidData); - assert_eq!(e.get_ref().unwrap().to_string(), "not a number"); + Err(RpcClientError::InvalidData(msg)) => { + assert!(msg.contains("not a number")); }, + Err(e) => panic!("Unexpected error type: {:?}", e), Ok(_) => panic!("Expected error"), } } From 63f4f8f578c569fc1870c4b74cfccc1f298ba552 Mon Sep 17 00:00:00 2001 From: elnosh Date: Wed, 18 Feb 2026 10:51:43 -0500 Subject: [PATCH 065/627] Replace `check_closed_broadcast!` macro with direct function calls Co-Authored-By: Claude Sonnet 4.6 --- lightning-persister/src/test_utils.rs | 5 +- lightning/src/ln/chanmon_update_fail_tests.rs | 14 +-- lightning/src/ln/functional_test_utils.rs | 11 --- lightning/src/ln/functional_tests.rs | 92 +++++++++---------- lightning/src/ln/htlc_reserve_unit_tests.rs | 30 +++--- lightning/src/ln/monitor_tests.rs | 34 +++---- lightning/src/ln/payment_tests.rs | 8 +- lightning/src/ln/reload_tests.rs | 4 +- lightning/src/ln/reorg_tests.rs | 14 +-- lightning/src/ln/shutdown_tests.rs | 8 +- lightning/src/ln/update_fee_tests.rs | 6 +- lightning/src/ln/zero_fee_commitment_tests.rs | 6 +- lightning/src/util/persist.rs | 5 +- 13 files changed, 112 insertions(+), 125 deletions(-) diff --git a/lightning-persister/src/test_utils.rs b/lightning-persister/src/test_utils.rs index 48b383ad1ea..b8f3eb0bd99 100644 --- a/lightning-persister/src/test_utils.rs +++ b/lightning-persister/src/test_utils.rs @@ -1,4 +1,3 @@ -use lightning::check_closed_broadcast; use lightning::events::ClosureReason; use lightning::ln::functional_test_utils::*; use lightning::util::persist::{ @@ -188,7 +187,7 @@ pub(crate) fn do_test_store(store_0: &K, store_1: &K) { .unwrap(); let reason = ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }; check_closed_event(&nodes[0], 1, reason, &[node_b_id], 100000); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); let node_txn = nodes[0].tx_broadcaster.txn_broadcasted.lock().unwrap(); @@ -202,7 +201,7 @@ pub(crate) fn do_test_store(store_0: &K, store_1: &K) { vec![node_txn[0].clone(), node_txn[0].clone()], ), ); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[1], 1, reason, &[nodes[0].node.get_our_node_id()], 100000); check_added_monitors(&nodes[1], 1); diff --git a/lightning/src/ln/chanmon_update_fail_tests.rs b/lightning/src/ln/chanmon_update_fail_tests.rs index b421114e911..4eb45620582 100644 --- a/lightning/src/ln/chanmon_update_fail_tests.rs +++ b/lightning/src/ln/chanmon_update_fail_tests.rs @@ -277,7 +277,7 @@ fn do_test_simple_monitor_temporary_update_fail(disconnect: bool) { }; nodes[0].node.force_close_broadcasting_latest_txn(&channel_id, &node_b_id, message).unwrap(); check_added_monitors(&nodes[0], 1); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); // TODO: Once we hit the chain with the failure transaction we should check that we get a // PaymentPathFailed event @@ -2509,7 +2509,7 @@ fn test_fail_htlc_on_broadcast_after_claim() { mine_transaction(&nodes[1], &bs_txn[0]); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[1], 1, reason, &[node_c_id], 100000); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); connect_blocks(&nodes[1], ANTI_REORG_DELAY - 1); check_added_monitors(&nodes[1], 1); expect_and_process_pending_htlcs_and_htlc_handling_failed( @@ -4043,7 +4043,7 @@ fn do_test_reload_mon_update_completion_actions(close_during_reload: bool) { }; nodes[0].node.force_close_broadcasting_latest_txn(&chan_id_ab, &node_b_id, msg).unwrap(); check_added_monitors(&nodes[0], 1); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_closed_event(&nodes[0], 1, reason, &[node_b_id], 100_000); let as_closing_tx = nodes[0].tx_broadcaster.txn_broadcasted.lock().unwrap().split_off(0); mine_transaction_without_consistency_checks(&nodes[1], &as_closing_tx[0]); @@ -4494,13 +4494,13 @@ fn test_claim_to_closed_channel_blocks_forwarded_preimage_removal() { check_added_monitors(&nodes[0], 1); let a_reason = ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }; check_closed_event(&nodes[0], 1, a_reason, &[node_b_id], 1000000); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); let as_commit_tx = nodes[0].tx_broadcaster.txn_broadcasted.lock().unwrap().split_off(0); assert_eq!(as_commit_tx.len(), 1); mine_transaction(&nodes[1], &as_commit_tx[0]); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); let b_reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[1], 1, b_reason, &[node_a_id], 1000000); @@ -4572,13 +4572,13 @@ fn test_claim_to_closed_channel_blocks_claimed_event() { check_added_monitors(&nodes[0], 1); let a_reason = ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }; check_closed_event(&nodes[0], 1, a_reason, &[node_b_id], 1000000); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); let as_commit_tx = nodes[0].tx_broadcaster.txn_broadcasted.lock().unwrap().split_off(0); assert_eq!(as_commit_tx.len(), 1); mine_transaction(&nodes[1], &as_commit_tx[0]); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); let b_reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[1], 1, b_reason, &[node_a_id], 1000000); diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 66a0147e131..eaa98b138e5 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -2313,17 +2313,6 @@ pub fn check_closed_broadcast( .collect() } -/// Check that a channel's closing channel update has been broadcasted, and optionally -/// check whether an error message event has occurred. -/// -/// Don't use this, use the identically-named function instead. -#[macro_export] -macro_rules! check_closed_broadcast { - ($node: expr, $with_error_msg: expr) => { - $crate::ln::functional_test_utils::check_closed_broadcast(&$node, 1, $with_error_msg).pop() - }; -} - #[derive(Default)] pub struct ExpectedCloseEvent { pub channel_capacity_sats: Option, diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index 6fe0c83dfe8..da21c120359 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -526,7 +526,7 @@ fn do_test_fail_back_before_backwards_timeout(post_fail_back_action: PostFailBac connect_blocks(&nodes[2], TEST_FINAL_CLTV - CLTV_CLAIM_BUFFER + 2); let node_2_txn = test_txn_broadcast(&nodes[2], &chan_2, None, HTLCType::SUCCESS); - check_closed_broadcast!(nodes[2], true); + check_closed_broadcast(&nodes[2], 1, true); let reason = ClosureReason::HTLCsTimedOut { payment_hash: Some(payment_hash) }; check_closed_event(&nodes[2], 1, reason, &[node_b_id], 100_000); check_added_monitors(&nodes[2], 1); @@ -618,7 +618,7 @@ pub fn channel_monitor_network_test() { .force_close_broadcasting_latest_txn(&chan_1.2, &node_a_id, message.clone()) .unwrap(); check_added_monitors(&nodes[1], 1); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); let reason = ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }; check_closed_event(&nodes[1], 1, reason, &[node_a_id], 100000); { @@ -650,7 +650,7 @@ pub fn channel_monitor_network_test() { .node .force_close_broadcasting_latest_txn(&chan_2.2, &node_c_id, message.clone()) .unwrap(); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); { let mut node_txn = test_txn_broadcast(&nodes[1], &chan_2, None, HTLCType::NONE); @@ -704,7 +704,7 @@ pub fn channel_monitor_network_test() { .force_close_broadcasting_latest_txn(&chan_3.2, &node_d_id, message.clone()) .unwrap(); check_added_monitors(&nodes[2], 1); - check_closed_broadcast!(nodes[2], true); + check_closed_broadcast(&nodes[2], 1, true); let node2_commitment_txid; { let node_txn = test_txn_broadcast(&nodes[2], &chan_3, None, HTLCType::NONE); @@ -1247,7 +1247,7 @@ pub fn do_test_multiple_package_conflicts(p2a_anchor: bool) { mine_transaction(&nodes[1], node2_commit_tx); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[1], 1, reason, &[node_c_id], CHAN_CAPACITY); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); // Node 1 should immediately claim package 1 but has to wait a block to claim package 2. @@ -1288,7 +1288,7 @@ pub fn do_test_multiple_package_conflicts(p2a_anchor: bool) { mine_transaction(&nodes[2], node2_commit_tx); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[2], 1, reason, &[node_b_id], CHAN_CAPACITY); - check_closed_broadcast!(nodes[2], true); + check_closed_broadcast(&nodes[2], 1, true); check_added_monitors(&nodes[2], 1); let process_bump_event = |node: &Node| { @@ -1463,7 +1463,7 @@ pub fn test_htlc_on_chain_success() { assert_eq!(updates.update_fulfill_htlcs.len(), 1); mine_transaction(&nodes[2], &commitment_tx[0]); - check_closed_broadcast!(nodes[2], true); + check_closed_broadcast(&nodes[2], 1, true); check_added_monitors(&nodes[2], 1); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[2], 1, reason, &[node_b_id], 100000); @@ -1585,7 +1585,7 @@ pub fn test_htlc_on_chain_success() { let node_a_commitment_tx = get_local_commitment_txn!(nodes[0], chan_1.2); check_spends!(node_a_commitment_tx[0], chan_1.3); mine_transaction(&nodes[1], &node_a_commitment_tx[0]); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[1], 1, reason, &[node_a_id], 100000); @@ -1620,7 +1620,7 @@ pub fn test_htlc_on_chain_success() { let txn = vec![node_a_commitment_tx[0].clone(), commitment_spend.clone()]; connect_block(&nodes[0], &create_dummy_block(nodes[0].best_block_hash(), 42, txn)); connect_blocks(&nodes[0], TEST_FINAL_CLTV + MIN_CLTV_EXPIRY_DELTA as u32); // Confirm blocks until the HTLC expires - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); let events = nodes[0].node.get_and_clear_pending_events(); check_added_monitors(&nodes[0], 2); @@ -1728,7 +1728,7 @@ fn do_test_htlc_on_chain_timeout(connect_style: ConnectStyle) { _ => panic!("Unexpected event"), }; mine_transaction(&nodes[2], &commitment_tx[0]); - check_closed_broadcast!(nodes[2], true); + check_closed_broadcast(&nodes[2], 1, true); check_added_monitors(&nodes[2], 1); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[2], 1, reason, &[node_b_id], 100000); @@ -1772,7 +1772,7 @@ fn do_test_htlc_on_chain_timeout(connect_style: ConnectStyle) { mine_transaction(&nodes[1], &timeout_tx); check_added_monitors(&nodes[1], 1); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); connect_blocks(&nodes[1], ANTI_REORG_DELAY - 1); @@ -1812,7 +1812,7 @@ fn do_test_htlc_on_chain_timeout(connect_style: ConnectStyle) { mine_transaction(&nodes[0], &commitment_tx[0]); connect_blocks(&nodes[0], TEST_FINAL_CLTV + MIN_CLTV_EXPIRY_DELTA as u32); // Confirm blocks until the HTLC expires - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[0], 1, reason, &[node_b_id], 100000); @@ -1864,7 +1864,7 @@ pub fn test_simple_commitment_revoked_fail_backward() { check_closed_event(&nodes[1], 1, reason, &[node_c_id], 100000); connect_blocks(&nodes[1], ANTI_REORG_DELAY - 1); check_added_monitors(&nodes[1], 1); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); expect_and_process_pending_htlcs_and_htlc_handling_failed( &nodes[1], @@ -2336,7 +2336,7 @@ pub fn fail_backward_pending_htlc_upon_channel_failure() { }, _ => panic!("Unexpected event {:?}", events[1]), } - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); } @@ -2373,7 +2373,7 @@ pub fn test_htlc_ignore_latest_remote_commitment() { .force_close_broadcasting_latest_txn(&chan_id, &node_b_id, message.clone()) .unwrap(); connect_blocks(&nodes[0], TEST_FINAL_CLTV + LATENCY_GRACE_PERIOD_BLOCKS + 1); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); let reason = ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }; check_closed_event(&nodes[0], 1, reason, &[node_b_id], 100000); @@ -2385,7 +2385,7 @@ pub fn test_htlc_ignore_latest_remote_commitment() { let block = create_dummy_block(nodes[1].best_block_hash(), 42, vec![node_txn[0].clone()]); connect_block(&nodes[1], &block); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[1], 1, reason, &[node_a_id], 100000); @@ -2454,7 +2454,7 @@ pub fn test_force_close_fail_back() { .node .force_close_broadcasting_latest_txn(&channel_id, &node_b_id, message.clone()) .unwrap(); - check_closed_broadcast!(nodes[2], true); + check_closed_broadcast(&nodes[2], 1, true); check_added_monitors(&nodes[2], 1); let reason = ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }; check_closed_event(&nodes[2], 1, reason, &[node_b_id], 100000); @@ -2471,7 +2471,7 @@ pub fn test_force_close_fail_back() { mine_transaction(&nodes[1], &commitment_tx); // Note no UpdateHTLCs event here from nodes[1] to nodes[0]! - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[1], 1, reason, &[node_c_id], 100000); @@ -3530,7 +3530,7 @@ pub fn test_claim_sizeable_push_msat() { .node .force_close_broadcasting_latest_txn(&chan.2, &node_a_id, message.clone()) .unwrap(); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); let reason = ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }; check_closed_event(&nodes[1], 1, reason, &[node_a_id], 100000); @@ -3571,7 +3571,7 @@ pub fn test_claim_on_remote_sizeable_push_msat() { .node .force_close_broadcasting_latest_txn(&chan.2, &node_b_id, message.clone()) .unwrap(); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); let reason = ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }; check_closed_event(&nodes[0], 1, reason, &[node_b_id], 100000); @@ -3582,7 +3582,7 @@ pub fn test_claim_on_remote_sizeable_push_msat() { assert_eq!(node_txn[0].output.len(), 2); // We can't force trimming of to_remote output as channel_reserve_satoshis block us to do so at channel opening mine_transaction(&nodes[1], &node_txn[0]); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[1], 1, reason, &[node_a_id], 100000); @@ -3615,7 +3615,7 @@ pub fn test_claim_on_remote_revoked_sizeable_push_msat() { claim_payment(&nodes[0], &[&nodes[1]], payment_preimage); mine_transaction(&nodes[1], &revoked_local_txn[0]); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[1], 1, reason, &[node_a_id], 100000); @@ -3762,7 +3762,7 @@ fn do_test_static_spendable_outputs_justice_tx_revoked_commitment_tx(split_tx: b } mine_transaction(&nodes[1], &revoked_local_txn[0]); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[1], 1, reason, &[node_a_id], 100000); @@ -3820,7 +3820,7 @@ pub fn test_static_spendable_outputs_justice_tx_revoked_htlc_timeout_tx() { // A will generate HTLC-Timeout from revoked commitment tx mine_transaction(&nodes[0], &revoked_local_txn[0]); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[0], 1, reason, &[node_b_id], 100000); @@ -3843,7 +3843,7 @@ pub fn test_static_spendable_outputs_justice_tx_revoked_htlc_timeout_tx() { // B will generate justice tx from A's revoked commitment/HTLC tx let txn = vec![revoked_local_txn[0].clone(), revoked_htlc_txn[0].clone()]; connect_block(&nodes[1], &create_dummy_block(nodes[1].best_block_hash(), 42, txn)); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[1], 1, reason, &[node_a_id], 100000); @@ -3904,7 +3904,7 @@ pub fn test_static_spendable_outputs_justice_tx_revoked_htlc_success_tx() { // B will generate HTLC-Success from revoked commitment tx mine_transaction(&nodes[1], &revoked_local_txn[0]); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[1], 1, reason, &[node_a_id], 100000); @@ -3925,7 +3925,7 @@ pub fn test_static_spendable_outputs_justice_tx_revoked_htlc_success_tx() { // A will generate justice tx from B's revoked commitment/HTLC tx let txn = vec![revoked_local_txn[0].clone(), revoked_htlc_txn[0].clone()]; connect_block(&nodes[0], &create_dummy_block(nodes[0].best_block_hash(), 42, txn)); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[0], 1, reason, &[node_b_id], 100000); @@ -4011,7 +4011,7 @@ pub fn test_onchain_to_onchain_claim() { assert!(updates.update_fail_malformed_htlcs.is_empty()); mine_transaction(&nodes[2], &commitment_tx[0]); - check_closed_broadcast!(nodes[2], true); + check_closed_broadcast(&nodes[2], 1, true); check_added_monitors(&nodes[2], 1); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[2], 1, reason, &[node_b_id], 100000); @@ -4107,7 +4107,7 @@ pub fn test_onchain_to_onchain_claim() { assert!(b_txn[0].output[0].script_pubkey.is_p2wpkh()); // direct payment assert_eq!(b_txn[0].lock_time.to_consensus_u32(), nodes[1].best_block_info().1); // Success tx - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); } @@ -4173,7 +4173,7 @@ pub fn test_duplicate_payment_hash_one_failure_one_success() { check_spends!(commitment_txn[0], chan_2.3); mine_transaction(&nodes[1], &commitment_txn[0]); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[1], 1, reason, &[node_c_id], 100000); @@ -4576,7 +4576,7 @@ fn do_test_fail_backwards_unrevoked_remote_announce(deliver_last_raa: bool, anno } connect_blocks(&nodes[2], ANTI_REORG_DELAY - 1); - check_closed_broadcast!(nodes[2], true); + check_closed_broadcast(&nodes[2], 1, true); if deliver_last_raa { nodes[2].node.process_pending_htlc_forwards(); @@ -4808,7 +4808,7 @@ pub fn test_dynamic_spendable_outputs_local_htlc_timeout_tx() { // Timeout HTLC on A's chain and so it can generate a HTLC-Timeout tx mine_transaction(&nodes[0], &local_txn[0]); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[0], 1, reason, &[node_b_id], 100000); @@ -4936,7 +4936,7 @@ pub fn test_key_derivation_params() { // Timeout HTLC on A's chain and so it can generate a HTLC-Timeout tx mine_transaction(&nodes[0], &local_txn_1[0]); connect_blocks(&nodes[0], TEST_FINAL_CLTV); // Confirm blocks until the HTLC expires - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[0], 1, reason, &[node_b_id], 100000); @@ -5045,7 +5045,7 @@ fn do_htlc_claim_local_commitment_only(use_dust: bool) { } let htlc_type = if use_dust { HTLCType::NONE } else { HTLCType::SUCCESS }; test_txn_broadcast(&nodes[1], &chan, None, htlc_type); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); let reason = ClosureReason::HTLCsTimedOut { payment_hash: Some(payment_hash) }; check_closed_event(&nodes[1], 1, reason, &[node_a_id], 100000); @@ -5086,7 +5086,7 @@ fn do_htlc_claim_current_remote_commitment_only(use_dust: bool) { block.header.prev_blockhash = block.block_hash(); } test_txn_broadcast(&nodes[0], &chan, None, HTLCType::NONE); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); let reason = ClosureReason::HTLCsTimedOut { payment_hash: Some(payment_hash) }; check_closed_event(&nodes[0], 1, reason, &[node_b_id], 100000); @@ -5147,7 +5147,7 @@ fn do_htlc_claim_previous_remote_commitment_only(use_dust: bool, check_revoke_no } if !check_revoke_no_close { test_txn_broadcast(&nodes[0], &chan, None, HTLCType::NONE); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); let reason = ClosureReason::HTLCsTimedOut { payment_hash: Some(our_payment_hash) }; check_closed_event(&nodes[0], 1, reason, &[node_b_id], 100000); @@ -5870,7 +5870,7 @@ fn do_test_failure_delay_dust_htlc_local_commitment(announce_latest: bool) { mine_transaction(&nodes[0], &as_prev_commitment_tx[0]); } - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[0], 1, reason, &[node_b_id], 100000); @@ -5954,7 +5954,7 @@ fn do_test_sweep_outbound_htlc_failure_update(revoked: bool, local: bool) { mine_transaction(&nodes[0], &as_commitment_tx[0]); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[0], 1, reason, &[node_b_id], 100000); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1); let conditions = PaymentFailedConditions::new().from_mon_update(); @@ -5977,7 +5977,7 @@ fn do_test_sweep_outbound_htlc_failure_update(revoked: bool, local: bool) { } else { // We fail dust-HTLC 1 by broadcast of remote commitment tx. If revoked, fail also non-dust HTLC mine_transaction(&nodes[0], &bs_commitment_tx[0]); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[0], 1, reason, &[node_b_id], 100000); @@ -6706,7 +6706,7 @@ pub fn test_counterparty_raa_skip_no_crash() { }; nodes[1].node.handle_revoke_and_ack(node_a_id, &raa); assert_eq!( - check_closed_broadcast!(nodes[1], true).unwrap().data, + check_closed_broadcast(&nodes[1], 1, true).pop().unwrap().data, "Received an unexpected revoke_and_ack" ); check_added_monitors(&nodes[1], 1); @@ -6748,7 +6748,7 @@ pub fn test_bump_txn_sanitize_tracking_maps() { assert_eq!(nodes[0].tx_broadcaster.txn_broadcasted.lock().unwrap().len(), 0); mine_transaction(&nodes[0], &revoked_local_txn[0]); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[0], 1, reason, &[node_b_id], 1000000); @@ -7733,7 +7733,7 @@ pub fn test_htlc_no_detection() { &block, nodes[0].best_block_info().1 + 1, ); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[0], 1, reason, &[node_b_id], 100000); @@ -7821,7 +7821,7 @@ fn do_test_onchain_htlc_settlement_after_close( .node .force_close_broadcasting_latest_txn(&chan_ab.2, &counterparty_node_id, message.clone()) .unwrap(); - check_closed_broadcast!(nodes[force_closing_node], true); + check_closed_broadcast(&nodes[force_closing_node], 1, true); check_added_monitors(&nodes[force_closing_node], 1); let reason = ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }; check_closed_event(&nodes[force_closing_node], 1, reason, &[counterparty_node_id], 100000); @@ -7836,7 +7836,7 @@ fn do_test_onchain_htlc_settlement_after_close( &create_dummy_block(nodes[1].best_block_hash(), 42, vec![txn_to_broadcast[0].clone()]), ); if broadcast_alice { - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[1], 1, reason, &[node_a_id], 100000); @@ -7925,7 +7925,7 @@ fn do_test_onchain_htlc_settlement_after_close( ); // If Bob was the one to force-close, he will have already passed these checks earlier. if broadcast_alice { - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[1], 1, reason, &[node_a_id], 100000); @@ -8120,7 +8120,7 @@ pub fn test_error_chans_closed() { &msgs::ErrorMessage { channel_id: chan_2.2, data: "ERR".to_owned() }, ); check_added_monitors(&nodes[0], 1); - check_closed_broadcast!(nodes[0], false); + check_closed_broadcast(&nodes[0], 1, false); let reason = ClosureReason::CounterpartyForceClosed { peer_msg: UntrustedString("ERR".to_string()) }; diff --git a/lightning/src/ln/htlc_reserve_unit_tests.rs b/lightning/src/ln/htlc_reserve_unit_tests.rs index 5b2ffca5fd4..8cbe2f5dcb2 100644 --- a/lightning/src/ln/htlc_reserve_unit_tests.rs +++ b/lightning/src/ln/htlc_reserve_unit_tests.rs @@ -1098,7 +1098,7 @@ pub fn test_chan_reserve_violation_inbound_htlc_outbound_channel() { // Check that the payment failed and the channel is closed in response to the malicious UpdateAdd. nodes[0].logger.assert_log_contains("lightning::ln::channelmanager", "Cannot accept HTLC that would put our balance under counterparty-announced channel reserve value", 3); assert_eq!(nodes[0].node.list_channels().len(), 0); - let err_msg = check_closed_broadcast!(nodes[0], true).unwrap(); + let err_msg = check_closed_broadcast(&nodes[0], 1, true).pop().unwrap(); assert_eq!(err_msg.data, "Cannot accept HTLC that would put our balance under counterparty-announced channel reserve value"); let reason = ClosureReason::ProcessingError { err: "Cannot accept HTLC that would put our balance under counterparty-announced channel reserve value".to_string() }; check_added_monitors(&nodes[0], 1); @@ -1291,7 +1291,7 @@ pub fn test_chan_reserve_violation_inbound_htlc_inbound_chan() { 3, ); assert_eq!(nodes[1].node.list_channels().len(), 1); - let err_msg = check_closed_broadcast!(nodes[1], true).unwrap(); + let err_msg = check_closed_broadcast(&nodes[1], 1, true).pop().unwrap(); assert_eq!(err_msg.data, "Remote HTLC add would put them under remote reserve value"); check_added_monitors(&nodes[1], 1); let reason = ClosureReason::ProcessingError { err: err_msg.data.clone() }; @@ -1409,7 +1409,7 @@ pub fn test_update_add_htlc_bolt2_receiver_zero_value_msat() { "Remote side tried to send a 0-msat HTLC", 3, ); - check_closed_broadcast!(nodes[1], true).unwrap(); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); let reason = ClosureReason::ProcessingError { err: "Remote side tried to send a 0-msat HTLC".to_string(), @@ -1566,7 +1566,7 @@ pub fn test_update_add_htlc_bolt2_receiver_check_amount_received_more_than_min() updates.update_add_htlcs[0].amount_msat = htlc_minimum_msat - 1; nodes[1].node.handle_update_add_htlc(node_a_id, &updates.update_add_htlcs[0]); assert!(nodes[1].node.list_channels().is_empty()); - let err_msg = check_closed_broadcast!(nodes[1], true).unwrap(); + let err_msg = check_closed_broadcast(&nodes[1], 1, true).pop().unwrap(); assert!(regex::Regex::new(r"Remote side tried to send less than our minimum HTLC value\. Lower limit: \(\d+\)\. Actual: \(\d+\)").unwrap().is_match(err_msg.data.as_str())); check_added_monitors(&nodes[1], 1); let reason = ClosureReason::ProcessingError { err: err_msg.data }; @@ -1611,7 +1611,7 @@ pub fn test_update_add_htlc_bolt2_receiver_sender_can_afford_amount_sent() { nodes[1].node.handle_update_add_htlc(node_a_id, &updates.update_add_htlcs[0]); assert!(nodes[1].node.list_channels().is_empty()); - let err_msg = check_closed_broadcast!(nodes[1], true).unwrap(); + let err_msg = check_closed_broadcast(&nodes[1], 1, true).pop().unwrap(); assert_eq!(err_msg.data, "Remote HTLC add would put them under remote reserve value"); check_added_monitors(&nodes[1], 1); let reason = ClosureReason::ProcessingError { err: err_msg.data }; @@ -1678,7 +1678,7 @@ pub fn test_update_add_htlc_bolt2_receiver_check_max_htlc_limit() { nodes[1].node.handle_update_add_htlc(node_a_id, &msg); assert!(nodes[1].node.list_channels().is_empty()); - let err_msg = check_closed_broadcast!(nodes[1], true).unwrap(); + let err_msg = check_closed_broadcast(&nodes[1], 1, true).pop().unwrap(); assert!(regex::Regex::new(r"Remote tried to push more than our max accepted HTLCs \(\d+\)") .unwrap() .is_match(err_msg.data.as_str())); @@ -1713,7 +1713,7 @@ pub fn test_update_add_htlc_bolt2_receiver_check_max_in_flight_msat() { nodes[1].node.handle_update_add_htlc(node_a_id, &updates.update_add_htlcs[0]); assert!(nodes[1].node.list_channels().is_empty()); - let err_msg = check_closed_broadcast!(nodes[1], true).unwrap(); + let err_msg = check_closed_broadcast(&nodes[1], 1, true).pop().unwrap(); assert!(regex::Regex::new("Remote HTLC add would put them over our max HTLC value") .unwrap() .is_match(err_msg.data.as_str())); @@ -1745,7 +1745,7 @@ pub fn test_update_add_htlc_bolt2_receiver_check_cltv_expiry() { nodes[1].node.handle_update_add_htlc(node_a_id, &updates.update_add_htlcs[0]); assert!(nodes[1].node.list_channels().is_empty()); - let err_msg = check_closed_broadcast!(nodes[1], true).unwrap(); + let err_msg = check_closed_broadcast(&nodes[1], 1, true).pop().unwrap(); assert_eq!(err_msg.data, "Remote provided CLTV expiry in seconds instead of block height"); check_added_monitors(&nodes[1], 1); let reason = ClosureReason::ProcessingError { err: err_msg.data }; @@ -1809,7 +1809,7 @@ pub fn test_update_add_htlc_bolt2_receiver_check_repeated_id_ignore() { nodes[1].node.handle_update_add_htlc(node_a_id, &updates.update_add_htlcs[0]); assert!(nodes[1].node.list_channels().is_empty()); - let err_msg = check_closed_broadcast!(nodes[1], true).unwrap(); + let err_msg = check_closed_broadcast(&nodes[1], 1, true).pop().unwrap(); assert!(regex::Regex::new(r"Remote skipped HTLC ID \(skipped ID: \d+\)") .unwrap() .is_match(err_msg.data.as_str())); @@ -1851,7 +1851,7 @@ pub fn test_update_fulfill_htlc_bolt2_update_fulfill_htlc_before_commitment() { nodes[0].node.handle_update_fulfill_htlc(node_b_id, update_msg); assert!(nodes[0].node.list_channels().is_empty()); - let err_msg = check_closed_broadcast!(nodes[0], true).unwrap(); + let err_msg = check_closed_broadcast(&nodes[0], 1, true).pop().unwrap(); assert!(regex::Regex::new( r"Remote tried to fulfill/fail HTLC \(\d+\) before it had been committed" ) @@ -1895,7 +1895,7 @@ pub fn test_update_fulfill_htlc_bolt2_update_fail_htlc_before_commitment() { nodes[0].node.handle_update_fail_htlc(node_b_id, &update_msg); assert!(nodes[0].node.list_channels().is_empty()); - let err_msg = check_closed_broadcast!(nodes[0], true).unwrap(); + let err_msg = check_closed_broadcast(&nodes[0], 1, true).pop().unwrap(); assert!(regex::Regex::new( r"Remote tried to fulfill/fail HTLC \(\d+\) before it had been committed" ) @@ -1938,7 +1938,7 @@ pub fn test_update_fulfill_htlc_bolt2_update_fail_malformed_htlc_before_commitme nodes[0].node.handle_update_fail_malformed_htlc(node_b_id, &update_msg); assert!(nodes[0].node.list_channels().is_empty()); - let err_msg = check_closed_broadcast!(nodes[0], true).unwrap(); + let err_msg = check_closed_broadcast(&nodes[0], 1, true).pop().unwrap(); assert!(regex::Regex::new( r"Remote tried to fulfill/fail HTLC \(\d+\) before it had been committed" ) @@ -2001,7 +2001,7 @@ pub fn test_update_fulfill_htlc_bolt2_incorrect_htlc_id() { nodes[0].node.handle_update_fulfill_htlc(node_b_id, update_fulfill_msg); assert!(nodes[0].node.list_channels().is_empty()); - let err_msg = check_closed_broadcast!(nodes[0], true).unwrap(); + let err_msg = check_closed_broadcast(&nodes[0], 1, true).pop().unwrap(); assert_eq!(err_msg.data, "Remote tried to fulfill/fail an HTLC we couldn't find"); check_added_monitors(&nodes[0], 1); let reason = ClosureReason::ProcessingError { err: err_msg.data }; @@ -2060,7 +2060,7 @@ pub fn test_update_fulfill_htlc_bolt2_wrong_preimage() { nodes[0].node.handle_update_fulfill_htlc(node_b_id, update_fulfill_msg); assert!(nodes[0].node.list_channels().is_empty()); - let err_msg = check_closed_broadcast!(nodes[0], true).unwrap(); + let err_msg = check_closed_broadcast(&nodes[0], 1, true).pop().unwrap(); assert!(regex::Regex::new(r"Remote tried to fulfill HTLC \(\d+\) with an incorrect preimage") .unwrap() .is_match(err_msg.data.as_str())); @@ -2133,7 +2133,7 @@ pub fn test_update_fulfill_htlc_bolt2_missing_badonion_bit_for_malformed_htlc_me nodes[0].node.handle_update_fail_malformed_htlc(node_b_id, &update_msg); assert!(nodes[0].node.list_channels().is_empty()); - let err_msg = check_closed_broadcast!(nodes[0], true).unwrap(); + let err_msg = check_closed_broadcast(&nodes[0], 1, true).pop().unwrap(); assert_eq!(err_msg.data, "Got update_fail_malformed_htlc with BADONION not set"); check_added_monitors(&nodes[0], 1); let reason = ClosureReason::ProcessingError { err: err_msg.data }; diff --git a/lightning/src/ln/monitor_tests.rs b/lightning/src/ln/monitor_tests.rs index fd33ec217ca..05bc85caa5f 100644 --- a/lightning/src/ln/monitor_tests.rs +++ b/lightning/src/ln/monitor_tests.rs @@ -84,7 +84,7 @@ fn chanmon_fail_from_stale_commitment() { // Don't bother delivering the new HTLC add/commits, instead confirming the pre-HTLC commitment // transaction for nodes[1]. mine_transaction(&nodes[1], &bs_txn[0]); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); check_closed_event(&nodes[1], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[2].node.get_our_node_id()], 100000); assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); @@ -140,7 +140,7 @@ fn revoked_output_htlc_resolution_timing() { // Confirm the revoked commitment transaction, closing the channel. mine_transaction(&nodes[1], &revoked_local_txn[0]); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); check_closed_event(&nodes[1], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[0].node.get_our_node_id()], 1000000); @@ -187,7 +187,7 @@ fn archive_fully_resolved_monitors() { let message = "Channel force-closed".to_owned(); nodes[0].node.force_close_broadcasting_latest_txn(&chan_id, &nodes[1].node.get_our_node_id(), message.clone()).unwrap(); check_added_monitors(&nodes[0], 1); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); let reason = ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }; check_closed_event(&nodes[0], 1, reason, &[nodes[1].node.get_our_node_id()], 1_000_000); @@ -678,11 +678,11 @@ fn do_test_claim_value_force_close(keyed_anchors: bool, p2a_anchor: bool, prev_c assert_eq!(remote_txn[0].output[b_broadcast_txn[0].input[0].previous_output.vout as usize].value.to_sat(), 3_000); assert_eq!(remote_txn[0].output[b_broadcast_txn[1].input[0].previous_output.vout as usize].value.to_sat(), 4_000); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); check_closed_event(&nodes[0], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[1].node.get_our_node_id()], 1000000); assert!(nodes[0].node.list_channels().is_empty()); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); check_closed_event(&nodes[1], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[0].node.get_our_node_id()], 1000000); assert!(nodes[1].node.list_channels().is_empty()); @@ -916,7 +916,7 @@ fn do_test_balances_on_local_commitment_htlcs(keyed_anchors: bool, p2a_anchor: b let node_a_commitment_claimable = nodes[0].best_block_info().1 + BREAKDOWN_TIMEOUT as u32; nodes[0].node.force_close_broadcasting_latest_txn(&chan_id, &nodes[1].node.get_our_node_id(), message.clone()).unwrap(); check_added_monitors(&nodes[0], 1); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); let reason = ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }; check_closed_event(&nodes[0], 1, reason, &[nodes[1].node.get_our_node_id()], 1000000); if keyed_anchors || p2a_anchor { @@ -976,7 +976,7 @@ fn do_test_balances_on_local_commitment_htlcs(keyed_anchors: bool, p2a_anchor: b // Get nodes[1]'s HTLC claim tx for the second HTLC mine_transaction(&nodes[1], &commitment_tx); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); check_closed_event(&nodes[1], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[0].node.get_our_node_id()], 1000000); let bs_htlc_claim_txn = nodes[1].tx_broadcaster.txn_broadcasted.lock().unwrap().split_off(0); @@ -1207,7 +1207,7 @@ fn test_no_preimage_inbound_htlc_balances() { mine_transaction(&nodes[0], &as_txn[0]); nodes[0].tx_broadcaster.clear(); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); check_closed_event(&nodes[0], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[1].node.get_our_node_id()], 1000000); @@ -1215,7 +1215,7 @@ fn test_no_preimage_inbound_htlc_balances() { sorted_vec(nodes[0].chain_monitor.chain_monitor.get_monitor(chan_id).unwrap().get_claimable_balances())); mine_transaction(&nodes[1], &as_txn[0]); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); check_closed_event(&nodes[1], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[0].node.get_our_node_id()], 1000000); @@ -1428,7 +1428,7 @@ fn do_test_revoked_counterparty_commitment_balances(keyed_anchors: bool, p2a_anc let _b_htlc_msgs = get_htlc_update_msgs(&nodes[1], &nodes[0].node.get_our_node_id()); connect_blocks(&nodes[0], htlc_cltv_timeout + 1 - 10); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); let mut events = nodes[0].node.get_and_clear_pending_events(); @@ -1457,7 +1457,7 @@ fn do_test_revoked_counterparty_commitment_balances(keyed_anchors: bool, p2a_anc } connect_blocks(&nodes[1], htlc_cltv_timeout + 1 - 10); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); check_closed_events(&nodes[1], &[ExpectedCloseEvent { channel_capacity_sats: Some(1_000_000), @@ -1718,7 +1718,7 @@ fn do_test_revoked_counterparty_htlc_tx_balances(keyed_anchors: bool, p2a_anchor // B will generate an HTLC-Success from its revoked commitment tx mine_transaction(&nodes[1], &revoked_local_txn[0]); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); check_closed_event(&nodes[1], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[0].node.get_our_node_id()], 1000000); if keyed_anchors || p2a_anchor { @@ -1762,7 +1762,7 @@ fn do_test_revoked_counterparty_htlc_tx_balances(keyed_anchors: bool, p2a_anchor &[HTLCHandlingFailureType::Receive { payment_hash: failed_payment_hash }]); // A will generate justice tx from B's revoked commitment/HTLC tx mine_transaction(&nodes[0], &revoked_local_txn[0]); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); check_closed_event(&nodes[0], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[1].node.get_our_node_id()], 1000000); let to_remote_conf_height = nodes[0].best_block_info().1 + ANTI_REORG_DELAY - 1; @@ -2042,7 +2042,7 @@ fn do_test_revoked_counterparty_aggregated_claims(keyed_anchors: bool, p2a_ancho sorted_vec(nodes[1].chain_monitor.chain_monitor.get_monitor(chan_id).unwrap().get_claimable_balances())); mine_transaction(&nodes[1], &as_revoked_txn[0]); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_closed_event(&nodes[1], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[0].node.get_our_node_id()], 1000000); check_added_monitors(&nodes[1], 1); @@ -2414,7 +2414,7 @@ fn do_test_monitor_rebroadcast_pending_claims(keyed_anchors: bool, p2a_anchor: b assert_eq!(commitment_txn.len(), if keyed_anchors || p2a_anchor { 1 /* commitment tx only */} else { 2 /* commitment and htlc timeout tx */ }); check_spends!(&commitment_txn[0], &funding_tx); mine_transaction(&nodes[0], &commitment_txn[0]); - check_closed_broadcast!(&nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_closed_event(&nodes[0], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[1].node.get_our_node_id()], 1000000); check_added_monitors(&nodes[0], 1); @@ -3156,13 +3156,13 @@ fn do_test_monitor_claims_with_random_signatures(keyed_anchors: bool, p2a_anchor if p2a_anchor { mine_transaction(closing_node, anchor_tx.as_ref().unwrap()); } - check_closed_broadcast!(closing_node, true); + check_closed_broadcast(closing_node, 1, true); check_added_monitors(&closing_node, 1); let message = "ChannelMonitor-initiated commitment transaction broadcast".to_string(); check_closed_event(&closing_node, 1, ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }, &[other_node.node.get_our_node_id()], 1_000_000); mine_transaction(other_node, &commitment_tx); - check_closed_broadcast!(other_node, true); + check_closed_broadcast(other_node, 1, true); check_added_monitors(&other_node, 1); check_closed_event(&other_node, 1, ClosureReason::CommitmentTxConfirmed, &[closing_node.node.get_our_node_id()], 1_000_000); diff --git a/lightning/src/ln/payment_tests.rs b/lightning/src/ln/payment_tests.rs index 0eace2eab08..581b01168a0 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -908,7 +908,7 @@ fn do_retry_with_no_persist(confirm_before_reload: bool) { }, _ => panic!("Unexpected event"), } - check_closed_broadcast!(nodes[1], false); + check_closed_broadcast(&nodes[1], 1, false); // Now claim the first payment, which should allow nodes[1] to claim the payment on-chain when // we close in a moment. @@ -1118,7 +1118,7 @@ fn do_test_completed_payment_not_retryable_on_reload(use_dust: bool) { }, _ => panic!("Unexpected event"), } - check_closed_broadcast!(nodes[1], false); + check_closed_broadcast(&nodes[1], 1, false); // Now fail back the payment from nodes[2] to nodes[1]. This doesn't really matter as the // previous hop channel is already on-chain, but it makes nodes[2] willing to see additional @@ -1283,7 +1283,7 @@ fn do_test_dup_htlc_onchain_doesnt_fail_on_reload( .node .force_close_broadcasting_latest_txn(&chan_id, &node_b_id, message.clone()) .unwrap(); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); let reason = ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }; check_closed_event(&nodes[0], 1, reason, &[node_b_id], 100000); @@ -1686,7 +1686,7 @@ fn onchain_failed_probe_yields_event() { // Node A, which after 6 confirmations should result in a probe failure event. let bs_txn = get_local_commitment_txn!(nodes[1], chan_id); confirm_transaction(&nodes[0], &bs_txn[0]); - check_closed_broadcast!(&nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); check_added_monitors(&nodes[0], 0); diff --git a/lightning/src/ln/reload_tests.rs b/lightning/src/ln/reload_tests.rs index 2e9b47725db..cc5eac60206 100644 --- a/lightning/src/ln/reload_tests.rs +++ b/lightning/src/ln/reload_tests.rs @@ -713,7 +713,7 @@ fn do_test_data_loss_protect(reconnect_panicing: bool, substantially_old: bool, ); let reason = ClosureReason::CounterpartyForceClosed { peer_msg: UntrustedString(peer_msg) }; check_closed_event(&nodes[1], 1, reason, &[nodes[0].node.get_our_node_id()], 1000000); - check_closed_broadcast!(nodes[1], false); + check_closed_broadcast(&nodes[1], 1, false); } } @@ -1022,7 +1022,7 @@ fn do_forwarded_payment_no_manager_persistence(use_cs_commitment: bool, claim_ht check_added_monitors(&nodes[2], 1); let reason = ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }; check_closed_event(&nodes[2], 1, reason, &[nodes[1].node.get_our_node_id()], 100000); - check_closed_broadcast!(nodes[2], true); + check_closed_broadcast(&nodes[2], 1, true); let chan_0_monitor_serialized = get_monitor!(nodes[1], chan_id_1).encode(); let chan_1_monitor_serialized = get_monitor!(nodes[1], chan_id_2).encode(); diff --git a/lightning/src/ln/reorg_tests.rs b/lightning/src/ln/reorg_tests.rs index b39e8d31a75..89d2f2c5ae6 100644 --- a/lightning/src/ln/reorg_tests.rs +++ b/lightning/src/ln/reorg_tests.rs @@ -79,7 +79,7 @@ fn do_test_onchain_htlc_reorg(local_commitment: bool, claim: bool) { // Give node 2 node 1's transactions and get its response (claiming the HTLC instead). connect_block(&nodes[2], &create_dummy_block(nodes[2].best_block_hash(), 42, node_1_commitment_txn.clone())); - check_closed_broadcast!(nodes[2], true); // We should get a BroadcastChannelUpdate (and *only* a BroadcstChannelUpdate) + check_closed_broadcast(&nodes[2], 1, true); // We should get a BroadcastChannelUpdate (and *only* a BroadcstChannelUpdate) check_added_monitors(&nodes[2], 1); check_closed_event(&nodes[2], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[1].node.get_our_node_id()], 100000); let node_2_commitment_txn = nodes[2].tx_broadcaster.txn_broadcasted.lock().unwrap().split_off(0); @@ -113,7 +113,7 @@ fn do_test_onchain_htlc_reorg(local_commitment: bool, claim: bool) { // ...but return node 2's commitment tx (and claim) in case claim is set and we're preparing to reorg vec![node_2_commitment_txn.pop().unwrap()] }; - check_closed_broadcast!(nodes[1], true); // We should get a BroadcastChannelUpdate (and *only* a BroadcstChannelUpdate) + check_closed_broadcast(&nodes[1], 1, true); // We should get a BroadcastChannelUpdate (and *only* a BroadcstChannelUpdate) check_added_monitors(&nodes[1], 1); check_closed_event(&nodes[1], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[2].node.get_our_node_id()], 100000); // Connect ANTI_REORG_DELAY - 2 blocks, giving us a confirmation count of ANTI_REORG_DELAY - 1. @@ -212,7 +212,7 @@ fn test_counterparty_revoked_reorg() { // Now mine A's old commitment transaction, which should close the channel, but take no action // on any of the HTLCs, at least until we get six confirmations (which we won't get). mine_transaction(&nodes[1], &revoked_local_txn[0]); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); check_closed_event(&nodes[1], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[0].node.get_our_node_id()], 1000000); @@ -497,7 +497,7 @@ fn test_set_outpoints_partial_claiming() { // Connect blocks on node A commitment transaction mine_transaction(&nodes[0], &remote_txn[0]); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_closed_event(&nodes[0], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[1].node.get_our_node_id()], 1000000); check_added_monitors(&nodes[0], 1); // Verify node A broadcast tx claiming both HTLCs @@ -512,7 +512,7 @@ fn test_set_outpoints_partial_claiming() { // Connect blocks on node B connect_blocks(&nodes[1], TEST_FINAL_CLTV + LATENCY_GRACE_PERIOD_BLOCKS + 1); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_closed_events(&nodes[1], &[ExpectedCloseEvent { channel_capacity_sats: Some(1_000_000), channel_id: Some(chan.2), @@ -596,11 +596,11 @@ fn do_test_to_remote_after_local_detection(style: ConnectStyle) { mine_transaction(&nodes[0], &remote_txn_a[0]); mine_transaction(&nodes[1], &remote_txn_a[0]); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); assert!(nodes[0].node.list_channels().is_empty()); check_added_monitors(&nodes[0], 1); check_closed_event(&nodes[0], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[1].node.get_our_node_id()], 1000000); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); assert!(nodes[1].node.list_channels().is_empty()); check_added_monitors(&nodes[1], 1); check_closed_event(&nodes[1], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[0].node.get_our_node_id()], 1000000); diff --git a/lightning/src/ln/shutdown_tests.rs b/lightning/src/ln/shutdown_tests.rs index 870f00ee9df..6cbf879c190 100644 --- a/lightning/src/ln/shutdown_tests.rs +++ b/lightning/src/ln/shutdown_tests.rs @@ -361,7 +361,7 @@ fn expect_channel_shutdown_state_with_force_closure() { .node .force_close_broadcasting_latest_txn(&chan_1.2, &node_a_id, message.clone()) .unwrap(); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); expect_channel_shutdown_state!(nodes[0], chan_1.2, ChannelShutdownState::NotShuttingDown); @@ -371,7 +371,7 @@ fn expect_channel_shutdown_state_with_force_closure() { assert_eq!(node_txn.len(), 1); check_spends!(node_txn[0], chan_1.3); mine_transaction(&nodes[0], &node_txn[0]); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); assert!(nodes[0].node.list_channels().is_empty()); @@ -834,7 +834,7 @@ fn do_test_shutdown_rebroadcast(recv_count: u8) { // get_closing_signed_broadcast usually eats the BroadcastChannelUpdate for us and // checks it, but in this case nodes[1] didn't ever get a chance to receive a // closing_signed so we do it ourselves - check_closed_broadcast!(nodes[1], false); + check_closed_broadcast(&nodes[1], 1, false); check_added_monitors(&nodes[1], 1); let peer_msg = format!( "Got a message for a channel from the wrong node! No such channel_id {} for the passed counterparty_node_id {}", @@ -1418,7 +1418,7 @@ fn do_test_closing_signed_reinit_timeout(timeout_step: TimeoutStep) { || (txn[0].output[1].script_pubkey.is_p2wpkh() && txn[0].output[0].script_pubkey.is_p2wsh()) ); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); let reason = ClosureReason::ProcessingError { err: "closing_signed negotiation failed to finish within two timer ticks".to_string(), diff --git a/lightning/src/ln/update_fee_tests.rs b/lightning/src/ln/update_fee_tests.rs index 24ae8525450..a31bf18ef38 100644 --- a/lightning/src/ln/update_fee_tests.rs +++ b/lightning/src/ln/update_fee_tests.rs @@ -523,7 +523,7 @@ pub fn do_test_update_fee_that_funder_cannot_afford(channel_type_features: Chann let err = "Funding remote cannot afford proposed new fee"; nodes[1].logger.assert_log_contains("lightning::ln::channelmanager", err, 3); check_added_monitors(&nodes[1], 1); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); let reason = ClosureReason::ProcessingError { err: err.to_string() }; check_closed_event(&nodes[1], 1, reason, &[node_a_id], channel_value); } @@ -620,7 +620,7 @@ pub fn test_update_fee_that_saturates_subs() { let err = "Funding remote cannot afford proposed new fee"; nodes[1].logger.assert_log_contains("lightning::ln::channelmanager", err, 3); check_added_monitors(&nodes[1], 1); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); let reason = ClosureReason::ProcessingError { err: err.to_string() }; check_closed_event(&nodes[1], 1, reason, &[node_a_id], 10_000); } @@ -1002,7 +1002,7 @@ pub fn accept_busted_but_better_fee() { required_feerate_sat_per_kw: 5000, }; check_closed_event(&nodes[1], 1, reason, &[node_a_id], 100000); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); }, _ => panic!("Unexpected event"), diff --git a/lightning/src/ln/zero_fee_commitment_tests.rs b/lightning/src/ln/zero_fee_commitment_tests.rs index b7221552603..aae9c8419ba 100644 --- a/lightning/src/ln/zero_fee_commitment_tests.rs +++ b/lightning/src/ln/zero_fee_commitment_tests.rs @@ -185,12 +185,12 @@ fn test_htlc_claim_chunking() { assert_eq!(htlc_claims[1].input.len(), 34); assert_eq!(htlc_claims[1].output.len(), 24); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[0], 1, reason, &[nodes[1].node.get_our_node_id()], CHAN_CAPACITY); assert!(nodes[0].node.list_channels().is_empty()); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); let reason = ClosureReason::CommitmentTxConfirmed; check_closed_event(&nodes[1], 1, reason, &[nodes[0].node.get_our_node_id()], CHAN_CAPACITY); @@ -346,7 +346,7 @@ fn test_anchor_tx_too_big() { .force_close_broadcasting_latest_txn(&chan_id, &node_a_id, message.clone()) .unwrap(); check_added_monitors(&nodes[1], 1); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); let reason = ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }; check_closed_event(&nodes[1], 1, reason, &[node_a_id], CHAN_CAPACITY); diff --git a/lightning/src/util/persist.rs b/lightning/src/util/persist.rs index cb4bdeb6a51..46d52915be9 100644 --- a/lightning/src/util/persist.rs +++ b/lightning/src/util/persist.rs @@ -1533,7 +1533,6 @@ impl From for UpdateName { mod tests { use super::*; use crate::chain::ChannelMonitorUpdateStatus; - use crate::check_closed_broadcast; use crate::events::ClosureReason; use crate::ln::functional_test_utils::*; use crate::ln::msgs::BaseMessageHandler; @@ -1756,7 +1755,7 @@ mod tests { let reason = ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }; check_closed_event(&nodes[0], 1, reason, &[node_id_1], 100000); - check_closed_broadcast!(nodes[0], true); + check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); let node_txn = nodes[0].tx_broadcaster.txn_broadcast(); @@ -1765,7 +1764,7 @@ mod tests { let dummy_block = create_dummy_block(nodes[0].best_block_hash(), 42, txn); connect_block(&nodes[1], &dummy_block); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); let reason = ClosureReason::CommitmentTxConfirmed; let node_id_0 = nodes[0].node.get_our_node_id(); check_closed_event(&nodes[1], 1, reason, &[node_id_0], 100000); From e48478a738f64afe2fc287510359eb7ee29917b2 Mon Sep 17 00:00:00 2001 From: elnosh Date: Wed, 18 Feb 2026 11:37:32 -0500 Subject: [PATCH 066/627] Replace `get_payment_preimage_hash!` macro with direct function calls Co-Authored-By: Claude Sonnet 4.6 --- lightning/src/ln/accountable_tests.rs | 3 +- lightning/src/ln/chanmon_update_fail_tests.rs | 11 +++---- lightning/src/ln/channelmanager.rs | 2 +- lightning/src/ln/functional_test_utils.rs | 22 +------------- lightning/src/ln/functional_tests.rs | 8 ++--- lightning/src/ln/htlc_reserve_unit_tests.rs | 3 +- lightning/src/ln/onion_route_tests.rs | 30 +++++++++---------- lightning/src/ln/payment_tests.rs | 8 ++--- lightning/src/ln/shutdown_tests.rs | 2 +- 9 files changed, 36 insertions(+), 53 deletions(-) diff --git a/lightning/src/ln/accountable_tests.rs b/lightning/src/ln/accountable_tests.rs index 16ca1425817..35c936f4dd6 100644 --- a/lightning/src/ln/accountable_tests.rs +++ b/lightning/src/ln/accountable_tests.rs @@ -26,7 +26,8 @@ fn test_accountable_forwarding_with_override( let _chan_ab = create_announced_chan_between_nodes(&nodes, 0, 1); let _chan_bc = create_announced_chan_between_nodes(&nodes, 1, 2); - let (payment_preimage, payment_hash, payment_secret) = get_payment_preimage_hash!(nodes[2]); + let (payment_preimage, payment_hash, payment_secret) = + get_payment_preimage_hash(&nodes[2], None, None); let route_params = RouteParameters::from_payment_params_and_value( PaymentParameters::from_node_id(nodes[2].node.get_our_node_id(), TEST_FINAL_CLTV), 100_000, diff --git a/lightning/src/ln/chanmon_update_fail_tests.rs b/lightning/src/ln/chanmon_update_fail_tests.rs index 4eb45620582..bcdc5b6eb0d 100644 --- a/lightning/src/ln/chanmon_update_fail_tests.rs +++ b/lightning/src/ln/chanmon_update_fail_tests.rs @@ -1382,9 +1382,9 @@ fn raa_no_response_awaiting_raa_state() { let (route, payment_hash_1, payment_preimage_1, payment_secret_1) = get_route_and_payment_hash!(nodes[0], nodes[1], 1000000); let (payment_preimage_2, payment_hash_2, payment_secret_2) = - get_payment_preimage_hash!(nodes[1]); + get_payment_preimage_hash(&nodes[1], None, None); let (payment_preimage_3, payment_hash_3, payment_secret_3) = - get_payment_preimage_hash!(nodes[1]); + get_payment_preimage_hash(&nodes[1], None, None); // Queue up two payments - one will be delivered right away, one immediately goes into the // holding cell as nodes[0] is AwaitingRAA. Ultimately this allows us to deliver an RAA @@ -1872,7 +1872,7 @@ fn test_monitor_update_fail_claim() { do_commitment_signed_dance(&nodes[1], &nodes[2], &payment_event.commitment_msg, false, true); expect_htlc_failure_conditions(nodes[1].node.get_and_clear_pending_events(), &[]); - let (_, payment_hash_3, payment_secret_3) = get_payment_preimage_hash!(nodes[0]); + let (_, payment_hash_3, payment_secret_3) = get_payment_preimage_hash(&nodes[0], None, None); let id_3 = PaymentId(payment_hash_3.0); let onion_3 = RecipientOnionFields::secret_only(payment_secret_3); nodes[2].node.send_payment_with_route(route, payment_hash_3, onion_3, id_3).unwrap(); @@ -2663,7 +2663,7 @@ fn do_channel_holding_cell_serialize(disconnect: bool, reload_a: bool) { let (route, payment_hash_1, payment_preimage_1, payment_secret_1) = get_route_and_payment_hash!(&nodes[0], nodes[1], 100000); let (payment_preimage_2, payment_hash_2, payment_secret_2) = - get_payment_preimage_hash!(&nodes[1]); + get_payment_preimage_hash(&nodes[1], None, None); // Do a really complicated dance to get an HTLC into the holding cell, with // MonitorUpdateInProgress set but AwaitingRemoteRevoke unset. When this test was written, any @@ -5099,7 +5099,8 @@ fn test_mpp_claim_to_holding_cell() { send_along_route_with_secret(&nodes[0], route, paths, 500_000, paymnt_hash_1, payment_secret); // Put the C <-> D channel into AwaitingRaa - let (preimage_2, paymnt_hash_2, payment_secret_2) = get_payment_preimage_hash!(nodes[3]); + let (preimage_2, paymnt_hash_2, payment_secret_2) = + get_payment_preimage_hash(&nodes[3], None, None); let onion = RecipientOnionFields::secret_only(payment_secret_2); let id = PaymentId([42; 32]); let pay_params = PaymentParameters::from_node_id(node_d_id, TEST_FINAL_CLTV); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 869a431e757..270f00782fd 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -20522,7 +20522,7 @@ mod tests { let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); - let (_, payment_hash, payment_secret) = get_payment_preimage_hash!(&nodes[0]); + let (_, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[0], None, None); let payment_data = msgs::FinalOnionHopData { payment_secret, total_msat: 100_000, diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index eaa98b138e5..ed74b393cc1 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -2818,26 +2818,6 @@ pub fn get_payment_preimage_hash( (payment_preimage, payment_hash, payment_secret) } -/// Get a payment preimage and hash. -/// -/// Don't use this, use the identically-named function instead. -#[macro_export] -macro_rules! get_payment_preimage_hash { - ($dest_node: expr) => { - get_payment_preimage_hash!($dest_node, None) - }; - ($dest_node: expr, $min_value_msat: expr) => { - $crate::get_payment_preimage_hash!($dest_node, $min_value_msat, None) - }; - ($dest_node: expr, $min_value_msat: expr, $min_final_cltv_expiry_delta: expr) => { - $crate::ln::functional_test_utils::get_payment_preimage_hash( - &$dest_node, - $min_value_msat, - $min_final_cltv_expiry_delta, - ) - }; -} - /// Gets a route from the given sender to the node described in `payment_params`. pub fn get_route(send_node: &Node, route_params: &RouteParameters) -> Result { let scorer = TestScorer::new(); @@ -3809,7 +3789,7 @@ pub fn send_along_route<'a, 'b, 'c>( recv_value: u64, ) -> (PaymentPreimage, PaymentHash, PaymentSecret, PaymentId) { let (our_payment_preimage, our_payment_hash, our_payment_secret) = - get_payment_preimage_hash!(expected_route.last().unwrap()); + get_payment_preimage_hash(expected_route.last().unwrap(), None, None); let payment_id = send_along_route_with_secret( origin_node, route, diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index da21c120359..be90130fb63 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -6057,7 +6057,7 @@ pub fn test_check_htlc_underpaying() { ) .unwrap(); - let (_, our_payment_hash, _) = get_payment_preimage_hash!(nodes[0]); + let (_, our_payment_hash, _) = get_payment_preimage_hash(&nodes[0], None, None); let our_payment_secret = nodes[1] .node .create_inbound_payment_for_hash(our_payment_hash, Some(100_000), 7200, None) @@ -8318,7 +8318,7 @@ fn do_test_dup_htlc_second_rejected(test_for_second_fail_panic: bool) { let route = get_route!(nodes[0], payment_params, 10_000).unwrap(); let (our_payment_preimage, our_payment_hash, our_payment_secret) = - get_payment_preimage_hash!(&nodes[1]); + get_payment_preimage_hash(&nodes[1], None, None); { let onion = RecipientOnionFields::secret_only(our_payment_secret); @@ -8467,7 +8467,7 @@ pub fn test_inconsistent_mpp_params() { } }); - let (preimage, hash, payment_secret) = get_payment_preimage_hash!(&nodes[3]); + let (preimage, hash, payment_secret) = get_payment_preimage_hash(&nodes[3], None, None); let cur_height = nodes[0].best_block_info().1; let id = PaymentId([42; 32]); @@ -9476,7 +9476,7 @@ fn do_payment_with_custom_min_final_cltv_expiry(valid_delta: bool, use_user_hash PaymentParameters::from_node_id(node_b_id, final_cltv_expiry_delta as u32); let (hash, payment_preimage, payment_secret) = if use_user_hash { let (payment_preimage, hash, payment_secret) = - get_payment_preimage_hash!(nodes[1], Some(recv_value), Some(min_cltv_expiry_delta)); + get_payment_preimage_hash(&nodes[1], Some(recv_value), Some(min_cltv_expiry_delta)); (hash, payment_preimage, payment_secret) } else { let (hash, payment_secret) = nodes[1] diff --git a/lightning/src/ln/htlc_reserve_unit_tests.rs b/lightning/src/ln/htlc_reserve_unit_tests.rs index 8cbe2f5dcb2..63faa984968 100644 --- a/lightning/src/ln/htlc_reserve_unit_tests.rs +++ b/lightning/src/ln/htlc_reserve_unit_tests.rs @@ -268,7 +268,8 @@ pub fn test_channel_reserve_holding_cell_htlcs() { { let mut route = route_1.clone(); route.paths[0].hops.last_mut().unwrap().fee_msat = recv_value_2 + 1; - let (_, our_payment_hash, our_payment_secret) = get_payment_preimage_hash!(nodes[2]); + let (_, our_payment_hash, our_payment_secret) = + get_payment_preimage_hash(&nodes[2], None, None); let onion = RecipientOnionFields::secret_only(our_payment_secret); let id = PaymentId(our_payment_hash.0); let res = nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id); diff --git a/lightning/src/ln/onion_route_tests.rs b/lightning/src/ln/onion_route_tests.rs index 27e0cfafade..fe7d8332101 100644 --- a/lightning/src/ln/onion_route_tests.rs +++ b/lightning/src/ln/onion_route_tests.rs @@ -418,7 +418,7 @@ fn test_fee_failures() { // If the hop gives fee_insufficient but enough fees were provided, then the previous hop // malleated the payment before forwarding, taking funds when they shouldn't have. However, // because we ignore channel update contents, we will still blame the 2nd channel. - let (_, payment_hash, payment_secret) = get_payment_preimage_hash!(nodes[2]); + let (_, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[2], None, None); let short_channel_id = channels[1].0.contents.short_channel_id; run_onion_failure_test( "fee_insufficient", @@ -449,7 +449,7 @@ fn test_fee_failures() { } let (payment_preimage_success, payment_hash_success, payment_secret_success) = - get_payment_preimage_hash!(nodes[2]); + get_payment_preimage_hash(&nodes[2], None, None); let recipient_onion = RecipientOnionFields::secret_only(payment_secret_success); let payment_id = PaymentId(payment_hash_success.0); nodes[0] @@ -667,7 +667,7 @@ fn test_onion_failure() { Some(route.paths[0].hops[1].short_channel_id), None, ); - let (_, payment_hash, payment_secret) = get_payment_preimage_hash!(nodes[2]); + let (_, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[2], None, None); // intermediate node failure run_onion_failure_test_with_fail_intercept( @@ -738,7 +738,7 @@ fn test_onion_failure() { Some(route.paths[0].hops[1].short_channel_id), None, ); - let (_, payment_hash, payment_secret) = get_payment_preimage_hash!(nodes[2]); + let (_, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[2], None, None); // intermediate node failure run_onion_failure_test_with_fail_intercept( @@ -811,7 +811,7 @@ fn test_onion_failure() { Some(route.paths[0].hops[1].short_channel_id), None, ); - let (_, payment_hash, payment_secret) = get_payment_preimage_hash!(nodes[2]); + let (_, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[2], None, None); // Our immediate peer sent UpdateFailMalformedHTLC because it couldn't understand the onion in // the UpdateAddHTLC that we sent. @@ -1142,7 +1142,7 @@ fn test_onion_failure() { None, None, ); - let (_, payment_hash, payment_secret) = get_payment_preimage_hash!(nodes[2]); + let (_, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[2], None, None); run_onion_failure_test( "final_expiry_too_soon", @@ -2426,7 +2426,7 @@ fn test_phantom_onion_hmac_failure() { // Get the route. let recv_value_msat = 10_000; let (_, payment_hash, payment_secret) = - get_payment_preimage_hash!(nodes[1], Some(recv_value_msat)); + get_payment_preimage_hash(&nodes[1], Some(recv_value_msat), None); let (route, phantom_scid) = get_phantom_route!(nodes, recv_value_msat, channel); // Route the HTLC through to the destination. @@ -2496,7 +2496,7 @@ fn test_phantom_invalid_onion_payload() { // Get the route. let recv_value_msat = 10_000; let (_, payment_hash, payment_secret) = - get_payment_preimage_hash!(nodes[1], Some(recv_value_msat)); + get_payment_preimage_hash(&nodes[1], Some(recv_value_msat), None); let (route, phantom_scid) = get_phantom_route!(nodes, recv_value_msat, channel); // We'll use the session priv later when constructing an invalid onion packet. @@ -2598,7 +2598,7 @@ fn test_phantom_final_incorrect_cltv_expiry() { // Get the route. let recv_value_msat = 10_000; let (_, payment_hash, payment_secret) = - get_payment_preimage_hash!(nodes[1], Some(recv_value_msat)); + get_payment_preimage_hash(&nodes[1], Some(recv_value_msat), None); let (route, phantom_scid) = get_phantom_route!(nodes, recv_value_msat, channel); // Route the HTLC through to the destination. @@ -2664,7 +2664,7 @@ fn test_phantom_failure_too_low_cltv() { // Get the route. let recv_value_msat = 10_000; let (_, payment_hash, payment_secret) = - get_payment_preimage_hash!(nodes[1], Some(recv_value_msat)); + get_payment_preimage_hash(&nodes[1], Some(recv_value_msat), None); let (mut route, phantom_scid) = get_phantom_route!(nodes, recv_value_msat, channel); // Modify the route to have a too-low cltv. @@ -2720,7 +2720,7 @@ fn test_phantom_failure_modified_cltv() { // Get the route. let recv_value_msat = 10_000; let (_, payment_hash, payment_secret) = - get_payment_preimage_hash!(nodes[1], Some(recv_value_msat)); + get_payment_preimage_hash(&nodes[1], Some(recv_value_msat), None); let (mut route, phantom_scid) = get_phantom_route!(nodes, recv_value_msat, channel); // Route the HTLC through to the destination. @@ -2775,7 +2775,7 @@ fn test_phantom_failure_expires_too_soon() { // Get the route. let recv_value_msat = 10_000; let (_, payment_hash, payment_secret) = - get_payment_preimage_hash!(nodes[1], Some(recv_value_msat)); + get_payment_preimage_hash(&nodes[1], Some(recv_value_msat), None); let (mut route, phantom_scid) = get_phantom_route!(nodes, recv_value_msat, channel); // Route the HTLC through to the destination. @@ -2825,7 +2825,7 @@ fn test_phantom_failure_too_low_recv_amt() { let recv_amt_msat = 10_000; let bad_recv_amt_msat = recv_amt_msat - 10; let (_, payment_hash, payment_secret) = - get_payment_preimage_hash!(nodes[1], Some(recv_amt_msat)); + get_payment_preimage_hash(&nodes[1], Some(recv_amt_msat), None); let (mut route, phantom_scid) = get_phantom_route!(nodes, bad_recv_amt_msat, channel); // Route the HTLC through to the destination. @@ -2894,7 +2894,7 @@ fn do_test_phantom_dust_exposure_failure(multiplier_dust_limit: bool) { // Get the route with an amount exceeding the dust exposure threshold of nodes[1]. let (_, payment_hash, payment_secret) = - get_payment_preimage_hash!(nodes[1], Some(max_dust_exposure + 1)); + get_payment_preimage_hash(&nodes[1], Some(max_dust_exposure + 1), None); let (mut route, phantom_scid) = get_phantom_route!(nodes, max_dust_exposure + 1, channel); // Route the HTLC through to the destination. @@ -2944,7 +2944,7 @@ fn test_phantom_failure_reject_payment() { // Get the route with a too-low amount. let recv_amt_msat = 10_000; let (_, payment_hash, payment_secret) = - get_payment_preimage_hash!(nodes[1], Some(recv_amt_msat)); + get_payment_preimage_hash(&nodes[1], Some(recv_amt_msat), None); let (mut route, phantom_scid) = get_phantom_route!(nodes, recv_amt_msat, channel); // Route the HTLC through to the destination. diff --git a/lightning/src/ln/payment_tests.rs b/lightning/src/ln/payment_tests.rs index 581b01168a0..f0b22135177 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -2168,7 +2168,7 @@ fn test_holding_cell_inflight_htlcs() { let (route, payment_hash_1, _, payment_secret_1) = get_route_and_payment_hash!(nodes[0], nodes[1], 1000000); - let (_, payment_hash_2, payment_secret_2) = get_payment_preimage_hash!(nodes[1]); + let (_, payment_hash_2, payment_secret_2) = get_payment_preimage_hash(&nodes[1], None, None); // Queue up two payments - one will be delivered right away, one immediately goes into the // holding cell as nodes[0] is AwaitingRAA. @@ -4290,7 +4290,7 @@ fn do_claim_from_closed_chan(fail_payment: bool) { let chan_bd = create_announced_chan_between_nodes_with_value(&nodes, 1, 3, 1_000_000, 0).2; create_announced_chan_between_nodes(&nodes, 2, 3); - let (payment_preimage, hash, payment_secret) = get_payment_preimage_hash!(nodes[3]); + let (payment_preimage, hash, payment_secret) = get_payment_preimage_hash(&nodes[3], None, None); let payment_params = PaymentParameters::from_node_id(node_d_id, TEST_FINAL_CLTV) .with_bolt11_features(nodes[1].node.bolt11_invoice_features()) .unwrap(); @@ -4688,7 +4688,7 @@ fn do_test_custom_tlvs_consistency( } }); - let (preimage, hash, payment_secret) = get_payment_preimage_hash!(&nodes[3]); + let (preimage, hash, payment_secret) = get_payment_preimage_hash(&nodes[3], None, None); let id = PaymentId([42; 32]); let amt_msat = 15_000_000; @@ -4832,7 +4832,7 @@ fn do_test_payment_metadata_consistency(do_reload: bool, do_modify: bool) { // Pay more than half of each channel's max, requiring MPP let amt_msat = 750_000_000; let (payment_preimage, payment_hash, payment_secret) = - get_payment_preimage_hash!(nodes[3], Some(amt_msat)); + get_payment_preimage_hash(&nodes[3], Some(amt_msat), None); let payment_id = PaymentId(payment_hash.0); let payment_metadata = vec![44, 49, 52, 142]; diff --git a/lightning/src/ln/shutdown_tests.rs b/lightning/src/ln/shutdown_tests.rs index 6cbf879c190..58c90b80fc3 100644 --- a/lightning/src/ln/shutdown_tests.rs +++ b/lightning/src/ln/shutdown_tests.rs @@ -410,7 +410,7 @@ fn updates_shutdown_wait() { assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); - let (_, payment_hash, payment_secret) = get_payment_preimage_hash!(nodes[0]); + let (_, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[0], None, None); let payment_params_1 = PaymentParameters::from_node_id(node_b_id, TEST_FINAL_CLTV) .with_bolt11_features(nodes[1].node.bolt11_invoice_features()) From 28388c405e3c497e02a0ffdef52b1d18089e59fb Mon Sep 17 00:00:00 2001 From: elnosh Date: Wed, 18 Feb 2026 16:53:54 -0500 Subject: [PATCH 067/627] Replace `get_closing_signed_broadcast!` macro with direct function calls Co-Authored-By: Claude Sonnet 4.6 --- lightning/src/ln/async_signer_tests.rs | 4 +- lightning/src/ln/chanmon_update_fail_tests.rs | 4 +- lightning/src/ln/functional_test_utils.rs | 54 +++++++++---------- lightning/src/ln/monitor_tests.rs | 4 +- lightning/src/ln/shutdown_tests.rs | 46 ++++++++-------- 5 files changed, 55 insertions(+), 57 deletions(-) diff --git a/lightning/src/ln/async_signer_tests.rs b/lightning/src/ln/async_signer_tests.rs index f34a2b3275c..d8dc22caca8 100644 --- a/lightning/src/ln/async_signer_tests.rs +++ b/lightning/src/ln/async_signer_tests.rs @@ -1308,9 +1308,9 @@ fn do_test_closing_signed(extra_closing_signed: bool, reconnect: bool) { } nodes[0].node.signer_unblocked(None); - let (_, node_0_2nd_closing_signed) = get_closing_signed_broadcast!(nodes[0].node, node_b_id); + let (_, node_0_2nd_closing_signed) = get_closing_signed_broadcast(&nodes[0], node_b_id); nodes[1].node.handle_closing_signed(node_a_id, &node_0_2nd_closing_signed.unwrap()); - let (_, node_1_closing_signed) = get_closing_signed_broadcast!(nodes[1].node, node_a_id); + let (_, node_1_closing_signed) = get_closing_signed_broadcast(&nodes[1], node_a_id); assert!(node_1_closing_signed.is_none()); assert!(nodes[0].node.list_channels().is_empty()); diff --git a/lightning/src/ln/chanmon_update_fail_tests.rs b/lightning/src/ln/chanmon_update_fail_tests.rs index bcdc5b6eb0d..5e544c7502d 100644 --- a/lightning/src/ln/chanmon_update_fail_tests.rs +++ b/lightning/src/ln/chanmon_update_fail_tests.rs @@ -3048,11 +3048,11 @@ fn test_temporary_error_during_shutdown() { node_b_id, &get_event_msg!(nodes[1], MessageSendEvent::SendClosingSigned, node_a_id), ); - let (_, closing_signed_a) = get_closing_signed_broadcast!(nodes[0].node, node_b_id); + let (_, closing_signed_a) = get_closing_signed_broadcast(&nodes[0], node_b_id); let txn_a = nodes[0].tx_broadcaster.txn_broadcasted.lock().unwrap().split_off(0); nodes[1].node.handle_closing_signed(node_a_id, &closing_signed_a.unwrap()); - let (_, none_b) = get_closing_signed_broadcast!(nodes[1].node, node_a_id); + let (_, none_b) = get_closing_signed_broadcast(&nodes[1], node_a_id); assert!(none_b.is_none()); let txn_b = nodes[1].tx_broadcaster.txn_broadcasted.lock().unwrap().split_off(0); diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index ed74b393cc1..45cab14be31 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -2210,31 +2210,31 @@ macro_rules! check_spends { } } -macro_rules! get_closing_signed_broadcast { - ($node: expr, $dest_pubkey: expr) => {{ - let events = $node.get_and_clear_pending_msg_events(); - assert!(events.len() == 1 || events.len() == 2); - ( - match events[events.len() - 1] { - MessageSendEvent::BroadcastChannelUpdate { ref msg, .. } => { - assert_eq!(msg.contents.channel_flags & 2, 2); - msg.clone() +pub fn get_closing_signed_broadcast( + node: &Node, dest_pubkey: PublicKey, +) -> (msgs::ChannelUpdate, Option) { + let events = node.node.get_and_clear_pending_msg_events(); + assert!(events.len() == 1 || events.len() == 2); + ( + match events[events.len() - 1] { + MessageSendEvent::BroadcastChannelUpdate { ref msg, .. } => { + assert_eq!(msg.contents.channel_flags & 2, 2); + msg.clone() + }, + _ => panic!("Unexpected event"), + }, + if events.len() == 2 { + match events[0] { + MessageSendEvent::SendClosingSigned { ref node_id, ref msg } => { + assert_eq!(*node_id, dest_pubkey); + Some(msg.clone()) }, _ => panic!("Unexpected event"), - }, - if events.len() == 2 { - match events[0] { - MessageSendEvent::SendClosingSigned { ref node_id, ref msg } => { - assert_eq!(*node_id, $dest_pubkey); - Some(msg.clone()) - }, - _ => panic!("Unexpected event"), - } - } else { - None - }, - ) - }}; + } + } else { + None + }, + ) } #[cfg(test)] @@ -2519,10 +2519,10 @@ pub fn close_channel<'a, 'b, 'c>( assert_eq!(broadcaster_b.txn_broadcasted.lock().unwrap().len(), 1); tx_b = broadcaster_b.txn_broadcasted.lock().unwrap().remove(0); let (bs_update, closing_signed_b) = - get_closing_signed_broadcast!(node_b, node_a.get_our_node_id()); + get_closing_signed_broadcast(struct_b, node_a.get_our_node_id()); node_a.handle_closing_signed(node_b.get_our_node_id(), &closing_signed_b.unwrap()); - let (as_update, none_a) = get_closing_signed_broadcast!(node_a, node_b.get_our_node_id()); + let (as_update, none_a) = get_closing_signed_broadcast(struct_a, node_b.get_our_node_id()); assert!(none_a.is_none()); assert_eq!(broadcaster_a.txn_broadcasted.lock().unwrap().len(), 1); tx_a = broadcaster_a.txn_broadcasted.lock().unwrap().remove(0); @@ -2539,10 +2539,10 @@ pub fn close_channel<'a, 'b, 'c>( assert_eq!(broadcaster_a.txn_broadcasted.lock().unwrap().len(), 1); tx_a = broadcaster_a.txn_broadcasted.lock().unwrap().remove(0); let (as_update, closing_signed_a) = - get_closing_signed_broadcast!(node_a, node_b.get_our_node_id()); + get_closing_signed_broadcast(struct_a, node_b.get_our_node_id()); node_b.handle_closing_signed(node_a.get_our_node_id(), &closing_signed_a.unwrap()); - let (bs_update, none_b) = get_closing_signed_broadcast!(node_b, node_a.get_our_node_id()); + let (bs_update, none_b) = get_closing_signed_broadcast(struct_b, node_a.get_our_node_id()); assert!(none_b.is_none()); assert_eq!(broadcaster_b.txn_broadcasted.lock().unwrap().len(), 1); tx_b = broadcaster_b.txn_broadcasted.lock().unwrap().remove(0); diff --git a/lightning/src/ln/monitor_tests.rs b/lightning/src/ln/monitor_tests.rs index 05bc85caa5f..157445874b7 100644 --- a/lightning/src/ln/monitor_tests.rs +++ b/lightning/src/ln/monitor_tests.rs @@ -369,9 +369,9 @@ fn do_chanmon_claim_value_coop_close(keyed_anchors: bool, p2a_anchor: bool) { nodes[1].node.handle_closing_signed(nodes[0].node.get_our_node_id(), &node_0_closing_signed); let node_1_closing_signed = get_event_msg!(nodes[1], MessageSendEvent::SendClosingSigned, nodes[0].node.get_our_node_id()); nodes[0].node.handle_closing_signed(nodes[1].node.get_our_node_id(), &node_1_closing_signed); - let (_, node_0_2nd_closing_signed) = get_closing_signed_broadcast!(nodes[0].node, nodes[1].node.get_our_node_id()); + let (_, node_0_2nd_closing_signed) = get_closing_signed_broadcast(&nodes[0], nodes[1].node.get_our_node_id()); nodes[1].node.handle_closing_signed(nodes[0].node.get_our_node_id(), &node_0_2nd_closing_signed.unwrap()); - let (_, node_1_none) = get_closing_signed_broadcast!(nodes[1].node, nodes[0].node.get_our_node_id()); + let (_, node_1_none) = get_closing_signed_broadcast(&nodes[1], nodes[0].node.get_our_node_id()); assert!(node_1_none.is_none()); let shutdown_tx = nodes[0].tx_broadcaster.txn_broadcasted.lock().unwrap().split_off(0); diff --git a/lightning/src/ln/shutdown_tests.rs b/lightning/src/ln/shutdown_tests.rs index 58c90b80fc3..474b422b655 100644 --- a/lightning/src/ln/shutdown_tests.rs +++ b/lightning/src/ln/shutdown_tests.rs @@ -71,9 +71,9 @@ fn pre_funding_lock_shutdown_test() { let node_1_closing_signed = get_event_msg!(nodes[1], MessageSendEvent::SendClosingSigned, node_a_id); nodes[0].node.handle_closing_signed(node_b_id, &node_1_closing_signed); - let (_, node_0_2nd_closing_signed) = get_closing_signed_broadcast!(nodes[0].node, node_b_id); + let (_, node_0_2nd_closing_signed) = get_closing_signed_broadcast(&nodes[0], node_b_id); nodes[1].node.handle_closing_signed(node_a_id, &node_0_2nd_closing_signed.unwrap()); - let (_, node_1_none) = get_closing_signed_broadcast!(nodes[1].node, node_a_id); + let (_, node_1_none) = get_closing_signed_broadcast(&nodes[1], node_a_id); assert!(node_1_none.is_none()); assert!(nodes[0].node.list_channels().is_empty()); @@ -122,9 +122,9 @@ fn expect_channel_shutdown_state() { let node_1_closing_signed = get_event_msg!(nodes[1], MessageSendEvent::SendClosingSigned, node_a_id); nodes[0].node.handle_closing_signed(node_b_id, &node_1_closing_signed); - let (_, node_0_2nd_closing_signed) = get_closing_signed_broadcast!(nodes[0].node, node_b_id); + let (_, node_0_2nd_closing_signed) = get_closing_signed_broadcast(&nodes[0], node_b_id); nodes[1].node.handle_closing_signed(node_a_id, &node_0_2nd_closing_signed.unwrap()); - let (_, node_1_none) = get_closing_signed_broadcast!(nodes[1].node, node_a_id); + let (_, node_1_none) = get_closing_signed_broadcast(&nodes[1], node_a_id); assert!(node_1_none.is_none()); assert!(nodes[0].node.list_channels().is_empty()); @@ -216,9 +216,9 @@ fn expect_channel_shutdown_state_with_htlc() { let node_1_closing_signed = get_event_msg!(nodes[1], MessageSendEvent::SendClosingSigned, node_a_id); nodes[0].node.handle_closing_signed(node_b_id, &node_1_closing_signed); - let (_, node_0_2nd_closing_signed) = get_closing_signed_broadcast!(nodes[0].node, node_b_id); + let (_, node_0_2nd_closing_signed) = get_closing_signed_broadcast(&nodes[0], node_b_id); nodes[1].node.handle_closing_signed(node_a_id, &node_0_2nd_closing_signed.unwrap()); - let (_, node_1_none) = get_closing_signed_broadcast!(nodes[1].node, node_a_id); + let (_, node_1_none) = get_closing_signed_broadcast(&nodes[1], node_a_id); assert!(node_1_none.is_none()); let reason_a = ClosureReason::LocallyInitiatedCooperativeClosure; check_closed_event(&nodes[0], 1, reason_a, &[node_b_id], 100000); @@ -284,9 +284,9 @@ fn test_lnd_bug_6039() { let node_1_closing_signed = get_event_msg!(nodes[1], MessageSendEvent::SendClosingSigned, node_a_id); nodes[0].node.handle_closing_signed(node_b_id, &node_1_closing_signed); - let (_, node_0_2nd_closing_signed) = get_closing_signed_broadcast!(nodes[0].node, node_b_id); + let (_, node_0_2nd_closing_signed) = get_closing_signed_broadcast(&nodes[0], node_b_id); nodes[1].node.handle_closing_signed(node_a_id, &node_0_2nd_closing_signed.unwrap()); - let (_, node_1_none) = get_closing_signed_broadcast!(nodes[1].node, node_a_id); + let (_, node_1_none) = get_closing_signed_broadcast(&nodes[1], node_a_id); assert!(node_1_none.is_none()); let reason_a = ClosureReason::LocallyInitiatedCooperativeClosure; @@ -483,9 +483,9 @@ fn updates_shutdown_wait() { let node_1_closing_signed = get_event_msg!(nodes[1], MessageSendEvent::SendClosingSigned, node_a_id); nodes[0].node.handle_closing_signed(node_b_id, &node_1_closing_signed); - let (_, node_0_2nd_closing_signed) = get_closing_signed_broadcast!(nodes[0].node, node_b_id); + let (_, node_0_2nd_closing_signed) = get_closing_signed_broadcast(&nodes[0], node_b_id); nodes[1].node.handle_closing_signed(node_a_id, &node_0_2nd_closing_signed.unwrap()); - let (_, node_1_none) = get_closing_signed_broadcast!(nodes[1].node, node_a_id); + let (_, node_1_none) = get_closing_signed_broadcast(&nodes[1], node_a_id); assert!(node_1_none.is_none()); let reason_a = ClosureReason::LocallyInitiatedCooperativeClosure; @@ -618,9 +618,9 @@ fn do_htlc_fail_async_shutdown(blinded_recipient: bool) { let node_1_closing_signed = get_event_msg!(nodes[1], MessageSendEvent::SendClosingSigned, node_a_id); nodes[0].node.handle_closing_signed(node_b_id, &node_1_closing_signed); - let (_, node_0_2nd_closing_signed) = get_closing_signed_broadcast!(nodes[0].node, node_b_id); + let (_, node_0_2nd_closing_signed) = get_closing_signed_broadcast(&nodes[0], node_b_id); nodes[1].node.handle_closing_signed(node_a_id, &node_0_2nd_closing_signed.unwrap()); - let (_, node_1_none) = get_closing_signed_broadcast!(nodes[1].node, node_a_id); + let (_, node_1_none) = get_closing_signed_broadcast(&nodes[1], node_a_id); assert!(node_1_none.is_none()); assert!(nodes[0].node.list_channels().is_empty()); @@ -750,8 +750,7 @@ fn do_test_shutdown_rebroadcast(recv_count: u8) { let node_1_closing_signed = get_event_msg!(nodes[1], MessageSendEvent::SendClosingSigned, node_a_id); nodes[0].node.handle_closing_signed(node_b_id, &node_1_closing_signed); - let (_, node_0_2nd_closing_signed) = - get_closing_signed_broadcast!(nodes[0].node, node_b_id); + let (_, node_0_2nd_closing_signed) = get_closing_signed_broadcast(&nodes[0], node_b_id); assert!(node_0_2nd_closing_signed.is_some()); } @@ -799,10 +798,9 @@ fn do_test_shutdown_rebroadcast(recv_count: u8) { let node_1_closing_signed = get_event_msg!(nodes[1], MessageSendEvent::SendClosingSigned, node_a_id); nodes[0].node.handle_closing_signed(node_b_id, &node_1_closing_signed); - let (_, node_0_2nd_closing_signed) = - get_closing_signed_broadcast!(nodes[0].node, node_b_id); + let (_, node_0_2nd_closing_signed) = get_closing_signed_broadcast(&nodes[0], node_b_id); nodes[1].node.handle_closing_signed(node_a_id, &node_0_2nd_closing_signed.unwrap()); - let (_, node_1_none) = get_closing_signed_broadcast!(nodes[1].node, node_a_id); + let (_, node_1_none) = get_closing_signed_broadcast(&nodes[1], node_a_id); assert!(node_1_none.is_none()); let reason = ClosureReason::LocallyInitiatedCooperativeClosure; check_closed_event(&nodes[1], 1, reason, &[node_a_id], 100000); @@ -1388,7 +1386,7 @@ fn do_test_closing_signed_reinit_timeout(timeout_step: TimeoutStep) { let node_1_closing_signed = get_event_msg!(nodes[1], MessageSendEvent::SendClosingSigned, node_a_id); nodes[0].node.handle_closing_signed(node_b_id, &node_1_closing_signed); - let node_0_2nd_closing_signed = get_closing_signed_broadcast!(nodes[0].node, node_b_id); + let node_0_2nd_closing_signed = get_closing_signed_broadcast(&nodes[0], node_b_id); if timeout_step == TimeoutStep::NoTimeout { nodes[1].node.handle_closing_signed(node_a_id, &node_0_2nd_closing_signed.1.unwrap()); let reason_b = ClosureReason::CounterpartyInitiatedCooperativeClosure; @@ -1480,11 +1478,11 @@ fn do_simple_legacy_shutdown_test(high_initiator_fee: bool) { } nodes[1].node.handle_closing_signed(node_a_id, &node_0_closing_signed); - let (_, mut node_1_closing_signed) = get_closing_signed_broadcast!(nodes[1].node, node_a_id); + let (_, mut node_1_closing_signed) = get_closing_signed_broadcast(&nodes[1], node_a_id); node_1_closing_signed.as_mut().unwrap().fee_range = None; nodes[0].node.handle_closing_signed(node_b_id, &node_1_closing_signed.unwrap()); - let (_, node_0_none) = get_closing_signed_broadcast!(nodes[0].node, node_b_id); + let (_, node_0_none) = get_closing_signed_broadcast(&nodes[0], node_b_id); assert!(node_0_none.is_none()); let reason_a = ClosureReason::LocallyInitiatedCooperativeClosure; check_closed_event(&nodes[0], 1, reason_a, &[node_b_id], 100000); @@ -1528,7 +1526,7 @@ fn simple_target_feerate_shutdown() { let node_0_closing_signed = get_event_msg!(nodes[0], MessageSendEvent::SendClosingSigned, node_b_id); nodes[1].node.handle_closing_signed(node_a_id, &node_0_closing_signed); - let (_, node_1_closing_signed_opt) = get_closing_signed_broadcast!(nodes[1].node, node_a_id); + let (_, node_1_closing_signed_opt) = get_closing_signed_broadcast(&nodes[1], node_a_id); let node_1_closing_signed = node_1_closing_signed_opt.unwrap(); // nodes[1] was passed a target which was larger than the current channel feerate, which it @@ -1558,7 +1556,7 @@ fn simple_target_feerate_shutdown() { assert_eq!(node_0_closing_signed.fee_satoshis, node_1_closing_signed.fee_satoshis); nodes[0].node.handle_closing_signed(node_b_id, &node_1_closing_signed); - let (_, node_0_none) = get_closing_signed_broadcast!(nodes[0].node, node_b_id); + let (_, node_0_none) = get_closing_signed_broadcast(&nodes[0], node_b_id); assert!(node_0_none.is_none()); let reason_a = ClosureReason::LocallyInitiatedCooperativeClosure; check_closed_event(&nodes[0], 1, reason_a, &[node_b_id], 100000); @@ -1660,9 +1658,9 @@ fn do_outbound_update_no_early_closing_signed(use_htlc: bool) { let bs_closing_signed = get_event_msg!(nodes[1], MessageSendEvent::SendClosingSigned, node_a_id); nodes[0].node.handle_closing_signed(node_b_id, &bs_closing_signed); - let (_, as_2nd_closing_signed) = get_closing_signed_broadcast!(nodes[0].node, node_b_id); + let (_, as_2nd_closing_signed) = get_closing_signed_broadcast(&nodes[0], node_b_id); nodes[1].node.handle_closing_signed(node_a_id, &as_2nd_closing_signed.unwrap()); - let (_, node_1_none) = get_closing_signed_broadcast!(nodes[1].node, node_a_id); + let (_, node_1_none) = get_closing_signed_broadcast(&nodes[1], node_a_id); assert!(node_1_none.is_none()); let reason_a = ClosureReason::LocallyInitiatedCooperativeClosure; From 16ea4c7e2e05573bcffb3d6aa92592c24d5132ae Mon Sep 17 00:00:00 2001 From: Apostlex0 Date: Thu, 19 Feb 2026 19:35:28 +0530 Subject: [PATCH 068/627] Error api change- replaced io::error with string based format --- lightning-block-sync/src/convert.rs | 199 +++++++++++----------------- lightning-block-sync/src/http.rs | 63 ++++++--- lightning-block-sync/src/rest.rs | 27 ++-- lightning-block-sync/src/rpc.rs | 13 +- 4 files changed, 141 insertions(+), 161 deletions(-) diff --git a/lightning-block-sync/src/convert.rs b/lightning-block-sync/src/convert.rs index 47c7586a2c4..48a80c8cbf1 100644 --- a/lightning-block-sync/src/convert.rs +++ b/lightning-block-sync/src/convert.rs @@ -13,15 +13,15 @@ use bitcoin::Transaction; use serde_json; use bitcoin::hashes::Hash; -use std::convert::From; +use std::convert::Infallible; use std::convert::TryFrom; use std::convert::TryInto; use std::io; use std::str::FromStr; impl TryInto for JsonResponse { - type Error = io::Error; - fn try_into(self) -> Result { + type Error = Infallible; + fn try_into(self) -> Result { Ok(self.0) } } @@ -53,8 +53,10 @@ impl From for BlockSourceError { BlockSourceError::persistent(HttpClientError::Http(http_err)) } }, - // Delegate to existing From implementation - HttpClientError::Io(io_err) => BlockSourceError::from(io_err), + // Parse errors are persistent (invalid data) + HttpClientError::Parse(msg) => { + BlockSourceError::persistent(HttpClientError::Parse(msg)) + }, } } } @@ -81,7 +83,9 @@ impl From for BlockSourceError { ))) } }, - HttpClientError::Io(io_err) => BlockSourceError::from(io_err), + HttpClientError::Parse(msg) => { + BlockSourceError::persistent(RpcClientError::Http(HttpClientError::Parse(msg))) + }, }, // RPC errors (e.g. "block not found") are transient RpcClientError::Rpc(rpc_err) => { @@ -97,49 +101,42 @@ impl From for BlockSourceError { /// Parses binary data as a block. impl TryInto for BinaryResponse { - type Error = io::Error; + type Error = (); - fn try_into(self) -> io::Result { - match encode::deserialize(&self.0) { - Err(_) => return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid block data")), - Ok(block) => Ok(block), - } + fn try_into(self) -> Result { + encode::deserialize(&self.0).map_err(|_| ()) } } /// Parses binary data as a block hash. impl TryInto for BinaryResponse { - type Error = io::Error; + type Error = (); - fn try_into(self) -> io::Result { - BlockHash::from_slice(&self.0) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "bad block hash length")) + fn try_into(self) -> Result { + BlockHash::from_slice(&self.0).map_err(|_| ()) } } /// Converts a JSON value into block header data. The JSON value may be an object representing a /// block header or an array of such objects. In the latter case, the first object is converted. impl TryInto for JsonResponse { - type Error = io::Error; + type Error = &'static str; - fn try_into(self) -> io::Result { + fn try_into(self) -> Result { let header = match self.0 { serde_json::Value::Array(mut array) if !array.is_empty() => { array.drain(..).next().unwrap() }, serde_json::Value::Object(_) => self.0, - _ => return Err(io::Error::new(io::ErrorKind::InvalidData, "unexpected JSON type")), + _ => return Err("unexpected JSON type"), }; if !header.is_object() { - return Err(io::Error::new(io::ErrorKind::InvalidData, "expected JSON object")); + return Err("expected JSON object"); } // Add an empty previousblockhash for the genesis block. - match header.try_into() { - Err(_) => Err(io::Error::new(io::ErrorKind::InvalidData, "invalid header data")), - Ok(header) => Ok(header), - } + header.try_into().map_err(|_| "invalid header data") } } @@ -179,15 +176,15 @@ impl TryFrom for BlockHeaderData { /// Converts a JSON value into a block. Assumes the block is hex-encoded in a JSON string. impl TryInto for JsonResponse { - type Error = io::Error; + type Error = &'static str; - fn try_into(self) -> io::Result { + fn try_into(self) -> Result { match self.0.as_str() { - None => Err(io::Error::new(io::ErrorKind::InvalidData, "expected JSON string")), + None => Err("expected JSON string"), Some(hex_data) => match Vec::::from_hex(hex_data) { - Err(_) => Err(io::Error::new(io::ErrorKind::InvalidData, "invalid hex data")), + Err(_) => Err("invalid hex data"), Ok(block_data) => match encode::deserialize(&block_data) { - Err(_) => Err(io::Error::new(io::ErrorKind::InvalidData, "invalid block data")), + Err(_) => Err("invalid block data"), Ok(block) => Ok(block), }, }, @@ -197,35 +194,31 @@ impl TryInto for JsonResponse { /// Converts a JSON value into the best block hash and optional height. impl TryInto<(BlockHash, Option)> for JsonResponse { - type Error = io::Error; + type Error = &'static str; - fn try_into(self) -> io::Result<(BlockHash, Option)> { + fn try_into(self) -> Result<(BlockHash, Option), &'static str> { if !self.0.is_object() { - return Err(io::Error::new(io::ErrorKind::InvalidData, "expected JSON object")); + return Err("expected JSON object"); } let hash = match &self.0["bestblockhash"] { serde_json::Value::String(hex_data) => match BlockHash::from_str(&hex_data) { - Err(_) => { - return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid hex data")) - }, + Err(_) => return Err("invalid hex data"), Ok(block_hash) => block_hash, }, - _ => return Err(io::Error::new(io::ErrorKind::InvalidData, "expected JSON string")), + _ => return Err("expected JSON string"), }; let height = match &self.0["blocks"] { serde_json::Value::Null => None, serde_json::Value::Number(height) => match height.as_u64() { - None => return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid height")), + None => return Err("invalid height"), Some(height) => match height.try_into() { - Err(_) => { - return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid height")) - }, + Err(_) => return Err("invalid height"), Ok(height) => Some(height), }, }, - _ => return Err(io::Error::new(io::ErrorKind::InvalidData, "expected JSON number")), + _ => return Err("expected JSON number"), }; Ok((hash, height)) @@ -233,22 +226,18 @@ impl TryInto<(BlockHash, Option)> for JsonResponse { } impl TryInto for JsonResponse { - type Error = io::Error; - fn try_into(self) -> io::Result { - let hex_data = self - .0 - .as_str() - .ok_or(io::Error::new(io::ErrorKind::InvalidData, "expected JSON string"))?; - Txid::from_str(hex_data) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string())) + type Error = String; + fn try_into(self) -> Result { + let hex_data = self.0.as_str().ok_or_else(|| "expected JSON string".to_string())?; + Txid::from_str(hex_data).map_err(|err| err.to_string()) } } /// Converts a JSON value into a transaction. WATCH OUT! this cannot be used for zero-input transactions /// (e.g. createrawtransaction). See impl TryInto for JsonResponse { - type Error = io::Error; - fn try_into(self) -> io::Result { + type Error = String; + fn try_into(self) -> Result { let hex_tx = if self.0.is_object() { // result is json encoded match &self.0["hex"] { @@ -262,10 +251,7 @@ impl TryInto for JsonResponse { _ => "Unknown error", }; - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("transaction couldn't be signed. {}", reason), - )); + return Err(format!("transaction couldn't be signed. {}", reason)); } else { hex_data } @@ -274,7 +260,7 @@ impl TryInto for JsonResponse { _ => hex_data, }, _ => { - return Err(io::Error::new(io::ErrorKind::InvalidData, "expected JSON string")); + return Err("expected JSON string".to_string()); }, } } else { @@ -282,15 +268,15 @@ impl TryInto for JsonResponse { match self.0.as_str() { Some(hex_tx) => hex_tx, None => { - return Err(io::Error::new(io::ErrorKind::InvalidData, "expected JSON string")); + return Err("expected JSON string".to_string()); }, } }; match Vec::::from_hex(hex_tx) { - Err(_) => Err(io::Error::new(io::ErrorKind::InvalidData, "invalid hex data")), + Err(_) => Err("invalid hex data".to_string()), Ok(tx_data) => match encode::deserialize(&tx_data) { - Err(_) => Err(io::Error::new(io::ErrorKind::InvalidData, "invalid transaction")), + Err(_) => Err("invalid transaction".to_string()), Ok(tx) => Ok(tx), }, } @@ -298,16 +284,13 @@ impl TryInto for JsonResponse { } impl TryInto for JsonResponse { - type Error = io::Error; + type Error = &'static str; - fn try_into(self) -> io::Result { + fn try_into(self) -> Result { match self.0.as_str() { - None => Err(io::Error::new(io::ErrorKind::InvalidData, "expected JSON string")), - Some(hex_data) if hex_data.len() != 64 => { - Err(io::Error::new(io::ErrorKind::InvalidData, "invalid hash length")) - }, - Some(hex_data) => BlockHash::from_str(hex_data) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid hex data")), + None => Err("expected JSON string"), + Some(hex_data) if hex_data.len() != 64 => Err("invalid hash length"), + Some(hex_data) => BlockHash::from_str(hex_data).map_err(|_| "invalid hex data"), } } } @@ -322,24 +305,21 @@ pub(crate) struct GetUtxosResponse { #[cfg(feature = "rest-client")] impl TryInto for JsonResponse { - type Error = io::Error; + type Error = &'static str; - fn try_into(self) -> io::Result { - let obj_err = || io::Error::new(io::ErrorKind::InvalidData, "expected an object"); - let bitmap_err = || io::Error::new(io::ErrorKind::InvalidData, "missing bitmap field"); - let bitstr_err = || io::Error::new(io::ErrorKind::InvalidData, "bitmap should be an str"); + fn try_into(self) -> Result { let bitmap_str = self .0 .as_object() - .ok_or_else(obj_err)? + .ok_or("expected an object")? .get("bitmap") - .ok_or_else(bitmap_err)? + .ok_or("missing bitmap field")? .as_str() - .ok_or_else(bitstr_err)?; + .ok_or("bitmap should be an str")?; let mut hit_bitmap_nonempty = false; for c in bitmap_str.chars() { if c < '0' || c > '9' { - return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid byte")); + return Err("invalid byte"); } if c > '0' { hit_bitmap_nonempty = true; @@ -381,8 +361,7 @@ pub(crate) mod tests { let response = JsonResponse(serde_json::json!(42)); match TryInto::::try_into(response) { Err(e) => { - assert_eq!(e.kind(), io::ErrorKind::InvalidData); - assert_eq!(e.get_ref().unwrap().to_string(), "unexpected JSON type"); + assert_eq!(e, "unexpected JSON type"); }, Ok(_) => panic!("Expected error"), } @@ -393,8 +372,7 @@ pub(crate) mod tests { let response = JsonResponse(serde_json::json!([42])); match TryInto::::try_into(response) { Err(e) => { - assert_eq!(e.kind(), io::ErrorKind::InvalidData); - assert_eq!(e.get_ref().unwrap().to_string(), "expected JSON object"); + assert_eq!(e, "expected JSON object"); }, Ok(_) => panic!("Expected error"), } @@ -411,8 +389,7 @@ pub(crate) mod tests { match TryInto::::try_into(response) { Err(e) => { - assert_eq!(e.kind(), io::ErrorKind::InvalidData); - assert_eq!(e.get_ref().unwrap().to_string(), "invalid header data"); + assert_eq!(e, "invalid header data"); }, Ok(_) => panic!("Expected error"), } @@ -429,8 +406,7 @@ pub(crate) mod tests { match TryInto::::try_into(response) { Err(e) => { - assert_eq!(e.kind(), io::ErrorKind::InvalidData); - assert_eq!(e.get_ref().unwrap().to_string(), "invalid header data"); + assert_eq!(e, "invalid header data"); }, Ok(_) => panic!("Expected error"), } @@ -524,8 +500,7 @@ pub(crate) mod tests { let response = JsonResponse(serde_json::json!({ "result": "foo" })); match TryInto::::try_into(response) { Err(e) => { - assert_eq!(e.kind(), io::ErrorKind::InvalidData); - assert_eq!(e.get_ref().unwrap().to_string(), "expected JSON string"); + assert_eq!(e, "expected JSON string"); }, Ok(_) => panic!("Expected error"), } @@ -536,8 +511,7 @@ pub(crate) mod tests { let response = JsonResponse(serde_json::json!("foobar")); match TryInto::::try_into(response) { Err(e) => { - assert_eq!(e.kind(), io::ErrorKind::InvalidData); - assert_eq!(e.get_ref().unwrap().to_string(), "invalid hex data"); + assert_eq!(e, "invalid hex data"); }, Ok(_) => panic!("Expected error"), } @@ -548,8 +522,7 @@ pub(crate) mod tests { let response = JsonResponse(serde_json::json!("abcd")); match TryInto::::try_into(response) { Err(e) => { - assert_eq!(e.kind(), io::ErrorKind::InvalidData); - assert_eq!(e.get_ref().unwrap().to_string(), "invalid block data"); + assert_eq!(e, "invalid block data"); }, Ok(_) => panic!("Expected error"), } @@ -570,8 +543,7 @@ pub(crate) mod tests { let response = JsonResponse(serde_json::json!("foo")); match TryInto::<(BlockHash, Option)>::try_into(response) { Err(e) => { - assert_eq!(e.kind(), io::ErrorKind::InvalidData); - assert_eq!(e.get_ref().unwrap().to_string(), "expected JSON object"); + assert_eq!(e, "expected JSON object"); }, Ok(_) => panic!("Expected error"), } @@ -582,8 +554,7 @@ pub(crate) mod tests { let response = JsonResponse(serde_json::json!({ "bestblockhash": 42 })); match TryInto::<(BlockHash, Option)>::try_into(response) { Err(e) => { - assert_eq!(e.kind(), io::ErrorKind::InvalidData); - assert_eq!(e.get_ref().unwrap().to_string(), "expected JSON string"); + assert_eq!(e, "expected JSON string"); }, Ok(_) => panic!("Expected error"), } @@ -594,8 +565,7 @@ pub(crate) mod tests { let response = JsonResponse(serde_json::json!({ "bestblockhash": "foobar"} )); match TryInto::<(BlockHash, Option)>::try_into(response) { Err(e) => { - assert_eq!(e.kind(), io::ErrorKind::InvalidData); - assert_eq!(e.get_ref().unwrap().to_string(), "invalid hex data"); + assert_eq!(e, "invalid hex data"); }, Ok(_) => panic!("Expected error"), } @@ -625,8 +595,7 @@ pub(crate) mod tests { })); match TryInto::<(BlockHash, Option)>::try_into(response) { Err(e) => { - assert_eq!(e.kind(), io::ErrorKind::InvalidData); - assert_eq!(e.get_ref().unwrap().to_string(), "expected JSON number"); + assert_eq!(e, "expected JSON number"); }, Ok(_) => panic!("Expected error"), } @@ -641,8 +610,7 @@ pub(crate) mod tests { })); match TryInto::<(BlockHash, Option)>::try_into(response) { Err(e) => { - assert_eq!(e.kind(), io::ErrorKind::InvalidData); - assert_eq!(e.get_ref().unwrap().to_string(), "invalid height"); + assert_eq!(e, "invalid height"); }, Ok(_) => panic!("Expected error"), } @@ -669,8 +637,7 @@ pub(crate) mod tests { let response = JsonResponse(serde_json::json!({ "result": "foo" })); match TryInto::::try_into(response) { Err(e) => { - assert_eq!(e.kind(), io::ErrorKind::InvalidData); - assert_eq!(e.get_ref().unwrap().to_string(), "expected JSON string"); + assert_eq!(e, "expected JSON string"); }, Ok(_) => panic!("Expected error"), } @@ -681,8 +648,7 @@ pub(crate) mod tests { let response = JsonResponse(serde_json::json!("foobar")); match TryInto::::try_into(response) { Err(e) => { - assert_eq!(e.kind(), io::ErrorKind::InvalidData); - assert_eq!(e.get_ref().unwrap().to_string(), "failed to parse hex"); + assert_eq!(e, "failed to parse hex"); }, Ok(_) => panic!("Expected error"), } @@ -693,8 +659,7 @@ pub(crate) mod tests { let response = JsonResponse(serde_json::json!("abcd")); match TryInto::::try_into(response) { Err(e) => { - assert_eq!(e.kind(), io::ErrorKind::InvalidData); - assert_eq!(e.get_ref().unwrap().to_string(), "failed to parse hex"); + assert_eq!(e, "failed to parse hex"); }, Ok(_) => panic!("Expected error"), } @@ -714,9 +679,8 @@ pub(crate) mod tests { fn into_txid_from_bitcoind_rpc_json_response() { let mut rpc_response = serde_json::json!( {"error": "", "id": "770", "result": "7934f775149929a8b742487129a7c3a535dfb612f0b726cc67bc10bc2628f906"} - ); - let r: io::Result = + let r: Result = JsonResponse(rpc_response.get_mut("result").unwrap().take()).try_into(); assert_eq!( r.unwrap().to_string(), @@ -736,8 +700,7 @@ pub(crate) mod tests { let response = JsonResponse(serde_json::json!("foobar")); match TryInto::::try_into(response) { Err(e) => { - assert_eq!(e.kind(), io::ErrorKind::InvalidData); - assert_eq!(e.get_ref().unwrap().to_string(), "invalid hex data"); + assert_eq!(e, "invalid hex data"); }, Ok(_) => panic!("Expected error"), } @@ -748,8 +711,7 @@ pub(crate) mod tests { let response = JsonResponse(Value::Number(Number::from_f64(1.0).unwrap())); match TryInto::::try_into(response) { Err(e) => { - assert_eq!(e.kind(), io::ErrorKind::InvalidData); - assert_eq!(e.get_ref().unwrap().to_string(), "expected JSON string"); + assert_eq!(e, "expected JSON string"); }, Ok(_) => panic!("Expected error"), } @@ -760,8 +722,7 @@ pub(crate) mod tests { let response = JsonResponse(serde_json::json!("abcd")); match TryInto::::try_into(response) { Err(e) => { - assert_eq!(e.kind(), io::ErrorKind::InvalidData); - assert_eq!(e.get_ref().unwrap().to_string(), "invalid transaction"); + assert_eq!(e, "invalid transaction"); }, Ok(_) => panic!("Expected error"), } @@ -797,8 +758,7 @@ pub(crate) mod tests { let response = JsonResponse(serde_json::json!({ "error": "foo" })); match TryInto::::try_into(response) { Err(e) => { - assert_eq!(e.kind(), io::ErrorKind::InvalidData); - assert_eq!(e.get_ref().unwrap().to_string(), "expected JSON string"); + assert_eq!(e, "expected JSON string"); }, Ok(_) => panic!("Expected error"), } @@ -809,12 +769,7 @@ pub(crate) mod tests { let response = JsonResponse(serde_json::json!({ "hex": "foo", "complete": false })); match TryInto::::try_into(response) { Err(e) => { - assert_eq!(e.kind(), io::ErrorKind::InvalidData); - assert!(e - .get_ref() - .unwrap() - .to_string() - .contains("transaction couldn't be signed")); + assert!(e.contains("transaction couldn't be signed")); }, Ok(_) => panic!("Expected error"), } diff --git a/lightning-block-sync/src/http.rs b/lightning-block-sync/src/http.rs index 29cc4256437..f473849226f 100644 --- a/lightning-block-sync/src/http.rs +++ b/lightning-block-sync/src/http.rs @@ -6,9 +6,40 @@ use serde_json; #[cfg(feature = "tokio")] use bitreq::RequestExt; +use std::convert::Infallible; use std::convert::TryFrom; use std::fmt; +/// Trait for converting parse errors into a String message. +pub trait ToParseErrorMessage { + /// Converts a parse error into a human-readable message. + fn to_parse_error_message(self) -> String; +} + +impl ToParseErrorMessage for Infallible { + fn to_parse_error_message(self) -> String { + match self {} + } +} + +impl ToParseErrorMessage for () { + fn to_parse_error_message(self) -> String { + "invalid data".to_string() + } +} + +impl ToParseErrorMessage for &'static str { + fn to_parse_error_message(self) -> String { + self.to_string() + } +} + +impl ToParseErrorMessage for String { + fn to_parse_error_message(self) -> String { + self + } +} + /// Timeout for requests in seconds. This is set to a high value as it is not uncommon for Bitcoin /// Core to be blocked waiting on UTXO cache flushes for upwards of 10 minutes on slow devices /// (e.g. RPis with SSDs over USB). @@ -26,7 +57,7 @@ pub enum HttpClientError { /// HTTP error response (non-2xx status code) Http(HttpError), /// Response parsing/conversion error - Io(std::io::Error), + Parse(String), } impl std::error::Error for HttpClientError { @@ -34,7 +65,7 @@ impl std::error::Error for HttpClientError { match self { HttpClientError::Transport(e) => Some(e), HttpClientError::Http(e) => Some(e), - HttpClientError::Io(e) => Some(e), + HttpClientError::Parse(_) => None, } } } @@ -44,17 +75,11 @@ impl fmt::Display for HttpClientError { match self { HttpClientError::Transport(e) => write!(f, "transport error: {}", e), HttpClientError::Http(e) => write!(f, "HTTP error: {}", e), - HttpClientError::Io(e) => write!(f, "Response parsing/conversion error: {}", e), + HttpClientError::Parse(e) => write!(f, "response parsing error: {}", e), } } } -impl From for HttpClientError { - fn from(e: std::io::Error) -> Self { - HttpClientError::Io(e) - } -} - impl From for HttpClientError { fn from(e: bitreq::Error) -> Self { HttpClientError::Transport(e) @@ -97,7 +122,8 @@ impl HttpClient { #[allow(dead_code)] pub async fn get(&self, uri: &str) -> Result where - F: TryFrom, Error = std::io::Error>, + F: TryFrom>, + >>::Error: ToParseErrorMessage, { let url = format!("{}{}", self.base_url, uri); let request = bitreq::get(url) @@ -106,7 +132,7 @@ impl HttpClient { #[cfg(feature = "tokio")] let request = request.with_pipelining(); let response_body = self.send_request(request).await?; - F::try_from(response_body).map_err(HttpClientError::Io) + F::try_from(response_body).map_err(|e| HttpClientError::Parse(e.to_parse_error_message())) } /// Sends a `POST` request for a resource identified by `uri` using the given HTTP @@ -119,7 +145,8 @@ impl HttpClient { &self, uri: &str, auth: &str, content: serde_json::Value, ) -> Result where - F: TryFrom, Error = std::io::Error>, + F: TryFrom>, + >>::Error: ToParseErrorMessage, { let url = format!("{}{}", self.base_url, uri); let request = bitreq::post(url) @@ -131,7 +158,7 @@ impl HttpClient { #[cfg(feature = "tokio")] let request = request.with_pipelining(); let response_body = self.send_request(request).await?; - F::try_from(response_body).map_err(HttpClientError::Io) + F::try_from(response_body).map_err(|e| HttpClientError::Parse(e.to_parse_error_message())) } /// Sends an HTTP request message and reads the response, returning its body. @@ -178,19 +205,19 @@ pub struct JsonResponse(pub serde_json::Value); /// Interprets bytes from an HTTP response body as binary data. impl TryFrom> for BinaryResponse { - type Error = std::io::Error; + type Error = Infallible; - fn try_from(bytes: Vec) -> std::io::Result { + fn try_from(bytes: Vec) -> Result { Ok(BinaryResponse(bytes)) } } /// Interprets bytes from an HTTP response body as a JSON value. impl TryFrom> for JsonResponse { - type Error = std::io::Error; + type Error = String; - fn try_from(bytes: Vec) -> std::io::Result { - Ok(JsonResponse(serde_json::from_slice(&bytes)?)) + fn try_from(bytes: Vec) -> Result { + serde_json::from_slice(&bytes).map(JsonResponse).map_err(|e| e.to_string()) } } diff --git a/lightning-block-sync/src/rest.rs b/lightning-block-sync/src/rest.rs index 0ea93895bcd..cdcf8424d2a 100644 --- a/lightning-block-sync/src/rest.rs +++ b/lightning-block-sync/src/rest.rs @@ -3,7 +3,7 @@ use crate::convert::GetUtxosResponse; use crate::gossip::UtxoSource; -use crate::http::{BinaryResponse, HttpClient, HttpClientError, JsonResponse}; +use crate::http::{BinaryResponse, HttpClient, HttpClientError, JsonResponse, ToParseErrorMessage}; use crate::{BlockData, BlockHeaderData, BlockSource, BlockSourceResult}; use bitcoin::hash_types::BlockHash; @@ -29,10 +29,13 @@ impl RestClient { /// Requests a resource encoded in `F` format and interpreted as type `T`. pub async fn request_resource(&self, resource_path: &str) -> Result where - F: TryFrom, Error = std::io::Error> + TryInto, + F: TryFrom> + TryInto, + >>::Error: ToParseErrorMessage, + >::Error: ToParseErrorMessage, { let uri = format!("/{}", resource_path); - self.client.get::(&uri).await?.try_into().map_err(HttpClientError::Io) + let response = self.client.get::(&uri).await?; + response.try_into().map_err(|e| HttpClientError::Parse(e.to_parse_error_message())) } } @@ -91,21 +94,15 @@ impl UtxoSource for RestClient { mod tests { use super::*; use crate::http::client_tests::{HttpServer, MessageBody}; - use crate::http::BinaryResponse; use bitcoin::hashes::Hash; /// Parses binary data as a string-encoded `u32`. impl TryInto for BinaryResponse { - type Error = std::io::Error; - - fn try_into(self) -> std::io::Result { - match std::str::from_utf8(&self.0) { - Err(e) => Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e)), - Ok(s) => match u32::from_str_radix(s, 10) { - Err(e) => Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e)), - Ok(n) => Ok(n), - }, - } + type Error = String; + + fn try_into(self) -> Result { + let s = std::str::from_utf8(&self.0).map_err(|e| e.to_string())?; + u32::from_str_radix(s, 10).map_err(|e| e.to_string()) } } @@ -127,7 +124,7 @@ mod tests { let client = RestClient::new(server.endpoint()); match client.request_resource::("/").await { - Err(HttpClientError::Io(_)) => {}, + Err(HttpClientError::Parse(_)) => {}, Err(e) => panic!("Unexpected error type: {:?}", e), Ok(_) => panic!("Expected error"), } diff --git a/lightning-block-sync/src/rpc.rs b/lightning-block-sync/src/rpc.rs index c81d7f23da9..bfa1b31c84a 100644 --- a/lightning-block-sync/src/rpc.rs +++ b/lightning-block-sync/src/rpc.rs @@ -2,7 +2,7 @@ //! endpoint. use crate::gossip::UtxoSource; -use crate::http::{HttpClient, HttpClientError, JsonResponse}; +use crate::http::{HttpClient, HttpClientError, JsonResponse, ToParseErrorMessage}; use crate::{BlockData, BlockHeaderData, BlockSource, BlockSourceResult}; use bitcoin::hash_types::BlockHash; @@ -106,7 +106,8 @@ impl RpcClient { &self, method: &str, params: &[serde_json::Value], ) -> Result where - JsonResponse: TryFrom, Error = std::io::Error> + TryInto, + JsonResponse: TryInto, + >::Error: ToParseErrorMessage, { let content = serde_json::json!({ "method": method, @@ -148,7 +149,7 @@ impl RpcClient { JsonResponse(result) .try_into() - .map_err(|e: std::io::Error| RpcClientError::InvalidData(e.to_string())) + .map_err(|e| RpcClientError::InvalidData(e.to_parse_error_message())) } } @@ -215,11 +216,11 @@ mod tests { /// Converts a JSON value into `u64`. impl TryInto for JsonResponse { - type Error = std::io::Error; + type Error = &'static str; - fn try_into(self) -> std::io::Result { + fn try_into(self) -> Result { match self.0.as_u64() { - None => Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "not a number")), + None => Err("not a number"), Some(n) => Ok(n), } } From 38db7abf7674154c09ea536b11048d4dc2ac44f3 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 26 Jan 2026 08:31:17 -0600 Subject: [PATCH 069/627] Add PaginatedKVStore traits upstreamed from ldk-server Allows for a paginated KV store for more efficient listing of keys so you don't need to fetch all at once. Uses monotonic counter or timestamp to track the order of keys and allow for pagination. The traits are largely just copy-pasted from ldk-server. Adds some basic tests that were generated using claude code. --- lightning/src/util/persist.rs | 186 ++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/lightning/src/util/persist.rs b/lightning/src/util/persist.rs index cb4bdeb6a51..a689e62fc46 100644 --- a/lightning/src/util/persist.rs +++ b/lightning/src/util/persist.rs @@ -17,6 +17,7 @@ use bitcoin::hashes::hex::FromHex; use bitcoin::{BlockHash, Txid}; use core::convert::Infallible; +use core::fmt; use core::future::Future; use core::mem; use core::ops::Deref; @@ -367,6 +368,191 @@ where } } +/// An opaque token used for paginated listing operations. +/// +/// This token should be treated as an opaque value by callers. Pass the token returned from +/// one `list_paginated` call to the next call to continue pagination. The internal format +/// is implementation-defined and may change between versions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PageToken(String); + +impl PageToken { + /// Creates a new `PageToken` from the given string. + pub fn new(token: String) -> Self { + PageToken(token) + } + + /// Returns the inner string representation of the `PageToken`. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for PageToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +/// Represents the response from a paginated `list` operation. +/// +/// Contains the list of keys and a token for retrieving the next page of results. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PaginatedListResponse { + /// A vector of keys, ordered from most recently created to least recently created. + pub keys: Vec, + + /// A token that can be passed to the next call to continue pagination. + /// + /// Is `None` if there are no more pages to retrieve. + pub next_page_token: Option, +} + +/// Extends [`KVStoreSync`] with paginated key listing in reverse creation order. +/// +/// While [`KVStoreSync::list`] returns all keys at once in arbitrary order, this trait adds a +/// [`list_paginated`] method that returns keys in pages ordered from newest to oldest. This is +/// useful when a namespace may contain a large number of keys that would be expensive to retrieve +/// in a single call. +/// +/// Namespace and key requirements are inherited from [`KVStoreSync`]. +/// +/// For an asynchronous version of this trait, see [`PaginatedKVStore`]. +/// +/// [`list_paginated`]: Self::list_paginated +pub trait PaginatedKVStoreSync: KVStoreSync { + /// Returns a paginated list of keys that are stored under the given `secondary_namespace` in + /// `primary_namespace`, ordered from most recently created to least recently created. + /// + /// Implementations must return keys in reverse creation order (newest first). How creation + /// order is tracked is implementation-defined (e.g., storing creation timestamps, using an + /// incrementing ID, or another mechanism). Creation order (not last-updated order) is used + /// to prevent race conditions during pagination: if keys were ordered by update time, a key + /// updated mid-pagination could shift position, causing it to be skipped or returned twice + /// across pages. + /// + /// If `page_token` is provided, listing continues from where the previous page left off. + /// If `None`, listing starts from the most recently created entry. The `next_page_token` + /// in the returned [`PaginatedListResponse`] can be passed to subsequent calls to fetch + /// the next page. + /// + /// Implementations must generate a [`PageToken`] that encodes enough information to resume + /// listing from the correct position. Tokens must remain valid across multiple calls within + /// a reasonable timeframe. If the entry referenced by a token has been deleted, + /// implementations should resume from the next valid position rather than failing. + /// Tokens are scoped to a specific `(primary_namespace, secondary_namespace)` pair and should + /// not be used across different namespace pairs. + /// + /// Returns an empty list if `primary_namespace` or `secondary_namespace` is unknown or if + /// there are no more keys to return. + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, + ) -> Result; +} + +/// A wrapper around a [`PaginatedKVStoreSync`] that implements the [`PaginatedKVStore`] trait. +/// It is not necessary to use this type directly. +#[derive(Clone)] +pub struct PaginatedKVStoreSyncWrapper(pub K) +where + K::Target: PaginatedKVStoreSync; + +/// This is not exported to bindings users as async is only supported in Rust. +impl KVStore for PaginatedKVStoreSyncWrapper +where + K::Target: PaginatedKVStoreSync, +{ + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> impl Future, io::Error>> + 'static + MaybeSend { + let res = self.0.read(primary_namespace, secondary_namespace, key); + + async move { res } + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> impl Future> + 'static + MaybeSend { + let res = self.0.write(primary_namespace, secondary_namespace, key, buf); + + async move { res } + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> impl Future> + 'static + MaybeSend { + let res = self.0.remove(primary_namespace, secondary_namespace, key, lazy); + + async move { res } + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> impl Future, io::Error>> + 'static + MaybeSend { + let res = self.0.list(primary_namespace, secondary_namespace); + + async move { res } + } +} + +/// This is not exported to bindings users as async is only supported in Rust. +impl PaginatedKVStore for PaginatedKVStoreSyncWrapper +where + K::Target: PaginatedKVStoreSync, +{ + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, + ) -> impl Future> + 'static + MaybeSend { + let res = self.0.list_paginated(primary_namespace, secondary_namespace, page_token); + + async move { res } + } +} + +/// Extends [`KVStore`] with paginated key listing in reverse creation order. +/// +/// While [`KVStore::list`] returns all keys at once in arbitrary order, this trait adds a +/// [`list_paginated`] method that returns keys in pages ordered from newest to oldest. This is +/// useful when a namespace may contain a large number of keys that would be expensive to retrieve +/// in a single call. +/// +/// Namespace and key requirements are inherited from [`KVStore`]. +/// +/// For a synchronous version of this trait, see [`PaginatedKVStoreSync`]. +/// +/// [`list_paginated`]: Self::list_paginated +/// +/// This is not exported to bindings users as async is only supported in Rust. +pub trait PaginatedKVStore: KVStore { + /// Returns a paginated list of keys that are stored under the given `secondary_namespace` in + /// `primary_namespace`, ordered from most recently created to least recently created. + /// + /// Implementations must return keys in reverse creation order (newest first). How creation + /// order is tracked is implementation-defined (e.g., storing creation timestamps, using an + /// incrementing ID, or another mechanism). Creation order (not last-updated order) is used + /// to prevent race conditions during pagination: if keys were ordered by update time, a key + /// updated mid-pagination could shift position, causing it to be skipped or returned twice + /// across pages. + /// + /// If `page_token` is provided, listing continues from where the previous page left off. + /// If `None`, listing starts from the most recently created entry. The `next_page_token` + /// in the returned [`PaginatedListResponse`] can be passed to subsequent calls to fetch + /// the next page. + /// + /// Implementations must generate a [`PageToken`] that encodes enough information to resume + /// listing from the correct position. Tokens must remain valid across multiple calls within + /// a reasonable timeframe. If the entry referenced by a token has been deleted, + /// implementations should resume from the next valid position rather than failing. + /// Tokens are scoped to a specific `(primary_namespace, secondary_namespace)` pair and should + /// not be used across different namespace pairs. + /// + /// Returns an empty list if `primary_namespace` or `secondary_namespace` is unknown or if + /// there are no more keys to return. + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, + ) -> impl Future> + 'static + MaybeSend; +} + /// Provides additional interface methods that are required for [`KVStore`]-to-[`KVStore`] /// data migration. pub trait MigratableKVStore: KVStoreSync { From 326981366c5e65a847b3927e054323c365bbb526 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 13 Feb 2026 14:17:54 -0600 Subject: [PATCH 070/627] prefactor: move FilesystemStore utilies into fs_store/common.rs Ahead of adding the FilesystemStoreV2 we move some common utilies into a shared file. We also move the FilesystemStore into its own module. --- bench/benches/bench.rs | 2 +- fuzz/src/fs_store.rs | 2 +- lightning-background-processor/src/lib.rs | 2 +- .../src/{fs_store.rs => fs_store/common.rs} | 551 +++++------------- lightning-persister/src/fs_store/mod.rs | 5 + lightning-persister/src/fs_store/v1.rs | 349 +++++++++++ 6 files changed, 511 insertions(+), 400 deletions(-) rename lightning-persister/src/{fs_store.rs => fs_store/common.rs} (68%) create mode 100644 lightning-persister/src/fs_store/mod.rs create mode 100644 lightning-persister/src/fs_store/v1.rs diff --git a/bench/benches/bench.rs b/bench/benches/bench.rs index b854ffb93ce..35a458ac1af 100644 --- a/bench/benches/bench.rs +++ b/bench/benches/bench.rs @@ -18,7 +18,7 @@ criterion_group!(benches, lightning::routing::router::benches::generate_large_mpp_routes_with_nonlinear_probabilistic_scorer, lightning::sign::benches::bench_get_secure_random_bytes, lightning::ln::channelmanager::bench::bench_sends, - lightning_persister::fs_store::bench::bench_sends, + lightning_persister::fs_store::v1::bench::bench_sends, lightning_rapid_gossip_sync::bench::bench_reading_full_graph_from_file, lightning::routing::gossip::benches::read_network_graph, lightning::routing::gossip::benches::write_network_graph, diff --git a/fuzz/src/fs_store.rs b/fuzz/src/fs_store.rs index 821439f390e..4d86ffce2e6 100644 --- a/fuzz/src/fs_store.rs +++ b/fuzz/src/fs_store.rs @@ -1,6 +1,6 @@ use core::hash::{BuildHasher, Hasher}; use lightning::util::persist::{KVStore, KVStoreSync}; -use lightning_persister::fs_store::FilesystemStore; +use lightning_persister::fs_store::v1::FilesystemStore; use std::fs; use tokio::runtime::Runtime; diff --git a/lightning-background-processor/src/lib.rs b/lightning-background-processor/src/lib.rs index f052f3d8d4c..da415c70a32 100644 --- a/lightning-background-processor/src/lib.rs +++ b/lightning-background-processor/src/lib.rs @@ -1934,7 +1934,7 @@ mod tests { use lightning::{get_event, get_event_msg}; use lightning_liquidity::utils::time::DefaultTimeProvider; use lightning_liquidity::{ALiquidityManagerSync, LiquidityManager, LiquidityManagerSync}; - use lightning_persister::fs_store::FilesystemStore; + use lightning_persister::fs_store::v1::FilesystemStore; use lightning_rapid_gossip_sync::RapidGossipSync; use std::collections::VecDeque; use std::path::PathBuf; diff --git a/lightning-persister/src/fs_store.rs b/lightning-persister/src/fs_store/common.rs similarity index 68% rename from lightning-persister/src/fs_store.rs rename to lightning-persister/src/fs_store/common.rs index 3129748afda..c4aa1d0c9d2 100644 --- a/lightning-persister/src/fs_store.rs +++ b/lightning-persister/src/fs_store/common.rs @@ -1,8 +1,10 @@ -//! Objects related to [`FilesystemStore`] live here. +//! Common utilities shared between [`FilesystemStore`]. +//! +//! [`FilesystemStore`]: crate::fs_store::v1::FilesystemStore + use crate::utils::{check_namespace_key_validity, is_valid_kvstore_str}; use lightning::types::string::PrintableString; -use lightning::util::persist::{KVStoreSync, MigratableKVStore}; use std::collections::HashMap; use std::fs; @@ -11,14 +13,14 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, RwLock}; +#[cfg(target_os = "windows")] +use std::ffi::OsStr; #[cfg(feature = "tokio")] -use core::future::Future; -#[cfg(feature = "tokio")] -use lightning::util::persist::KVStore; - +use std::future::Future; #[cfg(target_os = "windows")] -use {std::ffi::OsStr, std::os::windows::ffi::OsStrExt}; +use std::os::windows::ffi::OsStrExt; +/// Calls a Windows API function and returns Ok(()) on success or the last OS error on failure. #[cfg(target_os = "windows")] macro_rules! call { ($e: expr) => { @@ -30,6 +32,10 @@ macro_rules! call { }; } +#[cfg(target_os = "windows")] +use call; + +/// Converts a path to a null-terminated wide string for Windows API calls. #[cfg(target_os = "windows")] fn path_to_windows_str>(path: &T) -> Vec { path.as_ref().encode_wide().chain(Some(0)).collect() @@ -39,6 +45,10 @@ fn path_to_windows_str>(path: &T) -> Vec { // a consistent view and error out. const LIST_DIR_CONSISTENCY_RETRIES: usize = 10; +/// Inner state shared between sync and async operations for filesystem stores. +/// +/// This struct manages the data directory, temporary file counter, and per-path locks +/// that ensure we don't have concurrent writes to the same file. struct FilesystemStoreInner { data_dir: PathBuf, tmp_file_counter: AtomicUsize, @@ -48,10 +58,7 @@ struct FilesystemStoreInner { locks: Mutex>>>, } -/// A [`KVStore`] and [`KVStoreSync`] implementation that writes to and reads from the file system. -/// -/// [`KVStore`]: lightning::util::persist::KVStore -pub struct FilesystemStore { +pub(crate) struct FilesystemStoreState { inner: Arc, // Version counter to ensure that writes are applied in the correct order. It is assumed that read and list @@ -59,13 +66,15 @@ pub struct FilesystemStore { next_version: AtomicU64, } -impl FilesystemStore { - /// Constructs a new [`FilesystemStore`]. - pub fn new(data_dir: PathBuf) -> Self { - let locks = Mutex::new(HashMap::new()); - let tmp_file_counter = AtomicUsize::new(0); +impl FilesystemStoreState { + /// Creates a new [`FilesystemStoreInner`] with the given data directory. + pub(crate) fn new(data_dir: PathBuf) -> Self { Self { - inner: Arc::new(FilesystemStoreInner { data_dir, tmp_file_counter, locks }), + inner: Arc::new(FilesystemStoreInner { + data_dir, + tmp_file_counter: AtomicUsize::new(0), + locks: Mutex::new(HashMap::new()), + }), next_version: AtomicU64::new(1), } } @@ -96,58 +105,6 @@ impl FilesystemStore { } } -impl KVStoreSync for FilesystemStore { - fn read( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, - ) -> Result, lightning::io::Error> { - let path = self.inner.get_checked_dest_file_path( - primary_namespace, - secondary_namespace, - Some(key), - "read", - )?; - self.inner.read(path) - } - - fn write( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, - ) -> Result<(), lightning::io::Error> { - let path = self.inner.get_checked_dest_file_path( - primary_namespace, - secondary_namespace, - Some(key), - "write", - )?; - let (inner_lock_ref, version) = self.get_new_version_and_lock_ref(path.clone()); - self.inner.write_version(inner_lock_ref, path, buf, version) - } - - fn remove( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, - ) -> Result<(), lightning::io::Error> { - let path = self.inner.get_checked_dest_file_path( - primary_namespace, - secondary_namespace, - Some(key), - "remove", - )?; - let (inner_lock_ref, version) = self.get_new_version_and_lock_ref(path.clone()); - self.inner.remove_version(inner_lock_ref, path, lazy, version) - } - - fn list( - &self, primary_namespace: &str, secondary_namespace: &str, - ) -> Result, lightning::io::Error> { - let path = self.inner.get_checked_dest_file_path( - primary_namespace, - secondary_namespace, - None, - "list", - )?; - self.inner.list(path) - } -} - impl FilesystemStoreInner { fn get_inner_lock_ref(&self, path: PathBuf) -> Arc> { let mut outer_lock = self.locks.lock().unwrap(); @@ -458,9 +415,59 @@ impl FilesystemStoreInner { } } -#[cfg(feature = "tokio")] -impl KVStore for FilesystemStore { - fn read( +impl FilesystemStoreState { + pub(crate) fn read_impl( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> Result, lightning::io::Error> { + let path = self.inner.get_checked_dest_file_path( + primary_namespace, + secondary_namespace, + Some(key), + "read", + )?; + self.inner.read(path) + } + + pub(crate) fn write_impl( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> Result<(), lightning::io::Error> { + let path = self.inner.get_checked_dest_file_path( + primary_namespace, + secondary_namespace, + Some(key), + "write", + )?; + let (inner_lock_ref, version) = self.get_new_version_and_lock_ref(path.clone()); + self.inner.write_version(inner_lock_ref, path, buf, version) + } + + pub(crate) fn remove_impl( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> Result<(), lightning::io::Error> { + let path = self.inner.get_checked_dest_file_path( + primary_namespace, + secondary_namespace, + Some(key), + "remove", + )?; + let (inner_lock_ref, version) = self.get_new_version_and_lock_ref(path.clone()); + self.inner.remove_version(inner_lock_ref, path, lazy, version) + } + + pub(crate) fn list_impl( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> Result, lightning::io::Error> { + let path = self.inner.get_checked_dest_file_path( + primary_namespace, + secondary_namespace, + None, + "list", + )?; + self.inner.list(path) + } + + #[cfg(feature = "tokio")] + pub(crate) fn read_async( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, ) -> impl Future, lightning::io::Error>> + 'static + Send { let this = Arc::clone(&self.inner); @@ -482,7 +489,8 @@ impl KVStore for FilesystemStore { } } - fn write( + #[cfg(feature = "tokio")] + pub(crate) fn write_async( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, ) -> impl Future> + 'static + Send { let this = Arc::clone(&self.inner); @@ -503,7 +511,8 @@ impl KVStore for FilesystemStore { } } - fn remove( + #[cfg(feature = "tokio")] + pub(crate) fn remove_async( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, ) -> impl Future> + 'static + Send { let this = Arc::clone(&self.inner); @@ -524,7 +533,8 @@ impl KVStore for FilesystemStore { } } - fn list( + #[cfg(feature = "tokio")] + pub(crate) fn list_async( &self, primary_namespace: &str, secondary_namespace: &str, ) -> impl Future, lightning::io::Error>> + 'static + Send { let this = Arc::clone(&self.inner); @@ -542,6 +552,75 @@ impl KVStore for FilesystemStore { }) } } + + pub(crate) fn list_all_keys_impl( + &self, + ) -> Result, lightning::io::Error> { + let prefixed_dest = &self.inner.data_dir; + if !prefixed_dest.exists() { + return Ok(Vec::new()); + } + + let mut keys = Vec::new(); + + 'primary_loop: for primary_entry in fs::read_dir(prefixed_dest)? { + let primary_entry = primary_entry?; + let primary_path = primary_entry.path(); + + if dir_entry_is_key(&primary_entry)? { + let primary_namespace = String::new(); + let secondary_namespace = String::new(); + let key = get_key_from_dir_entry_path(&primary_path, prefixed_dest)?; + keys.push((primary_namespace, secondary_namespace, key)); + continue 'primary_loop; + } + + // The primary_entry is actually also a directory. + 'secondary_loop: for secondary_entry in fs::read_dir(&primary_path)? { + let secondary_entry = secondary_entry?; + let secondary_path = secondary_entry.path(); + + if dir_entry_is_key(&secondary_entry)? { + let primary_namespace = + get_key_from_dir_entry_path(&primary_path, prefixed_dest)?; + let secondary_namespace = String::new(); + let key = get_key_from_dir_entry_path(&secondary_path, &primary_path)?; + keys.push((primary_namespace, secondary_namespace, key)); + continue 'secondary_loop; + } + + // The secondary_entry is actually also a directory. + for tertiary_entry in fs::read_dir(&secondary_path)? { + let tertiary_entry = tertiary_entry?; + let tertiary_path = tertiary_entry.path(); + + if dir_entry_is_key(&tertiary_entry)? { + let primary_namespace = + get_key_from_dir_entry_path(&primary_path, prefixed_dest)?; + let secondary_namespace = + get_key_from_dir_entry_path(&secondary_path, &primary_path)?; + let key = get_key_from_dir_entry_path(&tertiary_path, &secondary_path)?; + keys.push((primary_namespace, secondary_namespace, key)); + } else { + debug_assert!( + false, + "Failed to list keys of path {}: only two levels of namespaces are supported", + PrintableString(tertiary_path.to_str().unwrap_or_default()) + ); + let msg = format!( + "Failed to list keys of path {}: only two levels of namespaces are supported", + PrintableString(tertiary_path.to_str().unwrap_or_default()) + ); + return Err(lightning::io::Error::new( + lightning::io::ErrorKind::Other, + msg, + )); + } + } + } + } + Ok(keys) + } } fn dir_entry_is_key(dir_entry: &fs::DirEntry) -> Result { @@ -631,325 +710,3 @@ fn get_key_from_dir_entry_path(p: &Path, base_path: &Path) -> Result Result, lightning::io::Error> { - let prefixed_dest = &self.inner.data_dir; - if !prefixed_dest.exists() { - return Ok(Vec::new()); - } - - let mut keys = Vec::new(); - - 'primary_loop: for primary_entry in fs::read_dir(prefixed_dest)? { - let primary_entry = primary_entry?; - let primary_path = primary_entry.path(); - - if dir_entry_is_key(&primary_entry)? { - let primary_namespace = String::new(); - let secondary_namespace = String::new(); - let key = get_key_from_dir_entry_path(&primary_path, prefixed_dest)?; - keys.push((primary_namespace, secondary_namespace, key)); - continue 'primary_loop; - } - - // The primary_entry is actually also a directory. - 'secondary_loop: for secondary_entry in fs::read_dir(&primary_path)? { - let secondary_entry = secondary_entry?; - let secondary_path = secondary_entry.path(); - - if dir_entry_is_key(&secondary_entry)? { - let primary_namespace = - get_key_from_dir_entry_path(&primary_path, prefixed_dest)?; - let secondary_namespace = String::new(); - let key = get_key_from_dir_entry_path(&secondary_path, &primary_path)?; - keys.push((primary_namespace, secondary_namespace, key)); - continue 'secondary_loop; - } - - // The secondary_entry is actually also a directory. - for tertiary_entry in fs::read_dir(&secondary_path)? { - let tertiary_entry = tertiary_entry?; - let tertiary_path = tertiary_entry.path(); - - if dir_entry_is_key(&tertiary_entry)? { - let primary_namespace = - get_key_from_dir_entry_path(&primary_path, prefixed_dest)?; - let secondary_namespace = - get_key_from_dir_entry_path(&secondary_path, &primary_path)?; - let key = get_key_from_dir_entry_path(&tertiary_path, &secondary_path)?; - keys.push((primary_namespace, secondary_namespace, key)); - } else { - debug_assert!( - false, - "Failed to list keys of path {}: only two levels of namespaces are supported", - PrintableString(tertiary_path.to_str().unwrap_or_default()) - ); - let msg = format!( - "Failed to list keys of path {}: only two levels of namespaces are supported", - PrintableString(tertiary_path.to_str().unwrap_or_default()) - ); - return Err(lightning::io::Error::new( - lightning::io::ErrorKind::Other, - msg, - )); - } - } - } - } - Ok(keys) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_utils::{ - do_read_write_remove_list_persist, do_test_data_migration, do_test_store, - }; - - use lightning::chain::chainmonitor::Persist; - use lightning::chain::ChannelMonitorUpdateStatus; - use lightning::events::ClosureReason; - use lightning::ln::functional_test_utils::*; - use lightning::ln::msgs::BaseMessageHandler; - use lightning::util::persist::read_channel_monitors; - use lightning::util::test_utils; - - impl Drop for FilesystemStore { - fn drop(&mut self) { - // We test for invalid directory names, so it's OK if directory removal - // fails. - match fs::remove_dir_all(&self.inner.data_dir) { - Err(e) => println!("Failed to remove test persister directory: {}", e), - _ => {}, - } - } - } - - #[test] - fn read_write_remove_list_persist() { - let mut temp_path = std::env::temp_dir(); - temp_path.push("test_read_write_remove_list_persist"); - let fs_store = FilesystemStore::new(temp_path); - do_read_write_remove_list_persist(&fs_store); - } - - #[cfg(feature = "tokio")] - #[tokio::test] - async fn read_write_remove_list_persist_async() { - use crate::fs_store::FilesystemStore; - use lightning::util::persist::KVStore; - use std::sync::Arc; - - let mut temp_path = std::env::temp_dir(); - temp_path.push("test_read_write_remove_list_persist_async"); - let fs_store = Arc::new(FilesystemStore::new(temp_path)); - assert_eq!(fs_store.state_size(), 0); - - let async_fs_store = Arc::clone(&fs_store); - - let data1 = vec![42u8; 32]; - let data2 = vec![43u8; 32]; - - let primary = "testspace"; - let secondary = "testsubspace"; - let key = "testkey"; - - // Test writing the same key twice with different data. Execute the asynchronous part out of order to ensure - // that eventual consistency works. - let fut1 = KVStore::write(&*async_fs_store, primary, secondary, key, data1); - assert_eq!(fs_store.state_size(), 1); - - let fut2 = KVStore::remove(&*async_fs_store, primary, secondary, key, false); - assert_eq!(fs_store.state_size(), 1); - - let fut3 = KVStore::write(&*async_fs_store, primary, secondary, key, data2.clone()); - assert_eq!(fs_store.state_size(), 1); - - fut3.await.unwrap(); - assert_eq!(fs_store.state_size(), 1); - - fut2.await.unwrap(); - assert_eq!(fs_store.state_size(), 1); - - fut1.await.unwrap(); - assert_eq!(fs_store.state_size(), 0); - - // Test list. - let listed_keys = KVStore::list(&*async_fs_store, primary, secondary).await.unwrap(); - assert_eq!(listed_keys.len(), 1); - assert_eq!(listed_keys[0], key); - - // Test read. We expect to read data2, as the write call was initiated later. - let read_data = KVStore::read(&*async_fs_store, primary, secondary, key).await.unwrap(); - assert_eq!(data2, &*read_data); - - // Test remove. - KVStore::remove(&*async_fs_store, primary, secondary, key, false).await.unwrap(); - - let listed_keys = KVStore::list(&*async_fs_store, primary, secondary).await.unwrap(); - assert_eq!(listed_keys.len(), 0); - } - - #[test] - fn test_data_migration() { - let mut source_temp_path = std::env::temp_dir(); - source_temp_path.push("test_data_migration_source"); - let mut source_store = FilesystemStore::new(source_temp_path); - - let mut target_temp_path = std::env::temp_dir(); - target_temp_path.push("test_data_migration_target"); - let mut target_store = FilesystemStore::new(target_temp_path); - - do_test_data_migration(&mut source_store, &mut target_store); - } - - #[test] - fn test_if_monitors_is_not_dir() { - let store = FilesystemStore::new("test_monitors_is_not_dir".into()); - - fs::create_dir_all(&store.get_data_dir()).unwrap(); - let mut path = std::path::PathBuf::from(&store.get_data_dir()); - path.push("monitors"); - fs::File::create(path).unwrap(); - - let chanmon_cfgs = create_chanmon_cfgs(1); - let mut node_cfgs = create_node_cfgs(1, &chanmon_cfgs); - let chain_mon_0 = test_utils::TestChainMonitor::new( - Some(&chanmon_cfgs[0].chain_source), - &chanmon_cfgs[0].tx_broadcaster, - &chanmon_cfgs[0].logger, - &chanmon_cfgs[0].fee_estimator, - &store, - node_cfgs[0].keys_manager, - ); - node_cfgs[0].chain_monitor = chain_mon_0; - let node_chanmgrs = create_node_chanmgrs(1, &node_cfgs, &[None]); - let nodes = create_network(1, &node_cfgs, &node_chanmgrs); - - // Check that read_channel_monitors() returns error if monitors/ is not a - // directory. - assert!( - read_channel_monitors(&store, nodes[0].keys_manager, nodes[0].keys_manager).is_err() - ); - } - - #[test] - fn test_filesystem_store() { - // Create the nodes, giving them FilesystemStores for data stores. - let store_0 = FilesystemStore::new("test_filesystem_store_0".into()); - let store_1 = FilesystemStore::new("test_filesystem_store_1".into()); - do_test_store(&store_0, &store_1) - } - - // Test that if the store's path to channel data is read-only, writing a - // monitor to it results in the store returning an UnrecoverableError. - // Windows ignores the read-only flag for folders, so this test is Unix-only. - #[cfg(not(target_os = "windows"))] - #[test] - fn test_readonly_dir_perm_failure() { - let store = FilesystemStore::new("test_readonly_dir_perm_failure".into()); - fs::create_dir_all(&store.get_data_dir()).unwrap(); - - // Set up a dummy channel and force close. This will produce a monitor - // that we can then use to test persistence. - let chanmon_cfgs = create_chanmon_cfgs(2); - let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); - let nodes = create_network(2, &node_cfgs, &node_chanmgrs); - - let node_a_id = nodes[0].node.get_our_node_id(); - - let chan = create_announced_chan_between_nodes(&nodes, 0, 1); - - let message = "Channel force-closed".to_owned(); - nodes[1] - .node - .force_close_broadcasting_latest_txn(&chan.2, &node_a_id, message.clone()) - .unwrap(); - let reason = - ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }; - check_closed_event(&nodes[1], 1, reason, &[node_a_id], 100000); - let mut added_monitors = nodes[1].chain_monitor.added_monitors.lock().unwrap(); - - // Set the store's directory to read-only, which should result in - // returning an unrecoverable failure when we then attempt to persist a - // channel update. - let path = &store.get_data_dir(); - let mut perms = fs::metadata(path).unwrap().permissions(); - perms.set_readonly(true); - fs::set_permissions(path, perms).unwrap(); - - let monitor_name = added_monitors[0].1.persistence_key(); - match store.persist_new_channel(monitor_name, &added_monitors[0].1) { - ChannelMonitorUpdateStatus::UnrecoverableError => {}, - _ => panic!("unexpected result from persisting new channel"), - } - - nodes[1].node.get_and_clear_pending_msg_events(); - added_monitors.clear(); - } - - // Test that if a store's directory name is invalid, monitor persistence - // will fail. - #[cfg(target_os = "windows")] - #[test] - fn test_fail_on_open() { - // Set up a dummy channel and force close. This will produce a monitor - // that we can then use to test persistence. - let chanmon_cfgs = create_chanmon_cfgs(2); - let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); - let nodes = create_network(2, &node_cfgs, &node_chanmgrs); - - let node_a_id = nodes[0].node.get_our_node_id(); - - let chan = create_announced_chan_between_nodes(&nodes, 0, 1); - - let message = "Channel force-closed".to_owned(); - nodes[1] - .node - .force_close_broadcasting_latest_txn(&chan.2, &node_a_id, message.clone()) - .unwrap(); - let reason = - ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }; - check_closed_event(&nodes[1], 1, reason, &[node_a_id], 100000); - let mut added_monitors = nodes[1].chain_monitor.added_monitors.lock().unwrap(); - let update_map = nodes[1].chain_monitor.latest_monitor_update_id.lock().unwrap(); - let update_id = update_map.get(&added_monitors[0].1.channel_id()).unwrap(); - - // Create the store with an invalid directory name and test that the - // channel fails to open because the directories fail to be created. There - // don't seem to be invalid filename characters on Unix that Rust doesn't - // handle, hence why the test is Windows-only. - let store = FilesystemStore::new(":<>/".into()); - - let monitor_name = added_monitors[0].1.persistence_key(); - match store.persist_new_channel(monitor_name, &added_monitors[0].1) { - ChannelMonitorUpdateStatus::UnrecoverableError => {}, - _ => panic!("unexpected result from persisting new channel"), - } - - nodes[1].node.get_and_clear_pending_msg_events(); - added_monitors.clear(); - } -} - -#[cfg(ldk_bench)] -/// Benches -pub mod bench { - use criterion::Criterion; - - /// Bench! - pub fn bench_sends(bench: &mut Criterion) { - let store_a = super::FilesystemStore::new("bench_filesystem_store_a".into()); - let store_b = super::FilesystemStore::new("bench_filesystem_store_b".into()); - lightning::ln::channelmanager::bench::bench_two_sends( - bench, - "bench_filesystem_persisted_sends", - store_a, - store_b, - ); - } -} diff --git a/lightning-persister/src/fs_store/mod.rs b/lightning-persister/src/fs_store/mod.rs new file mode 100644 index 00000000000..b460bdb089c --- /dev/null +++ b/lightning-persister/src/fs_store/mod.rs @@ -0,0 +1,5 @@ +//! Implementations of filesystem-backed key-value stores. + +pub mod v1; + +pub(crate) mod common; diff --git a/lightning-persister/src/fs_store/v1.rs b/lightning-persister/src/fs_store/v1.rs new file mode 100644 index 00000000000..8aa988e50c9 --- /dev/null +++ b/lightning-persister/src/fs_store/v1.rs @@ -0,0 +1,349 @@ +//! Objects related to [`FilesystemStore`] live here. +use crate::fs_store::common::FilesystemStoreState; + +use lightning::util::persist::{KVStoreSync, MigratableKVStore}; + +use std::path::PathBuf; + +#[cfg(feature = "tokio")] +use core::future::Future; +#[cfg(feature = "tokio")] +use lightning::util::persist::KVStore; + +/// A [`KVStore`] and [`KVStoreSync`] implementation that writes to and reads from the file system. +/// +/// [`KVStore`]: lightning::util::persist::KVStore +pub struct FilesystemStore { + state: FilesystemStoreState, +} + +impl FilesystemStore { + /// Constructs a new [`FilesystemStore`]. + pub fn new(data_dir: PathBuf) -> Self { + Self { state: FilesystemStoreState::new(data_dir) } + } + + /// Returns the data directory. + pub fn get_data_dir(&self) -> PathBuf { + self.state.get_data_dir() + } + + #[cfg(any(all(feature = "tokio", test), fuzzing))] + /// Returns the size of the async state. + pub fn state_size(&self) -> usize { + self.state.state_size() + } +} + +impl KVStoreSync for FilesystemStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> Result, lightning::io::Error> { + self.state.read_impl(primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> Result<(), lightning::io::Error> { + self.state.write_impl(primary_namespace, secondary_namespace, key, buf) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> Result<(), lightning::io::Error> { + self.state.remove_impl(primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> Result, lightning::io::Error> { + self.state.list_impl(primary_namespace, secondary_namespace) + } +} + +#[cfg(feature = "tokio")] +impl KVStore for FilesystemStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> impl Future, lightning::io::Error>> + 'static + Send { + self.state.read_async(primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> impl Future> + 'static + Send { + self.state.write_async(primary_namespace, secondary_namespace, key, buf) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> impl Future> + 'static + Send { + self.state.remove_async(primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> impl Future, lightning::io::Error>> + 'static + Send { + self.state.list_async(primary_namespace, secondary_namespace) + } +} + +impl MigratableKVStore for FilesystemStore { + fn list_all_keys(&self) -> Result, lightning::io::Error> { + self.state.list_all_keys_impl() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::{ + do_read_write_remove_list_persist, do_test_data_migration, do_test_store, + }; + + use lightning::chain::chainmonitor::Persist; + use lightning::chain::ChannelMonitorUpdateStatus; + use lightning::events::ClosureReason; + use lightning::ln::functional_test_utils::*; + use lightning::ln::msgs::BaseMessageHandler; + use lightning::util::persist::read_channel_monitors; + use lightning::util::test_utils; + + use std::fs; + + impl Drop for FilesystemStore { + fn drop(&mut self) { + // We test for invalid directory names, so it's OK if directory removal + // fails. + match fs::remove_dir_all(&self.get_data_dir()) { + Err(e) => println!("Failed to remove test persister directory: {}", e), + _ => {}, + } + } + } + + #[test] + fn read_write_remove_list_persist() { + let mut temp_path = std::env::temp_dir(); + temp_path.push("test_read_write_remove_list_persist"); + let fs_store = FilesystemStore::new(temp_path); + do_read_write_remove_list_persist(&fs_store); + } + + #[cfg(feature = "tokio")] + #[tokio::test] + async fn read_write_remove_list_persist_async() { + use lightning::util::persist::KVStore; + use std::sync::Arc; + + let mut temp_path = std::env::temp_dir(); + temp_path.push("test_read_write_remove_list_persist_async"); + let fs_store = Arc::new(FilesystemStore::new(temp_path)); + assert_eq!(fs_store.state_size(), 0); + + let async_fs_store = Arc::clone(&fs_store); + + let data1 = vec![42u8; 32]; + let data2 = vec![43u8; 32]; + + let primary = "testspace"; + let secondary = "testsubspace"; + let key = "testkey"; + + // Test writing the same key twice with different data. Execute the asynchronous part out of order to ensure + // that eventual consistency works. + let fut1 = KVStore::write(&*async_fs_store, primary, secondary, key, data1); + assert_eq!(fs_store.state_size(), 1); + + let fut2 = KVStore::remove(&*async_fs_store, primary, secondary, key, false); + assert_eq!(fs_store.state_size(), 1); + + let fut3 = KVStore::write(&*async_fs_store, primary, secondary, key, data2.clone()); + assert_eq!(fs_store.state_size(), 1); + + fut3.await.unwrap(); + assert_eq!(fs_store.state_size(), 1); + + fut2.await.unwrap(); + assert_eq!(fs_store.state_size(), 1); + + fut1.await.unwrap(); + assert_eq!(fs_store.state_size(), 0); + + // Test list. + let listed_keys = KVStore::list(&*async_fs_store, primary, secondary).await.unwrap(); + assert_eq!(listed_keys.len(), 1); + assert_eq!(listed_keys[0], key); + + // Test read. We expect to read data2, as the write call was initiated later. + let read_data = KVStore::read(&*async_fs_store, primary, secondary, key).await.unwrap(); + assert_eq!(data2, &*read_data); + + // Test remove. + KVStore::remove(&*async_fs_store, primary, secondary, key, false).await.unwrap(); + + let listed_keys = KVStore::list(&*async_fs_store, primary, secondary).await.unwrap(); + assert_eq!(listed_keys.len(), 0); + } + + #[test] + fn test_data_migration() { + let mut source_temp_path = std::env::temp_dir(); + source_temp_path.push("test_data_migration_source"); + let mut source_store = FilesystemStore::new(source_temp_path); + + let mut target_temp_path = std::env::temp_dir(); + target_temp_path.push("test_data_migration_target"); + let mut target_store = FilesystemStore::new(target_temp_path); + + do_test_data_migration(&mut source_store, &mut target_store); + } + + #[test] + fn test_if_monitors_is_not_dir() { + let store = FilesystemStore::new("test_monitors_is_not_dir".into()); + + fs::create_dir_all(&store.get_data_dir()).unwrap(); + let mut path = std::path::PathBuf::from(&store.get_data_dir()); + path.push("monitors"); + fs::File::create(path).unwrap(); + + let chanmon_cfgs = create_chanmon_cfgs(1); + let mut node_cfgs = create_node_cfgs(1, &chanmon_cfgs); + let chain_mon_0 = test_utils::TestChainMonitor::new( + Some(&chanmon_cfgs[0].chain_source), + &chanmon_cfgs[0].tx_broadcaster, + &chanmon_cfgs[0].logger, + &chanmon_cfgs[0].fee_estimator, + &store, + node_cfgs[0].keys_manager, + ); + node_cfgs[0].chain_monitor = chain_mon_0; + let node_chanmgrs = create_node_chanmgrs(1, &node_cfgs, &[None]); + let nodes = create_network(1, &node_cfgs, &node_chanmgrs); + + // Check that read_channel_monitors() returns error if monitors/ is not a + // directory. + assert!( + read_channel_monitors(&store, nodes[0].keys_manager, nodes[0].keys_manager).is_err() + ); + } + + #[test] + fn test_filesystem_store() { + // Create the nodes, giving them FilesystemStores for data stores. + let store_0 = FilesystemStore::new("test_filesystem_store_0".into()); + let store_1 = FilesystemStore::new("test_filesystem_store_1".into()); + do_test_store(&store_0, &store_1) + } + + // Test that if the store's path to channel data is read-only, writing a + // monitor to it results in the store returning an UnrecoverableError. + // Windows ignores the read-only flag for folders, so this test is Unix-only. + #[cfg(not(target_os = "windows"))] + #[test] + fn test_readonly_dir_perm_failure() { + let store = FilesystemStore::new("test_readonly_dir_perm_failure".into()); + fs::create_dir_all(&store.get_data_dir()).unwrap(); + + // Set up a dummy channel and force close. This will produce a monitor + // that we can then use to test persistence. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_a_id = nodes[0].node.get_our_node_id(); + + let chan = create_announced_chan_between_nodes(&nodes, 0, 1); + + let message = "Channel force-closed".to_owned(); + nodes[1] + .node + .force_close_broadcasting_latest_txn(&chan.2, &node_a_id, message.clone()) + .unwrap(); + let reason = + ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }; + check_closed_event(&nodes[1], 1, reason, &[node_a_id], 100000); + let mut added_monitors = nodes[1].chain_monitor.added_monitors.lock().unwrap(); + + // Set the store's directory to read-only, which should result in + // returning an unrecoverable failure when we then attempt to persist a + // channel update. + let path = &store.get_data_dir(); + let mut perms = fs::metadata(path).unwrap().permissions(); + perms.set_readonly(true); + fs::set_permissions(path, perms).unwrap(); + + let monitor_name = added_monitors[0].1.persistence_key(); + match store.persist_new_channel(monitor_name, &added_monitors[0].1) { + ChannelMonitorUpdateStatus::UnrecoverableError => {}, + _ => panic!("unexpected result from persisting new channel"), + } + + nodes[1].node.get_and_clear_pending_msg_events(); + added_monitors.clear(); + } + + // Test that if a store's directory name is invalid, monitor persistence + // will fail. + #[cfg(target_os = "windows")] + #[test] + fn test_fail_on_open() { + // Set up a dummy channel and force close. This will produce a monitor + // that we can then use to test persistence. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_a_id = nodes[0].node.get_our_node_id(); + + let chan = create_announced_chan_between_nodes(&nodes, 0, 1); + + let message = "Channel force-closed".to_owned(); + nodes[1] + .node + .force_close_broadcasting_latest_txn(&chan.2, &node_a_id, message.clone()) + .unwrap(); + let reason = + ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }; + check_closed_event(&nodes[1], 1, reason, &[node_a_id], 100000); + let mut added_monitors = nodes[1].chain_monitor.added_monitors.lock().unwrap(); + let update_map = nodes[1].chain_monitor.latest_monitor_update_id.lock().unwrap(); + let update_id = update_map.get(&added_monitors[0].1.channel_id()).unwrap(); + + // Create the store with an invalid directory name and test that the + // channel fails to open because the directories fail to be created. There + // don't seem to be invalid filename characters on Unix that Rust doesn't + // handle, hence why the test is Windows-only. + let store = FilesystemStore::new(":<>/".into()); + + let monitor_name = added_monitors[0].1.persistence_key(); + match store.persist_new_channel(monitor_name, &added_monitors[0].1) { + ChannelMonitorUpdateStatus::UnrecoverableError => {}, + _ => panic!("unexpected result from persisting new channel"), + } + + nodes[1].node.get_and_clear_pending_msg_events(); + added_monitors.clear(); + } +} + +#[cfg(ldk_bench)] +/// Benches +pub mod bench { + use criterion::Criterion; + + /// Bench! + pub fn bench_sends(bench: &mut Criterion) { + let store_a = super::FilesystemStore::new("bench_filesystem_store_a".into()); + let store_b = super::FilesystemStore::new("bench_filesystem_store_b".into()); + lightning::ln::channelmanager::bench::bench_two_sends( + bench, + "bench_filesystem_persisted_sends", + store_a, + store_b, + ); + } +} From 8f2266e41321a9f44eb144c59e03e46ec6e18bf2 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 13 Feb 2026 14:35:02 -0600 Subject: [PATCH 071/627] Add FilesystemStoreV2 with paginated listing support Implements PaginatedKVStore traits with timestamp-prefixed filenames for newest-first pagination and [empty] directory markers for consistent namespace hierarchy. Co-Authored-By: Claude Opus 4.5 --- lightning-persister/src/fs_store/common.rs | 169 ++++-- lightning-persister/src/fs_store/mod.rs | 1 + lightning-persister/src/fs_store/v1.rs | 18 +- lightning-persister/src/fs_store/v2.rs | 651 +++++++++++++++++++++ 4 files changed, 796 insertions(+), 43 deletions(-) create mode 100644 lightning-persister/src/fs_store/v2.rs diff --git a/lightning-persister/src/fs_store/common.rs b/lightning-persister/src/fs_store/common.rs index c4aa1d0c9d2..f2f5eeb8e2c 100644 --- a/lightning-persister/src/fs_store/common.rs +++ b/lightning-persister/src/fs_store/common.rs @@ -1,6 +1,7 @@ -//! Common utilities shared between [`FilesystemStore`]. +//! Common utilities shared between [`FilesystemStore`] and [`FilesystemStoreV2`] implementations. //! //! [`FilesystemStore`]: crate::fs_store::v1::FilesystemStore +//! [`FilesystemStoreV2`]: crate::fs_store::v2::FilesystemStoreV2 use crate::utils::{check_namespace_key_validity, is_valid_kvstore_str}; @@ -45,6 +46,11 @@ fn path_to_windows_str>(path: &T) -> Vec { // a consistent view and error out. const LIST_DIR_CONSISTENCY_RETRIES: usize = 10; +// The directory name used for empty namespaces in v2. +// Uses brackets which are not in KVSTORE_NAMESPACE_KEY_ALPHABET, preventing collisions +// with valid namespace names. +pub(crate) const EMPTY_NAMESPACE_DIR: &str = "[empty]"; + /// Inner state shared between sync and async operations for filesystem stores. /// /// This struct manages the data directory, temporary file counter, and per-path locks @@ -103,6 +109,19 @@ impl FilesystemStoreState { let outer_lock = self.inner.locks.lock().unwrap(); outer_lock.len() } + + pub(crate) fn get_checked_dest_file_path( + &self, primary_namespace: &str, secondary_namespace: &str, key: Option<&str>, + operation: &str, use_empty_ns_dir: bool, + ) -> lightning::io::Result { + self.inner.get_checked_dest_file_path( + primary_namespace, + secondary_namespace, + key, + operation, + use_empty_ns_dir, + ) + } } impl FilesystemStoreInner { @@ -112,7 +131,7 @@ impl FilesystemStoreInner { } fn get_dest_dir_path( - &self, primary_namespace: &str, secondary_namespace: &str, + &self, primary_namespace: &str, secondary_namespace: &str, use_empty_ns_dir: bool, ) -> std::io::Result { let mut dest_dir_path = { #[cfg(target_os = "windows")] @@ -127,9 +146,22 @@ impl FilesystemStoreInner { } }; - dest_dir_path.push(primary_namespace); - if !secondary_namespace.is_empty() { - dest_dir_path.push(secondary_namespace); + if use_empty_ns_dir { + dest_dir_path.push(if primary_namespace.is_empty() { + EMPTY_NAMESPACE_DIR + } else { + primary_namespace + }); + dest_dir_path.push(if secondary_namespace.is_empty() { + EMPTY_NAMESPACE_DIR + } else { + secondary_namespace + }); + } else { + dest_dir_path.push(primary_namespace); + if !secondary_namespace.is_empty() { + dest_dir_path.push(secondary_namespace); + } } Ok(dest_dir_path) @@ -137,11 +169,12 @@ impl FilesystemStoreInner { fn get_checked_dest_file_path( &self, primary_namespace: &str, secondary_namespace: &str, key: Option<&str>, - operation: &str, + operation: &str, use_empty_ns_dir: bool, ) -> lightning::io::Result { check_namespace_key_validity(primary_namespace, secondary_namespace, key, operation)?; - let mut dest_file_path = self.get_dest_dir_path(primary_namespace, secondary_namespace)?; + let mut dest_file_path = + self.get_dest_dir_path(primary_namespace, secondary_namespace, use_empty_ns_dir)?; if let Some(key) = key { dest_file_path.push(key); } @@ -217,8 +250,13 @@ impl FilesystemStoreInner { /// returns early without writing. fn write_version( &self, inner_lock_ref: Arc>, dest_file_path: PathBuf, buf: Vec, - version: u64, + version: u64, preserve_mtime: bool, ) -> lightning::io::Result<()> { + let mtime = if preserve_mtime { + fs::metadata(&dest_file_path).ok().and_then(|m| m.modified().ok()) + } else { + None + }; let parent_directory = dest_file_path.parent().ok_or_else(|| { let msg = format!("Could not retrieve parent directory of {}.", dest_file_path.display()); @@ -238,6 +276,13 @@ impl FilesystemStoreInner { { let mut tmp_file = fs::File::create(&tmp_file_path)?; tmp_file.write_all(&buf)?; + + // If we need to preserve the original mtime (for updates), set it before fsync. + if let Some(mtime) = mtime { + let times = fs::FileTimes::new().set_modified(mtime); + tmp_file.set_times(times)?; + } + tmp_file.sync_all()?; } @@ -370,13 +415,13 @@ impl FilesystemStoreInner { }) } - fn list(&self, prefixed_dest: PathBuf) -> lightning::io::Result> { + fn list(&self, prefixed_dest: PathBuf, is_v2: bool) -> lightning::io::Result> { if !Path::new(&prefixed_dest).exists() { return Ok(Vec::new()); } let mut keys; - let mut retries = LIST_DIR_CONSISTENCY_RETRIES; + let mut retries = if is_v2 { 0 } else { LIST_DIR_CONSISTENCY_RETRIES }; 'retry_list: loop { keys = Vec::new(); @@ -387,7 +432,7 @@ impl FilesystemStoreInner { let res = dir_entry_is_key(&entry); match res { Ok(true) => { - let key = get_key_from_dir_entry_path(&p, &prefixed_dest)?; + let key = get_key_from_dir_entry_path(&p, &prefixed_dest, false)?; keys.push(key); }, Ok(false) => { @@ -396,6 +441,14 @@ impl FilesystemStoreInner { continue 'skip_entry; }, Err(e) => { + // In version 2 if a file has been deleted between the `read_dir` and our attempt + // to access it, we should just add it to the list to give a more consistent view. + if is_v2 { + let key = get_key_from_dir_entry_path(&p, &prefixed_dest, false)?; + keys.push(key); + continue 'skip_entry; + } + if e.kind() == lightning::io::ErrorKind::NotFound && retries > 0 { // We had found the entry in `read_dir` above, so some race happend. // Retry the `read_dir` to get a consistent view. @@ -418,57 +471,65 @@ impl FilesystemStoreInner { impl FilesystemStoreState { pub(crate) fn read_impl( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + use_empty_ns_dir: bool, ) -> Result, lightning::io::Error> { let path = self.inner.get_checked_dest_file_path( primary_namespace, secondary_namespace, Some(key), "read", + use_empty_ns_dir, )?; self.inner.read(path) } pub(crate) fn write_impl( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + use_empty_ns_dir: bool, ) -> Result<(), lightning::io::Error> { let path = self.inner.get_checked_dest_file_path( primary_namespace, secondary_namespace, Some(key), "write", + use_empty_ns_dir, )?; let (inner_lock_ref, version) = self.get_new_version_and_lock_ref(path.clone()); - self.inner.write_version(inner_lock_ref, path, buf, version) + self.inner.write_version(inner_lock_ref, path, buf, version, use_empty_ns_dir) } pub(crate) fn remove_impl( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + use_empty_ns_dir: bool, ) -> Result<(), lightning::io::Error> { let path = self.inner.get_checked_dest_file_path( primary_namespace, secondary_namespace, Some(key), "remove", + use_empty_ns_dir, )?; let (inner_lock_ref, version) = self.get_new_version_and_lock_ref(path.clone()); self.inner.remove_version(inner_lock_ref, path, lazy, version) } pub(crate) fn list_impl( - &self, primary_namespace: &str, secondary_namespace: &str, + &self, primary_namespace: &str, secondary_namespace: &str, use_empty_ns_dir: bool, ) -> Result, lightning::io::Error> { let path = self.inner.get_checked_dest_file_path( primary_namespace, secondary_namespace, None, "list", + use_empty_ns_dir, )?; - self.inner.list(path) + self.inner.list(path, use_empty_ns_dir) } #[cfg(feature = "tokio")] pub(crate) fn read_async( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + use_empty_ns_dir: bool, ) -> impl Future, lightning::io::Error>> + 'static + Send { let this = Arc::clone(&self.inner); let path = this.get_checked_dest_file_path( @@ -476,6 +537,7 @@ impl FilesystemStoreState { secondary_namespace, Some(key), "read", + use_empty_ns_dir, ); async move { @@ -492,10 +554,17 @@ impl FilesystemStoreState { #[cfg(feature = "tokio")] pub(crate) fn write_async( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + use_empty_ns_dir: bool, ) -> impl Future> + 'static + Send { let this = Arc::clone(&self.inner); let path = this - .get_checked_dest_file_path(primary_namespace, secondary_namespace, Some(key), "write") + .get_checked_dest_file_path( + primary_namespace, + secondary_namespace, + Some(key), + "write", + use_empty_ns_dir, + ) .map(|path| (self.get_new_version_and_lock_ref(path.clone()), path)); async move { @@ -504,7 +573,7 @@ impl FilesystemStoreState { Err(e) => return Err(e), }; tokio::task::spawn_blocking(move || { - this.write_version(inner_lock_ref, path, buf, version) + this.write_version(inner_lock_ref, path, buf, version, use_empty_ns_dir) }) .await .unwrap_or_else(|e| Err(lightning::io::Error::new(lightning::io::ErrorKind::Other, e))) @@ -514,10 +583,17 @@ impl FilesystemStoreState { #[cfg(feature = "tokio")] pub(crate) fn remove_async( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + use_empty_ns_dir: bool, ) -> impl Future> + 'static + Send { let this = Arc::clone(&self.inner); let path = this - .get_checked_dest_file_path(primary_namespace, secondary_namespace, Some(key), "remove") + .get_checked_dest_file_path( + primary_namespace, + secondary_namespace, + Some(key), + "remove", + use_empty_ns_dir, + ) .map(|path| (self.get_new_version_and_lock_ref(path.clone()), path)); async move { @@ -535,26 +611,33 @@ impl FilesystemStoreState { #[cfg(feature = "tokio")] pub(crate) fn list_async( - &self, primary_namespace: &str, secondary_namespace: &str, + &self, primary_namespace: &str, secondary_namespace: &str, use_empty_ns_dir: bool, ) -> impl Future, lightning::io::Error>> + 'static + Send { let this = Arc::clone(&self.inner); - let path = - this.get_checked_dest_file_path(primary_namespace, secondary_namespace, None, "list"); + let path = this.get_checked_dest_file_path( + primary_namespace, + secondary_namespace, + None, + "list", + use_empty_ns_dir, + ); async move { let path = match path { Ok(path) => path, Err(e) => return Err(e), }; - tokio::task::spawn_blocking(move || this.list(path)).await.unwrap_or_else(|e| { - Err(lightning::io::Error::new(lightning::io::ErrorKind::Other, e)) - }) + tokio::task::spawn_blocking(move || this.list(path, use_empty_ns_dir)) + .await + .unwrap_or_else(|e| { + Err(lightning::io::Error::new(lightning::io::ErrorKind::Other, e)) + }) } } pub(crate) fn list_all_keys_impl( - &self, + &self, use_empty_ns_dir: bool, ) -> Result, lightning::io::Error> { let prefixed_dest = &self.inner.data_dir; if !prefixed_dest.exists() { @@ -570,7 +653,7 @@ impl FilesystemStoreState { if dir_entry_is_key(&primary_entry)? { let primary_namespace = String::new(); let secondary_namespace = String::new(); - let key = get_key_from_dir_entry_path(&primary_path, prefixed_dest)?; + let key = get_key_from_dir_entry_path(&primary_path, prefixed_dest, false)?; keys.push((primary_namespace, secondary_namespace, key)); continue 'primary_loop; } @@ -581,10 +664,13 @@ impl FilesystemStoreState { let secondary_path = secondary_entry.path(); if dir_entry_is_key(&secondary_entry)? { - let primary_namespace = - get_key_from_dir_entry_path(&primary_path, prefixed_dest)?; + let primary_namespace = get_key_from_dir_entry_path( + &primary_path, + prefixed_dest, + use_empty_ns_dir, + )?; let secondary_namespace = String::new(); - let key = get_key_from_dir_entry_path(&secondary_path, &primary_path)?; + let key = get_key_from_dir_entry_path(&secondary_path, &primary_path, false)?; keys.push((primary_namespace, secondary_namespace, key)); continue 'secondary_loop; } @@ -595,11 +681,18 @@ impl FilesystemStoreState { let tertiary_path = tertiary_entry.path(); if dir_entry_is_key(&tertiary_entry)? { - let primary_namespace = - get_key_from_dir_entry_path(&primary_path, prefixed_dest)?; - let secondary_namespace = - get_key_from_dir_entry_path(&secondary_path, &primary_path)?; - let key = get_key_from_dir_entry_path(&tertiary_path, &secondary_path)?; + let primary_namespace = get_key_from_dir_entry_path( + &primary_path, + prefixed_dest, + use_empty_ns_dir, + )?; + let secondary_namespace = get_key_from_dir_entry_path( + &secondary_path, + &primary_path, + use_empty_ns_dir, + )?; + let key = + get_key_from_dir_entry_path(&tertiary_path, &secondary_path, false)?; keys.push((primary_namespace, secondary_namespace, key)); } else { debug_assert!( @@ -663,10 +756,18 @@ fn dir_entry_is_key(dir_entry: &fs::DirEntry) -> Result Result { +/// Gets the key from a directory entry path by stripping the base path and validating the result. +/// If `map_empty_ns_dir` is true, treats entries with the name of `EMPTY_NAMESPACE_DIR` as an empty string. +/// `map_empty_ns_dir` should always be false when reading keys and only be true when listing namespaces. +pub(crate) fn get_key_from_dir_entry_path( + p: &Path, base_path: &Path, map_empty_ns_dir: bool, +) -> Result { match p.strip_prefix(&base_path) { Ok(stripped_path) => { if let Some(relative_path) = stripped_path.to_str() { + if map_empty_ns_dir && relative_path == EMPTY_NAMESPACE_DIR { + return Ok(String::new()); + } if is_valid_kvstore_str(relative_path) { return Ok(relative_path.to_string()); } else { diff --git a/lightning-persister/src/fs_store/mod.rs b/lightning-persister/src/fs_store/mod.rs index b460bdb089c..5fe7f6542ce 100644 --- a/lightning-persister/src/fs_store/mod.rs +++ b/lightning-persister/src/fs_store/mod.rs @@ -1,5 +1,6 @@ //! Implementations of filesystem-backed key-value stores. pub mod v1; +pub mod v2; pub(crate) mod common; diff --git a/lightning-persister/src/fs_store/v1.rs b/lightning-persister/src/fs_store/v1.rs index 8aa988e50c9..776aba630c4 100644 --- a/lightning-persister/src/fs_store/v1.rs +++ b/lightning-persister/src/fs_store/v1.rs @@ -39,25 +39,25 @@ impl KVStoreSync for FilesystemStore { fn read( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, ) -> Result, lightning::io::Error> { - self.state.read_impl(primary_namespace, secondary_namespace, key) + self.state.read_impl(primary_namespace, secondary_namespace, key, false) } fn write( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, ) -> Result<(), lightning::io::Error> { - self.state.write_impl(primary_namespace, secondary_namespace, key, buf) + self.state.write_impl(primary_namespace, secondary_namespace, key, buf, false) } fn remove( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, ) -> Result<(), lightning::io::Error> { - self.state.remove_impl(primary_namespace, secondary_namespace, key, lazy) + self.state.remove_impl(primary_namespace, secondary_namespace, key, lazy, false) } fn list( &self, primary_namespace: &str, secondary_namespace: &str, ) -> Result, lightning::io::Error> { - self.state.list_impl(primary_namespace, secondary_namespace) + self.state.list_impl(primary_namespace, secondary_namespace, false) } } @@ -66,31 +66,31 @@ impl KVStore for FilesystemStore { fn read( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, ) -> impl Future, lightning::io::Error>> + 'static + Send { - self.state.read_async(primary_namespace, secondary_namespace, key) + self.state.read_async(primary_namespace, secondary_namespace, key, false) } fn write( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, ) -> impl Future> + 'static + Send { - self.state.write_async(primary_namespace, secondary_namespace, key, buf) + self.state.write_async(primary_namespace, secondary_namespace, key, buf, false) } fn remove( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, ) -> impl Future> + 'static + Send { - self.state.remove_async(primary_namespace, secondary_namespace, key, lazy) + self.state.remove_async(primary_namespace, secondary_namespace, key, lazy, false) } fn list( &self, primary_namespace: &str, secondary_namespace: &str, ) -> impl Future, lightning::io::Error>> + 'static + Send { - self.state.list_async(primary_namespace, secondary_namespace) + self.state.list_async(primary_namespace, secondary_namespace, false) } } impl MigratableKVStore for FilesystemStore { fn list_all_keys(&self) -> Result, lightning::io::Error> { - self.state.list_all_keys_impl() + self.state.list_all_keys_impl(false) } } diff --git a/lightning-persister/src/fs_store/v2.rs b/lightning-persister/src/fs_store/v2.rs new file mode 100644 index 00000000000..7cff1d35313 --- /dev/null +++ b/lightning-persister/src/fs_store/v2.rs @@ -0,0 +1,651 @@ +//! Objects related to [`FilesystemStoreV2`] live here. +use crate::fs_store::common::{get_key_from_dir_entry_path, FilesystemStoreState}; + +use lightning::util::persist::{ + KVStoreSync, MigratableKVStore, PageToken, PaginatedKVStoreSync, PaginatedListResponse, +}; + +use std::fs; +use std::path::PathBuf; +use std::time::UNIX_EPOCH; + +#[cfg(feature = "tokio")] +use core::future::Future; +#[cfg(feature = "tokio")] +use lightning::util::persist::{KVStore, PaginatedKVStore}; +use std::sync::Arc; + +/// A [`KVStore`] and [`KVStoreSync`] implementation that writes to and reads from the file system. +/// +/// This is version 2 of the filesystem store which provides: +/// - Consistent directory structure using `[empty]` for empty namespaces +/// - File modification times for creation-order pagination +/// - Support for [`PaginatedKVStoreSync`] with newest-first ordering +/// +/// ## Directory Structure +/// +/// Files are stored with a consistent two-level namespace hierarchy: +/// ```text +/// data_dir/ +/// [empty]/ # empty primary namespace +/// [empty]/ # empty secondary namespace +/// {key} +/// primary_ns/ +/// [empty]/ # empty secondary namespace +/// {key} +/// secondary_ns/ +/// {key} +/// ``` +/// +/// ## File Ordering +/// +/// Files are ordered by their modification time (mtime). When a file is created, it gets +/// the current time. When updated, the original creation time is preserved by setting +/// the mtime of the new file to match the original before the atomic rename. +/// +/// [`KVStore`]: lightning::util::persist::KVStore +pub struct FilesystemStoreV2 { + inner: Arc, +} + +impl FilesystemStoreV2 { + /// Constructs a new [`FilesystemStoreV2`]. + /// + /// Returns an error if the data directory already exists and contains files at the top level, + /// which would indicate it was previously used by a [`FilesystemStore`] (v1). The v2 store + /// expects only directories (namespaces) at the top level. + /// + /// [`FilesystemStore`]: crate::fs_store::v1::FilesystemStore + pub fn new(data_dir: PathBuf) -> std::io::Result { + if data_dir.exists() { + for entry in fs::read_dir(&data_dir)? { + let entry = entry?; + if entry.file_type()?.is_file() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "Found file `{}` in the top-level data directory. \ + This indicates the directory was previously used by FilesystemStore (v1). \ + Please migrate your data or use a different directory.", + entry.path().display() + ), + )); + } + } + } + + Ok(Self { inner: Arc::new(FilesystemStoreState::new(data_dir)) }) + } + + /// Returns the data directory. + pub fn get_data_dir(&self) -> PathBuf { + self.inner.get_data_dir() + } + + #[cfg(any(all(feature = "tokio", test), fuzzing))] + /// Returns the size of the async state. + pub fn state_size(&self) -> usize { + self.inner.state_size() + } +} + +/// The fixed page size for paginated listing operations. +pub(crate) const PAGE_SIZE: usize = 50; + +/// The length of the timestamp in a page token (milliseconds since epoch as 16-digit decimal). +const PAGE_TOKEN_TIMESTAMP_LEN: usize = 16; + +impl FilesystemStoreState { + fn list_paginated_impl( + &self, prefixed_dest: PathBuf, page_token: Option, + ) -> Result { + if !prefixed_dest.exists() { + return Ok(PaginatedListResponse { keys: Vec::new(), next_page_token: None }); + } + + // Collect all entries with their modification times + let mut entries: Vec<(u64, String)> = Vec::new(); + for dir_entry in fs::read_dir(&prefixed_dest)? { + let dir_entry = dir_entry?; + + let key = + get_key_from_dir_entry_path(&dir_entry.path(), prefixed_dest.as_path(), false)?; + // Get modification time as millis since epoch + let mtime_millis = dir_entry + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + + entries.push((mtime_millis, key)); + } + + // Sort by mtime descending (newest first), then by key descending for same mtime + entries.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| b.1.cmp(&a.1))); + + // Find starting position based on page token + let start_idx = if let Some(token) = page_token { + let (token_mtime, token_key) = parse_page_token(token.as_str())?; + + // Find entries that come after the token (older entries = lower mtime) + // or same mtime but lexicographically smaller key (since we sort descending) + entries + .iter() + .position(|(mtime, key)| { + *mtime < token_mtime + || (*mtime == token_mtime && key.as_str() < token_key.as_str()) + }) + .unwrap_or(entries.len()) + } else { + 0 + }; + + // Take PAGE_SIZE entries starting from start_idx + let page_entries: Vec<_> = + entries.iter().skip(start_idx).take(PAGE_SIZE).cloned().collect(); + + let keys: Vec = page_entries.iter().map(|(_, key)| key.clone()).collect(); + + // Determine next page token + let next_page_token = if start_idx + PAGE_SIZE < entries.len() { + page_entries.last().map(|(mtime, key)| PageToken::new(format_page_token(*mtime, key))) + } else { + None + }; + + Ok(PaginatedListResponse { keys, next_page_token }) + } +} + +impl KVStoreSync for FilesystemStoreV2 { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> Result, lightning::io::Error> { + self.inner.read_impl(primary_namespace, secondary_namespace, key, true) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> Result<(), lightning::io::Error> { + self.inner.write_impl(primary_namespace, secondary_namespace, key, buf, true) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> Result<(), lightning::io::Error> { + self.inner.remove_impl(primary_namespace, secondary_namespace, key, lazy, true) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> Result, lightning::io::Error> { + self.inner.list_impl(primary_namespace, secondary_namespace, true) + } +} + +impl PaginatedKVStoreSync for FilesystemStoreV2 { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, + ) -> Result { + let prefixed_dest = self.inner.get_checked_dest_file_path( + primary_namespace, + secondary_namespace, + None, + "list_paginated", + true, + )?; + self.inner.list_paginated_impl(prefixed_dest, page_token) + } +} + +#[cfg(feature = "tokio")] +impl KVStore for FilesystemStoreV2 { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> impl Future, lightning::io::Error>> + 'static + Send { + self.inner.read_async(primary_namespace, secondary_namespace, key, true) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> impl Future> + 'static + Send { + self.inner.write_async(primary_namespace, secondary_namespace, key, buf, true) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> impl Future> + 'static + Send { + self.inner.remove_async(primary_namespace, secondary_namespace, key, lazy, true) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> impl Future, lightning::io::Error>> + 'static + Send { + self.inner.list_async(primary_namespace, secondary_namespace, true) + } +} + +#[cfg(feature = "tokio")] +impl PaginatedKVStore for FilesystemStoreV2 { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, + ) -> impl Future> + 'static + Send + { + let this = Arc::clone(&self.inner); + + let path = this.get_checked_dest_file_path( + primary_namespace, + secondary_namespace, + None, + "list_paginated", + true, + ); + + async move { + let path = match path { + Ok(path) => path, + Err(e) => return Err(e), + }; + tokio::task::spawn_blocking(move || this.list_paginated_impl(path, page_token)) + .await + .unwrap_or_else(|e| { + Err(lightning::io::Error::new(lightning::io::ErrorKind::Other, e)) + }) + } + } +} + +impl MigratableKVStore for FilesystemStoreV2 { + fn list_all_keys(&self) -> Result, lightning::io::Error> { + self.inner.list_all_keys_impl(true) + } +} + +/// Formats a page token from mtime (millis since epoch) and key. +pub(crate) fn format_page_token(mtime_millis: u64, key: &str) -> String { + format!("{mtime_millis:016}:{key}") +} + +/// Parses a page token into mtime (millis since epoch) and key. +pub(crate) fn parse_page_token(token: &str) -> lightning::io::Result<(u64, String)> { + if token.as_bytes().get(PAGE_TOKEN_TIMESTAMP_LEN) != Some(&b':') { + return Err(lightning::io::Error::new( + lightning::io::ErrorKind::InvalidInput, + "Invalid page token format", + )); + } + + let mtime = token[..PAGE_TOKEN_TIMESTAMP_LEN].parse::().map_err(|_| { + lightning::io::Error::new( + lightning::io::ErrorKind::InvalidInput, + "Invalid page token timestamp", + ) + })?; + + let key = token[PAGE_TOKEN_TIMESTAMP_LEN + 1..].to_string(); + + Ok((mtime, key)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fs_store::common::EMPTY_NAMESPACE_DIR; + use crate::test_utils::{ + do_read_write_remove_list_persist, do_test_data_migration, do_test_store, + }; + use std::fs::FileTimes; + use std::time::UNIX_EPOCH; + + impl Drop for FilesystemStoreV2 { + fn drop(&mut self) { + // We test for invalid directory names, so it's OK if directory removal + // fails. + match fs::remove_dir_all(&self.inner.get_data_dir()) { + Err(e) => println!("Failed to remove test persister directory: {}", e), + _ => {}, + } + } + } + + #[test] + fn read_write_remove_list_persist() { + let mut temp_path = std::env::temp_dir(); + temp_path.push("test_read_write_remove_list_persist_v2"); + let fs_store = FilesystemStoreV2::new(temp_path).unwrap(); + do_read_write_remove_list_persist(&fs_store); + } + + #[cfg(feature = "tokio")] + #[tokio::test] + async fn read_write_remove_list_persist_async() { + use lightning::util::persist::KVStore; + use std::sync::Arc; + + let mut temp_path = std::env::temp_dir(); + temp_path.push("test_read_write_remove_list_persist_async_v2"); + let fs_store = Arc::new(FilesystemStoreV2::new(temp_path).unwrap()); + assert_eq!(fs_store.state_size(), 0); + + let async_fs_store = Arc::clone(&fs_store); + + let data1 = vec![42u8; 32]; + let data2 = vec![43u8; 32]; + + let primary = "testspace"; + let secondary = "testsubspace"; + let key = "testkey"; + + // Test writing the same key twice with different data. Execute the asynchronous part out of order to ensure + // that eventual consistency works. + let fut1 = KVStore::write(&*async_fs_store, primary, secondary, key, data1); + assert_eq!(fs_store.state_size(), 1); + + let fut2 = KVStore::remove(&*async_fs_store, primary, secondary, key, false); + assert_eq!(fs_store.state_size(), 1); + + let fut3 = KVStore::write(&*async_fs_store, primary, secondary, key, data2.clone()); + assert_eq!(fs_store.state_size(), 1); + + fut3.await.unwrap(); + assert_eq!(fs_store.state_size(), 1); + + fut2.await.unwrap(); + assert_eq!(fs_store.state_size(), 1); + + fut1.await.unwrap(); + assert_eq!(fs_store.state_size(), 0); + + // Test list. + let listed_keys = KVStore::list(&*async_fs_store, primary, secondary).await.unwrap(); + assert_eq!(listed_keys.len(), 1); + assert_eq!(listed_keys[0], key); + + // Test read. We expect to read data2, as the write call was initiated later. + let read_data = KVStore::read(&*async_fs_store, primary, secondary, key).await.unwrap(); + assert_eq!(data2, &*read_data); + + // Test remove. + KVStore::remove(&*async_fs_store, primary, secondary, key, false).await.unwrap(); + + let listed_keys = KVStore::list(&*async_fs_store, primary, secondary).await.unwrap(); + assert_eq!(listed_keys.len(), 0); + } + + #[test] + fn test_data_migration() { + let mut source_temp_path = std::env::temp_dir(); + source_temp_path.push("test_data_migration_source_v2"); + let mut source_store = FilesystemStoreV2::new(source_temp_path).unwrap(); + + let mut target_temp_path = std::env::temp_dir(); + target_temp_path.push("test_data_migration_target_v2"); + let mut target_store = FilesystemStoreV2::new(target_temp_path).unwrap(); + + do_test_data_migration(&mut source_store, &mut target_store); + } + + #[test] + fn test_filesystem_store_v2() { + // Create the nodes, giving them FilesystemStoreV2s for data stores. + let store_0 = FilesystemStoreV2::new("test_filesystem_store_v2_0".into()).unwrap(); + let store_1 = FilesystemStoreV2::new("test_filesystem_store_v2_1".into()).unwrap(); + do_test_store(&store_0, &store_1) + } + + #[test] + fn test_page_token_format() { + let mtime: u64 = 1706500000000; + let key = "test_key"; + let token = format_page_token(mtime, key); + assert_eq!(token, "0001706500000000:test_key"); + + let parsed = parse_page_token(&token).unwrap(); + assert_eq!(parsed, (mtime, key.to_string())); + + // Test invalid tokens + assert!(parse_page_token("invalid").is_err()); + assert!(parse_page_token("0001706500000000_key").is_err()); // wrong separator + assert!(parse_page_token("0001706500000000").is_err()); // no separator and key + assert!(parse_page_token("1706500000000:key").is_err()); // too short timestamp + } + + #[test] + fn test_directory_structure() { + use lightning::util::persist::KVStoreSync; + + let mut temp_path = std::env::temp_dir(); + temp_path.push("test_directory_structure_v2"); + let fs_store = FilesystemStoreV2::new(temp_path.clone()).unwrap(); + + let data = vec![42u8; 32]; + + // Write with empty namespaces + KVStoreSync::write(&fs_store, "", "", "key1", data.clone()).unwrap(); + assert!(temp_path.join(EMPTY_NAMESPACE_DIR).join(EMPTY_NAMESPACE_DIR).exists()); + + // Write with non-empty primary, empty secondary + KVStoreSync::write(&fs_store, "primary", "", "key2", data.clone()).unwrap(); + assert!(temp_path.join("primary").join(EMPTY_NAMESPACE_DIR).exists()); + + // Write with both non-empty + KVStoreSync::write(&fs_store, "primary", "secondary", "key3", data.clone()).unwrap(); + assert!(temp_path.join("primary").join("secondary").exists()); + + // Verify we can read them back + assert_eq!(KVStoreSync::read(&fs_store, "", "", "key1").unwrap(), data); + assert_eq!(KVStoreSync::read(&fs_store, "primary", "", "key2").unwrap(), data); + assert_eq!(KVStoreSync::read(&fs_store, "primary", "secondary", "key3").unwrap(), data); + + // Verify files are named just by key (no timestamp prefix) + assert!(temp_path + .join(EMPTY_NAMESPACE_DIR) + .join(EMPTY_NAMESPACE_DIR) + .join("key1") + .exists()); + assert!(temp_path.join("primary").join(EMPTY_NAMESPACE_DIR).join("key2").exists()); + assert!(temp_path.join("primary").join("secondary").join("key3").exists()); + } + + #[test] + fn test_update_preserves_mtime() { + use lightning::util::persist::KVStoreSync; + + let mut temp_path = std::env::temp_dir(); + temp_path.push("test_update_preserves_mtime_v2"); + let fs_store = FilesystemStoreV2::new(temp_path.clone()).unwrap(); + + let data1 = vec![42u8; 32]; + let data2 = vec![43u8; 32]; + + // Write initial data + KVStoreSync::write(&fs_store, "ns", "sub", "key", data1).unwrap(); + + // Get the original mtime + let file_path = temp_path.join("ns").join("sub").join("key"); + let original_mtime = fs::metadata(&file_path).unwrap().modified().unwrap(); + + // Sleep briefly to ensure different timestamp if not preserved + std::thread::sleep(std::time::Duration::from_millis(50)); + + // Update with new data + KVStoreSync::write(&fs_store, "ns", "sub", "key", data2.clone()).unwrap(); + + // Verify mtime is preserved + let updated_mtime = fs::metadata(&file_path).unwrap().modified().unwrap(); + assert_eq!(original_mtime, updated_mtime); + + // Verify data was updated + assert_eq!(KVStoreSync::read(&fs_store, "ns", "sub", "key").unwrap(), data2); + } + + #[test] + fn test_paginated_listing() { + use lightning::util::persist::{KVStoreSync, PaginatedKVStoreSync}; + + let mut temp_path = std::env::temp_dir(); + temp_path.push("test_paginated_listing_v2"); + let fs_store = FilesystemStoreV2::new(temp_path).unwrap(); + + let data = vec![42u8; 32]; + + // Write several keys with small delays to ensure different mtimes + let keys: Vec = (0..5).map(|i| format!("key{}", i)).collect(); + for key in &keys { + KVStoreSync::write(&fs_store, "ns", "sub", key, data.clone()).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + // List paginated - should return newest first + let response = PaginatedKVStoreSync::list_paginated(&fs_store, "ns", "sub", None).unwrap(); + assert_eq!(response.keys.len(), 5); + // Newest key (key4) should be first + assert_eq!(response.keys[0], "key4"); + assert_eq!(response.keys[4], "key0"); + assert!(response.next_page_token.is_none()); // Less than PAGE_SIZE items + } + + #[test] + fn test_paginated_listing_with_pagination() { + use lightning::util::persist::{KVStoreSync, PaginatedKVStoreSync}; + + let mut temp_path = std::env::temp_dir(); + temp_path.push("test_paginated_listing_with_pagination_v2"); + let fs_store = FilesystemStoreV2::new(temp_path).unwrap(); + + let data = vec![42u8; 32]; + + // Write more than PAGE_SIZE keys + let num_keys = PAGE_SIZE + 50; + for i in 0..num_keys { + let key = format!("key{:04}", i); + KVStoreSync::write(&fs_store, "ns", "sub", &key, data.clone()).unwrap(); + // Small delay to ensure ordering + if i % 10 == 0 { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + } + + // First page + let response1 = PaginatedKVStoreSync::list_paginated(&fs_store, "ns", "sub", None).unwrap(); + assert_eq!(response1.keys.len(), PAGE_SIZE); + assert!(response1.next_page_token.is_some()); + + // Second page + let response2 = + PaginatedKVStoreSync::list_paginated(&fs_store, "ns", "sub", response1.next_page_token) + .unwrap(); + assert_eq!(response2.keys.len(), 50); + assert!(response2.next_page_token.is_none()); + + // Verify no duplicates between pages + let all_keys: std::collections::HashSet<_> = + response1.keys.iter().chain(response2.keys.iter()).collect(); + assert_eq!(all_keys.len(), num_keys); + } + + #[test] + fn test_page_token_after_deletion() { + use lightning::util::persist::{KVStoreSync, PaginatedKVStoreSync}; + + let mut temp_path = std::env::temp_dir(); + temp_path.push("test_page_token_after_deletion_v2"); + let fs_store = FilesystemStoreV2::new(temp_path).unwrap(); + + let data = vec![42u8; 32]; + + // Write keys + for i in 0..10 { + let key = format!("key{}", i); + KVStoreSync::write(&fs_store, "ns", "sub", &key, data.clone()).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + // Verify initial listing + let response1 = PaginatedKVStoreSync::list_paginated(&fs_store, "ns", "sub", None).unwrap(); + assert_eq!(response1.keys.len(), 10); + + // Delete some keys + KVStoreSync::remove(&fs_store, "ns", "sub", "key5", false).unwrap(); + KVStoreSync::remove(&fs_store, "ns", "sub", "key3", false).unwrap(); + + // List again - should work fine with deleted keys + let response2 = PaginatedKVStoreSync::list_paginated(&fs_store, "ns", "sub", None).unwrap(); + assert_eq!(response2.keys.len(), 8); // 10 - 2 deleted + } + + #[test] + fn test_same_mtime_sorted_by_key() { + use lightning::util::persist::PaginatedKVStoreSync; + use std::time::Duration; + + // Create files directly on disk first with the same mtime + let mut temp_path = std::env::temp_dir(); + temp_path.push("test_same_mtime_sorted_by_key_v2"); + let _ = fs::remove_dir_all(&temp_path); + + let data = vec![42u8; 32]; + let dir = temp_path.join("ns").join("sub"); + fs::create_dir_all(&dir).unwrap(); + + // Write files with the same mtime but different keys + let keys = vec!["zebra", "apple", "mango", "banana"]; + let fixed_time = UNIX_EPOCH + Duration::from_secs(1706500000); + + for key in &keys { + let file_path = dir.join(key); + let file = fs::File::create(&file_path).unwrap(); + std::io::Write::write_all(&mut &file, &data).unwrap(); + file.set_times(FileTimes::new().set_modified(fixed_time)).unwrap(); + } + + // Open the store + let fs_store = FilesystemStoreV2::new(temp_path.clone()).unwrap(); + + // List paginated - should return keys sorted by key in reverse order + // (for same mtime, keys are sorted reverse alphabetically) + let response = PaginatedKVStoreSync::list_paginated(&fs_store, "ns", "sub", None).unwrap(); + assert_eq!(response.keys.len(), 4); + + // Same mtime means sorted by key in reverse order (z > m > b > a) + assert_eq!(response.keys[0], "zebra"); + assert_eq!(response.keys[1], "mango"); + assert_eq!(response.keys[2], "banana"); + assert_eq!(response.keys[3], "apple"); + } + + #[test] + fn test_rejects_v1_data_directory() { + let mut temp_path = std::env::temp_dir(); + temp_path.push("test_rejects_v1_data_directory"); + let _ = fs::remove_dir_all(&temp_path); + fs::create_dir_all(&temp_path).unwrap(); + + // Create a file at the top level, as v1 would for an empty primary namespace + fs::write(temp_path.join("some_key"), b"data").unwrap(); + + // V2 construction should fail + match FilesystemStoreV2::new(temp_path.clone()) { + Err(err) => { + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + assert!(err.to_string().contains("FilesystemStore (v1)")); + }, + Ok(_) => panic!("Expected error for directory with top-level files"), + } + + // Clean up + let _ = fs::remove_dir_all(&temp_path); + + // An empty directory should succeed + fs::create_dir_all(&temp_path).unwrap(); + let result = FilesystemStoreV2::new(temp_path.clone()); + assert!(result.is_ok()); + + // A directory with only subdirectories should succeed + fs::create_dir_all(temp_path.join("some_namespace")).unwrap(); + let result = FilesystemStoreV2::new(temp_path); + assert!(result.is_ok()); + } +} From c31a7bee21824cdc39e12c31f8d82c393660c2c7 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Sun, 1 Feb 2026 12:29:08 +0000 Subject: [PATCH 072/627] Validate the `Router` is meeting MPP and max-fee limitations given When `OutboundPayments` calls the provided `Router` to fetch a `Route` it passes a `RouteParameters` with a specific max-fee. Here we validate that the `Route` returned sticks to the limits provided, and also that it meets the MPP rules of not having any single MPP part which can be removed while still meeting the desired payment amount. --- lightning/src/ln/chanmon_update_fail_tests.rs | 5 +- lightning/src/ln/channelmanager.rs | 1 + lightning/src/ln/functional_tests.rs | 3 +- lightning/src/ln/outbound_payment.rs | 51 ++++++++------ lightning/src/ln/payment_tests.rs | 4 ++ lightning/src/routing/router.rs | 66 ++++++++++++++++++- 6 files changed, 106 insertions(+), 24 deletions(-) diff --git a/lightning/src/ln/chanmon_update_fail_tests.rs b/lightning/src/ln/chanmon_update_fail_tests.rs index 5e544c7502d..b66695ca13e 100644 --- a/lightning/src/ln/chanmon_update_fail_tests.rs +++ b/lightning/src/ln/chanmon_update_fail_tests.rs @@ -2309,6 +2309,7 @@ fn test_path_paused_mpp() { route.paths[1].hops[0].pubkey = node_c_id; route.paths[1].hops[0].short_channel_id = chan_2_ann.contents.short_channel_id; route.paths[1].hops[1].short_channel_id = chan_4_id; + route.route_params.as_mut().unwrap().final_value_msat *= 2; // Set it so that the first monitor update (for the path 0 -> 1 -> 3) succeeds, but the second // (for the path 0 -> 2 -> 3) fails. @@ -4252,7 +4253,7 @@ fn do_test_partial_claim_mon_update_compl_actions(reload_a: bool, reload_b: bool let chan_4_scid = chan_4_update.contents.short_channel_id; let (mut route, payment_hash, preimage, payment_secret) = - get_route_and_payment_hash!(&nodes[0], nodes[3], 100000); + get_route_and_payment_hash!(&nodes[0], nodes[3], 100_000); let path = route.paths[0].clone(); route.paths.push(path); route.paths[0].hops[0].pubkey = node_b_id; @@ -4261,6 +4262,8 @@ fn do_test_partial_claim_mon_update_compl_actions(reload_a: bool, reload_b: bool route.paths[1].hops[0].pubkey = node_c_id; route.paths[1].hops[0].short_channel_id = chan_2_scid; route.paths[1].hops[1].short_channel_id = chan_4_scid; + route.route_params.as_mut().unwrap().final_value_msat *= 2; + let paths = &[&[&nodes[1], &nodes[3]][..], &[&nodes[2], &nodes[3]][..]]; send_along_route_with_secret(&nodes[0], route, paths, 200_000, payment_hash, payment_secret); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 08cbb6f6bf7..458a1f7ac98 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -20368,6 +20368,7 @@ mod tests { route.paths[1].hops[0].pubkey = nodes[2].node.get_our_node_id(); route.paths[1].hops[0].short_channel_id = chan_2_id; route.paths[1].hops[1].short_channel_id = chan_4_id; + route.route_params.as_mut().unwrap().final_value_msat *= 2; nodes[0].node.send_payment_with_route(route, payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0)).unwrap(); diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index be90130fb63..4a2c8e0ed1d 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -7205,7 +7205,7 @@ pub fn test_simple_mpp() { let chan_4_id = create_announced_chan_between_nodes(&nodes, 2, 3).0.contents.short_channel_id; let (mut route, payment_hash, payment_preimage, payment_secret) = - get_route_and_payment_hash!(&nodes[0], nodes[3], 100000); + get_route_and_payment_hash!(&nodes[0], nodes[3], 100_000); let path = route.paths[0].clone(); route.paths.push(path); route.paths[0].hops[0].pubkey = node_b_id; @@ -7214,6 +7214,7 @@ pub fn test_simple_mpp() { route.paths[1].hops[0].pubkey = node_c_id; route.paths[1].hops[0].short_channel_id = chan_2_id; route.paths[1].hops[1].short_channel_id = chan_4_id; + route.route_params.as_mut().unwrap().final_value_msat = 200_000; let paths: &[&[_]] = &[&[&nodes[1], &nodes[3]], &[&nodes[2], &nodes[3]]]; send_along_route_with_secret(&nodes[0], route, paths, 200_000, payment_hash, payment_secret); claim_payment_along_route(ClaimAlongRouteArgs::new(&nodes[0], paths, payment_preimage)); diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 170e4e13830..64f9f644174 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -894,6 +894,30 @@ impl OutboundPayments { } } +/// Validate that a [`Route`] picked by our [`Router`] is sane for the [`RouteParameters`] used to +/// request it. Failure here indicates a critical bug in the [`Router`]. +fn validate_found_route( + route: &mut Route, route_params: &RouteParameters, logger: &WithContext, +) -> Result<(), ()> { + if route.route_params.as_ref() != Some(route_params) { + debug_assert!( + false, + "Routers are expected to return a Route which includes the requested RouteParameters. Got {:?}, expected {route_params:?}", + route.route_params + ); + log_error!( + logger, + "Routers are expected to return a Route which includes the requested RouteParameters. Got {:?}, expected {route_params:?}", + route.route_params + ); + route.route_params = Some(route_params.clone()); + } + + route.debug_assert_route_meets_params(logger)?; + + Ok(()) +} + impl OutboundPayments { #[rustfmt::skip] pub(super) fn send_payment( @@ -1462,12 +1486,8 @@ impl OutboundPayments { RetryableSendFailure::RouteNotFound })?; - if route.route_params.as_ref() != Some(route_params) { - debug_assert!(false, - "Routers are expected to return a Route which includes the requested RouteParameters. Got {:?}, expected {:?}", - route.route_params, route_params); - route.route_params = Some(route_params.clone()); - } + validate_found_route(&mut route, route_params, logger) + .map_err(|()| RetryableSendFailure::RouteNotFound)?; Ok(route) } @@ -1552,18 +1572,9 @@ impl OutboundPayments { } }; - if route.route_params.as_ref() != Some(&route_params) { - debug_assert!(false, - "Routers are expected to return a Route which includes the requested RouteParameters"); - route.route_params = Some(route_params.clone()); - } - - for path in route.paths.iter() { - if path.hops.len() == 0 { - log_error!(logger, "Unusable path in route (path.hops.len() must be at least 1"); - self.abandon_payment(payment_id, PaymentFailureReason::UnexpectedError, pending_events); - return - } + if validate_found_route(&mut route, &route_params, logger).is_err() { + self.abandon_payment(payment_id, PaymentFailureReason::RouteNotFound, pending_events); + return } macro_rules! abandon_with_entry { @@ -2967,7 +2978,7 @@ mod tests { let sender_pk = PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap()); let receiver_pk = PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[43; 32]).unwrap()); let payment_params = PaymentParameters::from_node_id(sender_pk, 0); - let route_params = RouteParameters::from_payment_params_and_value(payment_params.clone(), 0); + let route_params = RouteParameters::from_payment_params_and_value(payment_params.clone(), 1); let failed_scid = 42; let route = Route { paths: vec![Path { hops: vec![RouteHop { @@ -2975,7 +2986,7 @@ mod tests { node_features: NodeFeatures::empty(), short_channel_id: failed_scid, channel_features: ChannelFeatures::empty(), - fee_msat: 0, + fee_msat: 1, cltv_expiry_delta: 0, maybe_announced_channel: true, }], blinded_tail: None }], diff --git a/lightning/src/ln/payment_tests.rs b/lightning/src/ln/payment_tests.rs index f0b22135177..aa4bf96b871 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -97,6 +97,8 @@ fn mpp_failure() { route.paths[1].hops[0].pubkey = node_c_id; route.paths[1].hops[0].short_channel_id = chan_2_id; route.paths[1].hops[1].short_channel_id = chan_4_id; + route.route_params.as_mut().unwrap().final_value_msat *= 2; + let paths: &[&[_]] = &[&[&nodes[1], &nodes[3]], &[&nodes[2], &nodes[3]]]; send_along_route_with_secret(&nodes[0], route, paths, 200_000, payment_hash, payment_secret); fail_payment_along_route(&nodes[0], paths, false, payment_hash); @@ -137,6 +139,7 @@ fn mpp_retry() { route.paths[1].hops[0].pubkey = node_c_id; route.paths[1].hops[0].short_channel_id = chan_2_update.contents.short_channel_id; route.paths[1].hops[1].short_channel_id = chan_4_update.contents.short_channel_id; + route.route_params.as_mut().unwrap().final_value_msat *= 2; // Initiate the MPP payment. let id = PaymentId(hash.0); @@ -360,6 +363,7 @@ fn do_mpp_receive_timeout(send_partial_mpp: bool) { route.paths[1].hops[0].pubkey = node_c_id; route.paths[1].hops[0].short_channel_id = chan_2_update.contents.short_channel_id; route.paths[1].hops[1].short_channel_id = chan_4_update.contents.short_channel_id; + route.route_params.as_mut().unwrap().final_value_msat *= 2; // Initiate the MPP payment. let onion = RecipientOnionFields::secret_only(payment_secret); diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index b27dee1a450..75c6a05a86d 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -633,7 +633,7 @@ impl Path { } } - /// Gets the final hop's CLTV expiry delta. + /// Gets the final hop's CLTV expiry delta, if there's a final non-blinded hop. #[rustfmt::skip] pub fn final_cltv_expiry_delta(&self) -> Option { match &self.blinded_tail { @@ -688,6 +688,66 @@ impl Route { pub fn get_total_amount(&self) -> u64 { self.paths.iter().map(|path| path.final_value_msat()).sum() } + + pub(crate) fn debug_assert_route_meets_params(&self, logger: L) -> Result<(), ()> { + if let Some(route_params) = self.route_params.as_ref() { + // Check that we actually pay less than the max fee we set. + if let Some(max_total_fee) = route_params.max_total_routing_fee_msat { + let total_fee = self.get_total_fees(); + if total_fee > max_total_fee { + let err = format!("Router returned an attempt to pay with a higher fee ({total_fee}msat) than we allowed ({max_total_fee}msat). Your router is critically buggy!"); + debug_assert!(false, "{}", err); + log_error!(logger, "{}", err); + return Err(()); + } + } + + if self.paths.is_empty() { + let err = "Selected route had no paths. Your router is buggy!"; + debug_assert!(false, "{}", err); + log_error!(logger, "{}", err); + return Err(()); + } + + for path in self.paths.iter() { + if path.hops.is_empty() { + let err = "Unusable path in route (path.hops.len() must be at least 1)"; + debug_assert!(false, "{}", err); + log_error!(logger, "{}", err); + return Err(()); + } + + if path.hops.len() > route_params.payment_params.max_path_length.into() { + let err = format!( + "Path had a length of {}, which is greater than the maximum we're allowed ({})", + path.hops.len(), + route_params.payment_params.max_path_length, + ); + #[cfg(any(test, feature = "_test_utils"))] + debug_assert!(false, "{}", err); + log_error!(logger, "{}", err); + // This is a bug, but there's not a material safety risk to making this + // payment, so we don't bother to error here. + } + } + + // Test that we don't contain any "extra" MPP parts - while we're allowed to overshoot + // the `final_value_msat` specified in the `route_params`, we aren't allowed to have + // any MPP parts which aren't needed to meet `route_params.final_value_msat`. + let min_mpp_part = self.paths.iter().map(|h| h.final_value_msat()).min().unwrap_or(0); + if self.get_total_amount() - min_mpp_part >= route_params.final_value_msat { + let err = format!( + "Router returned an attempt to include more MPP parts than needed. The smallest MPP part ({min_mpp_part}msat) was not needed for a payment of {}msat. Your router is critically buggy!", + route_params.final_value_msat + ); + debug_assert!(false, "{}", err); + log_error!(logger, "{}", err); + return Err(()); + } + } + + Ok(()) + } } impl fmt::Display for Route { @@ -2491,9 +2551,11 @@ pub fn find_route( scorer: &S, score_params: &S::ScoreParams, random_seed_bytes: &[u8; 32] ) -> Result { let graph_lock = network_graph.read_only(); - let mut route = get_route(our_node_pubkey, &route_params, &graph_lock, first_hops, logger, + let mut route = get_route(our_node_pubkey, &route_params, &graph_lock, first_hops, &logger, scorer, score_params, random_seed_bytes)?; add_random_cltv_offset(&mut route, &route_params.payment_params, &graph_lock, random_seed_bytes); + route.debug_assert_route_meets_params(&logger) + .map_err(|()| "Generated route doesn't comply with the parameters you specified. This indicates a bug in the router. Please report this bug!")?; Ok(route) } From abf258a6f579d64ddafff29de3f07fdb86117702 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Sun, 1 Feb 2026 20:42:54 +0000 Subject: [PATCH 073/627] Include MPP payment amount in `RecipientOnionFields` In some uses of LDK we need the ability to send HTLCs for only a portion of some larger MPP payment. This allows payers to make single payments which spend funds from multiple wallets, which may be important for ecash wallets holding funds in multiple mints or graduated wallets which hold funds across a trusted wallet and a self-custodial wallet. In order to allow for this, we need to separate the concept of the payment amount from the onion MPP amount. Here we start this process by adding a `total_mpp_amount_msat` field to `RecipientOnionFields` (which is the appropriate place for a field describing something in the recipient onion). We currently always assert that it is equal to the existing fields, but will relax this in the coming commit(s). We also start including a payment preimage on probe attempts, which appears to have been the intent of the code, but which did not work correctly. The bulk of the test updates were done by Claude. --- fuzz/src/chanmon_consistency.rs | 8 +- fuzz/src/full_stack.rs | 4 +- lightning/src/chain/channelmonitor.rs | 2 +- lightning/src/events/mod.rs | 9 +- lightning/src/ln/accountable_tests.rs | 2 +- lightning/src/ln/async_payments_tests.rs | 2 +- lightning/src/ln/async_signer_tests.rs | 12 +- lightning/src/ln/blinded_payment_tests.rs | 64 +++++----- lightning/src/ln/chanmon_update_fail_tests.rs | 56 ++++---- lightning/src/ln/channelmanager.rs | 70 +++++++--- lightning/src/ln/functional_test_utils.rs | 4 +- lightning/src/ln/functional_tests.rs | 105 ++++++++------- lightning/src/ln/htlc_reserve_unit_tests.rs | 76 +++++------ lightning/src/ln/interception_tests.rs | 2 +- lightning/src/ln/invoice_utils.rs | 5 +- .../src/ln/max_payment_path_len_tests.rs | 13 +- lightning/src/ln/monitor_tests.rs | 8 +- lightning/src/ln/offers_tests.rs | 2 +- lightning/src/ln/onion_payment.rs | 2 +- lightning/src/ln/onion_route_tests.rs | 46 ++++--- lightning/src/ln/onion_utils.rs | 47 ++++++- lightning/src/ln/outbound_payment.rs | 120 +++++++++++++----- lightning/src/ln/payment_tests.rs | 109 ++++++++-------- lightning/src/ln/priv_short_conf_tests.rs | 18 +-- lightning/src/ln/quiescence_tests.rs | 8 +- lightning/src/ln/reload_tests.rs | 27 ++-- lightning/src/ln/shutdown_tests.rs | 8 +- lightning/src/ln/splicing_tests.rs | 10 +- lightning/src/ln/update_fee_tests.rs | 8 +- 29 files changed, 504 insertions(+), 343 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 21623fdba1e..ade6790e6b1 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -613,7 +613,7 @@ fn send_payment( }], route_params: Some(route_params.clone()), }; - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt); let res = source.send_payment_with_route(route, payment_hash, onion, payment_id); match res { Err(err) => { @@ -683,7 +683,7 @@ fn send_hop_payment( }], route_params: Some(route_params.clone()), }; - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt); let res = source.send_payment_with_route(route, payment_hash, onion, payment_id); match res { Err(err) => { @@ -748,7 +748,7 @@ fn send_mpp_payment( amt, ); let route = Route { paths, route_params: Some(route_params) }; - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt); let res = source.send_payment_with_route(route, payment_hash, onion, payment_id); match res { Err(_) => false, @@ -844,7 +844,7 @@ fn send_mpp_hop_payment( amt, ); let route = Route { paths, route_params: Some(route_params) }; - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt); let res = source.send_payment_with_route(route, payment_hash, onion, payment_id); match res { Err(_) => false, diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index 085165e9e02..03d5e48a014 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -743,7 +743,7 @@ pub fn do_test(mut data: &[u8], logger: &Arc payments_sent += 1; let _ = channelmanager.send_payment( payment_hash, - RecipientOnionFields::spontaneous_empty(), + RecipientOnionFields::spontaneous_empty(final_value_msat), PaymentId(payment_hash.0), params, Retry::Attempts(2), @@ -765,7 +765,7 @@ pub fn do_test(mut data: &[u8], logger: &Arc payments_sent += 1; let _ = channelmanager.send_payment( payment_hash, - RecipientOnionFields::secret_only(payment_secret), + RecipientOnionFields::secret_only(payment_secret, final_value_msat), PaymentId(payment_hash.0), params, Retry::Attempts(2), diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs index 37351460634..a8d055a9c5b 100644 --- a/lightning/src/chain/channelmonitor.rs +++ b/lightning/src/chain/channelmonitor.rs @@ -6850,7 +6850,7 @@ mod tests { // the update through to the ChannelMonitor which will refuse it (as the channel is closed). let (route, payment_hash, _, payment_secret) = get_route_and_payment_hash!(nodes[1], nodes[0], 100_000); nodes[1].node.send_payment_with_route(route, payment_hash, - RecipientOnionFields::secret_only(payment_secret), PaymentId(payment_hash.0) + RecipientOnionFields::secret_only(payment_secret, 100_000), PaymentId(payment_hash.0) ).unwrap(); check_added_monitors(&nodes[1], 1); diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 3dfed10d5c8..1f030aac40d 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -41,8 +41,8 @@ use crate::types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; use crate::types::string::UntrustedString; use crate::util::errors::APIError; use crate::util::ser::{ - BigSize, FixedLengthReader, MaybeReadable, Readable, RequiredWrapper, UpgradableRequired, - WithoutLength, Writeable, Writer, + BigSize, FixedLengthReader, MaybeReadable, Readable, ReadableArgs, RequiredWrapper, + UpgradableRequired, WithoutLength, Writeable, Writer, }; use crate::io; @@ -2378,7 +2378,7 @@ impl MaybeReadable for Event { (6, _user_payment_id, option), (7, claim_deadline, option), (8, payment_preimage, option), - (9, onion_fields, option), + (9, onion_fields, (option: ReadableArgs, amount_msat)), (10, counterparty_skimmed_fee_msat_opt, option), (11, payment_context, option), (13, payment_id, option), @@ -2710,7 +2710,8 @@ impl MaybeReadable for Event { (4, amount_msat, required), (5, htlcs, optional_vec), (7, sender_intended_total_msat, option), - (9, onion_fields, option), + (9, onion_fields, (option: ReadableArgs, + sender_intended_total_msat.unwrap_or(amount_msat))), (11, payment_id, option), }); Ok(Some(Event::PaymentClaimed { diff --git a/lightning/src/ln/accountable_tests.rs b/lightning/src/ln/accountable_tests.rs index 35c936f4dd6..a2b918a3e14 100644 --- a/lightning/src/ln/accountable_tests.rs +++ b/lightning/src/ln/accountable_tests.rs @@ -32,7 +32,7 @@ fn test_accountable_forwarding_with_override( PaymentParameters::from_node_id(nodes[2].node.get_our_node_id(), TEST_FINAL_CLTV), 100_000, ); - let onion_fields = RecipientOnionFields::secret_only(payment_secret); + let onion_fields = RecipientOnionFields::secret_only(payment_secret, 100_000); let payment_id = PaymentId(payment_hash.0); nodes[0] .node diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index 8a991b1d98d..25522346d9c 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -615,7 +615,7 @@ fn invalid_keysend_payment_secret() { .node .send_spontaneous_payment( Some(keysend_preimage), - RecipientOnionFields::spontaneous_empty(), + RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(keysend_preimage.0), route_params, Retry::Attempts(0), diff --git a/lightning/src/ln/async_signer_tests.rs b/lightning/src/ln/async_signer_tests.rs index 537f29f1ca1..e6cd197bf1e 100644 --- a/lightning/src/ln/async_signer_tests.rs +++ b/lightning/src/ln/async_signer_tests.rs @@ -296,7 +296,7 @@ fn do_test_async_commitment_signature_for_commitment_signed_revoke_and_ack( let (route, our_payment_hash, _our_payment_preimage, our_payment_secret) = get_route_and_payment_hash!(src, dst, 8000000); - let recipient_fields = RecipientOnionFields::secret_only(our_payment_secret); + let recipient_fields = RecipientOnionFields::secret_only(our_payment_secret, 8000000); let payment_id = PaymentId(our_payment_hash.0); src.node .send_payment_with_route(route, our_payment_hash, recipient_fields, payment_id) @@ -520,7 +520,7 @@ fn do_test_async_raa_peer_disconnect( let (route, our_payment_hash, _our_payment_preimage, our_payment_secret) = get_route_and_payment_hash!(src, dst, 8000000); - let recipient_fields = RecipientOnionFields::secret_only(our_payment_secret); + let recipient_fields = RecipientOnionFields::secret_only(our_payment_secret, 8000000); let payment_id = PaymentId(our_payment_hash.0); src.node .send_payment_with_route(route, our_payment_hash, recipient_fields, payment_id) @@ -669,7 +669,7 @@ fn do_test_async_commitment_signature_peer_disconnect( let (route, our_payment_hash, _our_payment_preimage, our_payment_secret) = get_route_and_payment_hash!(src, dst, 8000000); - let recipient_fields = RecipientOnionFields::secret_only(our_payment_secret); + let recipient_fields = RecipientOnionFields::secret_only(our_payment_secret, 8000000); let payment_id = PaymentId(our_payment_hash.0); src.node .send_payment_with_route(route, our_payment_hash, recipient_fields, payment_id) @@ -804,7 +804,7 @@ fn do_test_async_commitment_signature_ordering(monitor_update_failure: bool) { // to the peer. let (route, payment_hash_2, payment_preimage_2, payment_secret_2) = get_route_and_payment_hash!(nodes[0], nodes[1], 1000000); - let recipient_fields = RecipientOnionFields::secret_only(payment_secret_2); + let recipient_fields = RecipientOnionFields::secret_only(payment_secret_2, 1000000); let payment_id = PaymentId(payment_hash_2.0); nodes[0] .node @@ -1343,14 +1343,14 @@ fn test_no_disconnect_while_async_revoke_and_ack_expecting_remote_commitment_sig // We'll send a payment from both nodes to each other. let (route1, payment_hash1, _, payment_secret1) = get_route_and_payment_hash!(&nodes[0], &nodes[1], payment_amount); - let onion1 = RecipientOnionFields::secret_only(payment_secret1); + let onion1 = RecipientOnionFields::secret_only(payment_secret1, payment_amount); let payment_id1 = PaymentId(payment_hash1.0); nodes[0].node.send_payment_with_route(route1, payment_hash1, onion1, payment_id1).unwrap(); check_added_monitors(&nodes[0], 1); let (route2, payment_hash2, _, payment_secret2) = get_route_and_payment_hash!(&nodes[1], &nodes[0], payment_amount); - let onion2 = RecipientOnionFields::secret_only(payment_secret2); + let onion2 = RecipientOnionFields::secret_only(payment_secret2, payment_amount); let payment_id2 = PaymentId(payment_hash2.0); nodes[1].node.send_payment_with_route(route2, payment_hash2, onion2, payment_id2).unwrap(); check_added_monitors(&nodes[1], 1); diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index d9f3374d481..3cabdee9667 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -187,7 +187,7 @@ fn do_one_hop_blinded_path(success: bool) { PaymentParameters::blinded(vec![blinded_path]), amt_msat, ); - nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(), + nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); check_added_monitors(&nodes[0], 1); pass_along_route(&nodes[0], &[&[&nodes[1]]], amt_msat, payment_hash, payment_secret); @@ -243,7 +243,7 @@ fn one_hop_blinded_path_with_dummy_hops() { .node .send_payment( payment_hash, - RecipientOnionFields::spontaneous_empty(), + RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(payment_hash.0), route_params, Retry::Attempts(0), @@ -307,7 +307,7 @@ fn mpp_to_one_hop_blinded_path() { PaymentParameters::blinded(vec![blinded_path]).with_bolt12_features(bolt12_features).unwrap(), amt_msat, ); - nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); + nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); check_added_monitors(&nodes[0], 2); let expected_route: &[&[&Node]] = &[&[&nodes[1], &nodes[3]], &[&nodes[2], &nodes[3]]]; @@ -399,7 +399,7 @@ fn mpp_to_three_hop_blinded_paths() { RouteParameters::from_payment_params_and_value(pay_params, amt_msat) }; - nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(), + nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); check_added_monitors(&nodes[0], 2); @@ -464,7 +464,7 @@ fn do_forward_checks_failure(check: ForwardCheckFail, intro_fails: bool) { let route = get_route(&nodes[0], &route_params).unwrap(); node_cfgs[0].router.expect_find_route(route_params.clone(), Ok(route.clone())); - nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); + nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); check_added_monitors(&nodes[0], 1); macro_rules! cause_error { @@ -474,7 +474,7 @@ fn do_forward_checks_failure(check: ForwardCheckFail, intro_fails: bool) { $update_add.cltv_expiry = 10; // causes outbound CLTV expiry to underflow }, ForwardCheckFail::ForwardPayloadEncodedAsReceive => { - let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(); + let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(amt_msat); let session_priv = SecretKey::from_slice(&[3; 32]).unwrap(); let mut onion_keys = onion_utils::construct_onion_keys(&Secp256k1::new(), &route.paths[0], &session_priv); let cur_height = nodes[0].best_block_info().1; @@ -594,7 +594,7 @@ fn failed_backwards_to_intro_node() { nodes.iter().skip(1).map(|n| n.node.get_our_node_id()).collect(), &[&chan_upd_1_2], &chanmon_cfgs[2].keys_manager); - nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); + nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); check_added_monitors(&nodes[0], 1); let mut events = nodes[0].node.get_and_clear_pending_msg_events(); @@ -680,7 +680,7 @@ fn do_forward_fail_in_process_pending_htlc_fwds(check: ProcessPendingHTLCsCheck, nodes.iter().skip(1).map(|n| n.node.get_our_node_id()).collect(), &[&chan_upd_1_2, &chan_upd_2_3], &chanmon_cfgs[2].keys_manager); - nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); + nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); check_added_monitors(&nodes[0], 1); let mut events = nodes[0].node.get_and_clear_pending_msg_events(); @@ -790,7 +790,7 @@ fn do_blinded_intercept_payment(intercept_node_fails: bool) { nodes.iter().skip(1).map(|n| n.node.get_our_node_id()).collect(), &[&intercept_chan_upd], &chanmon_cfgs[2].keys_manager); - nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(), + nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); check_added_monitors(&nodes[0], 1); let payment_event = { @@ -865,7 +865,7 @@ fn two_hop_blinded_path_success() { nodes.iter().skip(1).map(|n| n.node.get_our_node_id()).collect(), &[&chan_upd_1_2], &chanmon_cfgs[2].keys_manager); - nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); + nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); check_added_monitors(&nodes[0], 1); pass_along_route(&nodes[0], &[&[&nodes[1], &nodes[2]]], amt_msat, payment_hash, payment_secret); claim_payment(&nodes[0], &[&nodes[1], &nodes[2]], payment_preimage); @@ -895,7 +895,7 @@ fn three_hop_blinded_path_success() { nodes.iter().skip(2).map(|n| n.node.get_our_node_id()).collect(), &[&chan_upd_2_3, &chan_upd_3_4], &chanmon_cfgs[4].keys_manager); - nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); + nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); check_added_monitors(&nodes[0], 1); pass_along_route(&nodes[0], &[&[&nodes[1], &nodes[2], &nodes[3], &nodes[4]]], amt_msat, payment_hash, payment_secret); claim_payment(&nodes[0], &[&nodes[1], &nodes[2], &nodes[3], &nodes[4]], payment_preimage); @@ -920,7 +920,7 @@ fn three_hop_blinded_path_fail() { nodes.iter().skip(1).map(|n| n.node.get_our_node_id()).collect(), &[&chan_upd_1_2, &chan_upd_2_3], &chanmon_cfgs[3].keys_manager); - nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); + nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); check_added_monitors(&nodes[0], 1); pass_along_route(&nodes[0], &[&[&nodes[1], &nodes[2], &nodes[3]]], amt_msat, payment_hash, payment_secret); @@ -1021,7 +1021,7 @@ fn do_multi_hop_receiver_fail(check: ReceiveCheckFail) { find_route(&nodes[0], &route_params).unwrap() }; node_cfgs[0].router.expect_find_route(route_params.clone(), Ok(route.clone())); - nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); + nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); check_added_monitors(&nodes[0], 1); let mut payment_event_0_1 = { @@ -1064,7 +1064,7 @@ fn do_multi_hop_receiver_fail(check: ReceiveCheckFail) { let session_priv = SecretKey::from_slice(&session_priv).unwrap(); let mut onion_keys = onion_utils::construct_onion_keys(&Secp256k1::new(), &route.paths[0], &session_priv); let cur_height = nodes[0].best_block_info().1; - let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(); + let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(amt_msat); let (mut onion_payloads, ..) = onion_utils::build_onion_payloads( &route.paths[0], amt_msat, &recipient_onion_fields, cur_height, &None, None, None).unwrap(); @@ -1210,7 +1210,7 @@ fn blinded_path_retries() { RouteParameters::from_payment_params_and_value(pay_params, amt_msat) }; - nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0), route_params.clone(), Retry::Attempts(2)).unwrap(); + nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(payment_hash.0), route_params.clone(), Retry::Attempts(2)).unwrap(); check_added_monitors(&nodes[0], 1); pass_along_route(&nodes[0], &[&[&nodes[1], &nodes[3]]], amt_msat, payment_hash, payment_secret); @@ -1309,7 +1309,7 @@ fn min_htlc() { assert_eq!(min_htlc_msat, route_params.payment_params.payee.blinded_route_hints()[0].payinfo.htlc_minimum_msat); - nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0), route_params.clone(), Retry::Attempts(0)).unwrap(); + nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(min_htlc_msat), PaymentId(payment_hash.0), route_params.clone(), Retry::Attempts(0)).unwrap(); check_added_monitors(&nodes[0], 1); pass_along_route(&nodes[0], &[&[&nodes[1], &nodes[2], &nodes[3]]], min_htlc_msat, payment_hash, payment_secret); claim_payment(&nodes[0], &[&nodes[1], &nodes[2], &nodes[3]], payment_preimage); @@ -1322,7 +1322,7 @@ fn min_htlc() { route_hints[0].payinfo.htlc_minimum_msat -= 1; } else { panic!() } route_params.final_value_msat -= 1; - nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); + nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(route_params.final_value_msat), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); check_added_monitors(&nodes[0], 1); let mut payment_event_0_1 = { @@ -1387,7 +1387,7 @@ fn conditionally_round_fwd_amt() { &chanmon_cfgs[4].keys_manager); route_params.max_total_routing_fee_msat = None; - nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); + nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); check_added_monitors(&nodes[0], 1); pass_along_route(&nodes[0], &[&[&nodes[1], &nodes[2], &nodes[3], &nodes[4]]], amt_msat, payment_hash, payment_secret); nodes[4].node.claim_funds(payment_preimage); @@ -1432,7 +1432,7 @@ fn custom_tlvs_to_blinded_path() { amt_msat, ); - let recipient_onion_fields = RecipientOnionFields::spontaneous_empty() + let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(amt_msat) .with_custom_tlvs(RecipientCustomTlvs::new(vec![((1 << 16) + 1, vec![42, 42])]).unwrap()); nodes[0].node.send_payment(payment_hash, recipient_onion_fields.clone(), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); @@ -1487,7 +1487,7 @@ fn fails_receive_tlvs_authentication() { ); // Test authentication works normally. - nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); + nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); check_added_monitors(&nodes[0], 1); pass_along_route(&nodes[0], &[&[&nodes[1]]], amt_msat, payment_hash, payment_secret); claim_payment(&nodes[0], &[&nodes[1]], payment_preimage); @@ -1517,7 +1517,7 @@ fn fails_receive_tlvs_authentication() { amt_msat, ); - nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); + nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); check_added_monitors(&nodes[0], 1); let mut events = nodes[0].node.get_and_clear_pending_msg_events(); @@ -1574,7 +1574,7 @@ fn blinded_payment_path_padding() { let route_params = RouteParameters::from_payment_params_and_value(PaymentParameters::blinded(vec![blinded_path]), amt_msat); - nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); + nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); check_added_monitors(&nodes[0], 1); pass_along_route(&nodes[0], &[&[&nodes[1], &nodes[2], &nodes[3], &nodes[4]]], amt_msat, payment_hash, payment_secret); claim_payment(&nodes[0], &[&nodes[1], &nodes[2], &nodes[3], &nodes[4]], payment_preimage); @@ -1681,7 +1681,7 @@ fn route_blinding_spec_test_vector() { }), }; let cur_height = 747_000; - let (bob_onion, _, _) = onion_utils::create_payment_onion(&secp_ctx, &path, &session_priv, amt_msat, &RecipientOnionFields::spontaneous_empty(), cur_height, &PaymentHash([0; 32]), &None, None, [0; 32]).unwrap(); + let (bob_onion, _, _) = onion_utils::create_payment_onion(&secp_ctx, &path, &session_priv, amt_msat, &RecipientOnionFields::spontaneous_empty(amt_msat), cur_height, &PaymentHash([0; 32]), &None, None, [0; 32]).unwrap(); struct TestEcdhSigner { node_secret: SecretKey, @@ -1904,7 +1904,7 @@ fn test_combined_trampoline_onion_creation_vectors() { let amt_msat = 150_000_000; let cur_height = 800_000; - let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret); + let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret, amt_msat); let (bob_onion, htlc_msat, htlc_cltv) = onion_utils::create_payment_onion_internal(&secp_ctx, &path, &outer_session_key, amt_msat, &recipient_onion_fields, cur_height, &associated_data, &None, None, outer_onion_prng_seed, Some(session_priv), Some([0; 32])).unwrap(); let outer_onion_packet_hex = bob_onion.encode().to_lower_hex_string(); @@ -1995,7 +1995,7 @@ fn test_trampoline_inbound_payment_decoding() { let amt_msat = 150_000_001; let cur_height = 800_001; - let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret); + let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret, amt_msat); let (bob_onion, _, _) = onion_utils::create_payment_onion(&secp_ctx, &path, &session_priv, amt_msat, &recipient_onion_fields, cur_height, &PaymentHash([0; 32]), &None, None, [0; 32]).unwrap(); struct TestEcdhSigner { @@ -2166,12 +2166,11 @@ fn test_trampoline_forward_payload_encoded_as_receive() { route_params: None, }; - nodes[0].node.send_payment_with_route(route.clone(), payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0)).unwrap(); + nodes[0].node.send_payment_with_route(route.clone(), payment_hash, RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(payment_hash.0)).unwrap(); check_added_monitors(&nodes[0], 1); let replacement_onion = { // create a substitute onion where the last Trampoline hop is a forward - let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(); let mut blinded_tail = route.paths[0].blinded_tail.clone().unwrap(); @@ -2181,6 +2180,7 @@ fn test_trampoline_forward_payload_encoded_as_receive() { encrypted_payload: vec![], }); + let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(amt_msat); let (mut trampoline_payloads, outer_total_msat, outer_starting_htlc_offset) = onion_utils::build_trampoline_onion_payloads(&blinded_tail, amt_msat, &recipient_onion_fields, 32, &None).unwrap(); // pop the last dummy hop @@ -2195,6 +2195,7 @@ fn test_trampoline_forward_payload_encoded_as_receive() { None, ).unwrap(); + let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(outer_total_msat); let (outer_payloads, _, _) = onion_utils::build_onion_payloads(&route.paths[0], outer_total_msat, &recipient_onion_fields, outer_starting_htlc_offset, &None, None, Some(trampoline_packet)).unwrap(); let outer_onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route.clone().paths[0], &outer_session_priv); let outer_packet = onion_utils::construct_onion_packet( @@ -2331,7 +2332,7 @@ fn do_test_trampoline_single_hop_receive(success: bool) { route_params: None, }; - nodes[0].node.send_payment_with_route(route.clone(), payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0)).unwrap(); + nodes[0].node.send_payment_with_route(route.clone(), payment_hash, RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(payment_hash.0)).unwrap(); check_added_monitors(&nodes[0], 1); pass_along_route(&nodes[0], &[&[&nodes[1], &nodes[2]]], amt_msat, payment_hash, payment_secret); @@ -2477,7 +2478,7 @@ fn replacement_onion( ) -> msgs::OnionPacket { let outer_session_priv = SecretKey::from_slice(&override_random_bytes[..]).unwrap(); let trampoline_session_priv = onion_utils::compute_trampoline_session_priv(&outer_session_priv); - let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(); + let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(original_amt_msat); let blinded_tail = route.paths[0].blinded_tail.clone().unwrap(); @@ -2525,6 +2526,7 @@ fn replacement_onion( // Use a different session key to construct the replacement onion packet. Note that the // sender isn't aware of this and won't be able to decode the fulfill hold times. + let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(outer_total_msat); let (mut outer_payloads, _, _) = onion_utils::build_onion_payloads( &route.paths[0], outer_total_msat, @@ -2650,7 +2652,7 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { .send_payment_with_route( route.clone(), payment_hash, - RecipientOnionFields::spontaneous_empty(), + RecipientOnionFields::spontaneous_empty(original_amt_msat), PaymentId(payment_hash.0), ) .unwrap(); @@ -2832,7 +2834,7 @@ fn test_trampoline_forward_rejection() { route_params: None, }; - nodes[0].node.send_payment_with_route(route.clone(), payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0)).unwrap(); + nodes[0].node.send_payment_with_route(route.clone(), payment_hash, RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(payment_hash.0)).unwrap(); check_added_monitors(&nodes[0], 1); diff --git a/lightning/src/ln/chanmon_update_fail_tests.rs b/lightning/src/ln/chanmon_update_fail_tests.rs index b66695ca13e..cd32d219b93 100644 --- a/lightning/src/ln/chanmon_update_fail_tests.rs +++ b/lightning/src/ln/chanmon_update_fail_tests.rs @@ -187,7 +187,7 @@ fn do_test_simple_monitor_temporary_update_fail(disconnect: bool) { chanmon_cfgs[0].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress); - let onion = RecipientOnionFields::secret_only(payment_secret_1); + let onion = RecipientOnionFields::secret_only(payment_secret_1, 1000000); let id = PaymentId(payment_hash_1.0); nodes[0].node.send_payment_with_route(route, payment_hash_1, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -254,7 +254,7 @@ fn do_test_simple_monitor_temporary_update_fail(disconnect: bool) { get_route_and_payment_hash!(&nodes[0], nodes[1], 1000000); chanmon_cfgs[0].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress); - let onion = RecipientOnionFields::secret_only(payment_secret_2); + let onion = RecipientOnionFields::secret_only(payment_secret_2, 1000000); let id = PaymentId(payment_hash_2.0); nodes[0].node.send_payment_with_route(route, payment_hash_2, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -330,7 +330,7 @@ fn do_test_monitor_temporary_update_fail(disconnect_count: usize) { let (route, payment_hash_2, payment_preimage_2, payment_secret_2) = get_route_and_payment_hash!(nodes[0], nodes[1], 1000000); chanmon_cfgs[0].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress); - let onion = RecipientOnionFields::secret_only(payment_secret_2); + let onion = RecipientOnionFields::secret_only(payment_secret_2, 1000000); let id = PaymentId(payment_hash_2.0); nodes[0].node.send_payment_with_route(route, payment_hash_2, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -735,7 +735,7 @@ fn test_monitor_update_fail_cs() { let (route, our_payment_hash, payment_preimage, our_payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[1], 1000000); - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, 1000000); let id = PaymentId(our_payment_hash.0); nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -843,7 +843,7 @@ fn test_monitor_update_fail_no_rebroadcast() { let (route, our_payment_hash, payment_preimage_1, payment_secret_1) = get_route_and_payment_hash!(nodes[0], nodes[1], 1000000); - let onion = RecipientOnionFields::secret_only(payment_secret_1); + let onion = RecipientOnionFields::secret_only(payment_secret_1, 1000000); let id = PaymentId(our_payment_hash.0); nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -897,7 +897,7 @@ fn test_monitor_update_raa_while_paused() { send_payment(&nodes[0], &[&nodes[1]], 5000000); let (route, our_payment_hash_1, payment_preimage_1, our_payment_secret_1) = get_route_and_payment_hash!(nodes[0], nodes[1], 1000000); - let onion = RecipientOnionFields::secret_only(our_payment_secret_1); + let onion = RecipientOnionFields::secret_only(our_payment_secret_1, 1000000); let id = PaymentId(our_payment_hash_1.0); nodes[0].node.send_payment_with_route(route, our_payment_hash_1, onion, id).unwrap(); @@ -907,7 +907,7 @@ fn test_monitor_update_raa_while_paused() { let (route, our_payment_hash_2, payment_preimage_2, our_payment_secret_2) = get_route_and_payment_hash!(nodes[1], nodes[0], 1000000); - let onion_2 = RecipientOnionFields::secret_only(our_payment_secret_2); + let onion_2 = RecipientOnionFields::secret_only(our_payment_secret_2, 1000000); let id_2 = PaymentId(our_payment_hash_2.0); nodes[1].node.send_payment_with_route(route, our_payment_hash_2, onion_2, id_2).unwrap(); @@ -1008,7 +1008,7 @@ fn do_test_monitor_update_fail_raa(test_ignore_second_cs: bool) { // holding cell. let (route, payment_hash_2, payment_preimage_2, payment_secret_2) = get_route_and_payment_hash!(nodes[0], nodes[2], 1000000); - let onion_2 = RecipientOnionFields::secret_only(payment_secret_2); + let onion_2 = RecipientOnionFields::secret_only(payment_secret_2, 1000000); let id_2 = PaymentId(payment_hash_2.0); nodes[0].node.send_payment_with_route(route, payment_hash_2, onion_2, id_2).unwrap(); check_added_monitors(&nodes[0], 1); @@ -1034,7 +1034,7 @@ fn do_test_monitor_update_fail_raa(test_ignore_second_cs: bool) { // being paused waiting a monitor update. let (route, payment_hash_3, _, payment_secret_3) = get_route_and_payment_hash!(nodes[0], nodes[2], 1000000); - let onion_3 = RecipientOnionFields::secret_only(payment_secret_3); + let onion_3 = RecipientOnionFields::secret_only(payment_secret_3, 1000000); let id_3 = PaymentId(payment_hash_3.0); nodes[0].node.send_payment_with_route(route, payment_hash_3, onion_3, id_3).unwrap(); check_added_monitors(&nodes[0], 1); @@ -1055,7 +1055,7 @@ fn do_test_monitor_update_fail_raa(test_ignore_second_cs: bool) { // Try to route another payment backwards from 2 to make sure 1 holds off on responding let (route, payment_hash_4, payment_preimage_4, payment_secret_4) = get_route_and_payment_hash!(nodes[2], nodes[0], 1000000); - let onion_4 = RecipientOnionFields::secret_only(payment_secret_4); + let onion_4 = RecipientOnionFields::secret_only(payment_secret_4, 1000000); let id_4 = PaymentId(payment_hash_4.0); nodes[2].node.send_payment_with_route(route, payment_hash_4, onion_4, id_4).unwrap(); check_added_monitors(&nodes[2], 1); @@ -1391,11 +1391,11 @@ fn raa_no_response_awaiting_raa_state() { // immediately after a CS. By setting failing the monitor update failure from the CS (which // requires only an RAA response due to AwaitingRAA) we can deliver the RAA and require the CS // generation during RAA while in monitor-update-failed state. - let onion_1 = RecipientOnionFields::secret_only(payment_secret_1); + let onion_1 = RecipientOnionFields::secret_only(payment_secret_1, 1000000); let id_1 = PaymentId(payment_hash_1.0); nodes[0].node.send_payment_with_route(route.clone(), payment_hash_1, onion_1, id_1).unwrap(); check_added_monitors(&nodes[0], 1); - let onion_2 = RecipientOnionFields::secret_only(payment_secret_2); + let onion_2 = RecipientOnionFields::secret_only(payment_secret_2, 1000000); let id_2 = PaymentId(payment_hash_2.0); nodes[0].node.send_payment_with_route(route.clone(), payment_hash_2, onion_2, id_2).unwrap(); check_added_monitors(&nodes[0], 0); @@ -1444,7 +1444,7 @@ fn raa_no_response_awaiting_raa_state() { // We send a third payment here, which is somewhat of a redundant test, but the // chanmon_fail_consistency test required it to actually find the bug (by seeing out-of-sync // commitment transaction states) whereas here we can explicitly check for it. - let onion_3 = RecipientOnionFields::secret_only(payment_secret_3); + let onion_3 = RecipientOnionFields::secret_only(payment_secret_3, 1000000); let id_3 = PaymentId(payment_hash_3.0); nodes[0].node.send_payment_with_route(route, payment_hash_3, onion_3, id_3).unwrap(); check_added_monitors(&nodes[0], 0); @@ -1546,7 +1546,7 @@ fn claim_while_disconnected_monitor_update_fail() { // the monitor still failed let (route, payment_hash_2, payment_preimage_2, payment_secret_2) = get_route_and_payment_hash!(nodes[0], nodes[1], 1000000); - let onion_2 = RecipientOnionFields::secret_only(payment_secret_2); + let onion_2 = RecipientOnionFields::secret_only(payment_secret_2, 1000000); let id_2 = PaymentId(payment_hash_2.0); nodes[0].node.send_payment_with_route(route, payment_hash_2, onion_2, id_2).unwrap(); check_added_monitors(&nodes[0], 1); @@ -1653,7 +1653,7 @@ fn monitor_failed_no_reestablish_response() { // on receipt). let (route, payment_hash_1, payment_preimage_1, payment_secret_1) = get_route_and_payment_hash!(nodes[0], nodes[1], 1000000); - let onion = RecipientOnionFields::secret_only(payment_secret_1); + let onion = RecipientOnionFields::secret_only(payment_secret_1, 1000000); let id = PaymentId(payment_hash_1.0); nodes[0].node.send_payment_with_route(route, payment_hash_1, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -1737,7 +1737,7 @@ fn first_message_on_recv_ordering() { // can deliver it and fail the monitor update. let (route, payment_hash_1, payment_preimage_1, payment_secret_1) = get_route_and_payment_hash!(nodes[0], nodes[1], 1000000); - let onion_1 = RecipientOnionFields::secret_only(payment_secret_1); + let onion_1 = RecipientOnionFields::secret_only(payment_secret_1, 1000000); let id_1 = PaymentId(payment_hash_1.0); nodes[0].node.send_payment_with_route(route, payment_hash_1, onion_1, id_1).unwrap(); check_added_monitors(&nodes[0], 1); @@ -1761,7 +1761,7 @@ fn first_message_on_recv_ordering() { // Route the second payment, generating an update_add_htlc/commitment_signed let (route, payment_hash_2, payment_preimage_2, payment_secret_2) = get_route_and_payment_hash!(nodes[0], nodes[1], 1000000); - let onion_2 = RecipientOnionFields::secret_only(payment_secret_2); + let onion_2 = RecipientOnionFields::secret_only(payment_secret_2, 1000000); let id_2 = PaymentId(payment_hash_2.0); nodes[0].node.send_payment_with_route(route, payment_hash_2, onion_2, id_2).unwrap(); @@ -1854,7 +1854,7 @@ fn test_monitor_update_fail_claim() { let (route, payment_hash_2, _, payment_secret_2) = get_route_and_payment_hash!(nodes[2], nodes[0], 1_000_000); - let onion_2 = RecipientOnionFields::secret_only(payment_secret_2); + let onion_2 = RecipientOnionFields::secret_only(payment_secret_2, 1_000_000); let id_2 = PaymentId(payment_hash_2.0); nodes[2].node.send_payment_with_route(route.clone(), payment_hash_2, onion_2, id_2).unwrap(); check_added_monitors(&nodes[2], 1); @@ -1874,7 +1874,7 @@ fn test_monitor_update_fail_claim() { let (_, payment_hash_3, payment_secret_3) = get_payment_preimage_hash(&nodes[0], None, None); let id_3 = PaymentId(payment_hash_3.0); - let onion_3 = RecipientOnionFields::secret_only(payment_secret_3); + let onion_3 = RecipientOnionFields::secret_only(payment_secret_3, 1_000_000); nodes[2].node.send_payment_with_route(route, payment_hash_3, onion_3, id_3).unwrap(); check_added_monitors(&nodes[2], 1); @@ -1998,7 +1998,7 @@ fn test_monitor_update_on_pending_forwards() { let (route, payment_hash_2, payment_preimage_2, payment_secret_2) = get_route_and_payment_hash!(nodes[2], nodes[0], 1000000); - let onion = RecipientOnionFields::secret_only(payment_secret_2); + let onion = RecipientOnionFields::secret_only(payment_secret_2, 1000000); let id = PaymentId(payment_hash_2.0); nodes[2].node.send_payment_with_route(route, payment_hash_2, onion, id).unwrap(); check_added_monitors(&nodes[2], 1); @@ -2069,7 +2069,7 @@ fn monitor_update_claim_fail_no_response() { // Now start forwarding a second payment, skipping the last RAA so B is in AwaitingRAA let (route, payment_hash_2, payment_preimage_2, payment_secret_2) = get_route_and_payment_hash!(nodes[0], nodes[1], 1000000); - let onion = RecipientOnionFields::secret_only(payment_secret_2); + let onion = RecipientOnionFields::secret_only(payment_secret_2, 1000000); let id = PaymentId(payment_hash_2.0); nodes[0].node.send_payment_with_route(route, payment_hash_2, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -2317,7 +2317,7 @@ fn test_path_paused_mpp() { chanmon_cfgs[0].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress); // The first path should have succeeded with the second getting a MonitorUpdateInProgress err. - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 200000); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment_with_route(route, payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 2); @@ -2373,7 +2373,7 @@ fn test_pending_update_fee_ack_on_reconnect() { let (route, payment_hash, payment_preimage, payment_secret) = get_route_and_payment_hash!(&nodes[1], nodes[0], 1_000_000); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 1_000_000); let id = PaymentId(payment_hash.0); nodes[1].node.send_payment_with_route(route, payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[1], 1); @@ -2688,14 +2688,14 @@ fn do_channel_holding_cell_serialize(disconnect: bool, reload_a: bool) { // (c) will not be freed from the holding cell. let (payment_preimage_0, payment_hash_0, ..) = route_payment(&nodes[1], &[&nodes[0]], 100_000); - let onion_1 = RecipientOnionFields::secret_only(payment_secret_1); + let onion_1 = RecipientOnionFields::secret_only(payment_secret_1, 100000); let id_1 = PaymentId(payment_hash_1.0); nodes[0].node.send_payment_with_route(route.clone(), payment_hash_1, onion_1, id_1).unwrap(); check_added_monitors(&nodes[0], 1); let send = SendEvent::from_node(&nodes[0]); assert_eq!(send.msgs.len(), 1); - let onion_2 = RecipientOnionFields::secret_only(payment_secret_2); + let onion_2 = RecipientOnionFields::secret_only(payment_secret_2, 100000); let id_2 = PaymentId(payment_hash_2.0); nodes[0].node.send_payment_with_route(route, payment_hash_2, onion_2, id_2).unwrap(); check_added_monitors(&nodes[0], 0); @@ -2872,7 +2872,7 @@ fn do_test_reconnect_dup_htlc_claims(htlc_status: HTLCStatusAtDupClaim, second_f // awaiting a remote revoke_and_ack from nodes[0]. let (route, second_payment_hash, _, second_payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[1], 100_000); - let onion_2 = RecipientOnionFields::secret_only(second_payment_secret); + let onion_2 = RecipientOnionFields::secret_only(second_payment_secret, 100_000); let id_2 = PaymentId(second_payment_hash.0); nodes[0].node.send_payment_with_route(route, second_payment_hash, onion_2, id_2).unwrap(); check_added_monitors(&nodes[0], 1); @@ -4155,7 +4155,7 @@ fn do_test_glacial_peer_cant_hang(hold_chan_a: bool) { // With the A<->B preimage persistence not yet complete, the B<->C channel is stuck // waiting. - let onion_2 = RecipientOnionFields::secret_only(payment_secret_2); + let onion_2 = RecipientOnionFields::secret_only(payment_secret_2, 1_000_000); let id_2 = PaymentId(payment_hash_2.0); nodes[1].node.send_payment_with_route(route, payment_hash_2, onion_2, id_2).unwrap(); check_added_monitors(&nodes[1], 0); @@ -5104,7 +5104,7 @@ fn test_mpp_claim_to_holding_cell() { // Put the C <-> D channel into AwaitingRaa let (preimage_2, paymnt_hash_2, payment_secret_2) = get_payment_preimage_hash(&nodes[3], None, None); - let onion = RecipientOnionFields::secret_only(payment_secret_2); + let onion = RecipientOnionFields::secret_only(payment_secret_2, 400_000); let id = PaymentId([42; 32]); let pay_params = PaymentParameters::from_node_id(node_d_id, TEST_FINAL_CLTV); let route_params = RouteParameters::from_payment_params_and_value(pay_params, 400_000); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 458a1f7ac98..18bbbbc2821 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -1110,7 +1110,7 @@ impl_writeable_tlv_based!(ClaimingPayment, { (4, receiver_node_id, required), (5, htlcs, optional_vec), (7, sender_intended_value, option), - (9, onion_fields, option), + (9, onion_fields, (option: ReadableArgs, amount_msat.0.unwrap())), (11, payment_id, option), }); @@ -7932,6 +7932,7 @@ impl< payment_secret: Some(payment_data.payment_secret), payment_metadata, custom_tlvs, + total_mpp_amount_msat: payment_data.total_msat, }; ( incoming_cltv_expiry, @@ -7960,6 +7961,10 @@ impl< payment_secret: payment_data .as_ref() .map(|data| data.payment_secret), + total_mpp_amount_msat: payment_data + .as_ref() + .map(|data| data.total_msat) + .unwrap_or(outgoing_amt_msat), payment_metadata, custom_tlvs, }; @@ -17551,6 +17556,18 @@ impl Readable for VecDeque<(Event, Option)> { } } +/// We write the [`ClaimableHTLC`]'s [`RecipientOnionFields`] separately as they were added sometime +/// later. Because [`RecipientOnionFields`] only implements [`ReadableArgs`] we have to add a +/// wrapper which reads them without [`RecipientOnionFields::total_mpp_amount_msat`] and then fill +/// them in later. +struct AmountlessClaimablePaymentHTLCOnion(RecipientOnionFields); + +impl Readable for AmountlessClaimablePaymentHTLCOnion { + fn read(reader: &mut R) -> Result { + Ok(Self(ReadableArgs::read(reader, 0)?)) + } +} + // Raw deserialized data from a ChannelManager, before validation or reconstruction. // This is an internal DTO used in the two-stage deserialization process. pub(super) struct ChannelManagerData { @@ -17743,8 +17760,10 @@ impl<'a, ES: EntropySource, NS: NodeSigner, SP: SignerProvider, L: Logger> let mut fake_scid_rand_bytes: Option<[u8; 32]> = None; let mut probing_cookie_secret: Option<[u8; 32]> = None; let mut claimable_htlc_purposes = None; - let mut claimable_htlc_onion_fields = None; - let mut pending_claiming_payments = None; + let mut amountless_claimable_htlc_onion_fields: Option< + Vec>, + > = None; + let mut pending_claiming_payments = Some(new_hash_map()); let mut monitor_update_blocked_actions_per_peer: Option>)>> = None; let mut events_override = None; @@ -17771,7 +17790,7 @@ impl<'a, ES: EntropySource, NS: NodeSigner, SP: SignerProvider, L: Logger> (9, claimable_htlc_purposes, optional_vec), (10, legacy_in_flight_monitor_updates, option), (11, probing_cookie_secret, option), - (13, claimable_htlc_onion_fields, optional_vec), + (13, amountless_claimable_htlc_onion_fields, optional_vec), (14, decode_update_add_htlcs_legacy, option), (15, inbound_payment_id_secret, option), (17, in_flight_monitor_updates, option), @@ -17836,7 +17855,7 @@ impl<'a, ES: EntropySource, NS: NodeSigner, SP: SignerProvider, L: Logger> if purposes.len() != claimable_htlcs_list.len() { return Err(DecodeError::InvalidValue); } - if let Some(onion_fields) = claimable_htlc_onion_fields { + if let Some(onion_fields) = amountless_claimable_htlc_onion_fields { if onion_fields.len() != claimable_htlcs_list.len() { return Err(DecodeError::InvalidValue); } @@ -17844,7 +17863,20 @@ impl<'a, ES: EntropySource, NS: NodeSigner, SP: SignerProvider, L: Logger> .into_iter() .zip(onion_fields.into_iter().zip(claimable_htlcs_list.into_iter())) { - let claimable = ClaimablePayment { purpose, htlcs, onion_fields: onion }; + let htlcs_total_msat = + htlcs.first().ok_or(DecodeError::InvalidValue)?.total_msat; + let onion_fields = if let Some(mut onion) = onion { + if onion.0.total_mpp_amount_msat != 0 + && onion.0.total_mpp_amount_msat != htlcs_total_msat + { + return Err(DecodeError::InvalidValue); + } + onion.0.total_mpp_amount_msat = htlcs_total_msat; + Some(onion.0) + } else { + None + }; + let claimable = ClaimablePayment { purpose, htlcs, onion_fields }; let existing_payment = claimable_payments.insert(payment_hash, claimable); if existing_payment.is_some() { return Err(DecodeError::InvalidValue); @@ -20040,9 +20072,9 @@ mod tests { // indicates there are more HTLCs coming. let cur_height = CHAN_CONFIRM_DEPTH + 1; // route_payment calls send_payment, which adds 1 to the current height. So we do the same here to match. let session_privs = nodes[0].node.test_add_new_pending_payment(our_payment_hash, - RecipientOnionFields::secret_only(payment_secret), payment_id, &mpp_route).unwrap(); + RecipientOnionFields::secret_only(payment_secret, 200_000), payment_id, &mpp_route).unwrap(); nodes[0].node.test_send_payment_along_path(&mpp_route.paths[0], &our_payment_hash, - RecipientOnionFields::secret_only(payment_secret), 200_000, cur_height, payment_id, &None, session_privs[0]).unwrap(); + RecipientOnionFields::secret_only(payment_secret, 200_000), 200_000, cur_height, payment_id, &None, session_privs[0]).unwrap(); check_added_monitors(&nodes[0], 1); let mut events = nodes[0].node.get_and_clear_pending_msg_events(); assert_eq!(events.len(), 1); @@ -20050,7 +20082,7 @@ mod tests { // Next, send a keysend payment with the same payment_hash and make sure it fails. nodes[0].node.send_spontaneous_payment( - Some(payment_preimage), RecipientOnionFields::spontaneous_empty(), + Some(payment_preimage), RecipientOnionFields::spontaneous_empty(100_000), PaymentId(payment_preimage.0), route.route_params.clone().unwrap(), Retry::Attempts(0) ).unwrap(); check_added_monitors(&nodes[0], 1); @@ -20078,7 +20110,7 @@ mod tests { // Send the second half of the original MPP payment. nodes[0].node.test_send_payment_along_path(&mpp_route.paths[1], &our_payment_hash, - RecipientOnionFields::secret_only(payment_secret), 200_000, cur_height, payment_id, &None, session_privs[1]).unwrap(); + RecipientOnionFields::secret_only(payment_secret, 200_000), 200_000, cur_height, payment_id, &None, session_privs[1]).unwrap(); check_added_monitors(&nodes[0], 1); let mut events = nodes[0].node.get_and_clear_pending_msg_events(); assert_eq!(events.len(), 1); @@ -20168,7 +20200,7 @@ mod tests { PaymentParameters::for_keysend(expected_route.last().unwrap().node.get_our_node_id(), TEST_FINAL_CLTV, false), 100_000); nodes[0].node.send_spontaneous_payment( - Some(payment_preimage), RecipientOnionFields::spontaneous_empty(), + Some(payment_preimage), RecipientOnionFields::spontaneous_empty(100_000), PaymentId(payment_preimage.0), route_params.clone(), Retry::Attempts(0) ).unwrap(); check_added_monitors(&nodes[0], 1); @@ -20206,7 +20238,7 @@ mod tests { None, nodes[0].logger, &scorer, &Default::default(), &random_seed_bytes ).unwrap(); let payment_hash = nodes[0].node.send_spontaneous_payment( - Some(payment_preimage), RecipientOnionFields::spontaneous_empty(), + Some(payment_preimage), RecipientOnionFields::spontaneous_empty(100_000), PaymentId(payment_preimage.0), route.route_params.clone().unwrap(), Retry::Attempts(0) ).unwrap(); check_added_monitors(&nodes[0], 1); @@ -20219,7 +20251,7 @@ mod tests { // Next, attempt a regular payment and make sure it fails. let payment_secret = PaymentSecret([43; 32]); nodes[0].node.send_payment_with_route(route.clone(), payment_hash, - RecipientOnionFields::secret_only(payment_secret), PaymentId(payment_hash.0)).unwrap(); + RecipientOnionFields::secret_only(payment_secret, 100_000), PaymentId(payment_hash.0)).unwrap(); check_added_monitors(&nodes[0], 1); let mut events = nodes[0].node.get_and_clear_pending_msg_events(); assert_eq!(events.len(), 1); @@ -20249,7 +20281,7 @@ mod tests { // To start (3), send a keysend payment but don't claim it. let payment_id_1 = PaymentId([44; 32]); let payment_hash = nodes[0].node.send_spontaneous_payment( - Some(payment_preimage), RecipientOnionFields::spontaneous_empty(), payment_id_1, + Some(payment_preimage), RecipientOnionFields::spontaneous_empty(100_000), payment_id_1, route.route_params.clone().unwrap(), Retry::Attempts(0) ).unwrap(); check_added_monitors(&nodes[0], 1); @@ -20266,7 +20298,7 @@ mod tests { ); let payment_id_2 = PaymentId([45; 32]); nodes[0].node.send_spontaneous_payment( - Some(payment_preimage), RecipientOnionFields::spontaneous_empty(), payment_id_2, route_params, + Some(payment_preimage), RecipientOnionFields::spontaneous_empty(100_000), payment_id_2, route_params, Retry::Attempts(0) ).unwrap(); check_added_monitors(&nodes[0], 1); @@ -20324,9 +20356,9 @@ mod tests { let test_preimage = PaymentPreimage([42; 32]); let mismatch_payment_hash = PaymentHash([43; 32]); let session_privs = nodes[0].node.test_add_new_pending_payment(mismatch_payment_hash, - RecipientOnionFields::spontaneous_empty(), PaymentId(mismatch_payment_hash.0), &route).unwrap(); + RecipientOnionFields::spontaneous_empty(10_000), PaymentId(mismatch_payment_hash.0), &route).unwrap(); nodes[0].node.test_send_payment_internal(&route, mismatch_payment_hash, - RecipientOnionFields::spontaneous_empty(), Some(test_preimage), PaymentId(mismatch_payment_hash.0), None, session_privs).unwrap(); + RecipientOnionFields::spontaneous_empty(10_000), Some(test_preimage), PaymentId(mismatch_payment_hash.0), None, session_privs).unwrap(); check_added_monitors(&nodes[0], 1); let updates = get_htlc_update_msgs(&nodes[0], &nodes[1].node.get_our_node_id()); @@ -20371,7 +20403,7 @@ mod tests { route.route_params.as_mut().unwrap().final_value_msat *= 2; nodes[0].node.send_payment_with_route(route, payment_hash, - RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0)).unwrap(); + RecipientOnionFields::spontaneous_empty(200000), PaymentId(payment_hash.0)).unwrap(); let events = nodes[0].node.get_and_clear_pending_events(); assert_eq!(events.len(), 1); match events[0] { @@ -21209,7 +21241,7 @@ pub mod bench { let payment_hash = PaymentHash(Sha256::hash(&payment_preimage.0[..]).to_byte_array()); let payment_secret = $node_b.create_inbound_payment_for_hash(payment_hash, None, 7200, None).unwrap(); - $node_a.send_payment(payment_hash, RecipientOnionFields::secret_only(payment_secret), + $node_a.send_payment(payment_hash, RecipientOnionFields::secret_only(payment_secret, 10_000), PaymentId(payment_hash.0), RouteParameters::from_payment_params_and_value(payment_params, 10_000), Retry::Attempts(0)).unwrap(); diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index aa7eaa509ce..16616e5077c 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -3446,7 +3446,7 @@ pub fn send_along_route_with_secret<'a, 'b, 'c>( .node .send_payment( our_payment_hash, - RecipientOnionFields::secret_only(our_payment_secret), + RecipientOnionFields::secret_only(our_payment_secret, recv_value), payment_id, route.route_params.unwrap(), Retry::Attempts(0), @@ -3585,7 +3585,7 @@ pub fn do_pass_along_path<'a, 'b, 'c>(args: PassAlongPathArgs) -> Option if is_last_hop && is_probe { do_commitment_signed_dance(node, prev_node, &payment_event.commitment_msg, true, true); - node.node.process_pending_htlc_forwards(); + expect_and_process_pending_htlcs(node, true); check_added_monitors(node, 1); } else { let commitment = &payment_event.commitment_msg; diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index 4a2c8e0ed1d..796c1513382 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -2026,7 +2026,7 @@ fn do_test_commitment_revoked_fail_backward_exhaustive( // on nodes[2]'s RAA. let (route, fourth_payment_hash, _, fourth_payment_secret) = get_route_and_payment_hash!(nodes[1], nodes[2], 1000000); - let onion = RecipientOnionFields::secret_only(fourth_payment_secret); + let onion = RecipientOnionFields::secret_only(fourth_payment_secret, 1000000); let id = PaymentId(fourth_payment_hash.0); nodes[1].node.send_payment_with_route(route, fourth_payment_hash, onion, id).unwrap(); assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); @@ -2249,7 +2249,7 @@ pub fn fail_backward_pending_htlc_upon_channel_failure() { { let (route, payment_hash, _, payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[1], 50_000); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 50_000); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment_with_route(route, payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -2267,7 +2267,7 @@ pub fn fail_backward_pending_htlc_upon_channel_failure() { let (route, failed_payment_hash, _, failed_payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[1], 50_000); { - let onion = RecipientOnionFields::secret_only(failed_payment_secret); + let onion = RecipientOnionFields::secret_only(failed_payment_secret, 50_000); let id = PaymentId(failed_payment_hash.0); nodes[0].node.send_payment_with_route(route, failed_payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 0); @@ -2283,7 +2283,7 @@ pub fn fail_backward_pending_htlc_upon_channel_failure() { let secp_ctx = Secp256k1::new(); let session_priv = SecretKey::from_slice(&[42; 32]).unwrap(); let current_height = nodes[1].node.best_block.read().unwrap().height + 1; - let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret); + let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret, 50_000); let (onion_payloads, _amount_msat, cltv_expiry) = onion_utils::build_onion_payloads( &route.paths[0], 50_000, @@ -2419,7 +2419,7 @@ pub fn test_force_close_fail_back() { get_route_and_payment_hash!(nodes[0], nodes[2], 1000000); let mut payment_event = { - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, 1000000); let id = PaymentId(our_payment_hash.0); nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -2705,7 +2705,7 @@ fn do_test_drop_messages_peer_disconnect(messages_delivered: u8, simulate_broken get_route_and_payment_hash!(nodes[0], nodes[1], 1_000_000); let payment_event = { - let onion = RecipientOnionFields::secret_only(payment_secret_1); + let onion = RecipientOnionFields::secret_only(payment_secret_1, 1_000_000); let id = PaymentId(payment_hash_1.0); nodes[0].node.send_payment_with_route(route, payment_hash_1, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -3120,7 +3120,7 @@ pub fn test_drop_messages_peer_disconnect_dual_htlc() { // Now try to send a second payment which will fail to send let (route, payment_hash_2, payment_preimage_2, payment_secret_2) = get_route_and_payment_hash!(nodes[0], nodes[1], 1000000); - let onion = RecipientOnionFields::secret_only(payment_secret_2); + let onion = RecipientOnionFields::secret_only(payment_secret_2, 1000000); let id = PaymentId(payment_hash_2.0); nodes[0].node.send_payment_with_route(route, payment_hash_2, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -3309,7 +3309,7 @@ fn do_test_htlc_timeout(send_partial_mpp: bool) { // indicates there are more HTLCs coming. let cur_height = CHAN_CONFIRM_DEPTH + 1; // route_payment calls send_payment, which adds 1 to the current height. So we do the same here to match. let payment_id = PaymentId([42; 32]); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 100000); let session_privs = nodes[0] .node .test_add_new_pending_payment(our_payment_hash, onion, payment_id, &route) @@ -3320,7 +3320,7 @@ fn do_test_htlc_timeout(send_partial_mpp: bool) { .test_send_payment_along_path( &route.paths[0], &our_payment_hash, - RecipientOnionFields::secret_only(payment_secret), + RecipientOnionFields::secret_only(payment_secret, 200_000), 200_000, cur_height, payment_id, @@ -3409,7 +3409,7 @@ fn do_test_holding_cell_htlc_add_timeouts(forwarded_htlc: bool) { // Route a first payment to get the 1 -> 2 channel in awaiting_raa... let (route, first_payment_hash, _, first_payment_secret) = get_route_and_payment_hash!(nodes[1], nodes[2], 100000); - let onion = RecipientOnionFields::secret_only(first_payment_secret); + let onion = RecipientOnionFields::secret_only(first_payment_secret, 100000); let id = PaymentId(first_payment_hash.0); nodes[1].node.send_payment_with_route(route, first_payment_hash, onion, id).unwrap(); assert_eq!(nodes[1].node.get_and_clear_pending_msg_events().len(), 1); @@ -3419,7 +3419,7 @@ fn do_test_holding_cell_htlc_add_timeouts(forwarded_htlc: bool) { let sending_node = if forwarded_htlc { &nodes[0] } else { &nodes[1] }; let (route, second_payment_hash, _, second_payment_secret) = get_route_and_payment_hash!(sending_node, nodes[2], 100000); - let onion = RecipientOnionFields::secret_only(second_payment_secret); + let onion = RecipientOnionFields::secret_only(second_payment_secret, 100000); let id = PaymentId(second_payment_hash.0); sending_node.node.send_payment_with_route(route, second_payment_hash, onion, id).unwrap(); @@ -5065,7 +5065,8 @@ fn do_htlc_claim_current_remote_commitment_only(use_dust: bool) { let (route, payment_hash, _, payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[1], if use_dust { 50000 } else { 3000000 }); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = + RecipientOnionFields::secret_only(payment_secret, if use_dust { 50000 } else { 3000000 }); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment_with_route(route, payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -5235,7 +5236,7 @@ pub fn test_fail_holding_cell_htlc_upon_free() { get_route_and_payment_hash!(nodes[0], nodes[1], max_can_send); // Send a payment which passes reserve checks but gets stuck in the holding cell. - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, max_can_send); let id = PaymentId(our_payment_hash.0); nodes[0].node.send_payment_with_route(route.clone(), our_payment_hash, onion, id).unwrap(); chan_stat = get_channel_value_stat!(nodes[0], nodes[1], chan.2); @@ -5341,14 +5342,14 @@ pub fn test_free_and_fail_holding_cell_htlcs() { get_route_and_payment_hash!(nodes[0], nodes[1], amt_2); // Send 2 payments which pass reserve checks but get stuck in the holding cell. - let onion = RecipientOnionFields::secret_only(payment_secret_1); + let onion = RecipientOnionFields::secret_only(payment_secret_1, amt_1); let id_1 = PaymentId(payment_hash_1.0); nodes[0].node.send_payment_with_route(route_1, payment_hash_1, onion, id_1).unwrap(); chan_stat = get_channel_value_stat!(nodes[0], nodes[1], chan.2); assert_eq!(chan_stat.holding_cell_outbound_amount_msat, amt_1); let id_2 = PaymentId(nodes[0].keys_manager.get_secure_random_bytes()); - let onion = RecipientOnionFields::secret_only(payment_secret_2); + let onion = RecipientOnionFields::secret_only(payment_secret_2, amt_2); nodes[0].node.send_payment_with_route(route_2.clone(), payment_hash_2, onion, id_2).unwrap(); chan_stat = get_channel_value_stat!(nodes[0], nodes[1], chan.2); assert_eq!(chan_stat.holding_cell_outbound_amount_msat, amt_1 + amt_2); @@ -5487,7 +5488,7 @@ pub fn test_fail_holding_cell_htlc_upon_free_multihop() { let (route, our_payment_hash, _, our_payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[2], max_can_send); let payment_event = { - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, max_can_send); let id = PaymentId(our_payment_hash.0); nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -5595,7 +5596,7 @@ pub fn test_update_fulfill_htlc_bolt2_after_malformed_htlc_message_must_forward_ //First hop let mut payment_event = { - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, 100000); let id = PaymentId(our_payment_hash.0); nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -5708,7 +5709,7 @@ pub fn test_channel_failed_after_message_with_badonion_node_perm_bits_set() { // First hop let mut payment_event = { - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, 100_000); let id = PaymentId(our_payment_hash.0); nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -6062,7 +6063,7 @@ pub fn test_check_htlc_underpaying() { .node .create_inbound_payment_for_hash(our_payment_hash, Some(100_000), 7200, None) .unwrap(); - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, route.get_total_amount()); let id = PaymentId(our_payment_hash.0); nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -7007,7 +7008,7 @@ pub fn test_onion_value_mpp_set_calculation() { // Send payment let id = PaymentId(nodes[0].keys_manager.backing.get_secure_random_bytes()); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, total_msat); let onion_session_privs = nodes[0].node.test_add_new_pending_payment(hash, onion.clone(), id, &route).unwrap(); let amt = Some(total_msat); @@ -7040,7 +7041,7 @@ pub fn test_onion_value_mpp_set_calculation() { &route.paths[0], &session_priv, ); - let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret); + let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret, 100_000); let (mut onion_payloads, _, _) = onion_utils::build_onion_payloads( &route.paths[0], 100_000, @@ -7145,10 +7146,10 @@ fn do_test_overshoot_mpp(msat_amounts: &[u64], total_msat: u64) { // Send payment with manually set total_msat let id = PaymentId(nodes[src_idx].keys_manager.backing.get_secure_random_bytes()); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, total_msat); let onion_session_privs = nodes[src_idx].node.test_add_new_pending_payment(hash, onion, id, &route).unwrap(); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, total_msat); let amt = Some(total_msat); nodes[src_idx] .node @@ -7236,7 +7237,7 @@ pub fn test_preimage_storage() { let (payment_hash, payment_secret) = nodes[1].node.create_inbound_payment(Some(100_000), 7200, None).unwrap(); let (route, _, _, _) = get_route_and_payment_hash!(nodes[0], nodes[1], 100_000); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 100_000); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment_with_route(route, payment_hash, onion, id).unwrap(); @@ -7328,20 +7329,20 @@ pub fn test_bad_secret_hash() { let expected_err_data = [0, 0, 0, 0, 0, 1, 0x86, 0xa0, 0, 0, 0, CHAN_CONFIRM_DEPTH as u8]; // Send a payment with the right payment hash but the wrong payment secret - let onion = RecipientOnionFields::secret_only(random_secret); + let onion = RecipientOnionFields::secret_only(random_secret, 100_000); let id = PaymentId(our_payment_hash.0); nodes[0].node.send_payment_with_route(route.clone(), our_payment_hash, onion, id).unwrap(); handle_unknown_invalid_payment_data!(our_payment_hash); expect_payment_failed!(nodes[0], our_payment_hash, true, expected_err_code, expected_err_data); // Send a payment with a random payment hash, but the right payment secret - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, 100_000); nodes[0].node.send_payment_with_route(route.clone(), random_hash, onion, id).unwrap(); handle_unknown_invalid_payment_data!(random_hash); expect_payment_failed!(nodes[0], random_hash, true, expected_err_code, expected_err_data); // Send a payment with a random payment hash and random payment secret - let onion = RecipientOnionFields::secret_only(random_secret); + let onion = RecipientOnionFields::secret_only(random_secret, 100_000); nodes[0].node.send_payment_with_route(route, random_hash, onion, id).unwrap(); handle_unknown_invalid_payment_data!(random_hash); expect_payment_failed!(nodes[0], random_hash, true, expected_err_code, expected_err_data); @@ -7570,7 +7571,7 @@ pub fn test_concurrent_monitor_claim() { // Route another payment to generate another update with still previous HTLC pending let (route, payment_hash, _, payment_secret) = get_route_and_payment_hash!(nodes[1], nodes[0], 3000000); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 3000000); let id = PaymentId(payment_hash.0); nodes[1].node.send_payment_with_route(route, payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[1], 1); @@ -8322,7 +8323,7 @@ fn do_test_dup_htlc_second_rejected(test_for_second_fail_panic: bool) { get_payment_preimage_hash(&nodes[1], None, None); { - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, 10_000); let id = PaymentId(our_payment_hash.0); nodes[0].node.send_payment_with_route(route.clone(), our_payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -8338,7 +8339,7 @@ fn do_test_dup_htlc_second_rejected(test_for_second_fail_panic: bool) { { // Note that we use a different PaymentId here to allow us to duplicativly pay - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, 10_000); let id = PaymentId(our_payment_secret.0); nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -8478,10 +8479,10 @@ pub fn test_inconsistent_mpp_params() { // ultimately have, just not right away. let mut dup_route = route.clone(); dup_route.paths.push(route.paths[1].clone()); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 15_000_000); nodes[0].node.test_add_new_pending_payment(hash, onion, id, &dup_route).unwrap() }; - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 15_000_000); let path_a = &route.paths[0]; let real_amt = 15_000_000; let priv_a = session_privs[0]; @@ -8499,7 +8500,7 @@ pub fn test_inconsistent_mpp_params() { assert!(nodes[3].node.get_and_clear_pending_events().is_empty()); let path_b = &route.paths[1]; - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 14_000_000); let amt_b = 14_000_000; let priv_b = session_privs[1]; nodes[0] @@ -8559,7 +8560,7 @@ pub fn test_inconsistent_mpp_params() { let conditions = PaymentFailedConditions::new().mpp_parts_remain(); expect_payment_failed_conditions(&nodes[0], hash, true, conditions); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, real_amt); let path_b = &route.paths[1]; let priv_c = session_privs[2]; nodes[0] @@ -8627,7 +8628,7 @@ pub fn test_double_partial_claim() { pass_failed_payment_back(&nodes[0], paths, false, hash, reason); // nodes[1] now retries one of the two paths... - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 15_000_000); let id = PaymentId(hash.0); nodes[0].node.send_payment_with_route(route, hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 2); @@ -8859,12 +8860,18 @@ fn do_test_max_dust_htlc_exposure( }; // With default dust exposure: 5000 sats if on_holder_tx { - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only( + payment_secret, + dust_outbound_htlc_on_holder_tx_msat, + ); let id = PaymentId(payment_hash.0); let res = nodes[0].node.send_payment_with_route(route, payment_hash, onion, id); unwrap_send_err!(nodes[0], res, true, APIError::ChannelUnavailable { .. }, {}); } else { - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only( + payment_secret, + dust_htlc_on_counterparty_tx_msat + 1, + ); let id = PaymentId(payment_hash.0); let res = nodes[0].node.send_payment_with_route(route, payment_hash, onion, id); unwrap_send_err!(nodes[0], res, true, APIError::ChannelUnavailable { .. }, {}); @@ -8878,7 +8885,7 @@ fn do_test_max_dust_htlc_exposure( let (route, payment_hash, _, payment_secret) = get_route_and_payment_hash!(nodes[1], nodes[0], amount_msats); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amount_msats); let id = PaymentId(payment_hash.0); nodes[1].node.send_payment_with_route(route, payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[1], 1); @@ -8917,7 +8924,7 @@ fn do_test_max_dust_htlc_exposure( // to cross the threshold. for _ in 0..AT_FEE_OUTBOUND_HTLCS { let (_, hash, payment_secret) = get_payment_preimage_hash(&nodes[1], Some(1_000), None); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, route.get_total_amount()); let id = PaymentId(hash.0); nodes[0].node.send_payment_with_route(route.clone(), hash, onion, id).unwrap(); } @@ -9147,7 +9154,7 @@ pub fn test_nondust_htlc_excess_fees_are_dust() { // Send an additional non-dust htlc from 1 to 0, and check the complaint let (route, payment_hash, _, payment_secret) = get_route_and_payment_hash!(nodes[1], nodes[0], dust_limit * 2); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, route.get_total_amount()); let id = PaymentId(payment_hash.0); nodes[1].node.send_payment_with_route(route, payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[1], 1); @@ -9183,7 +9190,7 @@ pub fn test_nondust_htlc_excess_fees_are_dust() { assert_eq!(nodes[1].node.list_channels()[0].pending_outbound_htlcs.len(), 0); // Send an additional non-dust htlc from 0 to 1 using the pre-calculated route above, and check the immediate complaint - let onion = RecipientOnionFields::secret_only(payment_secret_0_1); + let onion = RecipientOnionFields::secret_only(payment_secret_0_1, route_0_1.get_total_amount()); let id = PaymentId(payment_hash_0_1.0); let res = nodes[0].node.send_payment_with_route(route_0_1, payment_hash_0_1, onion, id); unwrap_send_err!(nodes[0], res, true, APIError::ChannelUnavailable { .. }, {}); @@ -9201,7 +9208,7 @@ pub fn test_nondust_htlc_excess_fees_are_dust() { create_announced_chan_between_nodes(&nodes, 2, 0); let (route, payment_hash, _, payment_secret) = get_route_and_payment_hash!(nodes[2], nodes[1], dust_limit * 2); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, route.get_total_amount()); nodes[2].node.send_payment_with_route(route, payment_hash, onion, PaymentId([0; 32])).unwrap(); check_added_monitors(&nodes[2], 1); let send = SendEvent::from_node(&nodes[2]); @@ -9322,7 +9329,7 @@ fn do_test_nondust_htlc_fees_dust_exposure_delta(features: ChannelTypeFeatures) // Send an additional non-dust htlc from 0 to 1, and check the complaint let (route, payment_hash, _, payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[1], NON_DUST_HTLC_MSAT); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, NON_DUST_HTLC_MSAT); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment_with_route(route, payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -9404,7 +9411,7 @@ fn do_test_nondust_htlc_fees_dust_exposure_delta(features: ChannelTypeFeatures) nodes[1].node.update_partial_channel_config(&node_a_id, &[chan_id], &update).unwrap(); // Send an additional non-dust htlc from 1 to 0 using the pre-calculated route above, and check the immediate complaint - let onion = RecipientOnionFields::secret_only(payment_secret_1_0); + let onion = RecipientOnionFields::secret_only(payment_secret_1_0, NON_DUST_HTLC_MSAT); let id = PaymentId(payment_hash_1_0.0); let res = nodes[1].node.send_payment_with_route(route_1_0, payment_hash_1_0, onion, id); unwrap_send_err!(nodes[1], res, true, APIError::ChannelUnavailable { .. }, {}); @@ -9487,7 +9494,7 @@ fn do_payment_with_custom_min_final_cltv_expiry(valid_delta: bool, use_user_hash (hash, nodes[1].node.get_payment_preimage(hash, payment_secret).unwrap(), payment_secret) }; let route = get_route!(nodes[0], payment_parameters, recv_value).unwrap(); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, recv_value); nodes[0].node.send_payment_with_route(route, hash, onion, PaymentId(hash.0)).unwrap(); check_added_monitors(&nodes[0], 1); let mut events = nodes[0].node.get_and_clear_pending_msg_events(); @@ -9961,7 +9968,7 @@ fn do_test_multi_post_event_actions(do_reload: bool) { let (route, payment_hash_3, _, payment_secret_3) = get_route_and_payment_hash!(nodes[1], nodes[0], 100_000); let payment_id = PaymentId(payment_hash_3.0); - let onion = RecipientOnionFields::secret_only(payment_secret_3); + let onion = RecipientOnionFields::secret_only(payment_secret_3, 100_000); nodes[1].node.send_payment_with_route(route, payment_hash_3, onion, payment_id).unwrap(); check_added_monitors(&nodes[1], 1); @@ -10072,7 +10079,7 @@ pub fn test_dust_exposure_holding_cell_assertion() { // messages (leaving B waiting on C's RAA) the next HTLC will go into B's holding cell. let (route_bc, payment_hash_bc, _payment_preimage_bc, payment_secret_bc) = get_route_and_payment_hash!(nodes[1], nodes[2], DUST_HTLC_VALUE_MSAT); - let onion_bc = RecipientOnionFields::secret_only(payment_secret_bc); + let onion_bc = RecipientOnionFields::secret_only(payment_secret_bc, DUST_HTLC_VALUE_MSAT); let id = PaymentId(payment_hash_bc.0); nodes[1].node.send_payment_with_route(route_bc, payment_hash_bc, onion_bc, id).unwrap(); check_added_monitors(&nodes[1], 1); @@ -10092,7 +10099,7 @@ pub fn test_dust_exposure_holding_cell_assertion() { .unwrap(); let (route_ac, payment_hash_cell, _, payment_secret_ac) = get_route_and_payment_hash!(nodes[0], nodes[2], payment_params_ac, DUST_HTLC_VALUE_MSAT); - let onion_ac = RecipientOnionFields::secret_only(payment_secret_ac); + let onion_ac = RecipientOnionFields::secret_only(payment_secret_ac, DUST_HTLC_VALUE_MSAT); let id = PaymentId(payment_hash_cell.0); nodes[0].node.send_payment_with_route(route_ac, payment_hash_cell, onion_ac, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -10115,7 +10122,7 @@ pub fn test_dust_exposure_holding_cell_assertion() { // its holding cell as it would be over-exposed to dust. let (route_cb, payment_hash_cb, payment_preimage_cb, payment_secret_cb) = get_route_and_payment_hash!(nodes[2], nodes[1], DUST_HTLC_VALUE_MSAT); - let onion_cb = RecipientOnionFields::secret_only(payment_secret_cb); + let onion_cb = RecipientOnionFields::secret_only(payment_secret_cb, DUST_HTLC_VALUE_MSAT); let id = PaymentId(payment_hash_cb.0); nodes[2].node.send_payment_with_route(route_cb, payment_hash_cb, onion_cb, id).unwrap(); check_added_monitors(&nodes[2], 1); diff --git a/lightning/src/ln/htlc_reserve_unit_tests.rs b/lightning/src/ln/htlc_reserve_unit_tests.rs index 63faa984968..1a1cfedbec9 100644 --- a/lightning/src/ln/htlc_reserve_unit_tests.rs +++ b/lightning/src/ln/htlc_reserve_unit_tests.rs @@ -172,7 +172,7 @@ pub fn test_channel_reserve_holding_cell_htlcs() { route.paths[0].hops.last_mut().unwrap().fee_msat += 1; assert!(route.paths[0].hops.iter().rev().skip(1).all(|h| h.fee_msat == feemsat)); - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, route.get_total_amount()); let id = PaymentId(our_payment_hash.0); let res = nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id); unwrap_send_err!(nodes[0], res, true, APIError::ChannelUnavailable { .. }, {}); @@ -248,7 +248,7 @@ pub fn test_channel_reserve_holding_cell_htlcs() { get_route_and_payment_hash!(nodes[0], nodes[2], recv_value_1); let payment_event_1 = { let route = route_1.clone(); - let onion = RecipientOnionFields::secret_only(our_payment_secret_1); + let onion = RecipientOnionFields::secret_only(our_payment_secret_1, recv_value_1); let id = PaymentId(our_payment_hash_1.0); nodes[0].node.send_payment_with_route(route, our_payment_hash_1, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -270,7 +270,7 @@ pub fn test_channel_reserve_holding_cell_htlcs() { route.paths[0].hops.last_mut().unwrap().fee_msat = recv_value_2 + 1; let (_, our_payment_hash, our_payment_secret) = get_payment_preimage_hash(&nodes[2], None, None); - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, route.get_total_amount()); let id = PaymentId(our_payment_hash.0); let res = nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id); unwrap_send_err!(nodes[0], res, true, APIError::ChannelUnavailable { .. }, {}); @@ -298,7 +298,7 @@ pub fn test_channel_reserve_holding_cell_htlcs() { let (route_21, our_payment_hash_21, our_payment_preimage_21, our_payment_secret_21) = get_route_and_payment_hash!(nodes[0], nodes[2], recv_value_21); // but this will stuck in the holding cell - let onion = RecipientOnionFields::secret_only(our_payment_secret_21); + let onion = RecipientOnionFields::secret_only(our_payment_secret_21, recv_value_21); let id = PaymentId(our_payment_hash_21.0); nodes[0].node.send_payment_with_route(route_21, our_payment_hash_21, onion, id).unwrap(); check_added_monitors(&nodes[0], 0); @@ -310,7 +310,7 @@ pub fn test_channel_reserve_holding_cell_htlcs() { let (mut route, our_payment_hash, _, our_payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[2], recv_value_22); route.paths[0].hops.last_mut().unwrap().fee_msat += 1; - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, route.get_total_amount()); let id = PaymentId(our_payment_hash.0); let res = nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id); unwrap_send_err!(nodes[0], res, true, APIError::ChannelUnavailable { .. }, {}); @@ -320,7 +320,7 @@ pub fn test_channel_reserve_holding_cell_htlcs() { let (route_22, our_payment_hash_22, our_payment_preimage_22, our_payment_secret_22) = get_route_and_payment_hash!(nodes[0], nodes[2], recv_value_22); // this will also stuck in the holding cell - let onion = RecipientOnionFields::secret_only(our_payment_secret_22); + let onion = RecipientOnionFields::secret_only(our_payment_secret_22, recv_value_22); let id = PaymentId(our_payment_hash_22.0); nodes[0].node.send_payment_with_route(route_22, our_payment_hash_22, onion, id).unwrap(); check_added_monitors(&nodes[0], 0); @@ -496,7 +496,7 @@ pub fn channel_reserve_in_flight_removes() { let (route, payment_hash_3, payment_preimage_3, payment_secret_3) = get_route_and_payment_hash!(nodes[0], nodes[1], 100000); let send_1 = { - let onion = RecipientOnionFields::secret_only(payment_secret_3); + let onion = RecipientOnionFields::secret_only(payment_secret_3, 100000); let id = PaymentId(payment_hash_3.0); nodes[0].node.send_payment_with_route(route, payment_hash_3, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -573,7 +573,7 @@ pub fn channel_reserve_in_flight_removes() { let (route, payment_hash_4, payment_preimage_4, payment_secret_4) = get_route_and_payment_hash!(nodes[1], nodes[0], 10000); let send_2 = { - let onion = RecipientOnionFields::secret_only(payment_secret_4); + let onion = RecipientOnionFields::secret_only(payment_secret_4, 10000); let id = PaymentId(payment_hash_4.0); nodes[1].node.send_payment_with_route(route, payment_hash_4, onion, id).unwrap(); check_added_monitors(&nodes[1], 1); @@ -640,7 +640,7 @@ pub fn holding_cell_htlc_counting() { for _ in 0..50 { let (route, payment_hash, payment_preimage, payment_secret) = get_route_and_payment_hash!(nodes[1], nodes[2], 100000); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 100000); let id = PaymentId(payment_hash.0); nodes[1].node.send_payment_with_route(route, payment_hash, onion, id).unwrap(); payments.push((payment_preimage, payment_hash)); @@ -656,7 +656,7 @@ pub fn holding_cell_htlc_counting() { // the holding cell waiting on B's RAA to send. At this point we should not be able to add // another HTLC. { - let onion = RecipientOnionFields::secret_only(payment_secret_1); + let onion = RecipientOnionFields::secret_only(payment_secret_1, 100000); let id = PaymentId(payment_hash_1.0); let res = nodes[1].node.send_payment_with_route(route, payment_hash_1, onion, id); unwrap_send_err!(nodes[1], res, true, APIError::ChannelUnavailable { .. }, {}); @@ -666,7 +666,7 @@ pub fn holding_cell_htlc_counting() { // This should also be true if we try to forward a payment. let (route, payment_hash_2, _, payment_secret_2) = get_route_and_payment_hash!(nodes[0], nodes[2], 100000); - let onion = RecipientOnionFields::secret_only(payment_secret_2); + let onion = RecipientOnionFields::secret_only(payment_secret_2, 100000); let id = PaymentId(payment_hash_2.0); nodes[0].node.send_payment_with_route(route, payment_hash_2, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -772,7 +772,7 @@ pub fn test_basic_channel_reserve() { let (mut route, our_payment_hash, _, our_payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[1], max_can_send); route.paths[0].hops.last_mut().unwrap().fee_msat += 1; - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, max_can_send + 1); let id = PaymentId(our_payment_hash.0); let err = nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id); unwrap_send_err!(nodes[0], err, true, APIError::ChannelUnavailable { .. }, {}); @@ -820,7 +820,8 @@ pub fn do_test_fee_spike_buffer(cfg: Option, htlc_fails: bool) { let payment_amt_msat = 3460001; let onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route.paths[0], &session_priv); - let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret); + let recipient_onion_fields = + RecipientOnionFields::secret_only(payment_secret, payment_amt_msat); let (onion_payloads, htlc_msat, htlc_cltv) = onion_utils::build_onion_payloads( &route.paths[0], payment_amt_msat, @@ -1021,7 +1022,7 @@ pub fn test_chan_reserve_violation_outbound_htlc_inbound_chan() { } // However one more HTLC should be significantly over the reserve amount and fail. - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, 1_000_000); let id = PaymentId(our_payment_hash.0); let res = nodes[1].node.send_payment_with_route(route, our_payment_hash, onion, id); unwrap_send_err!(nodes[1], res, true, APIError::ChannelUnavailable { .. }, {}); @@ -1068,7 +1069,7 @@ pub fn test_chan_reserve_violation_inbound_htlc_outbound_channel() { let session_priv = SecretKey::from_slice(&[42; 32]).unwrap(); let cur_height = nodes[1].node.best_block.read().unwrap().height + 1; let onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route.paths[0], &session_priv); - let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret); + let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret, 700_000); let (onion_payloads, htlc_msat, htlc_cltv) = onion_utils::build_onion_payloads( &route.paths[0], 700_000, @@ -1153,7 +1154,7 @@ pub fn test_chan_reserve_dust_inbound_htlcs_outbound_chan() { let (mut route, our_payment_hash, _, our_payment_secret) = get_route_and_payment_hash!(nodes[1], nodes[0], dust_amt); route.paths[0].hops[0].fee_msat += 1; - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, dust_amt + 1); let id = PaymentId(our_payment_hash.0); let res = nodes[1].node.send_payment_with_route(route, our_payment_hash, onion, id); unwrap_send_err!(nodes[1], res, true, APIError::ChannelUnavailable { .. }, {}); @@ -1224,7 +1225,7 @@ pub fn test_chan_reserve_violation_inbound_htlc_inbound_chan() { let (route_1, our_payment_hash_1, _, our_payment_secret_1) = get_route_and_payment_hash!(nodes[0], nodes[2], amt_msat_1); let payment_event_1 = { - let onion = RecipientOnionFields::secret_only(our_payment_secret_1); + let onion = RecipientOnionFields::secret_only(our_payment_secret_1, amt_msat_1); let id = PaymentId(our_payment_hash_1.0); let route = route_1.clone(); nodes[0].node.send_payment_with_route(route, our_payment_hash_1, onion, id).unwrap(); @@ -1253,7 +1254,7 @@ pub fn test_chan_reserve_violation_inbound_htlc_inbound_chan() { let session_priv = SecretKey::from_slice(&[42; 32]).unwrap(); let cur_height = nodes[0].node.best_block.read().unwrap().height + 1; let onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route_2.paths[0], &session_priv); - let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(); + let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(recv_value_2); let (onion_payloads, htlc_msat, htlc_cltv) = onion_utils::build_onion_payloads( &route_2.paths[0], recv_value_2, @@ -1323,7 +1324,7 @@ pub fn test_payment_route_reaching_same_channel_twice() { route.paths[0].hops.extend_from_slice(&cloned_hops); unwrap_send_err!(nodes[0], nodes[0].node.send_payment_with_route(route, our_payment_hash, - RecipientOnionFields::secret_only(our_payment_secret), PaymentId(our_payment_hash.0) + RecipientOnionFields::secret_only(our_payment_secret, 100000000), PaymentId(our_payment_hash.0) ), false, APIError::InvalidRoute { ref err }, assert_eq!(err, &"Path went through the same channel twice")); assert!(nodes[0].node.list_recent_payments().is_empty()); @@ -1347,7 +1348,7 @@ pub fn test_update_add_htlc_bolt2_sender_value_below_minimum_msat() { get_route_and_payment_hash!(nodes[0], nodes[1], 100000); route.paths[0].hops[0].fee_msat = 100; - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, 100); let id = PaymentId(our_payment_hash.0); let res = nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id); unwrap_send_err!(nodes[0], res, true, APIError::ChannelUnavailable { .. }, {}); @@ -1367,7 +1368,7 @@ pub fn test_update_add_htlc_bolt2_sender_zero_value_msat() { let (mut route, our_payment_hash, _, our_payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[1], 100000); route.paths[0].hops[0].fee_msat = 0; - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, 0); let id = PaymentId(our_payment_hash.0); let res = nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id); unwrap_send_err!(nodes[0], res, @@ -1397,7 +1398,7 @@ pub fn test_update_add_htlc_bolt2_receiver_zero_value_msat() { let (route, our_payment_hash, _, our_payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[1], 100000); - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, 100000); let id = PaymentId(our_payment_hash.0); nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -1438,7 +1439,7 @@ pub fn test_update_add_htlc_bolt2_sender_cltv_expiry_too_high() { get_route_and_payment_hash!(nodes[0], nodes[1], payment_params, 100000000); route.paths[0].hops.last_mut().unwrap().cltv_expiry_delta = 500000001; - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, 100000000); let id = PaymentId(our_payment_hash.0); let res = nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id); unwrap_send_err!(nodes[0], res, true, APIError::InvalidRoute { ref err }, @@ -1473,7 +1474,7 @@ pub fn test_update_add_htlc_bolt2_sender_exceed_max_htlc_num_and_htlc_id_increme let (route, our_payment_hash, _, our_payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[1], 100000); let payment_event = { - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, 100000); let id = PaymentId(our_payment_hash.0); nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -1499,7 +1500,7 @@ pub fn test_update_add_htlc_bolt2_sender_exceed_max_htlc_num_and_htlc_id_increme expect_and_process_pending_htlcs(&nodes[1], false); expect_payment_claimable!(nodes[1], our_payment_hash, our_payment_secret, 100000); } - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, 100000); let id = PaymentId(our_payment_hash.0); let res = nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id); unwrap_send_err!(nodes[0], res, true, APIError::ChannelUnavailable { .. }, {}); @@ -1527,7 +1528,7 @@ pub fn test_update_add_htlc_bolt2_sender_exceed_max_htlc_value_in_flight() { // Manually create a route over our max in flight (which our router normally automatically // limits us to. route.paths[0].hops[0].fee_msat = max_in_flight + 1; - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, max_in_flight + 1); let id = PaymentId(our_payment_hash.0); let res = nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id); unwrap_send_err!(nodes[0], res, true, APIError::ChannelUnavailable { .. }, {}); @@ -1559,7 +1560,7 @@ pub fn test_update_add_htlc_bolt2_receiver_check_amount_received_more_than_min() let (route, our_payment_hash, _, our_payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[1], htlc_minimum_msat); - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, htlc_minimum_msat); let id = PaymentId(our_payment_hash.0); nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -1599,7 +1600,7 @@ pub fn test_update_add_htlc_bolt2_receiver_sender_can_afford_amount_sent() { let max_can_send = 5000000 - channel_reserve - commit_tx_fee_outbound; let (route, our_payment_hash, _, our_payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[1], max_can_send); - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, max_can_send); let id = PaymentId(our_payment_hash.0); nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -1643,7 +1644,7 @@ pub fn test_update_add_htlc_bolt2_receiver_check_max_htlc_limit() { &route.paths[0], &session_priv, ); - let recipient_onion_fields = RecipientOnionFields::secret_only(our_payment_secret); + let recipient_onion_fields = RecipientOnionFields::secret_only(our_payment_secret, send_amt); let (onion_payloads, _htlc_msat, htlc_cltv) = onion_utils::build_onion_payloads( &route.paths[0], send_amt, @@ -1703,7 +1704,7 @@ pub fn test_update_add_htlc_bolt2_receiver_check_max_in_flight_msat() { let (route, our_payment_hash, _, our_payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[1], 1000000); - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, 1000000); let id = PaymentId(our_payment_hash.0); nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -1737,7 +1738,7 @@ pub fn test_update_add_htlc_bolt2_receiver_check_cltv_expiry() { create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100000, 95000000); let (route, our_payment_hash, _, our_payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[1], 1000000); - let reason = RecipientOnionFields::secret_only(our_payment_secret); + let reason = RecipientOnionFields::secret_only(our_payment_secret, 1000000); let id = PaymentId(our_payment_hash.0); nodes[0].node.send_payment_with_route(route, our_payment_hash, reason, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -1769,7 +1770,7 @@ pub fn test_update_add_htlc_bolt2_receiver_check_repeated_id_ignore() { create_announced_chan_between_nodes(&nodes, 0, 1); let (route, our_payment_hash, _, our_payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[1], 1000000); - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, 1000000); let id = PaymentId(our_payment_hash.0); nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id).unwrap(); @@ -1834,7 +1835,7 @@ pub fn test_update_fulfill_htlc_bolt2_update_fulfill_htlc_before_commitment() { let chan = create_announced_chan_between_nodes(&nodes, 0, 1); let (route, our_payment_hash, our_payment_preimage, our_payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[1], 1000000); - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, 1000000); let id = PaymentId(our_payment_hash.0); nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id).unwrap(); @@ -1879,7 +1880,7 @@ pub fn test_update_fulfill_htlc_bolt2_update_fail_htlc_before_commitment() { let (route, our_payment_hash, _, our_payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[1], 1000000); - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, 1000000); let id = PaymentId(our_payment_hash.0); nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -1923,7 +1924,7 @@ pub fn test_update_fulfill_htlc_bolt2_update_fail_malformed_htlc_before_commitme let (route, our_payment_hash, _, our_payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[1], 1000000); - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, 1000000); let id = PaymentId(our_payment_hash.0); nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -2086,7 +2087,7 @@ pub fn test_update_fulfill_htlc_bolt2_missing_badonion_bit_for_malformed_htlc_me let (route, our_payment_hash, _, our_payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[1], 1000000); - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, 1000000); let id = PaymentId(our_payment_hash.0); nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -2244,7 +2245,8 @@ pub fn do_test_dust_limit_fee_accounting(can_afford: bool) { let onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route_0_1.paths[0], &session_priv); - let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret_0_1); + let recipient_onion_fields = + RecipientOnionFields::secret_only(payment_secret_0_1, HTLC_AMT_SAT * 1000); let (onion_payloads, amount_msat, cltv_expiry) = onion_utils::build_onion_payloads( &route_0_1.paths[0], HTLC_AMT_SAT * 1000, diff --git a/lightning/src/ln/interception_tests.rs b/lightning/src/ln/interception_tests.rs index c3cd52a0e2e..5fece51c027 100644 --- a/lightning/src/ln/interception_tests.rs +++ b/lightning/src/ln/interception_tests.rs @@ -163,7 +163,7 @@ fn do_test_htlc_interception_flags( None => {}, } - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let payment_id = PaymentId(payment_hash.0); nodes[0].node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); check_added_monitors(&nodes[0], 1); diff --git a/lightning/src/ln/invoice_utils.rs b/lightning/src/ln/invoice_utils.rs index 1503a9a3a63..ae87307a9fb 100644 --- a/lightning/src/ln/invoice_utils.rs +++ b/lightning/src/ln/invoice_utils.rs @@ -1284,7 +1284,10 @@ mod test { let payment_hash = invoice.payment_hash(); let id = PaymentId(payment_hash.0); - let onion = RecipientOnionFields::secret_only(*invoice.payment_secret()); + let onion = RecipientOnionFields::secret_only( + *invoice.payment_secret(), + invoice.amount_milli_satoshis().unwrap(), + ); nodes[0].node.send_payment(payment_hash, onion, id, params, Retry::Attempts(0)).unwrap(); check_added_monitors(&nodes[0], 1); diff --git a/lightning/src/ln/max_payment_path_len_tests.rs b/lightning/src/ln/max_payment_path_len_tests.rs index b947273115e..ea78449316c 100644 --- a/lightning/src/ln/max_payment_path_len_tests.rs +++ b/lightning/src/ln/max_payment_path_len_tests.rs @@ -87,6 +87,7 @@ fn large_payment_metadata() { payment_secret: Some(payment_secret), payment_metadata: Some(payment_metadata.clone()), custom_tlvs: Vec::new(), + total_mpp_amount_msat: amt_msat, }; let route_params = route_0_1.route_params.clone().unwrap(); let id = PaymentId(payment_hash.0); @@ -128,6 +129,7 @@ fn large_payment_metadata() { // If our payment_metadata contains 1 additional byte, we'll fail prior to pathfinding. let mut too_large_onion = max_sized_onion.clone(); too_large_onion.payment_metadata.as_mut().map(|mut md| md.push(42)); + too_large_onion.total_mpp_amount_msat = MIN_FINAL_VALUE_ESTIMATE_WITH_OVERPAY; // First confirm we'll fail to create the onion packet directly. let secp_ctx = Secp256k1::signing_only(); @@ -167,6 +169,7 @@ fn large_payment_metadata() { payment_secret: Some(payment_secret_2), payment_metadata: Some(two_hop_metadata.clone()), custom_tlvs: Vec::new(), + total_mpp_amount_msat: amt_msat, }; let mut route_params_0_2 = route_0_2.route_params.clone().unwrap(); route_params_0_2.payment_params.max_path_length = 2; @@ -261,7 +264,7 @@ fn one_hop_blinded_path_with_custom_tlv() { - final_payload_len_without_custom_tlv; // Check that we can send the maximum custom TLV with 1 blinded hop. - let max_sized_onion = RecipientOnionFields::spontaneous_empty().with_custom_tlvs( + let max_sized_onion = RecipientOnionFields::spontaneous_empty(amt_msat).with_custom_tlvs( RecipientCustomTlvs::new(vec![(CUSTOM_TLV_TYPE, vec![42; max_custom_tlv_len])]).unwrap(), ); let id = PaymentId(payment_hash.0); @@ -369,7 +372,7 @@ fn blinded_path_with_custom_tlv() { let reserved_packet_bytes_without_custom_tlv: usize = onion_utils::build_onion_payloads( &route.paths[0], MIN_FINAL_VALUE_ESTIMATE_WITH_OVERPAY, - &RecipientOnionFields::spontaneous_empty(), + &RecipientOnionFields::spontaneous_empty(MIN_FINAL_VALUE_ESTIMATE_WITH_OVERPAY), nodes[0].best_block_info().1 + DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA, &None, None, @@ -387,7 +390,7 @@ fn blinded_path_with_custom_tlv() { - reserved_packet_bytes_without_custom_tlv; // Check that we can send the maximum custom TLV size with 0 intermediate unblinded hops. - let max_sized_onion = RecipientOnionFields::spontaneous_empty().with_custom_tlvs( + let max_sized_onion = RecipientOnionFields::spontaneous_empty(amt_msat).with_custom_tlvs( RecipientCustomTlvs::new(vec![(CUSTOM_TLV_TYPE, vec![42; max_custom_tlv_len])]).unwrap(), ); let no_retry = Retry::Attempts(0); @@ -420,10 +423,12 @@ fn blinded_path_with_custom_tlv() { .unwrap_err(); assert_eq!(err, RetryableSendFailure::OnionPacketSizeExceeded); - // Confirm that we can't construct an onion packet given this too-large custom TLV. + // Confirm that we can't construct an onion packet given this too-large custom TLV (as long as + // we actually use the amount the payment logic uses when validating). let secp_ctx = Secp256k1::signing_only(); route.paths[0].hops[0].fee_msat = MIN_FINAL_VALUE_ESTIMATE_WITH_OVERPAY; route.paths[0].hops[0].cltv_expiry_delta = DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA; + too_large_onion.total_mpp_amount_msat = MIN_FINAL_VALUE_ESTIMATE_WITH_OVERPAY; let err = onion_utils::create_payment_onion( &secp_ctx, &route.paths[0], diff --git a/lightning/src/ln/monitor_tests.rs b/lightning/src/ln/monitor_tests.rs index 157445874b7..18a976871a6 100644 --- a/lightning/src/ln/monitor_tests.rs +++ b/lightning/src/ln/monitor_tests.rs @@ -68,7 +68,7 @@ fn chanmon_fail_from_stale_commitment() { let (route, payment_hash, _, payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[2], 1_000_000); nodes[0].node.send_payment_with_route(route, payment_hash, - RecipientOnionFields::secret_only(payment_secret), PaymentId(payment_hash.0)).unwrap(); + RecipientOnionFields::secret_only(payment_secret, 1_000_000), PaymentId(payment_hash.0)).unwrap(); check_added_monitors(&nodes[0], 1); let bs_txn = get_local_commitment_txn!(nodes[1], chan_id_2); @@ -881,7 +881,7 @@ fn do_test_balances_on_local_commitment_htlcs(keyed_anchors: bool, p2a_anchor: b let (route, payment_hash, _, payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[1], 10_000_000); let htlc_cltv_timeout = nodes[0].best_block_info().1 + TEST_FINAL_CLTV + 1; // Note ChannelManager adds one to CLTV timeouts for safety nodes[0].node.send_payment_with_route(route, payment_hash, - RecipientOnionFields::secret_only(payment_secret), PaymentId(payment_hash.0)).unwrap(); + RecipientOnionFields::secret_only(payment_secret, 10_000_000), PaymentId(payment_hash.0)).unwrap(); check_added_monitors(&nodes[0], 1); let updates = get_htlc_update_msgs(&nodes[0], &nodes[1].node.get_our_node_id()); @@ -893,7 +893,7 @@ fn do_test_balances_on_local_commitment_htlcs(keyed_anchors: bool, p2a_anchor: b let (route_2, payment_hash_2, payment_preimage_2, payment_secret_2) = get_route_and_payment_hash!(nodes[0], nodes[1], 20_000_000); nodes[0].node.send_payment_with_route(route_2, payment_hash_2, - RecipientOnionFields::secret_only(payment_secret_2), PaymentId(payment_hash_2.0)).unwrap(); + RecipientOnionFields::secret_only(payment_secret_2, 20_000_000), PaymentId(payment_hash_2.0)).unwrap(); check_added_monitors(&nodes[0], 1); let updates = get_htlc_update_msgs(&nodes[0], &nodes[1].node.get_our_node_id()); @@ -3630,7 +3630,7 @@ fn do_test_lost_timeout_monitor_events(confirm_tx: CommitmentType, dust_htlcs: b let (route, hash_b, _, payment_secret_b) = get_route_and_payment_hash!(nodes[1], nodes[2], amt); - let onion = RecipientOnionFields::secret_only(payment_secret_b); + let onion = RecipientOnionFields::secret_only(payment_secret_b, amt); nodes[1].node.send_payment_with_route(route, hash_b, onion, PaymentId(hash_b.0)).unwrap(); check_added_monitors(&nodes[1], 1); diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index a4a09dd1910..de08af5d276 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -2469,7 +2469,7 @@ fn rejects_keysend_to_non_static_invoice_path() { let route_params = RouteParameters::from_payment_params_and_value(pay_params, amt_msat); let keysend_payment_id = PaymentId([2; 32]); let payment_hash = nodes[0].node.send_spontaneous_payment( - Some(payment_preimage), RecipientOnionFields::spontaneous_empty(), keysend_payment_id, + Some(payment_preimage), RecipientOnionFields::spontaneous_empty(amt_msat), keysend_payment_id, route_params, Retry::Attempts(0) ).unwrap(); check_added_monitors(&nodes[0], 1); diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index 555cc7a87af..d0d50c6a315 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -879,7 +879,7 @@ mod tests { let total_amt_msat = 1000; let cur_height = 1000; let pay_secret = PaymentSecret([99; 32]); - let recipient_onion = RecipientOnionFields::secret_only(pay_secret); + let recipient_onion = RecipientOnionFields::secret_only(pay_secret, total_amt_msat); let preimage_bytes = [43; 32]; let preimage = PaymentPreimage(preimage_bytes); let rhash_bytes = Sha256::hash(&preimage_bytes).to_byte_array(); diff --git a/lightning/src/ln/onion_route_tests.rs b/lightning/src/ln/onion_route_tests.rs index fe7d8332101..74c76ee06af 100644 --- a/lightning/src/ln/onion_route_tests.rs +++ b/lightning/src/ln/onion_route_tests.rs @@ -128,7 +128,8 @@ fn run_onion_failure_test_with_fail_intercept( // 0 ~~> 2 send payment let payment_id = PaymentId(nodes[0].keys_manager.backing.get_secure_random_bytes()); - let recipient_onion = RecipientOnionFields::secret_only(*payment_secret); + let recipient_onion = + RecipientOnionFields::secret_only(*payment_secret, route.get_total_amount()); nodes[0] .node .send_payment_with_route(route.clone(), *payment_hash, recipient_onion, payment_id) @@ -399,7 +400,7 @@ fn test_fee_failures() { // positive case let (route, payment_hash_success, payment_preimage_success, payment_secret_success) = get_route_and_payment_hash!(nodes[0], nodes[2], 40_000); - let recipient_onion = RecipientOnionFields::secret_only(payment_secret_success); + let recipient_onion = RecipientOnionFields::secret_only(payment_secret_success, 40_000); let payment_id = PaymentId(payment_hash_success.0); nodes[0] .node @@ -450,7 +451,7 @@ fn test_fee_failures() { let (payment_preimage_success, payment_hash_success, payment_secret_success) = get_payment_preimage_hash(&nodes[2], None, None); - let recipient_onion = RecipientOnionFields::secret_only(payment_secret_success); + let recipient_onion = RecipientOnionFields::secret_only(payment_secret_success, 40_000); let payment_id = PaymentId(payment_hash_success.0); nodes[0] .node @@ -523,7 +524,7 @@ fn test_onion_failure() { let cur_height = nodes[0].best_block_info().1 + 1; let onion_keys = construct_onion_keys(&Secp256k1::new(), &route.paths[0], &session_priv); - let recipient_fields = RecipientOnionFields::spontaneous_empty(); + let recipient_fields = RecipientOnionFields::spontaneous_empty(40000); let path = &route.paths[0]; let (mut onion_payloads, _htlc_msat, _htlc_cltv) = build_onion_payloads(path, 40000, &recipient_fields, cur_height, &None, None, None) @@ -565,7 +566,7 @@ fn test_onion_failure() { let cur_height = nodes[0].best_block_info().1 + 1; let onion_keys = construct_onion_keys(&Secp256k1::new(), &route.paths[0], &session_priv); - let recipient_fields = RecipientOnionFields::spontaneous_empty(); + let recipient_fields = RecipientOnionFields::spontaneous_empty(40000); let path = &route.paths[0]; let (mut onion_payloads, _htlc_msat, _htlc_cltv) = build_onion_payloads(path, 40000, &recipient_fields, cur_height, &None, None, None) @@ -1284,7 +1285,7 @@ fn test_onion_failure() { CLTV_FAR_FAR_AWAY + route.paths[0].hops[0].cltv_expiry_delta + 1; let onion_keys = construct_onion_keys(&Secp256k1::new(), &route.paths[0], &session_priv); - let recipient_fields = RecipientOnionFields::spontaneous_empty(); + let recipient_fields = RecipientOnionFields::spontaneous_empty(40000); let path = &route.paths[0]; let (onion_payloads, _, htlc_cltv) = build_onion_payloads(path, 40000, &recipient_fields, height, &None, None, None) @@ -1542,7 +1543,7 @@ fn test_overshoot_final_cltv() { get_route_and_payment_hash!(nodes[0], nodes[2], 40000); let payment_id = PaymentId(nodes[0].keys_manager.backing.get_secure_random_bytes()); - let recipient_onion = RecipientOnionFields::secret_only(payment_secret); + let recipient_onion = RecipientOnionFields::secret_only(payment_secret, 40000); nodes[0] .node .send_payment_with_route(route, payment_hash, recipient_onion, payment_id) @@ -1837,7 +1838,7 @@ fn test_always_create_tlv_format_onion_payloads() { assert!(!hops[1].node_features.supports_variable_length_onion()); let cur_height = nodes[0].best_block_info().1 + 1; - let recipient_fields = RecipientOnionFields::spontaneous_empty(); + let recipient_fields = RecipientOnionFields::spontaneous_empty(40000); let path = &route.paths[0]; let (onion_payloads, _htlc_msat, _htlc_cltv) = build_onion_payloads(path, 40000, &recipient_fields, cur_height, &None, None, None) @@ -1973,7 +1974,7 @@ fn test_trampoline_onion_payload_assembly_values() { let payment_secret = PaymentSecret( SecretKey::from_slice(&>::from_hex(SECRET_HEX).unwrap()).unwrap().secret_bytes(), ); - let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret); + let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret, amt_msat); let (trampoline_payloads, outer_total_msat, outer_starting_htlc_offset) = onion_utils::build_trampoline_onion_payloads( &path.blinded_tail.as_ref().unwrap(), @@ -2038,6 +2039,8 @@ fn test_trampoline_onion_payload_assembly_values() { ) .unwrap(); + let recipient_onion_fields = + RecipientOnionFields::secret_only(payment_secret, outer_total_msat); let (outer_payloads, total_msat, total_htlc_offset) = build_onion_payloads( &path, outer_total_msat, @@ -2072,6 +2075,7 @@ fn test_trampoline_onion_payload_assembly_values() { panic!("Bob payload must be Forward"); } + let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret, amt_msat); let (_, total_msat_combined, total_htlc_offset_combined) = onion_utils::create_payment_onion( &Secp256k1::new(), &path, @@ -2280,7 +2284,7 @@ fn do_test_fail_htlc_backwards_with_reason(failure_code: FailureCode) { let payment_amount = 100_000; let (route, payment_hash, _, payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[1], payment_amount); - let recipient_onion = RecipientOnionFields::secret_only(payment_secret); + let recipient_onion = RecipientOnionFields::secret_only(payment_secret, payment_amount); nodes[0] .node .send_payment_with_route(route, payment_hash, recipient_onion, PaymentId(payment_hash.0)) @@ -2430,7 +2434,7 @@ fn test_phantom_onion_hmac_failure() { let (route, phantom_scid) = get_phantom_route!(nodes, recv_value_msat, channel); // Route the HTLC through to the destination. - let recipient_onion = RecipientOnionFields::secret_only(payment_secret); + let recipient_onion = RecipientOnionFields::secret_only(payment_secret, recv_value_msat); nodes[0] .node .send_payment_with_route(route, payment_hash, recipient_onion, PaymentId(payment_hash.0)) @@ -2502,7 +2506,7 @@ fn test_phantom_invalid_onion_payload() { // We'll use the session priv later when constructing an invalid onion packet. let session_priv = [3; 32]; *nodes[0].keys_manager.override_random_bytes.lock().unwrap() = Some(session_priv); - let recipient_onion = RecipientOnionFields::secret_only(payment_secret); + let recipient_onion = RecipientOnionFields::secret_only(payment_secret, recv_value_msat); let payment_id = PaymentId(payment_hash.0); nodes[0] .node @@ -2534,7 +2538,8 @@ fn test_phantom_invalid_onion_payload() { let session_priv = SecretKey::from_slice(&session_priv).unwrap(); let mut onion_keys = construct_onion_keys(&Secp256k1::new(), &route.paths[0], &session_priv); - let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret); + let recipient_onion_fields = + RecipientOnionFields::secret_only(payment_secret, msgs::MAX_VALUE_MSAT + 1); let (mut onion_payloads, _, _) = build_onion_payloads( &route.paths[0], msgs::MAX_VALUE_MSAT + 1, @@ -2602,7 +2607,7 @@ fn test_phantom_final_incorrect_cltv_expiry() { let (route, phantom_scid) = get_phantom_route!(nodes, recv_value_msat, channel); // Route the HTLC through to the destination. - let recipient_onion = RecipientOnionFields::secret_only(payment_secret); + let recipient_onion = RecipientOnionFields::secret_only(payment_secret, recv_value_msat); nodes[0] .node .send_payment_with_route(route, payment_hash, recipient_onion, PaymentId(payment_hash.0)) @@ -2671,7 +2676,7 @@ fn test_phantom_failure_too_low_cltv() { route.paths[0].hops[1].cltv_expiry_delta = 5; // Route the HTLC through to the destination. - let recipient_onion = RecipientOnionFields::secret_only(payment_secret); + let recipient_onion = RecipientOnionFields::secret_only(payment_secret, recv_value_msat); nodes[0] .node .send_payment_with_route(route, payment_hash, recipient_onion, PaymentId(payment_hash.0)) @@ -2724,7 +2729,7 @@ fn test_phantom_failure_modified_cltv() { let (mut route, phantom_scid) = get_phantom_route!(nodes, recv_value_msat, channel); // Route the HTLC through to the destination. - let recipient_onion = RecipientOnionFields::secret_only(payment_secret); + let recipient_onion = RecipientOnionFields::secret_only(payment_secret, recv_value_msat); nodes[0] .node .send_payment_with_route(route, payment_hash, recipient_onion, PaymentId(payment_hash.0)) @@ -2779,7 +2784,7 @@ fn test_phantom_failure_expires_too_soon() { let (mut route, phantom_scid) = get_phantom_route!(nodes, recv_value_msat, channel); // Route the HTLC through to the destination. - let recipient_onion = RecipientOnionFields::secret_only(payment_secret); + let recipient_onion = RecipientOnionFields::secret_only(payment_secret, recv_value_msat); nodes[0] .node .send_payment_with_route(route, payment_hash, recipient_onion, PaymentId(payment_hash.0)) @@ -2829,7 +2834,8 @@ fn test_phantom_failure_too_low_recv_amt() { let (mut route, phantom_scid) = get_phantom_route!(nodes, bad_recv_amt_msat, channel); // Route the HTLC through to the destination. - let recipient_onion = RecipientOnionFields::secret_only(payment_secret); + let recipient_onion = + RecipientOnionFields::secret_only(payment_secret, route.get_total_amount()); nodes[0] .node .send_payment_with_route(route, payment_hash, recipient_onion, PaymentId(payment_hash.0)) @@ -2898,7 +2904,7 @@ fn do_test_phantom_dust_exposure_failure(multiplier_dust_limit: bool) { let (mut route, phantom_scid) = get_phantom_route!(nodes, max_dust_exposure + 1, channel); // Route the HTLC through to the destination. - let recipient_onion = RecipientOnionFields::secret_only(payment_secret); + let recipient_onion = RecipientOnionFields::secret_only(payment_secret, max_dust_exposure + 1); let payment_id = PaymentId(payment_hash.0); nodes[0] .node @@ -2948,7 +2954,7 @@ fn test_phantom_failure_reject_payment() { let (mut route, phantom_scid) = get_phantom_route!(nodes, recv_amt_msat, channel); // Route the HTLC through to the destination. - let recipient_onion = RecipientOnionFields::secret_only(payment_secret); + let recipient_onion = RecipientOnionFields::secret_only(payment_secret, recv_amt_msat); let payment_id = PaymentId(payment_hash.0); nodes[0] .node diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 605f27e9666..22cb758284f 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -219,6 +219,7 @@ impl<'a, 'b> OnionPayload<'a, 'b> for msgs::OutboundOnionPayload<'a> { recipient_onion: &'a RecipientOnionFields, keysend_preimage: Option, sender_intended_htlc_amt_msat: u64, total_msat: u64, cltv_expiry_height: u32, ) -> Result { + debug_assert_eq!(total_msat, recipient_onion.total_mpp_amount_msat); Ok(Self::Receive { payment_data: recipient_onion .payment_secret @@ -257,6 +258,7 @@ impl<'a, 'b> OnionPayload<'a, 'b> for msgs::OutboundOnionPayload<'a> { total_msat: u64, amt_to_forward: u64, outgoing_cltv_value: u32, recipient_onion: &'a RecipientOnionFields, packet: msgs::TrampolineOnionPacket, ) -> Result { + debug_assert_eq!(total_msat, recipient_onion.total_mpp_amount_msat); Ok(Self::TrampolineEntrypoint { amt_to_forward, outgoing_cltv_value, @@ -443,6 +445,8 @@ pub(super) fn build_onion_payloads<'a>( invoice_request: Option<&'a InvoiceRequest>, trampoline_packet: Option, ) -> Result<(Vec>, u64, u32), APIError> { + debug_assert_eq!(total_msat, recipient_onion.total_mpp_amount_msat); + let mut res: Vec = Vec::with_capacity( path.hops.len() + path.blinded_tail.as_ref().map_or(0, |t| t.hops.len()), ); @@ -514,6 +518,8 @@ where let mut cur_cltv = starting_htlc_offset; let mut last_hop_id = None; + debug_assert_eq!(total_msat, recipient_onion.total_mpp_amount_msat); + for (idx, hop) in hops.rev().enumerate() { // First hop gets special values so that it can check, on receipt, that everything is // exactly as it should be (and the next hop isn't trying to probe to find out if we're @@ -661,11 +667,15 @@ pub(crate) fn set_max_path_length( maybe_announced_channel: false, }; let mut num_reserved_bytes: usize = 0; + // TODO: Find a way to avoid `clone`ing the whole recipient onion without re-adding the + // explicit amount parameter to build_onion_payloads_callback. + let mut recipient_onion_with_excess_value = recipient_onion.clone(); + recipient_onion_with_excess_value.total_mpp_amount_msat = final_value_msat_with_overpay_buffer; let build_payloads_res = build_onion_payloads_callback( core::iter::once(&unblinded_route_hop), blinded_tail_opt, final_value_msat_with_overpay_buffer, - &recipient_onion, + &recipient_onion_with_excess_value, best_block_height, &keysend_preimage, invoice_request, @@ -2623,11 +2633,29 @@ pub(crate) fn create_payment_onion_internal( prng_seed: [u8; 32], trampoline_session_priv_override: Option, trampoline_prng_seed_override: Option<[u8; 32]>, ) -> Result<(msgs::OnionPacket, u64, u32), APIError> { + debug_assert_eq!(total_msat, recipient_onion.total_mpp_amount_msat); + let mut outer_total_msat = total_msat; let mut outer_starting_htlc_offset = cur_block_height; - let mut trampoline_packet_option = None; - if let Some(blinded_tail) = &path.blinded_tail { + // If we're paying to a recipient through a trampoline, we use the `payment_secret` provided in + // `recipient_onion` as the MPP identifier for the trampoline entry point, allowing it to + // detect when when it has received all the MPP parts. + // A `total_mpp_amount_msat` is also provided to the trampoline entry point, but set in the + // below `if` block. + let mut trampoline_outer_onion = RecipientOnionFields { + payment_secret: recipient_onion.payment_secret, + total_mpp_amount_msat: 0, + payment_metadata: None, + custom_tlvs: Vec::new(), + }; + let (outer_onion, trampoline_packet_option) = if let Some(blinded_tail) = &path.blinded_tail { + if recipient_onion.payment_metadata.is_some() { + return Err(APIError::InvalidRoute { + err: "Cannot pass payment_metadata to a blinded recipient".to_owned(), + }); + } + if !blinded_tail.trampoline_hops.is_empty() { let trampoline_payloads; (trampoline_payloads, outer_total_msat, outer_starting_htlc_offset) = @@ -2638,6 +2666,7 @@ pub(crate) fn create_payment_onion_internal( cur_block_height, keysend_preimage, )?; + trampoline_outer_onion.total_mpp_amount_msat = outer_total_msat; let trampoline_session_priv = trampoline_session_priv_override .unwrap_or_else(|| compute_trampoline_session_priv(session_priv)); @@ -2656,14 +2685,18 @@ pub(crate) fn create_payment_onion_internal( err: "Route size too large considering onion data".to_owned(), })?; - trampoline_packet_option = Some(trampoline_packet); + (&trampoline_outer_onion, Some(trampoline_packet)) + } else { + (recipient_onion, None) } - } + } else { + (recipient_onion, None) + }; let (onion_payloads, htlc_msat, htlc_cltv) = build_onion_payloads( &path, outer_total_msat, - recipient_onion, + outer_onion, outer_starting_htlc_offset, keysend_preimage, invoice_request, @@ -4029,7 +4062,7 @@ mod tests { max_total_routing_fee_msat: Some(u64::MAX), }; route_params.payment_params.max_total_cltv_expiry_delta = u32::MAX; - let recipient_onion = RecipientOnionFields::spontaneous_empty(); + let recipient_onion = RecipientOnionFields::spontaneous_empty(u64::MAX); set_max_path_length(&mut route_params, &recipient_onion, None, None, 42).unwrap(); } diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 64f9f644174..b9a58847746 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -21,6 +21,7 @@ use crate::ln::channelmanager::{ EventCompletionAction, HTLCSource, OptionalBolt11PaymentParams, PaymentCompleteUpdate, PaymentId, }; +use crate::ln::msgs::DecodeError; use crate::ln::onion_utils; use crate::ln::onion_utils::{DecodedOnionFailure, HTLCFailReason}; use crate::offers::invoice::{Bolt12Invoice, DerivedSigningPubkey, InvoiceBuilder}; @@ -44,8 +45,10 @@ use core::fmt::{self, Display, Formatter}; use core::sync::atomic::{AtomicBool, Ordering}; use core::time::Duration; +use crate::io; use crate::prelude::*; use crate::sync::Mutex; +use crate::util::ser; /// The number of ticks of [`ChannelManager::timer_tick_occurred`] until we time-out the idempotency /// of payments by [`PaymentId`]. See [`OutboundPayments::remove_stale_payments`]. @@ -758,33 +761,83 @@ pub struct RecipientOnionFields { pub payment_metadata: Option>, /// See [`Self::custom_tlvs`] for more info. pub(super) custom_tlvs: Vec<(u64, Vec)>, + /// The total payment amount which is being sent. + /// + /// This is communicated to the recipient as an indication that they should delay claiming the + /// payment until they've received multiple payment parts totaling at least this amount. + /// + /// Note that in order to properly communicate this, the recipient must either be paid using + /// blinded paths or a [`Self::payment_secret`] must be set. + pub total_mpp_amount_msat: u64, } -impl_writeable_tlv_based!(RecipientOnionFields, { - (0, payment_secret, option), - (1, custom_tlvs, optional_vec), - (2, payment_metadata, option), -}); +impl ser::Writeable for RecipientOnionFields { + fn write(&self, writer: &mut W) -> Result<(), io::Error> { + write_tlv_fields!(writer, { + (0, self.payment_secret, option), + (1, self.custom_tlvs, optional_vec), + (2, self.payment_metadata, option), + (3, self.total_mpp_amount_msat, required), + }); + Ok(()) + } +} + +impl ser::ReadableArgs for RecipientOnionFields { + fn read( + reader: &mut R, default_total_mpp_amount_msat: u64, + ) -> Result { + _init_and_read_len_prefixed_tlv_fields!(reader, { + (0, payment_secret, option), + (1, custom_tlvs, optional_vec), + (2, payment_metadata, option), + // Added and always written in LDK 0.3 + (3, total_mpp_amount_msat, option), + }); + Ok(Self { + payment_secret, + custom_tlvs: custom_tlvs.unwrap_or(Vec::new()), + payment_metadata, + total_mpp_amount_msat: total_mpp_amount_msat.unwrap_or(default_total_mpp_amount_msat), + }) + } +} impl RecipientOnionFields { - /// Creates a [`RecipientOnionFields`] from only a [`PaymentSecret`]. This is the most common - /// set of onion fields for today's BOLT11 invoices - most nodes require a [`PaymentSecret`] - /// but do not require or provide any further data. + /// Creates a [`RecipientOnionFields`] from only a [`PaymentSecret`] and total MPP amount. This + /// is the most common set of onion fields for today's BOLT11 invoices - most nodes require a + /// [`PaymentSecret`] but do not require or provide any further data. #[rustfmt::skip] - pub fn secret_only(payment_secret: PaymentSecret) -> Self { - Self { payment_secret: Some(payment_secret), payment_metadata: None, custom_tlvs: Vec::new() } + pub fn secret_only(payment_secret: PaymentSecret, total_mpp_amount_msat: u64) -> Self { + Self { + payment_secret: Some(payment_secret), + payment_metadata: None, + custom_tlvs: Vec::new(), + total_mpp_amount_msat, + } } - /// Creates a new [`RecipientOnionFields`] with no fields. This generally does not create - /// payable HTLCs except for single-path spontaneous payments, i.e. this should generally - /// only be used for calls to [`ChannelManager::send_spontaneous_payment`]. If you are sending - /// a spontaneous MPP this will not work as all MPP require payment secrets; you may - /// instead want to use [`RecipientOnionFields::secret_only`]. + /// Creates a new [`RecipientOnionFields`] with no fields but the total MPP amount. This is + /// useful when paying a blinded path, where the `payment_secret` and `payment_metadata` are + /// not provided but rather stored transparently in the blinded path itself. + /// + /// Otherwise, this generally does not create payable HTLCs except for single-path spontaneous + /// payments, i.e. those for calls to [`ChannelManager::send_spontaneous_payment`]. + /// + /// Note that due to protocol limitations, in non-blinded-path cases, you cannot make an MPP + /// payment without a `payment_secret`. Thus, in such cases `total_mpp_amount_msat` is ignored. + /// If you intend to send a spontaneous MPP you may instead want to use + /// [`RecipientOnionFields::secret_only`]. /// /// [`ChannelManager::send_spontaneous_payment`]: super::channelmanager::ChannelManager::send_spontaneous_payment /// [`RecipientOnionFields::secret_only`]: RecipientOnionFields::secret_only - pub fn spontaneous_empty() -> Self { - Self { payment_secret: None, payment_metadata: None, custom_tlvs: Vec::new() } + pub fn spontaneous_empty(total_mpp_amount_msat: u64) -> Self { + Self { + payment_secret: None, + payment_metadata: None, + custom_tlvs: Vec::new(), + total_mpp_amount_msat, + } } /// Creates a new [`RecipientOnionFields`] from an existing one, adding validated custom TLVs. @@ -837,6 +890,9 @@ impl RecipientOnionFields { pub(super) fn check_merge(&mut self, further_htlc_fields: &mut Self) -> Result<(), ()> { if self.payment_secret != further_htlc_fields.payment_secret { return Err(()); } if self.payment_metadata != further_htlc_fields.payment_metadata { return Err(()); } + if self.total_mpp_amount_msat != further_htlc_fields.total_mpp_amount_msat { + return Err(()); + } let tlvs = &mut self.custom_tlvs; let further_tlvs = &mut further_htlc_fields.custom_tlvs; @@ -984,8 +1040,9 @@ impl OutboundPayments { (None, None) => return Err(Bolt11PaymentError::InvalidAmount), }; - let mut recipient_onion = RecipientOnionFields::secret_only(*invoice.payment_secret()) - .with_custom_tlvs(optional_params.custom_tlvs); + let mut recipient_onion = + RecipientOnionFields::secret_only(*invoice.payment_secret(), amount) + .with_custom_tlvs(optional_params.custom_tlvs); recipient_onion.payment_metadata = invoice.payment_metadata().map(|v| v.clone()); let payment_params = PaymentParameters::from_bolt11_invoice(invoice) @@ -1084,6 +1141,7 @@ impl OutboundPayments { payment_secret: None, payment_metadata: None, custom_tlvs: vec![], + total_mpp_amount_msat: route_params.final_value_msat, }; let route = match self.find_initial_route( payment_id, payment_hash, &recipient_onion, keysend_preimage, invoice_request, @@ -1224,7 +1282,7 @@ impl OutboundPayments { if let Err(()) = onion_utils::set_max_path_length( &mut route_params, - &RecipientOnionFields::spontaneous_empty(), + &RecipientOnionFields::spontaneous_empty(amount_msat), Some(keysend_preimage), Some(invreq), best_block_height, @@ -1620,6 +1678,7 @@ impl OutboundPayments { payment_secret: *payment_secret, payment_metadata: payment_metadata.clone(), custom_tlvs: custom_tlvs.clone(), + total_mpp_amount_msat: total_msat, }; let keysend_preimage = *keysend_preimage; let invoice_request = invoice_request.clone(); @@ -1824,15 +1883,16 @@ impl OutboundPayments { } let route = Route { paths: vec![path], route_params: None }; + let recipient_onion_fields = + RecipientOnionFields::secret_only(payment_secret, route.get_total_amount()); let onion_session_privs = self.add_new_pending_payment(payment_hash, - RecipientOnionFields::secret_only(payment_secret), payment_id, None, &route, None, None, + recipient_onion_fields.clone(), payment_id, None, &route, None, None, entropy_source, best_block_height, None ).map_err(|e| { debug_assert!(matches!(e, PaymentSendFailure::DuplicatePayment)); ProbeSendFailure::DuplicateProbe })?; - let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(); match self.pay_route_internal(&route, payment_hash, &recipient_onion_fields, None, None, None, payment_id, None, &onion_session_privs, false, node_signer, best_block_height, &send_payment_along_path @@ -2847,7 +2907,7 @@ mod tests { #[test] #[rustfmt::skip] fn test_recipient_onion_fields_with_custom_tlvs() { - let onion_fields = RecipientOnionFields::spontaneous_empty(); + let onion_fields = RecipientOnionFields::spontaneous_empty(42); let bad_type_range_tlvs = RecipientCustomTlvs::new(vec![ (0, vec![42]), @@ -2895,7 +2955,7 @@ mod tests { let expired_route_params = RouteParameters::from_payment_params_and_value(payment_params, 0); let pending_events = Mutex::new(VecDeque::new()); if on_retry { - outbound_payments.add_new_pending_payment(PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(), + outbound_payments.add_new_pending_payment(PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(0), PaymentId([0; 32]), None, &Route { paths: vec![], route_params: None }, Some(Retry::Attempts(1)), Some(expired_route_params.payment_params.clone()), &&keys_manager, 0, None).unwrap(); @@ -2910,7 +2970,7 @@ mod tests { } else { panic!("Unexpected event"); } } else { let err = outbound_payments.send_payment( - PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(), PaymentId([0; 32]), + PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(0), PaymentId([0; 32]), Retry::Attempts(0), expired_route_params, &&router, vec![], || InFlightHtlcs::new(), &&keys_manager, &&keys_manager, 0, &pending_events, |_| Ok(()), &log).unwrap_err(); if let RetryableSendFailure::PaymentExpired = err { } else { panic!("Unexpected error"); } @@ -2941,7 +3001,7 @@ mod tests { let pending_events = Mutex::new(VecDeque::new()); if on_retry { - outbound_payments.add_new_pending_payment(PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(), + outbound_payments.add_new_pending_payment(PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(0), PaymentId([0; 32]), None, &Route { paths: vec![], route_params: None }, Some(Retry::Attempts(1)), Some(route_params.payment_params.clone()), &&keys_manager, 0, None).unwrap(); @@ -2954,7 +3014,7 @@ mod tests { if let Event::PaymentFailed { .. } = events[0].0 { } else { panic!("Unexpected event"); } } else { let err = outbound_payments.send_payment( - PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(), PaymentId([0; 32]), + PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(0), PaymentId([0; 32]), Retry::Attempts(0), route_params, &&router, vec![], || InFlightHtlcs::new(), &&keys_manager, &&keys_manager, 0, &pending_events, |_| Ok(()), &log).unwrap_err(); if let RetryableSendFailure::RouteNotFound = err { @@ -3005,7 +3065,7 @@ mod tests { // PaymentPathFailed event. let pending_events = Mutex::new(VecDeque::new()); outbound_payments.send_payment( - PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(), PaymentId([0; 32]), + PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(1), PaymentId([0; 32]), Retry::Attempts(0), route_params.clone(), &&router, vec![], || InFlightHtlcs::new(), &&keys_manager, &&keys_manager, 0, &pending_events, |_| Err(APIError::ChannelUnavailable { err: "test".to_owned() }), &log).unwrap(); @@ -3023,7 +3083,7 @@ mod tests { // Ensure that a MonitorUpdateInProgress "error" will not result in a PaymentPathFailed event. outbound_payments.send_payment( - PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(), PaymentId([0; 32]), + PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(1), PaymentId([0; 32]), Retry::Attempts(0), route_params.clone(), &&router, vec![], || InFlightHtlcs::new(), &&keys_manager, &&keys_manager, 0, &pending_events, |_| Err(APIError::MonitorUpdateInProgress), &log).unwrap(); @@ -3031,7 +3091,7 @@ mod tests { // Ensure that any other error will result in a PaymentPathFailed event but no blamed scid. outbound_payments.send_payment( - PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(), PaymentId([1; 32]), + PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(1), PaymentId([1; 32]), Retry::Attempts(0), route_params.clone(), &&router, vec![], || InFlightHtlcs::new(), &&keys_manager, &&keys_manager, 0, &pending_events, |_| Err(APIError::APIMisuseError { err: "test".to_owned() }), &log).unwrap(); diff --git a/lightning/src/ln/payment_tests.rs b/lightning/src/ln/payment_tests.rs index aa4bf96b871..1a013588409 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -146,7 +146,7 @@ fn mpp_retry() { let mut route_params = route.route_params.clone().unwrap(); nodes[0].router.expect_find_route(route_params.clone(), Ok(route.clone())); - let onion = RecipientOnionFields::secret_only(pay_secret); + let onion = RecipientOnionFields::secret_only(pay_secret, amt_msat * 2); let retry = Retry::Attempts(1); nodes[0].node.send_payment(hash, onion, id, route_params.clone(), retry).unwrap(); check_added_monitors(&nodes[0], 2); // one monitor per path @@ -264,7 +264,7 @@ fn mpp_retry_overpay() { let mut route_params = route.route_params.clone().unwrap(); nodes[0].router.expect_find_route(route_params.clone(), Ok(route.clone())); - let onion = RecipientOnionFields::secret_only(pay_secret); + let onion = RecipientOnionFields::secret_only(pay_secret, amt_msat); let retry = Retry::Attempts(1); nodes[0].node.send_payment(hash, onion, id, route_params.clone(), retry).unwrap(); check_added_monitors(&nodes[0], 2); // one monitor per path @@ -366,7 +366,7 @@ fn do_mpp_receive_timeout(send_partial_mpp: bool) { route.route_params.as_mut().unwrap().final_value_msat *= 2; // Initiate the MPP payment. - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 200_000); nodes[0].node.send_payment_with_route(route, hash, onion, PaymentId(hash.0)).unwrap(); check_added_monitors(&nodes[0], 2); // one monitor per path let mut events = nodes[0].node.get_and_clear_pending_msg_events(); @@ -461,7 +461,7 @@ fn do_test_keysend_payments(public_node: bool) { { let preimage = Some(PaymentPreimage([42; 32])); - let onion = RecipientOnionFields::spontaneous_empty(); + let onion = RecipientOnionFields::spontaneous_empty(10000); let retry = Retry::Attempts(1); let id = PaymentId([42; 32]); nodes[0].node.send_spontaneous_payment(preimage, onion, id, route_params, retry).unwrap(); @@ -511,7 +511,7 @@ fn test_mpp_keysend() { let preimage = Some(PaymentPreimage([42; 32])); let payment_secret = PaymentSecret([42; 32]); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, recv_value); let retry = Retry::Attempts(0); let id = PaymentId([42; 32]); let hash = @@ -554,7 +554,7 @@ fn test_fulfill_hold_times() { let preimage = Some(PaymentPreimage([42; 32])); let payment_secret = PaymentSecret([42; 32]); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, recv_value); let retry = Retry::Attempts(0); let id = PaymentId([42; 32]); let hash = @@ -624,7 +624,7 @@ fn test_reject_mpp_keysend_htlc_mismatching_secret() { let payment_id_0 = PaymentId(nodes[0].keys_manager.backing.get_secure_random_bytes()); nodes[0].router.expect_find_route(route.route_params.clone().unwrap(), Ok(route.clone())); let params = route.route_params.clone().unwrap(); - let onion = RecipientOnionFields::spontaneous_empty(); + let onion = RecipientOnionFields::spontaneous_empty(amount); let retry = Retry::Attempts(0); nodes[0].node.send_spontaneous_payment(preimage, onion, payment_id_0, params, retry).unwrap(); check_added_monitors(&nodes[0], 1); @@ -672,7 +672,7 @@ fn test_reject_mpp_keysend_htlc_mismatching_secret() { let payment_id_1 = PaymentId(nodes[0].keys_manager.backing.get_secure_random_bytes()); nodes[0].router.expect_find_route(route.route_params.clone().unwrap(), Ok(route.clone())); - let onion = RecipientOnionFields::spontaneous_empty(); + let onion = RecipientOnionFields::spontaneous_empty(amount); let params = route.route_params.clone().unwrap(); let retry = Retry::Attempts(0); nodes[0].node.send_spontaneous_payment(preimage, onion, payment_id_1, params, retry).unwrap(); @@ -761,7 +761,7 @@ fn no_pending_leak_on_initial_send_failure() { nodes[0].node.peer_disconnected(node_b_id); nodes[1].node.peer_disconnected(node_a_id); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 100_000); let payment_id = PaymentId(payment_hash.0); let res = nodes[0].node.send_payment_with_route(route, payment_hash, onion, payment_id); unwrap_send_err!(nodes[0], res, true, APIError::ChannelUnavailable { ref err }, @@ -814,7 +814,7 @@ fn do_retry_with_no_persist(confirm_before_reload: bool) { send_along_route(&nodes[0], route.clone(), &[&nodes[1], &nodes[2]], 1_000_000); let route_params = route.route_params.unwrap().clone(); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment(payment_hash, onion, id, route_params, Retry::Attempts(1)).unwrap(); check_added_monitors(&nodes[0], 1); @@ -996,7 +996,7 @@ fn do_retry_with_no_persist(confirm_before_reload: bool) { nodes[1].node.timer_tick_occurred(); } - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 1_000_000); // Check that we cannot retry a fulfilled payment nodes[0] .node @@ -1004,7 +1004,7 @@ fn do_retry_with_no_persist(confirm_before_reload: bool) { .unwrap_err(); // ...but if we send with a different PaymentId the payment should fly let id = PaymentId(payment_hash.0); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 1_000_000); nodes[0].node.send_payment_with_route(new_route.clone(), payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -1173,7 +1173,7 @@ fn do_test_completed_payment_not_retryable_on_reload(use_dust: bool) { // If we attempt to retry prior to the HTLC-Timeout (or commitment transaction, for dust HTLCs) // confirming, we will fail as it's considered still-pending... let (new_route, _, _, _) = get_route_and_payment_hash!(nodes[0], nodes[2], amt); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt); match nodes[0].node.send_payment_with_route(new_route.clone(), hash, onion, payment_id) { Err(RetryableSendFailure::DuplicatePayment) => {}, _ => panic!("Unexpected error"), @@ -1193,7 +1193,7 @@ fn do_test_completed_payment_not_retryable_on_reload(use_dust: bool) { node_a_ser = nodes[0].node.encode(); // After the payment failed, we're free to send it again. - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt); nodes[0].node.send_payment_with_route(new_route.clone(), hash, onion, payment_id).unwrap(); assert!(!nodes[0].node.get_and_clear_pending_msg_events().is_empty()); @@ -1210,13 +1210,13 @@ fn do_test_completed_payment_not_retryable_on_reload(use_dust: bool) { // Now resend the payment, delivering the HTLC and actually claiming it this time. This ensures // the payment is not (spuriously) listed as still pending. - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt); nodes[0].node.send_payment_with_route(new_route.clone(), hash, onion, payment_id).unwrap(); check_added_monitors(&nodes[0], 1); pass_along_route(&nodes[0], &[&[&nodes[1], &nodes[2]]], amt, hash, payment_secret); claim_payment(&nodes[0], &[&nodes[1], &nodes[2]], payment_preimage); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt); match nodes[0].node.send_payment_with_route(new_route.clone(), hash, onion, payment_id) { Err(RetryableSendFailure::DuplicatePayment) => {}, _ => panic!("Unexpected error"), @@ -1238,7 +1238,7 @@ fn do_test_completed_payment_not_retryable_on_reload(use_dust: bool) { reconnect_nodes(ReconnectArgs::new(&nodes[0], &nodes[1])); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt); match nodes[0].node.send_payment_with_route(new_route, hash, onion, payment_id) { Err(RetryableSendFailure::DuplicatePayment) => {}, _ => panic!("Unexpected error"), @@ -1531,7 +1531,7 @@ fn get_ldk_payment_preimage() { &Default::default(), &random_seed_bytes, ); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment_with_route(route.unwrap(), payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -1884,7 +1884,7 @@ fn claimed_send_payment_idempotent() { () => { // If we try to resend a new payment with a different payment_hash but with the same // payment_id, it should be rejected. - let onion = RecipientOnionFields::secret_only(second_payment_secret); + let onion = RecipientOnionFields::secret_only(second_payment_secret, 100_000); let send_result = nodes[0].node.send_payment_with_route(route.clone(), hash_b, onion, payment_id); match send_result { @@ -1896,7 +1896,7 @@ fn claimed_send_payment_idempotent() { // also be rejected. let send_result = nodes[0].node.send_spontaneous_payment( None, - RecipientOnionFields::spontaneous_empty(), + RecipientOnionFields::spontaneous_empty(100_000), payment_id, route.route_params.clone().unwrap(), Retry::Attempts(0), @@ -1940,7 +1940,7 @@ fn claimed_send_payment_idempotent() { nodes[0].node.timer_tick_occurred(); } - let onion = RecipientOnionFields::secret_only(second_payment_secret); + let onion = RecipientOnionFields::secret_only(second_payment_secret, 100_000); nodes[0].node.send_payment_with_route(route, hash_b, onion, payment_id).unwrap(); check_added_monitors(&nodes[0], 1); pass_along_route(&nodes[0], &[&[&nodes[1]]], 100_000, hash_b, second_payment_secret); @@ -1967,7 +1967,7 @@ fn abandoned_send_payment_idempotent() { () => { // If we try to resend a new payment with a different payment_hash but with the same // payment_id, it should be rejected. - let onion = RecipientOnionFields::secret_only(second_payment_secret); + let onion = RecipientOnionFields::secret_only(second_payment_secret, 100_000); let send_result = nodes[0].node.send_payment_with_route(route.clone(), hash_b, onion, payment_id); match send_result { @@ -1979,7 +1979,7 @@ fn abandoned_send_payment_idempotent() { // also be rejected. let send_result = nodes[0].node.send_spontaneous_payment( None, - RecipientOnionFields::spontaneous_empty(), + RecipientOnionFields::spontaneous_empty(100_000), payment_id, route.route_params.clone().unwrap(), Retry::Attempts(0), @@ -2009,7 +2009,7 @@ fn abandoned_send_payment_idempotent() { // However, we can reuse the PaymentId immediately after we `abandon_payment` upon passing the // failed payment back. - let onion = RecipientOnionFields::secret_only(second_payment_secret); + let onion = RecipientOnionFields::secret_only(second_payment_secret, 100_000); nodes[0].node.send_payment_with_route(route, hash_b, onion, payment_id).unwrap(); check_added_monitors(&nodes[0], 1); pass_along_route(&nodes[0], &[&[&nodes[1]]], 100_000, hash_b, second_payment_secret); @@ -2177,12 +2177,12 @@ fn test_holding_cell_inflight_htlcs() { // Queue up two payments - one will be delivered right away, one immediately goes into the // holding cell as nodes[0] is AwaitingRAA. { - let onion = RecipientOnionFields::secret_only(payment_secret_1); + let onion = RecipientOnionFields::secret_only(payment_secret_1, 1000000); let id = PaymentId(payment_hash_1.0); nodes[0].node.send_payment_with_route(route.clone(), payment_hash_1, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); - let onion = RecipientOnionFields::secret_only(payment_secret_2); + let onion = RecipientOnionFields::secret_only(payment_secret_2, 1000000); let id = PaymentId(payment_hash_2.0); nodes[0].node.send_payment_with_route(route, payment_hash_2, onion, id).unwrap(); check_added_monitors(&nodes[0], 0); @@ -2272,7 +2272,7 @@ fn do_test_intercepted_payment(test: InterceptTest) { let (hash, payment_secret) = nodes[2].node.create_inbound_payment(Some(amt_msat), 60 * 60, None).unwrap(); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let id = PaymentId(hash.0); nodes[0].node.send_payment_with_route(route.clone(), hash, onion, id).unwrap(); let payment_event = { @@ -2508,7 +2508,7 @@ fn do_accept_underpaying_htlcs_config(num_mpp_parts: usize) { let (payment_hash, payment_secret) = nodes[2].node.create_inbound_payment(Some(amt_msat), 60 * 60, None).unwrap(); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment(payment_hash, onion, id, route_params, Retry::Attempts(0)).unwrap(); @@ -2720,7 +2720,7 @@ fn do_automatic_retries(test: AutoRetry) { if test == AutoRetry::Success { // Test that we can succeed on the first retry. - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let id = PaymentId(hash.0); let retry = Retry::Attempts(1); nodes[0].node.send_payment(hash, onion, id, route_params, retry).unwrap(); @@ -2746,7 +2746,7 @@ fn do_automatic_retries(test: AutoRetry) { preimage, )); } else if test == AutoRetry::Spontaneous { - let onion = RecipientOnionFields::spontaneous_empty(); + let onion = RecipientOnionFields::spontaneous_empty(amt_msat); let id = PaymentId(hash.0); nodes[0] .node @@ -2771,7 +2771,7 @@ fn do_automatic_retries(test: AutoRetry) { claim_payment_along_route(ClaimAlongRouteArgs::new(&nodes[0], &[path], preimage)); } else if test == AutoRetry::FailAttempts { // Ensure ChannelManager will not retry a payment if it has run out of payment attempts. - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let id = PaymentId(hash.0); nodes[0].node.send_payment(hash, onion, id, route_params, Retry::Attempts(1)).unwrap(); pass_failed_attempt_with_retry_along_path!(channel_id_2, true); @@ -2792,7 +2792,7 @@ fn do_automatic_retries(test: AutoRetry) { #[cfg(feature = "std")] { // Ensure ChannelManager will not retry a payment if it times out due to Retry::Timeout. - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let id = PaymentId(hash.0); let retry = Retry::Timeout(Duration::from_secs(60)); nodes[0].node.send_payment(hash, onion, id, route_params, retry).unwrap(); @@ -2820,7 +2820,7 @@ fn do_automatic_retries(test: AutoRetry) { } else if test == AutoRetry::FailOnRestart { // Ensure ChannelManager will not retry a payment after restart, even if there were retry // attempts remaining prior to restart. - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let id = PaymentId(hash.0); nodes[0].node.send_payment(hash, onion, id, route_params, Retry::Attempts(2)).unwrap(); pass_failed_attempt_with_retry_along_path!(channel_id_2, true); @@ -2854,7 +2854,7 @@ fn do_automatic_retries(test: AutoRetry) { _ => panic!("Unexpected event"), } } else if test == AutoRetry::FailOnRetry { - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let id = PaymentId(hash.0); nodes[0].node.send_payment(hash, onion, id, route_params, Retry::Attempts(1)).unwrap(); pass_failed_attempt_with_retry_along_path!(channel_id_2, true); @@ -3016,7 +3016,7 @@ fn auto_retry_partial_failure() { nodes[0].router.expect_find_route(retry_2_params, Ok(retry_2_route)); // Send a payment that will partially fail on send, then partially fail on retry, then succeed. - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment(payment_hash, onion, id, route_params, Retry::Attempts(3)).unwrap(); @@ -3178,7 +3178,7 @@ fn auto_retry_zero_attempts_send_error() { }; nodes[0].router.expect_find_route(route_params.clone(), Ok(send_route)); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment(payment_hash, onion, id, route_params, Retry::Attempts(0)).unwrap(); @@ -3226,7 +3226,7 @@ fn fails_paying_after_rejected_by_payee() { .unwrap(); let route_params = RouteParameters::from_payment_params_and_value(payment_params, amt_msat); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment(payment_hash, onion, id, route_params, Retry::Attempts(1)).unwrap(); check_added_monitors(&nodes[0], 1); @@ -3342,7 +3342,9 @@ fn retry_multi_path_single_failed_payment() { scorer.expect_usage(chans[1].short_channel_id.unwrap(), usage); } - let onion = RecipientOnionFields::secret_only(payment_secret); + // Note that while we actaully pay amt_msat + 1, we should really set the onion amount to + // amt_msat as that's what we built a route for. + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat + 1); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment(payment_hash, onion, id, route_params, Retry::Attempts(1)).unwrap(); let events = nodes[0].node.get_and_clear_pending_events(); @@ -3423,7 +3425,7 @@ fn immediate_retry_on_failure() { route.route_params = Some(retry_params.clone()); nodes[0].router.expect_find_route(retry_params, Ok(route.clone())); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment(payment_hash, onion, id, route_params, Retry::Attempts(1)).unwrap(); let events = nodes[0].node.get_and_clear_pending_events(); @@ -3562,7 +3564,7 @@ fn no_extra_retries_on_back_to_back_fail() { // We can't use the commitment_signed_dance macro helper because in this test we'll be sending // two HTLCs back-to-back on the same channel, and the macro only expects to handle one at a // time. - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment(payment_hash, onion, id, route_params, Retry::Attempts(1)).unwrap(); @@ -3807,7 +3809,7 @@ fn test_simple_partial_retry() { // We can't use the commitment_signed_dance macro helper because in this test we'll be sending // two HTLCs back-to-back on the same channel, and the macro only expects to handle one at a // time. - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment(payment_hash, onion, id, route_params, Retry::Attempts(1)).unwrap(); let first_htlc = SendEvent::from_node(&nodes[0]); @@ -4009,7 +4011,7 @@ fn test_threaded_payment_retries() { }; nodes[0].router.expect_find_route(route_params.clone(), Ok(route.clone())); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let id = PaymentId(payment_hash.0); let retry = Retry::Attempts(0xdeadbeef); nodes[0].node.send_payment(payment_hash, onion, id, route_params.clone(), retry).unwrap(); @@ -4320,7 +4322,7 @@ fn do_claim_from_closed_chan(fail_payment: bool) { let final_cltv = nodes[0].best_block_info().1 + TEST_FINAL_CLTV + 8 + 1; nodes[0].router.expect_find_route(route_params.clone(), Ok(route.clone())); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let id = PaymentId(hash.0); nodes[0].node.send_payment(hash, onion, id, route_params, Retry::Attempts(1)).unwrap(); @@ -4489,6 +4491,7 @@ fn do_test_custom_tlvs(spontaneous: bool, even_tlvs: bool, known_tlvs: bool) { payment_secret: if spontaneous { None } else { Some(payment_secret) }, payment_metadata: None, custom_tlvs: custom_tlvs.clone(), + total_mpp_amount_msat: amt_msat, }; if spontaneous { let params = route.route_params.unwrap(); @@ -4569,7 +4572,7 @@ fn test_retry_custom_tlvs() { let mut route_params = route.route_params.clone().unwrap(); let custom_tlvs = vec![((1 << 16) + 1, vec![0x42u8; 16])]; - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let onion = onion.with_custom_tlvs(RecipientCustomTlvs::new(custom_tlvs.clone()).unwrap()); nodes[0].router.expect_find_route(route_params.clone(), Ok(route.clone())); @@ -4701,6 +4704,7 @@ fn do_test_custom_tlvs_consistency( payment_secret: Some(payment_secret), payment_metadata: None, custom_tlvs: first_tlvs, + total_mpp_amount_msat: amt_msat, }; let session_privs = nodes[0].node.test_add_new_pending_payment(hash, onion.clone(), id, &route).unwrap(); @@ -4726,6 +4730,7 @@ fn do_test_custom_tlvs_consistency( payment_secret: Some(payment_secret), payment_metadata: None, custom_tlvs: second_tlvs, + total_mpp_amount_msat: amt_msat, }; let path_b = &route.paths[1]; let priv_b = session_privs[1]; @@ -4850,6 +4855,7 @@ fn do_test_payment_metadata_consistency(do_reload: bool, do_modify: bool) { payment_secret: Some(payment_secret), payment_metadata: Some(payment_metadata), custom_tlvs: vec![], + total_mpp_amount_msat: amt_msat, }; let retry = Retry::Attempts(1); nodes[0].node.send_payment(payment_hash, onion, payment_id, route_params, retry).unwrap(); @@ -5043,7 +5049,10 @@ fn test_htlc_forward_considers_anchor_outputs_value() { nodes[2], sendable_balance_msat + anchor_outpus_value_msat ); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only( + payment_secret, + sendable_balance_msat + anchor_outpus_value_msat, + ); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment_with_route(route, payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -5108,7 +5117,7 @@ fn peel_payment_onion_custom_tlvs() { let payment_params = PaymentParameters::for_keysend(node_b_id, TEST_FINAL_CLTV, false); let route_params = RouteParameters::from_payment_params_and_value(payment_params, amt_msat); let route = functional_test_utils::get_route(&nodes[0], &route_params).unwrap(); - let mut recipient_onion = RecipientOnionFields::spontaneous_empty() + let mut recipient_onion = RecipientOnionFields::spontaneous_empty(amt_msat) .with_custom_tlvs(RecipientCustomTlvs::new(vec![(414141, vec![42; 1200])]).unwrap()); let prng_seed = chanmon_cfgs[0].keys_manager.get_secure_random_bytes(); let session_priv = SecretKey::from_slice(&prng_seed[..]).expect("RNG is busted"); @@ -5203,7 +5212,7 @@ fn test_non_strict_forwarding() { for i in 0..4 { let (payment_preimage, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[2], Some(payment_value), None); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, payment_value); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment_with_route(route.clone(), payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -5242,7 +5251,7 @@ fn test_non_strict_forwarding() { // Send a 5th payment which will fail. let (_, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[2], Some(payment_value), None); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, payment_value); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment_with_route(route.clone(), payment_hash, onion, id).unwrap(); @@ -5303,7 +5312,7 @@ fn remove_pending_outbounds_on_buggy_router() { nodes[0].router.expect_find_route(route_params.clone(), Ok(route.clone())); // Send the payment with one retry allowed, but the payment should still fail - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let retry = Retry::Attempts(1); nodes[0].node.send_payment(payment_hash, onion, payment_id, route_params, retry).unwrap(); let events = nodes[0].node.get_and_clear_pending_events(); @@ -5379,7 +5388,7 @@ fn pay_route_without_params() { get_route_and_payment_hash!(nodes[0], nodes[1], payment_params, amt_msat); route.route_params.take(); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let id = PaymentId(hash.0); nodes[0].node.send_payment_with_route(route, hash, onion, id).unwrap(); diff --git a/lightning/src/ln/priv_short_conf_tests.rs b/lightning/src/ln/priv_short_conf_tests.rs index a5ccac780f9..ffe5ea6cbb1 100644 --- a/lightning/src/ln/priv_short_conf_tests.rs +++ b/lightning/src/ln/priv_short_conf_tests.rs @@ -81,7 +81,7 @@ fn test_priv_forwarding_rejection() { let (route, our_payment_hash, our_payment_preimage, our_payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[2], payment_params, 10_000); - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, 10_000); let id = PaymentId(our_payment_hash.0); nodes[0].node.send_payment_with_route(route.clone(), our_payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -164,7 +164,7 @@ fn test_priv_forwarding_rejection() { get_event_msg!(nodes[1], MessageSendEvent::SendChannelUpdate, node_c_id); get_event_msg!(nodes[2], MessageSendEvent::SendChannelUpdate, node_b_id); - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, 10_000); let id = PaymentId(our_payment_hash.0); nodes[0].node.send_payment_with_route(route, our_payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -348,7 +348,7 @@ fn test_routed_scid_alias() { get_route_and_payment_hash!(nodes[0], nodes[2], payment_params, 100_000); assert_eq!(route.paths[0].hops[1].short_channel_id, last_hop[0].inbound_scid_alias.unwrap()); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 100_000); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment_with_route(route, payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -578,7 +578,7 @@ fn test_inbound_scid_privacy() { get_route_and_payment_hash!(nodes[0], nodes[2], payment_params, 100_000); assert_eq!(route.paths[0].hops[1].short_channel_id, last_hop[0].inbound_scid_alias.unwrap()); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 100_000); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment_with_route(route, payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -599,7 +599,7 @@ fn test_inbound_scid_privacy() { get_route_and_payment_hash!(nodes[0], nodes[2], payment_params_2, 100_000); assert_eq!(route_2.paths[0].hops[1].short_channel_id, last_hop[0].short_channel_id.unwrap()); - let onion = RecipientOnionFields::secret_only(payment_secret_2); + let onion = RecipientOnionFields::secret_only(payment_secret_2, 100_000); let id = PaymentId(payment_hash_2.0); nodes[0].node.send_payment_with_route(route_2, payment_hash_2, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -695,7 +695,7 @@ fn test_scid_alias_returned() { route.paths[0].hops[1].fee_msat = 10_000_000; // Overshoot the last channel's value // Route the HTLC through to the destination. - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, route.get_total_amount()); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment_with_route(route.clone(), payment_hash, onion, id).unwrap(); @@ -732,7 +732,7 @@ fn test_scid_alias_returned() { route.paths[0].hops[0].fee_msat = 0; // But set fee paid to the middle hop to 0 // Route the HTLC through to the destination. - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 10_000); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment_with_route(route, payment_hash, onion, id).unwrap(); @@ -934,7 +934,7 @@ fn test_0conf_channel_with_async_monitor() { let (route, payment_hash, payment_preimage, payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[2], 1_000_000); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 1_000_000); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment_with_route(route, payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -1283,7 +1283,7 @@ fn test_0conf_channel_reorg() { ); claim_payment(&nodes[0], &[&nodes[1], &nodes[2]], payment_preimage); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 10_000); let id = PaymentId([0; 32]); nodes[1].node.send_payment_with_route(route, payment_hash, onion.clone(), id).unwrap(); let mut conditions = PaymentFailedConditions::new(); diff --git a/lightning/src/ln/quiescence_tests.rs b/lightning/src/ln/quiescence_tests.rs index d972fb6a5c5..3557b03697e 100644 --- a/lightning/src/ln/quiescence_tests.rs +++ b/lightning/src/ln/quiescence_tests.rs @@ -98,7 +98,7 @@ fn allow_shutdown_while_awaiting_quiescence(local_shutdown: bool) { let payment_amount = 1_000_000; let (route, payment_hash, _, payment_secret) = get_route_and_payment_hash!(local_node, remote_node, payment_amount); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, payment_amount); let payment_id = PaymentId(payment_hash.0); local_node.node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); check_added_monitors(&local_node, 1); @@ -304,7 +304,7 @@ fn test_quiescence_on_final_revoke_and_ack_pending_monitor_update() { let payment_amount = 1_000_000; let (route, payment_hash, _, payment_secret) = get_route_and_payment_hash!(&nodes[0], &nodes[1], payment_amount); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, payment_amount); let payment_id = PaymentId(payment_hash.0); nodes[0].node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -370,7 +370,7 @@ fn quiescence_updates_go_to_holding_cell(fail_htlc: bool) { let (route1, payment_hash1, payment_preimage1, payment_secret1) = get_route_and_payment_hash!(&nodes[1], &nodes[0], payment_amount); - let onion1 = RecipientOnionFields::secret_only(payment_secret1); + let onion1 = RecipientOnionFields::secret_only(payment_secret1, payment_amount); let payment_id1 = PaymentId(payment_hash1.0); nodes[1].node.send_payment_with_route(route1, payment_hash1, onion1, payment_id1).unwrap(); check_added_monitors(&nodes[1], 0); @@ -380,7 +380,7 @@ fn quiescence_updates_go_to_holding_cell(fail_htlc: bool) { // allowed to make updates. let (route2, payment_hash2, payment_preimage2, payment_secret2) = get_route_and_payment_hash!(&nodes[0], &nodes[1], payment_amount); - let onion2 = RecipientOnionFields::secret_only(payment_secret2); + let onion2 = RecipientOnionFields::secret_only(payment_secret2, payment_amount); let payment_id2 = PaymentId(payment_hash2.0); nodes[0].node.send_payment_with_route(route2, payment_hash2, onion2, payment_id2).unwrap(); check_added_monitors(&nodes[0], 1); diff --git a/lightning/src/ln/reload_tests.rs b/lightning/src/ln/reload_tests.rs index cc5eac60206..2e8a060d2c7 100644 --- a/lightning/src/ln/reload_tests.rs +++ b/lightning/src/ln/reload_tests.rs @@ -545,7 +545,7 @@ fn do_test_data_loss_protect(reconnect_panicing: bool, substantially_old: bool, // `not_stale` to test the boundary condition. let pay_params = PaymentParameters::for_keysend(nodes[1].node.get_our_node_id(), 100, false); let route_params = RouteParameters::from_payment_params_and_value(pay_params, 40000); - nodes[0].node.send_spontaneous_payment(None, RecipientOnionFields::spontaneous_empty(), PaymentId([0; 32]), route_params, Retry::Attempts(0)).unwrap(); + nodes[0].node.send_spontaneous_payment(None, RecipientOnionFields::spontaneous_empty(40000), PaymentId([0; 32]), route_params, Retry::Attempts(0)).unwrap(); check_added_monitors(&nodes[0], 1); let update_add_commit = SendEvent::from_node(&nodes[0]); @@ -766,7 +766,7 @@ fn do_test_partial_claim_before_restart(persist_both_monitors: bool, double_rest }); nodes[0].node.send_payment_with_route(route, payment_hash, - RecipientOnionFields::secret_only(payment_secret), PaymentId(payment_hash.0)).unwrap(); + RecipientOnionFields::secret_only(payment_secret, 15_000_000), PaymentId(payment_hash.0)).unwrap(); check_added_monitors(&nodes[0], 2); // Send the payment through to nodes[3] *without* clearing the PaymentClaimable event @@ -964,7 +964,7 @@ fn do_forwarded_payment_no_manager_persistence(use_cs_commitment: bool, claim_ht let payment_id = PaymentId(nodes[0].keys_manager.backing.get_secure_random_bytes()); let htlc_expiry = nodes[0].best_block_info().1 + TEST_FINAL_CLTV; nodes[0].node.send_payment_with_route(route, payment_hash, - RecipientOnionFields::secret_only(payment_secret), payment_id).unwrap(); + RecipientOnionFields::secret_only(payment_secret, 1_000_000), payment_id).unwrap(); check_added_monitors(&nodes[0], 1); let payment_event = SendEvent::from_node(&nodes[0]); @@ -1219,7 +1219,7 @@ fn do_manager_persisted_pre_outbound_edge_forward(intercept_htlc: bool) { if intercept_htlc { route.paths[0].hops[1].short_channel_id = nodes[1].node.get_intercept_scid(); } - nodes[0].node.send_payment_with_route(route, payment_hash, RecipientOnionFields::secret_only(payment_secret), PaymentId(payment_hash.0)).unwrap(); + nodes[0].node.send_payment_with_route(route, payment_hash, RecipientOnionFields::secret_only(payment_secret, amt_msat), PaymentId(payment_hash.0)).unwrap(); check_added_monitors(&nodes[0], 1); let updates = get_htlc_update_msgs(&nodes[0], &nodes[1].node.get_our_node_id()); nodes[1].node.handle_update_add_htlc(nodes[0].node.get_our_node_id(), &updates.update_add_htlcs[0]); @@ -1312,7 +1312,7 @@ fn test_manager_persisted_post_outbound_edge_forward() { // Lock in the HTLC from node_a <> node_b. let amt_msat = 5000; let (mut route, payment_hash, payment_preimage, payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[2], amt_msat); - nodes[0].node.send_payment_with_route(route, payment_hash, RecipientOnionFields::secret_only(payment_secret), PaymentId(payment_hash.0)).unwrap(); + nodes[0].node.send_payment_with_route(route, payment_hash, RecipientOnionFields::secret_only(payment_secret, amt_msat), PaymentId(payment_hash.0)).unwrap(); check_added_monitors(&nodes[0], 1); let updates = get_htlc_update_msgs(&nodes[0], &nodes[1].node.get_our_node_id()); nodes[1].node.handle_update_add_htlc(nodes[0].node.get_our_node_id(), &updates.update_add_htlcs[0]); @@ -1371,7 +1371,8 @@ fn test_manager_persisted_post_outbound_edge_holding_cell() { // Lock in the HTLC from node_a <> node_b. let amt_msat = 1000; let (route, payment_hash, payment_preimage, payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[2], amt_msat); - nodes[0].node.send_payment_with_route(route, payment_hash, RecipientOnionFields::secret_only(payment_secret), PaymentId(payment_hash.0)).unwrap(); + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); + nodes[0].node.send_payment_with_route(route, payment_hash, onion, PaymentId(payment_hash.0)).unwrap(); check_added_monitors(&nodes[0], 1); let updates = get_htlc_update_msgs(&nodes[0], &nodes[1].node.get_our_node_id()); nodes[1].node.handle_update_add_htlc(nodes[0].node.get_our_node_id(), &updates.update_add_htlcs[0]); @@ -1380,7 +1381,8 @@ fn test_manager_persisted_post_outbound_edge_holding_cell() { // Send a 2nd HTLC node_c -> node_b, to force the first HTLC into the holding cell. chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress); let (route_2, payment_hash_2, payment_preimage_2, payment_secret_2) = get_route_and_payment_hash!(nodes[2], nodes[1], amt_msat); - nodes[2].node.send_payment_with_route(route_2, payment_hash_2, RecipientOnionFields::secret_only(payment_secret_2), PaymentId(payment_hash_2.0)).unwrap(); + let onion = RecipientOnionFields::secret_only(payment_secret_2, amt_msat); + nodes[2].node.send_payment_with_route(route_2, payment_hash_2, onion, PaymentId(payment_hash_2.0)).unwrap(); let send_event = SendEvent::from_event(nodes[2].node.get_and_clear_pending_msg_events().remove(0)); nodes[1].node.handle_update_add_htlc(nodes[2].node.get_our_node_id(), &send_event.msgs[0]); @@ -1554,9 +1556,9 @@ fn test_htlc_localremoved_persistence() { let test_preimage = PaymentPreimage([42; 32]); let mismatch_payment_hash = PaymentHash([43; 32]); let session_privs = nodes[0].node.test_add_new_pending_payment(mismatch_payment_hash, - RecipientOnionFields::spontaneous_empty(), PaymentId(mismatch_payment_hash.0), &route).unwrap(); + RecipientOnionFields::spontaneous_empty(10_000), PaymentId(mismatch_payment_hash.0), &route).unwrap(); nodes[0].node.test_send_payment_internal(&route, mismatch_payment_hash, - RecipientOnionFields::spontaneous_empty(), Some(test_preimage), PaymentId(mismatch_payment_hash.0), None, session_privs).unwrap(); + RecipientOnionFields::spontaneous_empty(10_000), Some(test_preimage), PaymentId(mismatch_payment_hash.0), None, session_privs).unwrap(); check_added_monitors(&nodes[0], 1); let updates = get_htlc_update_msgs(&nodes[0], &nodes[1].node.get_our_node_id()); @@ -1741,7 +1743,7 @@ fn test_hold_completed_inflight_monitor_updates_upon_manager_reload() { let (route, payment_hash, _, payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[1], 1_000_000); let payment_id = PaymentId(payment_hash.0); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 1_000_000); nodes[0].node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); check_added_monitors(&nodes[0], 1); @@ -2127,9 +2129,8 @@ fn test_reload_with_mpp_claims_on_same_channel() { get_route_and_payment_hash!(nodes[0], nodes[2], amt_msat); let payment_id = PaymentId(nodes[0].keys_manager.backing.get_secure_random_bytes()); - nodes[0].node.send_payment_with_route( - route, payment_hash, RecipientOnionFields::secret_only(payment_secret), payment_id, - ).unwrap(); + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); + nodes[0].node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); check_added_monitors(&nodes[0], 1); // Forward the first HTLC nodes[0] -> nodes[1] -> nodes[2]. Note that the second HTLC is released diff --git a/lightning/src/ln/shutdown_tests.rs b/lightning/src/ln/shutdown_tests.rs index 474b422b655..d70b240e4e4 100644 --- a/lightning/src/ln/shutdown_tests.rs +++ b/lightning/src/ln/shutdown_tests.rs @@ -443,12 +443,12 @@ fn updates_shutdown_wait() { ) .unwrap(); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 100_000); let id = PaymentId(payment_hash.0); let res = nodes[0].node.send_payment_with_route(route_1, payment_hash, onion, id); unwrap_send_err!(nodes[0], res, true, APIError::ChannelUnavailable { .. }, {}); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 100_000); let res = nodes[1].node.send_payment_with_route(route_2, payment_hash, onion, id); unwrap_send_err!(nodes[1], res, true, APIError::ChannelUnavailable { .. }, {}); @@ -544,7 +544,7 @@ fn do_htlc_fail_async_shutdown(blinded_recipient: bool) { amt_msat, ) }; - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, amt_msat); let id = PaymentId(our_payment_hash.0); nodes[0] .node @@ -1903,7 +1903,7 @@ fn test_pending_htlcs_arent_lost_on_mon_delay() { // moment `cs_last_raa` is received by B. let (route_b, payment_hash_b, _preimage, payment_secret_b) = get_route_and_payment_hash!(&nodes[0], nodes[2], 900_000); - let onion = RecipientOnionFields::secret_only(payment_secret_b); + let onion = RecipientOnionFields::secret_only(payment_secret_b, 900_000); let id = PaymentId(payment_hash_b.0); nodes[0].node.send_payment_with_route(route_b, payment_hash_b, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 92a298f6ef1..409ab3ec8de 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -1980,7 +1980,7 @@ fn fail_splice_on_interactive_tx_error() { // Queue an outgoing HTLC to the holding cell. It should be freed once we exit quiescence. let (route, payment_hash, _payment_preimage, payment_secret) = get_route_and_payment_hash!(initiator, acceptor, 1_000_000); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 1_000_000); let payment_id = PaymentId(payment_hash.0); initiator.node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); @@ -2055,7 +2055,7 @@ fn fail_splice_on_tx_abort() { // Queue an outgoing HTLC to the holding cell. It should be freed once we exit quiescence. let (route, payment_hash, _payment_preimage, payment_secret) = get_route_and_payment_hash!(initiator, acceptor, 1_000_000); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 1_000_000); let payment_id = PaymentId(payment_hash.0); initiator.node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); @@ -2124,7 +2124,7 @@ fn fail_splice_on_tx_complete_error() { // Queue an outgoing HTLC to the holding cell. It should be freed once we exit quiescence. let (route, payment_hash, _payment_preimage, payment_secret) = get_route_and_payment_hash!(initiator, acceptor, 1_000_000); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 1_000_000); let payment_id = PaymentId(payment_hash.0); acceptor.node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); @@ -2208,7 +2208,7 @@ fn free_holding_cell_on_tx_signatures_quiescence_exit() { // Queue an outgoing HTLC to the holding cell. It should be freed once we exit quiescence. let (route, payment_hash, _payment_preimage, payment_secret) = get_route_and_payment_hash!(initiator, acceptor, 1_000_000); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 1_000_000); let payment_id = PaymentId(payment_hash.0); initiator.node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); assert!(initiator.node.get_and_clear_pending_msg_events().is_empty()); @@ -2402,7 +2402,7 @@ fn do_test_splice_with_inflight_htlc_forward_and_resolution(expire_scid_pre_forw let route = get_route(&nodes[0], &route_params).unwrap(); let (_, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[2], Some(payment_amount), None); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, payment_amount); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment_with_route(route.clone(), payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); diff --git a/lightning/src/ln/update_fee_tests.rs b/lightning/src/ln/update_fee_tests.rs index ff3e2a0b7e3..1886b0fc134 100644 --- a/lightning/src/ln/update_fee_tests.rs +++ b/lightning/src/ln/update_fee_tests.rs @@ -80,7 +80,7 @@ pub fn test_async_inbound_update_fee() { // ...but before it's delivered, nodes[1] starts to send a payment back to nodes[0]... let (route, our_payment_hash, _, our_payment_secret) = get_route_and_payment_hash!(nodes[1], nodes[0], 40000); - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, 40000); let id = PaymentId(our_payment_hash.0); nodes[1].node.send_payment_with_route(route, our_payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[1], 1); @@ -181,7 +181,7 @@ pub fn test_update_fee_unordered_raa() { // ...but before it's delivered, nodes[1] starts to send a payment back to nodes[0]... let (route, our_payment_hash, _, our_payment_secret) = get_route_and_payment_hash!(nodes[1], nodes[0], 40000); - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, 40000); let id = PaymentId(our_payment_hash.0); nodes[1].node.send_payment_with_route(route, our_payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[1], 1); @@ -665,7 +665,7 @@ pub fn test_update_fee_with_fundee_update_add_htlc() { get_route_and_payment_hash!(nodes[1], nodes[0], 800000); // nothing happens since node[1] is in AwaitingRemoteRevoke - let onion = RecipientOnionFields::secret_only(our_payment_secret); + let onion = RecipientOnionFields::secret_only(our_payment_secret, 800000); let id = PaymentId(our_payment_hash.0); nodes[1].node.send_payment_with_route(route, our_payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[1], 0); @@ -1101,7 +1101,7 @@ pub fn do_cannot_afford_on_holding_cell_release( let (route, payment_hash, _, payment_secret) = get_route_and_payment_hash!(nodes[1], nodes[0], 5000 * 1000); - let onion = RecipientOnionFields::secret_only(payment_secret); + let onion = RecipientOnionFields::secret_only(payment_secret, 5000 * 1000); let id = PaymentId(payment_hash.0); nodes[1].node.send_payment_with_route(route, payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[1], 1); From d9495081d826f14774df1334ea55b7029bb40388 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 2 Feb 2026 20:52:29 +0000 Subject: [PATCH 074/627] Replace existing MPP-total args with `RecipientOnionFields` In some uses of LDK we need the ability to send HTLCs for only a portion of some larger MPP payment. This allows payers to make single payments which spend funds from multiple wallets, which may be important for ecash wallets holding funds in multiple mints or graduated wallets which hold funds across a trusted wallet and a self-custodial wallet. In the previous commit we added a new field to `RecipientOnionFields` to describe the total value of an MPP payment. Here we start using this field when building onions, dropping existing arguments to onion-building methods. --- lightning/src/ln/blinded_payment_tests.rs | 22 ++--- lightning/src/ln/channelmanager.rs | 16 ++-- lightning/src/ln/functional_tests.rs | 20 ++-- lightning/src/ln/htlc_reserve_unit_tests.rs | 15 +-- .../src/ln/max_payment_path_len_tests.rs | 5 +- lightning/src/ln/onion_payment.rs | 8 +- lightning/src/ln/onion_route_tests.rs | 19 ++-- lightning/src/ln/onion_utils.rs | 91 ++++++++++--------- lightning/src/ln/outbound_payment.rs | 29 +++--- lightning/src/ln/payment_tests.rs | 9 +- lightning/src/ln/reload_tests.rs | 2 +- 11 files changed, 101 insertions(+), 135 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index 3cabdee9667..e8469cade60 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -478,8 +478,8 @@ fn do_forward_checks_failure(check: ForwardCheckFail, intro_fails: bool) { let session_priv = SecretKey::from_slice(&[3; 32]).unwrap(); let mut onion_keys = onion_utils::construct_onion_keys(&Secp256k1::new(), &route.paths[0], &session_priv); let cur_height = nodes[0].best_block_info().1; - let (mut onion_payloads, ..) = onion_utils::build_onion_payloads( - &route.paths[0], amt_msat, &recipient_onion_fields, cur_height, &None, None, None).unwrap(); + let (mut onion_payloads, ..) = onion_utils::test_build_onion_payloads( + &route.paths[0], &recipient_onion_fields, cur_height, &None, None, None).unwrap(); // Remove the receive payload so the blinded forward payload is encoded as a final payload // (i.e. next_hop_hmac == [0; 32]) onion_payloads.pop(); @@ -1065,8 +1065,8 @@ fn do_multi_hop_receiver_fail(check: ReceiveCheckFail) { let mut onion_keys = onion_utils::construct_onion_keys(&Secp256k1::new(), &route.paths[0], &session_priv); let cur_height = nodes[0].best_block_info().1; let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(amt_msat); - let (mut onion_payloads, ..) = onion_utils::build_onion_payloads( - &route.paths[0], amt_msat, &recipient_onion_fields, cur_height, &None, None, None).unwrap(); + let (mut onion_payloads, ..) = onion_utils::test_build_onion_payloads( + &route.paths[0], &recipient_onion_fields, cur_height, &None, None, None).unwrap(); let update_add = &mut payment_event_1_2.msgs[0]; onion_payloads.last_mut().map(|p| { @@ -1681,7 +1681,7 @@ fn route_blinding_spec_test_vector() { }), }; let cur_height = 747_000; - let (bob_onion, _, _) = onion_utils::create_payment_onion(&secp_ctx, &path, &session_priv, amt_msat, &RecipientOnionFields::spontaneous_empty(amt_msat), cur_height, &PaymentHash([0; 32]), &None, None, [0; 32]).unwrap(); + let (bob_onion, _, _) = onion_utils::create_payment_onion(&secp_ctx, &path, &session_priv, &RecipientOnionFields::spontaneous_empty(amt_msat), cur_height, &PaymentHash([0; 32]), &None, None, [0; 32]).unwrap(); struct TestEcdhSigner { node_secret: SecretKey, @@ -1905,7 +1905,7 @@ fn test_combined_trampoline_onion_creation_vectors() { let amt_msat = 150_000_000; let cur_height = 800_000; let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret, amt_msat); - let (bob_onion, htlc_msat, htlc_cltv) = onion_utils::create_payment_onion_internal(&secp_ctx, &path, &outer_session_key, amt_msat, &recipient_onion_fields, cur_height, &associated_data, &None, None, outer_onion_prng_seed, Some(session_priv), Some([0; 32])).unwrap(); + let (bob_onion, htlc_msat, htlc_cltv) = onion_utils::create_payment_onion_internal(&secp_ctx, &path, &outer_session_key, &recipient_onion_fields, cur_height, &associated_data, &None, None, outer_onion_prng_seed, Some(session_priv), Some([0; 32])).unwrap(); let outer_onion_packet_hex = bob_onion.encode().to_lower_hex_string(); assert_eq!(outer_onion_packet_hex, "00025fd60556c134ae97e4baedba220a644037754ee67c54fd05e93bf40c17cbb73362fb9dee96001ff229945595b6edb59437a6bc143406d3f90f749892a84d8d430c6890437d26d5bfc599d565316ef51347521075bbab87c59c57bcf20af7e63d7192b46cf171e4f73cb11f9f603915389105d91ad630224bea95d735e3988add1e24b5bf28f1d7128db64284d90a839ba340d088c74b1fb1bd21136b1809428ec5399c8649e9bdf92d2dcfc694deae5046fa5b2bdf646847aaad73f5e95275763091c90e71031cae1f9a770fdea559642c9c02f424a2a28163dd0957e3874bd28a97bec67d18c0321b0e68bc804aa8345b17cb626e2348ca06c8312a167c989521056b0f25c55559d446507d6c491d50605cb79fa87929ce64b0a9860926eeaec2c431d926a1cadb9a1186e4061cb01671a122fc1f57602cbef06d6c194ec4b715c2e3dd4120baca3172cd81900b49fef857fb6d6afd24c983b608108b0a5ac0c1c6c52011f23b8778059ffadd1bb7cd06e2525417365f485a7fd1d4a9ba3818ede7cdc9e71afee8532252d08e2531ca52538655b7e8d912f7ec6d37bbcce8d7ec690709dbf9321e92c565b78e7fe2c22edf23e0902153d1ca15a112ad32fb19695ec65ce11ddf670da7915f05ad4b86c154fb908cb567315d1124f303f75fa075ebde8ef7bb12e27737ad9e4924439097338ea6d7a6fc3721b88c9b830a34e8d55f4c582b74a3895cc848fe57f4fe29f115dabeb6b3175be15d94408ed6771109cfaf57067ae658201082eae7605d26b1449af4425ae8e8f58cdda5c6265f1fd7a386fc6cea3074e4f25b909b96175883676f7610a00fdf34df9eb6c7b9a4ae89b839c69fd1f285e38cdceb634d782cc6d81179759bc9fd47d7fd060470d0b048287764c6837963274e708314f017ac7dc26d0554d59bfcfd3136225798f65f0b0fea337c6b256ebbb63a90b994c0ab93fd8b1d6bd4c74aebe535d6110014cd3d525394027dfe8faa98b4e9b2bee7949eb1961f1b026791092f84deea63afab66603dbe9b6365a102a1fef2f6b9744bc1bb091a8da9130d34d4d39f25dbad191649cfb67e10246364b7ce0c6ec072f9690cabb459d9fda0c849e17535de4357e9907270c75953fca3c845bb613926ecf73205219c7057a4b6bb244c184362bb4e2f24279dc4e60b94a5b1ec11c34081a628428ba5646c995b9558821053ba9c84a05afbf00dabd60223723096516d2f5668f3ec7e11612b01eb7a3a0506189a2272b88e89807943adb34291a17f6cb5516ffd6f945a1c42a524b21f096d66f350b1dad4db455741ae3d0e023309fbda5ef55fb0dc74f3297041448b2be76c525141963934c6afc53d263fb7836626df502d7c2ee9e79cbbd87afd84bbb8dfbf45248af3cd61ad5fac827e7683ca4f91dfad507a8eb9c17b2c9ac5ec051fe645a4a6cb37136f6f19b611e0ea8da7960af2d779507e55f57305bc74b7568928c5dd5132990fe54c22117df91c257d8c7b61935a018a28c1c3b17bab8e4294fa699161ec21123c9fc4e71079df31f300c2822e1246561e04765d3aab333eafd026c7431ac7616debb0e022746f4538e1c6348b600c988eeb2d051fc60c468dca260a84c79ab3ab8342dc345a764672848ea234e17332bc124799daf7c5fcb2e2358514a7461357e1c19c802c5ee32deccf1776885dd825bedd5f781d459984370a6b7ae885d4483a76ddb19b30f47ed47cd56aa5a079a89793dbcad461c59f2e002067ac98dd5a534e525c9c46c2af730741bf1f8629357ec0bfc0bc9ecb31af96777e507648ff4260dc3673716e098d9111dfd245f1d7c55a6de340deb8bd7a053e5d62d760f184dc70ca8fa255b9023b9b9aedfb6e419a5b5951ba0f83b603793830ee68d442d7b88ee1bbf6bbd1bcd6f68cc1af"); @@ -1996,7 +1996,7 @@ fn test_trampoline_inbound_payment_decoding() { let amt_msat = 150_000_001; let cur_height = 800_001; let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret, amt_msat); - let (bob_onion, _, _) = onion_utils::create_payment_onion(&secp_ctx, &path, &session_priv, amt_msat, &recipient_onion_fields, cur_height, &PaymentHash([0; 32]), &None, None, [0; 32]).unwrap(); + let (bob_onion, _, _) = onion_utils::create_payment_onion(&secp_ctx, &path, &session_priv, &recipient_onion_fields, cur_height, &PaymentHash([0; 32]), &None, None, [0; 32]).unwrap(); struct TestEcdhSigner { node_secret: SecretKey, @@ -2181,7 +2181,7 @@ fn test_trampoline_forward_payload_encoded_as_receive() { }); let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(amt_msat); - let (mut trampoline_payloads, outer_total_msat, outer_starting_htlc_offset) = onion_utils::build_trampoline_onion_payloads(&blinded_tail, amt_msat, &recipient_onion_fields, 32, &None).unwrap(); + let (mut trampoline_payloads, outer_total_msat, outer_starting_htlc_offset) = onion_utils::build_trampoline_onion_payloads(&blinded_tail, &recipient_onion_fields, 32, &None).unwrap(); // pop the last dummy hop trampoline_payloads.pop(); @@ -2196,7 +2196,7 @@ fn test_trampoline_forward_payload_encoded_as_receive() { ).unwrap(); let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(outer_total_msat); - let (outer_payloads, _, _) = onion_utils::build_onion_payloads(&route.paths[0], outer_total_msat, &recipient_onion_fields, outer_starting_htlc_offset, &None, None, Some(trampoline_packet)).unwrap(); + let (outer_payloads, _, _) = onion_utils::test_build_onion_payloads(&route.paths[0], &recipient_onion_fields, outer_starting_htlc_offset, &None, None, Some(trampoline_packet)).unwrap(); let outer_onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route.clone().paths[0], &outer_session_priv); let outer_packet = onion_utils::construct_onion_packet( outer_payloads, @@ -2489,7 +2489,6 @@ fn replacement_onion( let (mut trampoline_payloads, outer_total_msat, outer_starting_htlc_offset) = onion_utils::build_trampoline_onion_payloads( &blinded_tail, - original_amt_msat, &recipient_onion_fields, starting_htlc_offset, &None, @@ -2527,9 +2526,8 @@ fn replacement_onion( // Use a different session key to construct the replacement onion packet. Note that the // sender isn't aware of this and won't be able to decode the fulfill hold times. let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(outer_total_msat); - let (mut outer_payloads, _, _) = onion_utils::build_onion_payloads( + let (mut outer_payloads, _, _) = onion_utils::test_build_onion_payloads( &route.paths[0], - outer_total_msat, &recipient_onion_fields, outer_starting_htlc_offset, &None, diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 18bbbbc2821..99d579420a1 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5153,15 +5153,14 @@ impl< #[cfg(any(test, feature = "_externalize_tests"))] pub(crate) fn test_send_payment_along_path( &self, path: &Path, payment_hash: &PaymentHash, recipient_onion: RecipientOnionFields, - total_value: u64, cur_height: u32, payment_id: PaymentId, - keysend_preimage: &Option, session_priv_bytes: [u8; 32], + cur_height: u32, payment_id: PaymentId, keysend_preimage: &Option, + session_priv_bytes: [u8; 32], ) -> Result<(), APIError> { let _lck = self.total_consistency_lock.read().unwrap(); self.send_payment_along_path(SendAlongPathArgs { path, payment_hash, recipient_onion: &recipient_onion, - total_value, cur_height, payment_id, keysend_preimage, @@ -5177,7 +5176,6 @@ impl< path, payment_hash, recipient_onion, - total_value, cur_height, payment_id, keysend_preimage, @@ -5202,7 +5200,6 @@ impl< &self.secp_ctx, &path, &session_priv, - total_value, recipient_onion, cur_height, payment_hash, @@ -5421,7 +5418,7 @@ impl< pub(super) fn test_send_payment_internal( &self, route: &Route, payment_hash: PaymentHash, recipient_onion: RecipientOnionFields, keysend_preimage: Option, payment_id: PaymentId, - recv_value_msat: Option, onion_session_privs: Vec<[u8; 32]>, + onion_session_privs: Vec<[u8; 32]>, ) -> Result<(), PaymentSendFailure> { let best_block_height = self.best_block.read().unwrap().height; let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); @@ -5431,7 +5428,6 @@ impl< recipient_onion, keysend_preimage, payment_id, - recv_value_msat, onion_session_privs, &self.node_signer, best_block_height, @@ -20074,7 +20070,7 @@ mod tests { let session_privs = nodes[0].node.test_add_new_pending_payment(our_payment_hash, RecipientOnionFields::secret_only(payment_secret, 200_000), payment_id, &mpp_route).unwrap(); nodes[0].node.test_send_payment_along_path(&mpp_route.paths[0], &our_payment_hash, - RecipientOnionFields::secret_only(payment_secret, 200_000), 200_000, cur_height, payment_id, &None, session_privs[0]).unwrap(); + RecipientOnionFields::secret_only(payment_secret, 200_000), cur_height, payment_id, &None, session_privs[0]).unwrap(); check_added_monitors(&nodes[0], 1); let mut events = nodes[0].node.get_and_clear_pending_msg_events(); assert_eq!(events.len(), 1); @@ -20110,7 +20106,7 @@ mod tests { // Send the second half of the original MPP payment. nodes[0].node.test_send_payment_along_path(&mpp_route.paths[1], &our_payment_hash, - RecipientOnionFields::secret_only(payment_secret, 200_000), 200_000, cur_height, payment_id, &None, session_privs[1]).unwrap(); + RecipientOnionFields::secret_only(payment_secret, 200_000), cur_height, payment_id, &None, session_privs[1]).unwrap(); check_added_monitors(&nodes[0], 1); let mut events = nodes[0].node.get_and_clear_pending_msg_events(); assert_eq!(events.len(), 1); @@ -20358,7 +20354,7 @@ mod tests { let session_privs = nodes[0].node.test_add_new_pending_payment(mismatch_payment_hash, RecipientOnionFields::spontaneous_empty(10_000), PaymentId(mismatch_payment_hash.0), &route).unwrap(); nodes[0].node.test_send_payment_internal(&route, mismatch_payment_hash, - RecipientOnionFields::spontaneous_empty(10_000), Some(test_preimage), PaymentId(mismatch_payment_hash.0), None, session_privs).unwrap(); + RecipientOnionFields::spontaneous_empty(10_000), Some(test_preimage), PaymentId(mismatch_payment_hash.0), session_privs).unwrap(); check_added_monitors(&nodes[0], 1); let updates = get_htlc_update_msgs(&nodes[0], &nodes[1].node.get_our_node_id()); diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index 796c1513382..09a87d93156 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -2284,9 +2284,8 @@ pub fn fail_backward_pending_htlc_upon_channel_failure() { let session_priv = SecretKey::from_slice(&[42; 32]).unwrap(); let current_height = nodes[1].node.best_block.read().unwrap().height + 1; let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret, 50_000); - let (onion_payloads, _amount_msat, cltv_expiry) = onion_utils::build_onion_payloads( + let (onion_payloads, _amount_msat, cltv_expiry) = onion_utils::test_build_onion_payloads( &route.paths[0], - 50_000, &recipient_onion_fields, current_height, &None, @@ -3321,7 +3320,6 @@ fn do_test_htlc_timeout(send_partial_mpp: bool) { &route.paths[0], &our_payment_hash, RecipientOnionFields::secret_only(payment_secret, 200_000), - 200_000, cur_height, payment_id, &None, @@ -7011,10 +7009,9 @@ pub fn test_onion_value_mpp_set_calculation() { let onion = RecipientOnionFields::secret_only(payment_secret, total_msat); let onion_session_privs = nodes[0].node.test_add_new_pending_payment(hash, onion.clone(), id, &route).unwrap(); - let amt = Some(total_msat); nodes[0] .node - .test_send_payment_internal(&route, hash, onion, None, id, amt, onion_session_privs) + .test_send_payment_internal(&route, hash, onion, None, id, onion_session_privs) .unwrap(); check_added_monitors(&nodes[0], expected_paths.len()); @@ -7042,9 +7039,8 @@ pub fn test_onion_value_mpp_set_calculation() { &session_priv, ); let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret, 100_000); - let (mut onion_payloads, _, _) = onion_utils::build_onion_payloads( + let (mut onion_payloads, _, _) = onion_utils::test_build_onion_payloads( &route.paths[0], - 100_000, &recipient_onion_fields, height + 1, &None, @@ -7150,10 +7146,9 @@ fn do_test_overshoot_mpp(msat_amounts: &[u64], total_msat: u64) { let onion_session_privs = nodes[src_idx].node.test_add_new_pending_payment(hash, onion, id, &route).unwrap(); let onion = RecipientOnionFields::secret_only(payment_secret, total_msat); - let amt = Some(total_msat); nodes[src_idx] .node - .test_send_payment_internal(&route, hash, onion, None, id, amt, onion_session_privs) + .test_send_payment_internal(&route, hash, onion, None, id, onion_session_privs) .unwrap(); check_added_monitors(&nodes[src_idx], expected_paths.len()); @@ -8488,7 +8483,7 @@ pub fn test_inconsistent_mpp_params() { let priv_a = session_privs[0]; nodes[0] .node - .test_send_payment_along_path(path_a, &hash, onion, real_amt, cur_height, id, &None, priv_a) + .test_send_payment_along_path(path_a, &hash, onion, cur_height, id, &None, priv_a) .unwrap(); check_added_monitors(&nodes[0], 1); @@ -8501,11 +8496,10 @@ pub fn test_inconsistent_mpp_params() { let path_b = &route.paths[1]; let onion = RecipientOnionFields::secret_only(payment_secret, 14_000_000); - let amt_b = 14_000_000; let priv_b = session_privs[1]; nodes[0] .node - .test_send_payment_along_path(path_b, &hash, onion, amt_b, cur_height, id, &None, priv_b) + .test_send_payment_along_path(path_b, &hash, onion, cur_height, id, &None, priv_b) .unwrap(); check_added_monitors(&nodes[0], 1); @@ -8565,7 +8559,7 @@ pub fn test_inconsistent_mpp_params() { let priv_c = session_privs[2]; nodes[0] .node - .test_send_payment_along_path(path_b, &hash, onion, real_amt, cur_height, id, &None, priv_c) + .test_send_payment_along_path(path_b, &hash, onion, cur_height, id, &None, priv_c) .unwrap(); check_added_monitors(&nodes[0], 1); diff --git a/lightning/src/ln/htlc_reserve_unit_tests.rs b/lightning/src/ln/htlc_reserve_unit_tests.rs index 1a1cfedbec9..6f02c936cff 100644 --- a/lightning/src/ln/htlc_reserve_unit_tests.rs +++ b/lightning/src/ln/htlc_reserve_unit_tests.rs @@ -822,9 +822,8 @@ pub fn do_test_fee_spike_buffer(cfg: Option, htlc_fails: bool) { let onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route.paths[0], &session_priv); let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret, payment_amt_msat); - let (onion_payloads, htlc_msat, htlc_cltv) = onion_utils::build_onion_payloads( + let (onion_payloads, htlc_msat, htlc_cltv) = onion_utils::test_build_onion_payloads( &route.paths[0], - payment_amt_msat, &recipient_onion_fields, cur_height, &None, @@ -1070,9 +1069,8 @@ pub fn test_chan_reserve_violation_inbound_htlc_outbound_channel() { let cur_height = nodes[1].node.best_block.read().unwrap().height + 1; let onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route.paths[0], &session_priv); let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret, 700_000); - let (onion_payloads, htlc_msat, htlc_cltv) = onion_utils::build_onion_payloads( + let (onion_payloads, htlc_msat, htlc_cltv) = onion_utils::test_build_onion_payloads( &route.paths[0], - 700_000, &recipient_onion_fields, cur_height, &None, @@ -1255,9 +1253,8 @@ pub fn test_chan_reserve_violation_inbound_htlc_inbound_chan() { let cur_height = nodes[0].node.best_block.read().unwrap().height + 1; let onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route_2.paths[0], &session_priv); let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(recv_value_2); - let (onion_payloads, htlc_msat, htlc_cltv) = onion_utils::build_onion_payloads( + let (onion_payloads, htlc_msat, htlc_cltv) = onion_utils::test_build_onion_payloads( &route_2.paths[0], - recv_value_2, &recipient_onion_fields, cur_height, &None, @@ -1645,9 +1642,8 @@ pub fn test_update_add_htlc_bolt2_receiver_check_max_htlc_limit() { &session_priv, ); let recipient_onion_fields = RecipientOnionFields::secret_only(our_payment_secret, send_amt); - let (onion_payloads, _htlc_msat, htlc_cltv) = onion_utils::build_onion_payloads( + let (onion_payloads, _htlc_msat, htlc_cltv) = onion_utils::test_build_onion_payloads( &route.paths[0], - send_amt, &recipient_onion_fields, cur_height, &None, @@ -2247,9 +2243,8 @@ pub fn do_test_dust_limit_fee_accounting(can_afford: bool) { onion_utils::construct_onion_keys(&secp_ctx, &route_0_1.paths[0], &session_priv); let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret_0_1, HTLC_AMT_SAT * 1000); - let (onion_payloads, amount_msat, cltv_expiry) = onion_utils::build_onion_payloads( + let (onion_payloads, amount_msat, cltv_expiry) = onion_utils::test_build_onion_payloads( &route_0_1.paths[0], - HTLC_AMT_SAT * 1000, &recipient_onion_fields, cur_height, &None, diff --git a/lightning/src/ln/max_payment_path_len_tests.rs b/lightning/src/ln/max_payment_path_len_tests.rs index ea78449316c..45640d3486d 100644 --- a/lightning/src/ln/max_payment_path_len_tests.rs +++ b/lightning/src/ln/max_payment_path_len_tests.rs @@ -139,7 +139,6 @@ fn large_payment_metadata() { &secp_ctx, &route_0_1.paths[0], &test_utils::privkey(42), - MIN_FINAL_VALUE_ESTIMATE_WITH_OVERPAY, &too_large_onion, nodes[0].best_block_info().1 + DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA, &payment_hash, @@ -369,9 +368,8 @@ fn blinded_path_with_custom_tlv() { // Calculate the maximum custom TLV value size where a valid onion packet is still possible. const CUSTOM_TLV_TYPE: u64 = 65537; let mut route = get_route(&nodes[1], &route_params).unwrap(); - let reserved_packet_bytes_without_custom_tlv: usize = onion_utils::build_onion_payloads( + let reserved_packet_bytes_without_custom_tlv: usize = onion_utils::test_build_onion_payloads( &route.paths[0], - MIN_FINAL_VALUE_ESTIMATE_WITH_OVERPAY, &RecipientOnionFields::spontaneous_empty(MIN_FINAL_VALUE_ESTIMATE_WITH_OVERPAY), nodes[0].best_block_info().1 + DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA, &None, @@ -433,7 +431,6 @@ fn blinded_path_with_custom_tlv() { &secp_ctx, &route.paths[0], &test_utils::privkey(42), - MIN_FINAL_VALUE_ESTIMATE_WITH_OVERPAY, &too_large_onion, nodes[0].best_block_info().1 + DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA, &payment_hash, diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index d0d50c6a315..def4a1861c4 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -779,7 +779,7 @@ mod tests { let charlie_pk = PublicKey::from_secret_key(&secp_ctx, &charlie.get_node_secret_key()); let ( - session_priv, total_amt_msat, cur_height, mut recipient_onion, keysend_preimage, payment_hash, + session_priv, _total_amt_msat, cur_height, mut recipient_onion, keysend_preimage, payment_hash, prng_seed, hops, .. ) = payment_onion_args(bob_pk, charlie_pk); @@ -788,8 +788,8 @@ mod tests { let path = Path { hops, blinded_tail: None, }; let onion_keys = super::onion_utils::construct_onion_keys(&secp_ctx, &path, &session_priv); - let (onion_payloads, ..) = super::onion_utils::build_onion_payloads( - &path, total_amt_msat, &recipient_onion, cur_height + 1, &Some(keysend_preimage), None, None + let (onion_payloads, ..) = super::onion_utils::test_build_onion_payloads( + &path, &recipient_onion, cur_height + 1, &Some(keysend_preimage), None, None ).unwrap(); assert!(super::onion_utils::construct_onion_packet( @@ -817,7 +817,7 @@ mod tests { }; let (onion, amount_msat, cltv_expiry) = create_payment_onion( - &secp_ctx, &path, &session_priv, total_amt_msat, &recipient_onion, + &secp_ctx, &path, &session_priv, &recipient_onion, cur_height, &payment_hash, &Some(preimage), None, prng_seed ).unwrap(); diff --git a/lightning/src/ln/onion_route_tests.rs b/lightning/src/ln/onion_route_tests.rs index 74c76ee06af..ceb930014ff 100644 --- a/lightning/src/ln/onion_route_tests.rs +++ b/lightning/src/ln/onion_route_tests.rs @@ -25,7 +25,7 @@ use crate::ln::msgs::{ OutboundOnionPayload, OutboundTrampolinePayload, }; use crate::ln::onion_utils::{ - self, build_onion_payloads, construct_onion_keys, LocalHTLCFailureReason, + self, construct_onion_keys, test_build_onion_payloads, LocalHTLCFailureReason, }; use crate::ln::outbound_payment::RecipientOnionFields; use crate::ln::wire::Encode; @@ -527,7 +527,7 @@ fn test_onion_failure() { let recipient_fields = RecipientOnionFields::spontaneous_empty(40000); let path = &route.paths[0]; let (mut onion_payloads, _htlc_msat, _htlc_cltv) = - build_onion_payloads(path, 40000, &recipient_fields, cur_height, &None, None, None) + test_build_onion_payloads(path, &recipient_fields, cur_height, &None, None, None) .unwrap(); let mut new_payloads = Vec::new(); for payload in onion_payloads.drain(..) { @@ -569,7 +569,7 @@ fn test_onion_failure() { let recipient_fields = RecipientOnionFields::spontaneous_empty(40000); let path = &route.paths[0]; let (mut onion_payloads, _htlc_msat, _htlc_cltv) = - build_onion_payloads(path, 40000, &recipient_fields, cur_height, &None, None, None) + test_build_onion_payloads(path, &recipient_fields, cur_height, &None, None, None) .unwrap(); let mut new_payloads = Vec::new(); for payload in onion_payloads.drain(..) { @@ -1288,7 +1288,7 @@ fn test_onion_failure() { let recipient_fields = RecipientOnionFields::spontaneous_empty(40000); let path = &route.paths[0]; let (onion_payloads, _, htlc_cltv) = - build_onion_payloads(path, 40000, &recipient_fields, height, &None, None, None) + test_build_onion_payloads(path, &recipient_fields, height, &None, None, None) .unwrap(); let onion_packet = onion_utils::construct_onion_packet( onion_payloads, @@ -1841,8 +1841,7 @@ fn test_always_create_tlv_format_onion_payloads() { let recipient_fields = RecipientOnionFields::spontaneous_empty(40000); let path = &route.paths[0]; let (onion_payloads, _htlc_msat, _htlc_cltv) = - build_onion_payloads(path, 40000, &recipient_fields, cur_height, &None, None, None) - .unwrap(); + test_build_onion_payloads(path, &recipient_fields, cur_height, &None, None, None).unwrap(); match onion_payloads[0] { msgs::OutboundOnionPayload::Forward { .. } => {}, @@ -1978,7 +1977,6 @@ fn test_trampoline_onion_payload_assembly_values() { let (trampoline_payloads, outer_total_msat, outer_starting_htlc_offset) = onion_utils::build_trampoline_onion_payloads( &path.blinded_tail.as_ref().unwrap(), - amt_msat, &recipient_onion_fields, cur_height, &None, @@ -2041,9 +2039,8 @@ fn test_trampoline_onion_payload_assembly_values() { let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret, outer_total_msat); - let (outer_payloads, total_msat, total_htlc_offset) = build_onion_payloads( + let (outer_payloads, total_msat, total_htlc_offset) = test_build_onion_payloads( &path, - outer_total_msat, &recipient_onion_fields, outer_starting_htlc_offset, &None, @@ -2080,7 +2077,6 @@ fn test_trampoline_onion_payload_assembly_values() { &Secp256k1::new(), &path, &session_priv, - amt_msat, &recipient_onion_fields, cur_height, &payment_hash, @@ -2540,9 +2536,8 @@ fn test_phantom_invalid_onion_payload() { construct_onion_keys(&Secp256k1::new(), &route.paths[0], &session_priv); let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret, msgs::MAX_VALUE_MSAT + 1); - let (mut onion_payloads, _, _) = build_onion_payloads( + let (mut onion_payloads, _, _) = test_build_onion_payloads( &route.paths[0], - msgs::MAX_VALUE_MSAT + 1, &recipient_onion_fields, height + 1, &None, diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 22cb758284f..a95012dc7f2 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -193,7 +193,7 @@ trait OnionPayload<'a, 'b> { ) -> Self; fn new_receive( recipient_onion: &'a RecipientOnionFields, keysend_preimage: Option, - sender_intended_htlc_amt_msat: u64, total_msat: u64, cltv_expiry_height: u32, + sender_intended_htlc_amt_msat: u64, cltv_expiry_height: u32, ) -> Result; fn new_blinded_forward( encrypted_tlvs: &'a Vec, intro_node_blinding_point: Option, @@ -205,8 +205,8 @@ trait OnionPayload<'a, 'b> { custom_tlvs: &'a Vec<(u64, Vec)>, ) -> Self; fn new_trampoline_entry( - total_msat: u64, amt_to_forward: u64, outgoing_cltv_value: u32, - recipient_onion: &'a RecipientOnionFields, packet: msgs::TrampolineOnionPacket, + amt_to_forward: u64, outgoing_cltv_value: u32, recipient_onion: &'a RecipientOnionFields, + packet: msgs::TrampolineOnionPacket, ) -> Result; } impl<'a, 'b> OnionPayload<'a, 'b> for msgs::OutboundOnionPayload<'a> { @@ -217,13 +217,15 @@ impl<'a, 'b> OnionPayload<'a, 'b> for msgs::OutboundOnionPayload<'a> { } fn new_receive( recipient_onion: &'a RecipientOnionFields, keysend_preimage: Option, - sender_intended_htlc_amt_msat: u64, total_msat: u64, cltv_expiry_height: u32, + sender_intended_htlc_amt_msat: u64, cltv_expiry_height: u32, ) -> Result { - debug_assert_eq!(total_msat, recipient_onion.total_mpp_amount_msat); Ok(Self::Receive { - payment_data: recipient_onion - .payment_secret - .map(|payment_secret| msgs::FinalOnionHopData { payment_secret, total_msat }), + payment_data: recipient_onion.payment_secret.map(|payment_secret| { + msgs::FinalOnionHopData { + payment_secret, + total_msat: recipient_onion.total_mpp_amount_msat, + } + }), payment_metadata: recipient_onion.payment_metadata.as_ref(), keysend_preimage, custom_tlvs: &recipient_onion.custom_tlvs, @@ -255,16 +257,18 @@ impl<'a, 'b> OnionPayload<'a, 'b> for msgs::OutboundOnionPayload<'a> { } fn new_trampoline_entry( - total_msat: u64, amt_to_forward: u64, outgoing_cltv_value: u32, - recipient_onion: &'a RecipientOnionFields, packet: msgs::TrampolineOnionPacket, + amt_to_forward: u64, outgoing_cltv_value: u32, recipient_onion: &'a RecipientOnionFields, + packet: msgs::TrampolineOnionPacket, ) -> Result { - debug_assert_eq!(total_msat, recipient_onion.total_mpp_amount_msat); Ok(Self::TrampolineEntrypoint { amt_to_forward, outgoing_cltv_value, - multipath_trampoline_data: recipient_onion - .payment_secret - .map(|payment_secret| msgs::FinalOnionHopData { payment_secret, total_msat }), + multipath_trampoline_data: recipient_onion.payment_secret.map(|payment_secret| { + msgs::FinalOnionHopData { + payment_secret, + total_msat: recipient_onion.total_mpp_amount_msat, + } + }), trampoline_packet: packet, }) } @@ -279,7 +283,7 @@ impl<'a, 'b> OnionPayload<'a, 'b> for msgs::OutboundTrampolinePayload<'a> { } fn new_receive( _recipient_onion: &'a RecipientOnionFields, _keysend_preimage: Option, - _sender_intended_htlc_amt_msat: u64, _total_msat: u64, _cltv_expiry_height: u32, + _sender_intended_htlc_amt_msat: u64, _cltv_expiry_height: u32, ) -> Result { Err(APIError::InvalidRoute { err: "Unblinded receiving is not supported for Trampoline!".to_string(), @@ -308,7 +312,7 @@ impl<'a, 'b> OnionPayload<'a, 'b> for msgs::OutboundTrampolinePayload<'a> { } fn new_trampoline_entry( - _total_msat: u64, _amt_to_forward: u64, _outgoing_cltv_value: u32, + _amt_to_forward: u64, _outgoing_cltv_value: u32, _recipient_onion: &'a RecipientOnionFields, _packet: msgs::TrampolineOnionPacket, ) -> Result { Err(APIError::InvalidRoute { @@ -410,7 +414,7 @@ pub(super) fn construct_trampoline_onion_keys( } pub(super) fn build_trampoline_onion_payloads<'a>( - blinded_tail: &'a BlindedTail, total_msat: u64, recipient_onion: &'a RecipientOnionFields, + blinded_tail: &'a BlindedTail, recipient_onion: &'a RecipientOnionFields, starting_htlc_offset: u32, keysend_preimage: &Option, ) -> Result<(Vec>, u64, u32), APIError> { let mut res: Vec = @@ -425,7 +429,6 @@ pub(super) fn build_trampoline_onion_payloads<'a>( let (value_msat, cltv) = build_onion_payloads_callback( blinded_tail.trampoline_hops.iter(), Some(blinded_tail_with_hop_iter), - total_msat, recipient_onion, starting_htlc_offset, keysend_preimage, @@ -439,14 +442,28 @@ pub(super) fn build_trampoline_onion_payloads<'a>( } /// returns the hop data, as well as the first-hop value_msat and CLTV value we should send. -pub(super) fn build_onion_payloads<'a>( - path: &'a Path, total_msat: u64, recipient_onion: &'a RecipientOnionFields, - starting_htlc_offset: u32, keysend_preimage: &Option, - invoice_request: Option<&'a InvoiceRequest>, +#[cfg(any(test, feature = "_externalize_tests"))] +pub(crate) fn test_build_onion_payloads<'a>( + path: &'a Path, recipient_onion: &'a RecipientOnionFields, starting_htlc_offset: u32, + keysend_preimage: &Option, invoice_request: Option<&'a InvoiceRequest>, trampoline_packet: Option, ) -> Result<(Vec>, u64, u32), APIError> { - debug_assert_eq!(total_msat, recipient_onion.total_mpp_amount_msat); + build_onion_payloads( + path, + recipient_onion, + starting_htlc_offset, + keysend_preimage, + invoice_request, + trampoline_packet, + ) +} +/// returns the hop data, as well as the first-hop value_msat and CLTV value we should send. +fn build_onion_payloads<'a>( + path: &'a Path, recipient_onion: &'a RecipientOnionFields, starting_htlc_offset: u32, + keysend_preimage: &Option, invoice_request: Option<&'a InvoiceRequest>, + trampoline_packet: Option, +) -> Result<(Vec>, u64, u32), APIError> { let mut res: Vec = Vec::with_capacity( path.hops.len() + path.blinded_tail.as_ref().map_or(0, |t| t.hops.len()), ); @@ -472,7 +489,6 @@ pub(super) fn build_onion_payloads<'a>( let (value_msat, cltv) = build_onion_payloads_callback( path.hops.iter(), blinded_tail_with_hop_iter, - total_msat, recipient_onion, starting_htlc_offset, keysend_preimage, @@ -503,7 +519,7 @@ enum PayloadCallbackAction { PushFront, } fn build_onion_payloads_callback<'a, 'b, H, B, F, OP>( - hops: H, mut blinded_tail: Option>, total_msat: u64, + hops: H, mut blinded_tail: Option>, recipient_onion: &'a RecipientOnionFields, starting_htlc_offset: u32, keysend_preimage: &Option, invoice_request: Option<&'a InvoiceRequest>, mut callback: F, @@ -518,8 +534,6 @@ where let mut cur_cltv = starting_htlc_offset; let mut last_hop_id = None; - debug_assert_eq!(total_msat, recipient_onion.total_mpp_amount_msat); - for (idx, hop) in hops.rev().enumerate() { // First hop gets special values so that it can check, on receipt, that everything is // exactly as it should be (and the next hop isn't trying to probe to find out if we're @@ -548,7 +562,7 @@ where PayloadCallbackAction::PushBack, OP::new_blinded_receive( final_value_msat, - total_msat, + recipient_onion.total_mpp_amount_msat, cur_cltv + excess_final_cltv_expiry_delta, &blinded_hop.encrypted_payload, blinding_point.take(), @@ -576,7 +590,6 @@ where callback( PayloadCallbackAction::PushBack, OP::new_trampoline_entry( - total_msat, final_value_msat + hop.fee_msat(), cur_cltv, &recipient_onion, @@ -587,13 +600,7 @@ where None => { callback( PayloadCallbackAction::PushBack, - OP::new_receive( - &recipient_onion, - *keysend_preimage, - value_msat, - total_msat, - cltv, - )?, + OP::new_receive(&recipient_onion, *keysend_preimage, value_msat, cltv)?, ); }, } @@ -674,7 +681,6 @@ pub(crate) fn set_max_path_length( let build_payloads_res = build_onion_payloads_callback( core::iter::once(&unblinded_route_hop), blinded_tail_opt, - final_value_msat_with_overpay_buffer, &recipient_onion_with_excess_value, best_block_height, &keysend_preimage, @@ -2596,7 +2602,7 @@ pub(super) fn peel_dummy_hop_update_add_htlc( - secp_ctx: &Secp256k1, path: &Path, session_priv: &SecretKey, total_msat: u64, + secp_ctx: &Secp256k1, path: &Path, session_priv: &SecretKey, recipient_onion: &RecipientOnionFields, cur_block_height: u32, payment_hash: &PaymentHash, keysend_preimage: &Option, invoice_request: Option<&InvoiceRequest>, prng_seed: [u8; 32], @@ -2605,7 +2611,6 @@ pub fn create_payment_onion( secp_ctx, path, session_priv, - total_msat, recipient_onion, cur_block_height, payment_hash, @@ -2627,15 +2632,12 @@ pub(super) fn compute_trampoline_session_priv(outer_onion_session_priv: &SecretK /// Build a payment onion, returning the first hop msat and cltv values as well. /// `cur_block_height` should be set to the best known block height + 1. pub(crate) fn create_payment_onion_internal( - secp_ctx: &Secp256k1, path: &Path, session_priv: &SecretKey, total_msat: u64, + secp_ctx: &Secp256k1, path: &Path, session_priv: &SecretKey, recipient_onion: &RecipientOnionFields, cur_block_height: u32, payment_hash: &PaymentHash, keysend_preimage: &Option, invoice_request: Option<&InvoiceRequest>, prng_seed: [u8; 32], trampoline_session_priv_override: Option, trampoline_prng_seed_override: Option<[u8; 32]>, ) -> Result<(msgs::OnionPacket, u64, u32), APIError> { - debug_assert_eq!(total_msat, recipient_onion.total_mpp_amount_msat); - - let mut outer_total_msat = total_msat; let mut outer_starting_htlc_offset = cur_block_height; // If we're paying to a recipient through a trampoline, we use the `payment_secret` provided in @@ -2658,10 +2660,10 @@ pub(crate) fn create_payment_onion_internal( if !blinded_tail.trampoline_hops.is_empty() { let trampoline_payloads; + let outer_total_msat; (trampoline_payloads, outer_total_msat, outer_starting_htlc_offset) = build_trampoline_onion_payloads( &blinded_tail, - total_msat, recipient_onion, cur_block_height, keysend_preimage, @@ -2695,7 +2697,6 @@ pub(crate) fn create_payment_onion_internal( let (onion_payloads, htlc_msat, htlc_cltv) = build_onion_payloads( &path, - outer_total_msat, outer_onion, outer_starting_htlc_offset, keysend_preimage, diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index b9a58847746..65cc21a3182 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -913,7 +913,6 @@ pub(super) struct SendAlongPathArgs<'a> { pub path: &'a Path, pub payment_hash: &'a PaymentHash, pub recipient_onion: &'a RecipientOnionFields, - pub total_value: u64, pub cur_height: u32, pub payment_id: PaymentId, pub keysend_preimage: &'a Option, @@ -1192,7 +1191,7 @@ impl OutboundPayments { let result = self.pay_route_internal( &route, payment_hash, &recipient_onion, keysend_preimage, invoice_request, Some(&bolt12_invoice), payment_id, - Some(route_params.final_value_msat), &onion_session_privs, hold_htlcs_at_next_hop, node_signer, + &onion_session_privs, hold_htlcs_at_next_hop, node_signer, best_block_height, &send_payment_along_path ); log_info!( @@ -1583,7 +1582,7 @@ impl OutboundPayments { })?; let res = self.pay_route_internal(&route, payment_hash, &recipient_onion, - keysend_preimage, None, None, payment_id, None, &onion_session_privs, false, node_signer, + keysend_preimage, None, None, payment_id, &onion_session_privs, false, node_signer, best_block_height, &send_payment_along_path); log_info!(logger, "Sending payment with id {} and hash {} returned {:?}", payment_id, payment_hash, res); @@ -1650,7 +1649,7 @@ impl OutboundPayments { } } } - let (total_msat, recipient_onion, keysend_preimage, onion_session_privs, invoice_request, bolt12_invoice) = { + let (recipient_onion, keysend_preimage, onion_session_privs, invoice_request, bolt12_invoice) = { let mut outbounds = self.pending_outbound_payments.lock().unwrap(); match outbounds.entry(payment_id) { hash_map::Entry::Occupied(mut payment) => { @@ -1673,12 +1672,11 @@ impl OutboundPayments { return } - let total_msat = *total_msat; let recipient_onion = RecipientOnionFields { payment_secret: *payment_secret, payment_metadata: payment_metadata.clone(), custom_tlvs: custom_tlvs.clone(), - total_mpp_amount_msat: total_msat, + total_mpp_amount_msat: *total_msat, }; let keysend_preimage = *keysend_preimage; let invoice_request = invoice_request.clone(); @@ -1695,7 +1693,7 @@ impl OutboundPayments { payment.get_mut().increment_attempts(); let bolt12_invoice = payment.get().bolt12_invoice(); - (total_msat, recipient_onion, keysend_preimage, onion_session_privs, invoice_request, bolt12_invoice.cloned()) + (recipient_onion, keysend_preimage, onion_session_privs, invoice_request, bolt12_invoice.cloned()) }, PendingOutboundPayment::Legacy { .. } => { log_error!(logger, "Unable to retry payments that were initially sent on LDK versions prior to 0.0.102"); @@ -1735,7 +1733,7 @@ impl OutboundPayments { } }; let res = self.pay_route_internal(&route, payment_hash, &recipient_onion, keysend_preimage, - invoice_request.as_ref(), bolt12_invoice.as_ref(), payment_id, Some(total_msat), + invoice_request.as_ref(), bolt12_invoice.as_ref(), payment_id, &onion_session_privs, false, node_signer, best_block_height, &send_payment_along_path); log_info!(logger, "Result retrying payment id {}: {:?}", &payment_id, res); if let Err(e) = res { @@ -1894,7 +1892,7 @@ impl OutboundPayments { })?; match self.pay_route_internal(&route, payment_hash, &recipient_onion_fields, - None, None, None, payment_id, None, &onion_session_privs, false, node_signer, + None, None, None, payment_id, &onion_session_privs, false, node_signer, best_block_height, &send_payment_along_path ) { Ok(()) => Ok((payment_hash, payment_id)), @@ -2139,7 +2137,7 @@ impl OutboundPayments { fn pay_route_internal( &self, route: &Route, payment_hash: PaymentHash, recipient_onion: &RecipientOnionFields, keysend_preimage: Option, invoice_request: Option<&InvoiceRequest>, bolt12_invoice: Option<&PaidBolt12Invoice>, - payment_id: PaymentId, recv_value_msat: Option, onion_session_privs: &Vec<[u8; 32]>, + payment_id: PaymentId, onion_session_privs: &Vec<[u8; 32]>, hold_htlcs_at_next_hop: bool, node_signer: &NS, best_block_height: u32, send_payment_along_path: &F ) -> Result<(), PaymentSendFailure> where @@ -2153,7 +2151,6 @@ impl OutboundPayments { { return Err(PaymentSendFailure::ParameterError(APIError::APIMisuseError{err: "Payment secret is required for multi-path payments".to_owned()})); } - let mut total_value = 0; let our_node_id = node_signer.get_node_id(Recipient::Node).unwrap(); // TODO no unwrap let mut path_errs = Vec::with_capacity(route.paths.len()); 'path_check: for path in route.paths.iter() { @@ -2176,22 +2173,18 @@ impl OutboundPayments { continue 'path_check; } } - total_value += path.final_value_msat(); path_errs.push(Ok(())); } if path_errs.iter().any(|e| e.is_err()) { return Err(PaymentSendFailure::PathParameterError(path_errs)); } - if let Some(amt_msat) = recv_value_msat { - total_value = amt_msat; - } let cur_height = best_block_height + 1; let mut results = Vec::new(); debug_assert_eq!(route.paths.len(), onion_session_privs.len()); for (path, session_priv_bytes) in route.paths.iter().zip(onion_session_privs.iter()) { let path_res = send_payment_along_path(SendAlongPathArgs { - path: &path, payment_hash: &payment_hash, recipient_onion, total_value, + path: &path, payment_hash: &payment_hash, recipient_onion, cur_height, payment_id, keysend_preimage: &keysend_preimage, invoice_request, bolt12_invoice, hold_htlc_at_next_hop: hold_htlcs_at_next_hop, session_priv_bytes: *session_priv_bytes @@ -2252,7 +2245,7 @@ impl OutboundPayments { #[rustfmt::skip] pub(super) fn test_send_payment_internal( &self, route: &Route, payment_hash: PaymentHash, recipient_onion: RecipientOnionFields, - keysend_preimage: Option, payment_id: PaymentId, recv_value_msat: Option, + keysend_preimage: Option, payment_id: PaymentId, onion_session_privs: Vec<[u8; 32]>, node_signer: &NS, best_block_height: u32, send_payment_along_path: F ) -> Result<(), PaymentSendFailure> @@ -2260,7 +2253,7 @@ impl OutboundPayments { F: Fn(SendAlongPathArgs) -> Result<(), APIError>, { self.pay_route_internal(route, payment_hash, &recipient_onion, - keysend_preimage, None, None, payment_id, recv_value_msat, &onion_session_privs, + keysend_preimage, None, None, payment_id, &onion_session_privs, false, node_signer, best_block_height, &send_payment_along_path) .map_err(|e| { self.remove_outbound_if_all_failed(payment_id, &e); e }) } diff --git a/lightning/src/ln/payment_tests.rs b/lightning/src/ln/payment_tests.rs index 1a013588409..c618b512426 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -3342,9 +3342,7 @@ fn retry_multi_path_single_failed_payment() { scorer.expect_usage(chans[1].short_channel_id.unwrap(), usage); } - // Note that while we actaully pay amt_msat + 1, we should really set the onion amount to - // amt_msat as that's what we built a route for. - let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat + 1); + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment(payment_hash, onion, id, route_params, Retry::Attempts(1)).unwrap(); let events = nodes[0].node.get_and_clear_pending_events(); @@ -4713,7 +4711,7 @@ fn do_test_custom_tlvs_consistency( let priv_a = session_privs[0]; nodes[0] .node - .test_send_payment_along_path(path_a, &hash, onion, amt_msat, cur_height, id, &None, priv_a) + .test_send_payment_along_path(path_a, &hash, onion, cur_height, id, &None, priv_a) .unwrap(); check_added_monitors(&nodes[0], 1); @@ -4736,7 +4734,7 @@ fn do_test_custom_tlvs_consistency( let priv_b = session_privs[1]; nodes[0] .node - .test_send_payment_along_path(path_b, &hash, onion, amt_msat, cur_height, id, &None, priv_b) + .test_send_payment_along_path(path_b, &hash, onion, cur_height, id, &None, priv_b) .unwrap(); check_added_monitors(&nodes[0], 1); @@ -5128,7 +5126,6 @@ fn peel_payment_onion_custom_tlvs() { &secp_ctx, &route.paths[0], &session_priv, - amt_msat, &recipient_onion, nodes[0].best_block_info().1, &payment_hash, diff --git a/lightning/src/ln/reload_tests.rs b/lightning/src/ln/reload_tests.rs index 2e8a060d2c7..bb730f8fba8 100644 --- a/lightning/src/ln/reload_tests.rs +++ b/lightning/src/ln/reload_tests.rs @@ -1558,7 +1558,7 @@ fn test_htlc_localremoved_persistence() { let session_privs = nodes[0].node.test_add_new_pending_payment(mismatch_payment_hash, RecipientOnionFields::spontaneous_empty(10_000), PaymentId(mismatch_payment_hash.0), &route).unwrap(); nodes[0].node.test_send_payment_internal(&route, mismatch_payment_hash, - RecipientOnionFields::spontaneous_empty(10_000), Some(test_preimage), PaymentId(mismatch_payment_hash.0), None, session_privs).unwrap(); + RecipientOnionFields::spontaneous_empty(10_000), Some(test_preimage), PaymentId(mismatch_payment_hash.0), session_privs).unwrap(); check_added_monitors(&nodes[0], 1); let updates = get_htlc_update_msgs(&nodes[0], &nodes[1].node.get_our_node_id()); From 541723fbd0df9fde760520c9cc6992334d84d93a Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 2 Feb 2026 01:09:57 +0000 Subject: [PATCH 075/627] Add total-MPP-value storage in pending payments In some uses of LDK we need the ability to send HTLCs for only a portion of some larger MPP payment. This allows payers to make single payments which spend funds from multiple wallets, which may be important for ecash wallets holding funds in multiple mints or graduated wallets which hold funds across a trusted wallet and a self-custodial wallet. In the previous commits we moved the total-MPP-value we set in onions from being manually passed through onion-building to passing it via `RecipientOnionFields`. This introduced a subtle bug, though - payments which are retried will get a fresh `RecipientOnionFields` built from the data in `PendingOutboundPayment::Retryable`, losing any custom total-MPP-value settings and causing retries to fail. Here we fix this by storing the total-MPP-value directly in `PendingOutboundPayment::Retryable`. --- lightning/src/ln/outbound_payment.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 65cc21a3182..fa1118440fa 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -133,6 +133,11 @@ pub(crate) enum PendingOutboundPayment { pending_fee_msat: Option, /// The total payment amount across all paths, used to verify that a retry is not overpaying. total_msat: u64, + /// The total payment amount which is set in the onion. + /// + /// This is generally equal to [`Self::Retryable::total_msat`] but may differ when making + /// payments which are sent MPP from different sources. + onion_total_msat: u64, /// Our best known block height at the time this payment was initiated. starting_block_height: u32, remaining_max_total_routing_fee_msat: Option, @@ -1656,7 +1661,7 @@ impl OutboundPayments { match payment.get() { PendingOutboundPayment::Retryable { total_msat, keysend_preimage, payment_secret, payment_metadata, - custom_tlvs, pending_amt_msat, invoice_request, .. + custom_tlvs, pending_amt_msat, invoice_request, onion_total_msat, .. } => { const RETRY_OVERFLOW_PERCENTAGE: u64 = 10; let retry_amt_msat = route.get_total_amount(); @@ -1676,7 +1681,7 @@ impl OutboundPayments { payment_secret: *payment_secret, payment_metadata: payment_metadata.clone(), custom_tlvs: custom_tlvs.clone(), - total_mpp_amount_msat: *total_msat, + total_mpp_amount_msat: *onion_total_msat, }; let keysend_preimage = *keysend_preimage; let invoice_request = invoice_request.clone(); @@ -1992,6 +1997,7 @@ impl OutboundPayments { custom_tlvs: recipient_onion.custom_tlvs, starting_block_height: best_block_height, total_msat: route.get_total_amount(), + onion_total_msat: recipient_onion.total_mpp_amount_msat, remaining_max_total_routing_fee_msat: route.route_params.as_ref().and_then(|p| p.max_total_routing_fee_msat), }; @@ -2699,6 +2705,7 @@ impl OutboundPayments { pending_amt_msat: path_amt, pending_fee_msat: Some(path_fee), total_msat: path_amt, + onion_total_msat: path_amt, starting_block_height: best_block_height, remaining_max_total_routing_fee_msat: None, // only used for retries, and we'll never retry on startup } @@ -2781,6 +2788,21 @@ impl_writeable_tlv_based_enum_upgradable!(PendingOutboundPayment, (9, custom_tlvs, optional_vec), (10, starting_block_height, required), (11, remaining_max_total_routing_fee_msat, option), + (12, onion_total_msat, (custom, u64, + // Once we get here, `total_msat` will have been read (or we'll fail to read) + |read_val: Option| Ok(read_val.unwrap_or(total_msat.0.unwrap())), + |us: &PendingOutboundPayment| { + match us { + PendingOutboundPayment::Retryable { total_msat, onion_total_msat, .. } => { + if total_msat != onion_total_msat { + Some(*onion_total_msat) + } else { + None + } + }, + _ => unreachable!(), + } + })), (13, invoice_request, option), (15, bolt12_invoice, option), (not_written, retry_strategy, (static_value, None)), From e395cb10ac86e4bb2ac761b902a11fd0b2b25466 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 2 Feb 2026 01:49:34 +0000 Subject: [PATCH 076/627] Allow BOLT 11 payments to be a part of a larger MPP payment In some uses of LDK we need the ability to send HTLCs for only a portion of some larger MPP payment. This allows payers to make single payments which spend funds from multiple wallets, which may be important for ecash wallets holding funds in multiple mints or graduated wallets which hold funds across a trusted wallet and a self-custodial wallet. In the previous few commits we added support for making these kinds of payments when using the payment methods which explicitly accepted a `RecipientOnionFields`. Here we also add support for such payments made via the `pay_for_bolt11_invoice` method, utilizing the new `OptionalBolt11PaymentParams` to hide the parameter from most calls. Test mostly by Claude --- lightning/src/ln/channelmanager.rs | 32 +- lightning/src/ln/invoice_utils.rs | 1 + lightning/src/ln/outbound_payment.rs | 21 +- lightning/src/ln/payment_tests.rs | 485 +++++++++++++++++++++++++-- 4 files changed, 498 insertions(+), 41 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 99d579420a1..4ac15da712d 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -686,6 +686,20 @@ pub struct OptionalBolt11PaymentParams { /// will ultimately fail once all pending paths have failed (generating an /// [`Event::PaymentFailed`]). pub retry_strategy: Retry, + /// If the payment being made from this node is part of a larger MPP payment from multiple + /// nodes (i.e. because a single payment is being made from multiple wallets), you can specify + /// the total amount being paid here. + /// + /// If this is set, it must be at least the [`Bolt11Invoice::amount_milli_satoshis`] for the + /// invoice provided to [`ChannelManager::pay_for_bolt11_invoice`]. Further, if this is set, + /// the `amount_msats` provided to [`ChannelManager::pay_for_bolt11_invoice`] is allowed to be + /// lower than [`Bolt11Invoice::amount_milli_satoshis`] (as the payment we're making may be a + /// small part of the amount needed to meet the invoice's minimum). + /// + /// If this is lower than the `amount_msats` passed to + /// [`ChannelManager::pay_for_bolt11_invoice`] the call will fail with + /// [`Bolt11PaymentError::InvalidAmount`]. + pub declared_total_mpp_value_msat_override: Option, } impl Default for OptionalBolt11PaymentParams { @@ -697,6 +711,7 @@ impl Default for OptionalBolt11PaymentParams { retry_strategy: Retry::Timeout(core::time::Duration::from_secs(2)), #[cfg(not(feature = "std"))] retry_strategy: Retry::Attempts(3), + declared_total_mpp_value_msat_override: None, } } } @@ -5478,10 +5493,19 @@ impl< /// The invoice's `payment_hash().0` serves as a reliable choice for the `payment_id`. /// /// # Handling Invoice Amounts - /// Some invoices include a specific amount, while others require you to specify one. - /// - If the invoice **includes** an amount, user may provide an amount greater or equal to it - /// to allow for overpayments. - /// - If the invoice **doesn't include** an amount, you'll need to specify `amount_msats`. + /// Some invoices require a specific minimum amount (which can be fetched with + /// [`Bolt11Invoice::amount_milli_satoshis`]) while others allow you to pay any amount. + /// + /// - If the invoice **includes** an amount, `amount_msats` may be `None` to pay exactly + /// [`Bolt11Invoice::amount_milli_satoshis`] or may be `Some` with a value greater than or + /// equal to the [`Bolt11Invoice::amount_milli_satoshis`] to allow for deliberate overpayment + /// (e.g. for "tips"). + /// - If the invoice **doesn't include** an amount, `amount_msats` must be `Some`. + /// + /// In the special case that + /// [`OptionalBolt11PaymentParams::declared_total_mpp_value_msat_override`] is set, + /// `amount_msats` may be `Some` and lower than [`Bolt11Invoice::amount_milli_satoshis`]. See + /// the parameter for more details. /// /// If these conditions aren’t met, the function will return [`Bolt11PaymentError::InvalidAmount`]. /// diff --git a/lightning/src/ln/invoice_utils.rs b/lightning/src/ln/invoice_utils.rs index ae87307a9fb..63ad110bba0 100644 --- a/lightning/src/ln/invoice_utils.rs +++ b/lightning/src/ln/invoice_utils.rs @@ -690,6 +690,7 @@ mod test { custom_tlvs: custom_tlvs.clone(), route_params_config: RouteParametersConfig::default(), retry_strategy: Retry::Attempts(0), + declared_total_mpp_value_msat_override: None, }; nodes[0] diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index fa1118440fa..b08b0f5a886 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -627,7 +627,12 @@ pub(crate) enum PaymentSendFailure { #[derive(Debug)] pub enum Bolt11PaymentError { /// Incorrect amount was provided to [`ChannelManager::pay_for_bolt11_invoice`]. - /// This happens when the user-provided amount is less than an amount specified in the [`Bolt11Invoice`]. + /// + /// This happens when the payment amount (either the [`ChannelManager::pay_for_bolt11_invoice`] + /// `amount` or [`OptionalBolt11PaymentParams::declared_total_mpp_value_msat_override`]) is less than + /// [`Bolt11Invoice::amount_milli_satoshis`] or the amount set at + /// [`OptionalBolt11PaymentParams::declared_total_mpp_value_msat_override`] was lower than the + /// explicit amount provided to [`ChannelManager::pay_for_bolt11_invoice`]. /// /// [`Bolt11Invoice`]: lightning_invoice::Bolt11Invoice /// [`ChannelManager::pay_for_bolt11_invoice`]: crate::ln::channelmanager::ChannelManager::pay_for_bolt11_invoice @@ -1037,9 +1042,11 @@ impl OutboundPayments { { let payment_hash = invoice.payment_hash(); + let partial_payment = optional_params.declared_total_mpp_value_msat_override.is_some(); let amount = match (invoice.amount_milli_satoshis(), amount_msats) { (Some(amt), None) | (None, Some(amt)) => amt, - (Some(inv_amt), Some(user_amt)) if user_amt < inv_amt => return Err(Bolt11PaymentError::InvalidAmount), + (Some(inv_amt), Some(user_amt)) if user_amt < inv_amt && !partial_payment => + return Err(Bolt11PaymentError::InvalidAmount), (Some(_), Some(user_amt)) => user_amt, (None, None) => return Err(Bolt11PaymentError::InvalidAmount), }; @@ -1049,6 +1056,16 @@ impl OutboundPayments { .with_custom_tlvs(optional_params.custom_tlvs); recipient_onion.payment_metadata = invoice.payment_metadata().map(|v| v.clone()); + if let Some(mpp_amt) = optional_params.declared_total_mpp_value_msat_override { + if mpp_amt < amount { + return Err(Bolt11PaymentError::InvalidAmount); + } + if invoice.amount_milli_satoshis().is_some_and(|invoice_amt| mpp_amt < invoice_amt) { + return Err(Bolt11PaymentError::InvalidAmount); + } + recipient_onion.total_mpp_amount_msat = mpp_amt; + } + let payment_params = PaymentParameters::from_bolt11_invoice(invoice) .with_user_config_ignoring_fee_limit(optional_params.route_params_config); diff --git a/lightning/src/ln/payment_tests.rs b/lightning/src/ln/payment_tests.rs index c618b512426..b5cbe0fee98 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -38,8 +38,7 @@ use crate::ln::outbound_payment::{ use crate::ln::types::ChannelId; use crate::routing::gossip::{EffectiveCapacity, RoutingFees}; use crate::routing::router::{ - get_route, Path, PaymentParameters, Route, RouteHint, RouteHintHop, RouteHop, RouteParameters, - Router, + Path, PaymentParameters, Route, RouteHint, RouteHintHop, RouteHop, RouteParameters, Router, }; use crate::routing::scoring::ChannelUsage; use crate::sign::EntropySource; @@ -49,11 +48,8 @@ use crate::types::string::UntrustedString; use crate::util::config::HTLCInterceptionFlags; use crate::util::errors::APIError; use crate::util::ser::Writeable; -use crate::util::test_utils; - use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::Hash; -use bitcoin::network::Network; use bitcoin::secp256k1::{Secp256k1, SecretKey}; use crate::prelude::*; @@ -1503,7 +1499,6 @@ fn get_ldk_payment_preimage() { let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); - let node_a_id = nodes[0].node.get_our_node_id(); let node_b_id = nodes[1].node.get_our_node_id(); create_announced_chan_between_nodes(&nodes, 0, 1); @@ -1516,24 +1511,11 @@ fn get_ldk_payment_preimage() { let payment_params = PaymentParameters::from_node_id(node_b_id, TEST_FINAL_CLTV) .with_bolt11_features(nodes[1].node.bolt11_invoice_features()) .unwrap(); - let scorer = test_utils::TestScorer::new(); - let keys_manager = test_utils::TestKeysInterface::new(&[0u8; 32], Network::Testnet); - let random_seed_bytes = keys_manager.get_secure_random_bytes(); let route_params = RouteParameters::from_payment_params_and_value(payment_params, amt_msat); - let first_hops = nodes[0].node.list_usable_channels(); - let route = get_route( - &node_a_id, - &route_params, - &nodes[0].network_graph.read_only(), - Some(&first_hops.iter().collect::>()), - nodes[0].logger, - &scorer, - &Default::default(), - &random_seed_bytes, - ); + let route = get_route(&nodes[0], &route_params).unwrap(); let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let id = PaymentId(payment_hash.0); - nodes[0].node.send_payment_with_route(route.unwrap(), payment_hash, onion, id).unwrap(); + nodes[0].node.send_payment_with_route(route, payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); // Make sure to use `get_payment_preimage` @@ -2238,9 +2220,6 @@ fn do_test_intercepted_payment(test: InterceptTest) { let node_b_id = nodes[1].node.get_our_node_id(); let node_c_id = nodes[2].node.get_our_node_id(); - let scorer = test_utils::TestScorer::new(); - let random_seed_bytes = chanmon_cfgs[0].keys_manager.get_secure_random_bytes(); - let _ = create_announced_chan_between_nodes(&nodes, 0, 1).2; let amt_msat = 100_000; @@ -2258,17 +2237,7 @@ fn do_test_intercepted_payment(test: InterceptTest) { .with_bolt11_features(nodes[2].node.bolt11_invoice_features()) .unwrap(); let route_params = RouteParameters::from_payment_params_and_value(payment_params, amt_msat); - let route = get_route( - &node_a_id, - &route_params, - &nodes[0].network_graph.read_only(), - None, - nodes[0].logger, - &scorer, - &Default::default(), - &random_seed_bytes, - ) - .unwrap(); + let route = get_route(&nodes[0], &route_params).unwrap(); let (hash, payment_secret) = nodes[2].node.create_inbound_payment(Some(amt_msat), 60 * 60, None).unwrap(); @@ -5448,3 +5417,449 @@ fn max_out_mpp_path() { check_added_monitors(&nodes[0], 2); // one monitor update per MPP part nodes[0].node.get_and_clear_pending_msg_events(); } + +fn do_bolt11_multi_node_mpp(use_bolt11_pay: bool) { + // Test that multiple nodes can collaborate to pay a single BOLT 11 invoice, with each node + // paying a portion of the total invoice amount. This is useful for scenarios like: + // - Paying from multiple wallets (e.g., ecash wallets with funds in multiple mints) + // - Graduated wallets (funds split between trusted and self-custodial wallets) + + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + + // Create channels: A<>C and B<>C + create_announced_chan_between_nodes(&nodes, 0, 2); + create_announced_chan_between_nodes(&nodes, 1, 2); + + // Node C creates a BOLT 11 invoice for 100_000 msat + let invoice_amt_msat = 100_000; + let invoice_params = crate::ln::channelmanager::Bolt11InvoiceParameters { + amount_msats: Some(invoice_amt_msat), + ..Default::default() + }; + let invoice = nodes[2].node.create_bolt11_invoice(invoice_params).unwrap(); + let pmt_hash = invoice.payment_hash(); + + // Node A pays 60_000 msat (part of the total) + let node_a_payment_amt = 60_000; + let payment_id_a = PaymentId([1; 32]); + if use_bolt11_pay { + let params = crate::ln::channelmanager::OptionalBolt11PaymentParams { + declared_total_mpp_value_msat_override: Some(invoice_amt_msat), + ..Default::default() + }; + nodes[0] + .node + .pay_for_bolt11_invoice(&invoice, payment_id_a, Some(node_a_payment_amt), params) + .unwrap(); + } else { + let onion = RecipientOnionFields::secret_only(*invoice.payment_secret(), invoice_amt_msat); + let pay_params = PaymentParameters::from_bolt11_invoice(&invoice); + let route_params = + RouteParameters::from_payment_params_and_value(pay_params, node_a_payment_amt); + let retry = Retry::Attempts(0); + nodes[0].node.send_payment(pmt_hash, onion, payment_id_a, route_params, retry).unwrap(); + } + check_added_monitors(&nodes[0], 1); + + // Node B pays 40_000 msat (the remaining part) + let node_b_payment_amt = 40_000; + let payment_id_b = PaymentId([2; 32]); + let optional_params_b = crate::ln::channelmanager::OptionalBolt11PaymentParams { + declared_total_mpp_value_msat_override: Some(invoice_amt_msat), + ..Default::default() + }; + nodes[1] + .node + .pay_for_bolt11_invoice(&invoice, payment_id_b, Some(node_b_payment_amt), optional_params_b) + .unwrap(); + check_added_monitors(&nodes[1], 1); + + let payment_event_a = SendEvent::from_node(&nodes[0]); + nodes[2].node.handle_update_add_htlc(nodes[0].node.get_our_node_id(), &payment_event_a.msgs[0]); + do_commitment_signed_dance(&nodes[2], &nodes[0], &payment_event_a.commitment_msg, false, false); + + let payment_event_b = SendEvent::from_node(&nodes[1]); + nodes[2].node.handle_update_add_htlc(nodes[1].node.get_our_node_id(), &payment_event_b.msgs[0]); + do_commitment_signed_dance(&nodes[2], &nodes[1], &payment_event_b.commitment_msg, false, false); + + // Process the pending HTLCs on node C and generate the PaymentClaimable event + assert!(nodes[2].node.get_and_clear_pending_events().is_empty()); + expect_and_process_pending_htlcs(&nodes[2], false); + let events = nodes[2].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + let payment_preimage = match &events[0] { + Event::PaymentClaimable { + payment_hash, + amount_msat, + onion_fields, + purpose: PaymentPurpose::Bolt11InvoicePayment { payment_preimage, .. }, + .. + } => { + assert_eq!(*payment_hash, invoice.payment_hash()); + assert_eq!(*amount_msat, invoice_amt_msat); + assert_eq!(onion_fields.as_ref().unwrap().total_mpp_amount_msat, invoice_amt_msat); + payment_preimage.unwrap() + }, + _ => panic!("Unexpected event: {:?}", events[0]), + }; + + nodes[2].node.claim_funds(payment_preimage); + + expect_payment_claimed!(nodes[2], invoice.payment_hash(), invoice_amt_msat); + check_added_monitors(&nodes[2], 2); + + // Get the fulfill messages from C to both A and B + let mut events_c = nodes[2].node.get_and_clear_pending_msg_events(); + assert_eq!(events_c.len(), 2); + + // Handle fulfill message from C to A + let fulfill_idx_a = events_c + .iter() + .position(|ev| { + if let MessageSendEvent::UpdateHTLCs { node_id, .. } = ev { + *node_id == nodes[0].node.get_our_node_id() + } else { + false + } + }) + .unwrap(); + let fulfill_idx_b = 1 - fulfill_idx_a; + + if let MessageSendEvent::UpdateHTLCs { ref updates, .. } = events_c[fulfill_idx_a] { + nodes[0].node.handle_update_fulfill_htlc( + nodes[2].node.get_our_node_id(), + updates.update_fulfill_htlcs[0].clone(), + ); + do_commitment_signed_dance(&nodes[0], &nodes[2], &updates.commitment_signed, false, false); + } + + let payment_sent = nodes[0].node.get_and_clear_pending_events(); + check_added_monitors(&nodes[0], 1); + + assert_eq!(payment_sent.len(), 2, "{payment_sent:?}"); + if let Event::PaymentSent { payment_id, payment_hash, amount_msat, fee_paid_msat, .. } = + &payment_sent[0] + { + assert_eq!(*payment_id, Some(payment_id_a)); + assert_eq!(*payment_hash, invoice.payment_hash()); + assert_eq!(*amount_msat, Some(node_a_payment_amt)); + assert_eq!(*fee_paid_msat, Some(0)); + } else { + panic!("{payment_sent:?}"); + } + if let Event::PaymentPathSuccessful { payment_id, .. } = &payment_sent[1] { + assert_eq!(*payment_id, payment_id_a); + } else { + panic!("{payment_sent:?}"); + } + + // Handle fulfill message from C to B + if let MessageSendEvent::UpdateHTLCs { ref updates, .. } = events_c[fulfill_idx_b] { + nodes[1].node.handle_update_fulfill_htlc( + nodes[2].node.get_our_node_id(), + updates.update_fulfill_htlcs[0].clone(), + ); + do_commitment_signed_dance(&nodes[1], &nodes[2], &updates.commitment_signed, false, false); + } + + let payment_sent = nodes[1].node.get_and_clear_pending_events(); + check_added_monitors(&nodes[1], 1); + + assert_eq!(payment_sent.len(), 2, "{payment_sent:?}"); + if let Event::PaymentSent { payment_id, payment_hash, amount_msat, fee_paid_msat, .. } = + &payment_sent[0] + { + assert_eq!(*payment_id, Some(payment_id_b)); + assert_eq!(*payment_hash, invoice.payment_hash()); + assert_eq!(*amount_msat, Some(node_b_payment_amt)); + assert_eq!(*fee_paid_msat, Some(0)); + } else { + panic!("{payment_sent:?}"); + } + if let Event::PaymentPathSuccessful { payment_id, .. } = &payment_sent[1] { + assert_eq!(*payment_id, payment_id_b); + } else { + panic!("{payment_sent:?}"); + } +} + +#[test] +fn bolt11_multi_node_mpp() { + do_bolt11_multi_node_mpp(true); + do_bolt11_multi_node_mpp(false); +} + +#[test] +fn bolt11_multi_node_mpp_with_retry() { + // Test that multi-node MPP payments work correctly when one node's initial payment attempt + // fails and needs to be retried. Node A pays through an intermediate node C, whose first + // forwarding attempt fails (due to insufficient fee on the injected route). After A + // automatically retries with a corrected route, and B's direct payment also arrives at D, + // D can claim the full payment. + // + // Network topology: A(0) -> C(2) -> D(3), B(1) -> D(3) + + let chanmon_cfgs = create_chanmon_cfgs(4); + let node_cfgs = create_node_cfgs(4, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(4, &node_cfgs, &[None, None, None, None]); + let nodes = create_network(4, &node_cfgs, &node_chanmgrs); + + let node_a_id = nodes[0].node.get_our_node_id(); + let node_b_id = nodes[1].node.get_our_node_id(); + let node_c_id = nodes[2].node.get_our_node_id(); + let node_d_id = nodes[3].node.get_our_node_id(); + + // Create channels: A<>C, C<>D, B<>D + create_announced_chan_between_nodes_with_value(&nodes, 0, 2, 10_000_000, 0); + let chan_c_d = create_announced_chan_between_nodes_with_value(&nodes, 2, 3, 10_000_000, 0); + let chan_c_d_scid = chan_c_d.0.contents.short_channel_id; + create_announced_chan_between_nodes(&nodes, 1, 3); + + // Sync all nodes to the same block height, since create_announced_chan_between_nodes only + // connects blocks on the two nodes involved in each channel. + let max_height = nodes.iter().map(|n| n.best_block_info().1).max().unwrap(); + for node in &nodes { + let height = node.best_block_info().1; + if height < max_height { + connect_blocks(node, max_height - height); + } + } + + // Node D creates a BOLT 11 invoice for 100_000 msat + let invoice_amt_msat = 100_000; + let invoice_params = crate::ln::channelmanager::Bolt11InvoiceParameters { + amount_msats: Some(invoice_amt_msat), + ..Default::default() + }; + let invoice = nodes[3].node.create_bolt11_invoice(invoice_params).unwrap(); + + // Construct the RouteParameters that pay_for_bolt11_invoice will generate internally, + // then use get_route to compute a natural route from A through C to D. + let node_a_payment_amt = 60_000; + let payment_params = PaymentParameters::from_bolt11_invoice(&invoice); + let route_params = + RouteParameters::from_payment_params_and_value(payment_params.clone(), node_a_payment_amt); + + let mut route = get_route(&nodes[0], &route_params).unwrap(); + assert_eq!(route.paths.len(), 1); + assert_eq!(route.paths[0].hops.len(), 2); // A -> C -> D + let expected_fee = route.paths[0].hops[0].fee_msat; + + // First route for A: same path but with fee_msat=0 at C to trigger a forwarding failure + let mut first_route = route.clone(); + first_route.paths[0].hops[0].fee_msat = 0; + first_route.route_params = Some(route_params.clone()); + nodes[0].router.expect_find_route(route_params.clone(), Ok(first_route)); + + // Retry route for A: the natural route with correct fees (will succeed) + let mut retry_payment_params = payment_params.clone(); + retry_payment_params.previously_failed_channels = vec![chan_c_d_scid]; + let retry_route_params = RouteParameters { + final_value_msat: node_a_payment_amt, + payment_params: retry_payment_params, + max_total_routing_fee_msat: route_params.max_total_routing_fee_msat, + }; + route.route_params = Some(retry_route_params.clone()); + nodes[0].router.expect_find_route(retry_route_params, Ok(route)); + + // Node A pays 60_000 msat (part of the total) with retry enabled + let payment_id_a = PaymentId([1; 32]); + let optional_params_a = crate::ln::channelmanager::OptionalBolt11PaymentParams { + declared_total_mpp_value_msat_override: Some(invoice_amt_msat), + retry_strategy: Retry::Attempts(1), + ..Default::default() + }; + nodes[0] + .node + .pay_for_bolt11_invoice(&invoice, payment_id_a, Some(node_a_payment_amt), optional_params_a) + .unwrap(); + check_added_monitors(&nodes[0], 1); + + // Node B pays 40_000 msat (the remaining part) + let node_b_payment_amt = 40_000; + let payment_id_b = PaymentId([2; 32]); + let optional_params_b = crate::ln::channelmanager::OptionalBolt11PaymentParams { + declared_total_mpp_value_msat_override: Some(invoice_amt_msat), + ..Default::default() + }; + nodes[1] + .node + .pay_for_bolt11_invoice(&invoice, payment_id_b, Some(node_b_payment_amt), optional_params_b) + .unwrap(); + check_added_monitors(&nodes[1], 1); + + // Forward B's HTLC directly to D first (it will be held pending at D) + let payment_event_b = SendEvent::from_node(&nodes[1]); + nodes[3].node.handle_update_add_htlc(node_b_id, &payment_event_b.msgs[0]); + do_commitment_signed_dance(&nodes[3], &nodes[1], &payment_event_b.commitment_msg, false, false); + + // Forward A's first HTLC to C + let payment_event_a = SendEvent::from_node(&nodes[0]); + nodes[2].node.handle_update_add_htlc(node_a_id, &payment_event_a.msgs[0]); + do_commitment_signed_dance(&nodes[2], &nodes[0], &payment_event_a.commitment_msg, false, false); + + // C tries to forward to D but fails (fee too low) + expect_and_process_pending_htlcs(&nodes[2], false); + let next_hop_failure = + HTLCHandlingFailureType::Forward { node_id: Some(node_d_id), channel_id: chan_c_d.2 }; + expect_htlc_handling_failed_destinations!( + nodes[2].node.get_and_clear_pending_events(), + core::slice::from_ref(&next_hop_failure) + ); + check_added_monitors(&nodes[2], 1); + + // C sends update_fail_htlc back to A + let c_fail_updates = get_htlc_update_msgs(&nodes[2], &node_a_id); + assert_eq!(c_fail_updates.update_fail_htlcs.len(), 1); + nodes[0].node.handle_update_fail_htlc(node_c_id, &c_fail_updates.update_fail_htlcs[0]); + do_commitment_signed_dance( + &nodes[0], + &nodes[2], + &c_fail_updates.commitment_signed, + false, + false, + ); + + // A receives PaymentPathFailed (not permanent, can retry) + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match &events[0] { + Event::PaymentPathFailed { payment_hash, payment_failed_permanently, .. } => { + assert_eq!(*payment_hash, invoice.payment_hash()); + assert!(!payment_failed_permanently); + }, + _ => panic!("Expected PaymentPathFailed, got: {:?}", events[0]), + } + + // A automatically retries by processing pending HTLC forwards + nodes[0].node.process_pending_htlc_forwards(); + let retry_event = SendEvent::from_node(&nodes[0]); + check_added_monitors(&nodes[0], 1); + + // Forward retry HTLC from A to C + nodes[2].node.handle_update_add_htlc(node_a_id, &retry_event.msgs[0]); + do_commitment_signed_dance(&nodes[2], &nodes[0], &retry_event.commitment_msg, false, false); + + // C successfully forwards to D this time + expect_and_process_pending_htlcs(&nodes[2], false); + check_added_monitors(&nodes[2], 1); + let c_forward = get_htlc_update_msgs(&nodes[2], &node_d_id); + nodes[3].node.handle_update_add_htlc(node_c_id, &c_forward.update_add_htlcs[0]); + do_commitment_signed_dance(&nodes[3], &nodes[2], &c_forward.commitment_signed, false, false); + + // D now has both HTLCs (A's retry via C and B's direct). Process and claim. + assert!(nodes[3].node.get_and_clear_pending_events().is_empty()); + expect_and_process_pending_htlcs(&nodes[3], false); + let events = nodes[3].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + let payment_preimage = match &events[0] { + Event::PaymentClaimable { + payment_hash, + amount_msat, + onion_fields, + purpose: PaymentPurpose::Bolt11InvoicePayment { payment_preimage, .. }, + .. + } => { + assert_eq!(*payment_hash, invoice.payment_hash()); + assert_eq!(*amount_msat, invoice_amt_msat); + assert_eq!(onion_fields.as_ref().unwrap().total_mpp_amount_msat, invoice_amt_msat); + payment_preimage.unwrap() + }, + _ => panic!("Unexpected event: {:?}", events[0]), + }; + + nodes[3].node.claim_funds(payment_preimage); + + expect_payment_claimed!(nodes[3], invoice.payment_hash(), invoice_amt_msat); + check_added_monitors(&nodes[3], 2); + + // Get the fulfill messages from D to both C (for A) and B + let mut events_d = nodes[3].node.get_and_clear_pending_msg_events(); + assert_eq!(events_d.len(), 2); + + // Find which event goes to C and which to B + let fulfill_idx_c = events_d + .iter() + .position(|ev| { + if let MessageSendEvent::UpdateHTLCs { node_id, .. } = ev { + *node_id == node_c_id + } else { + false + } + }) + .unwrap(); + let fulfill_idx_b = 1 - fulfill_idx_c; + + // Handle fulfill from D to C (intermediate node). C persists the preimage to + // the upstream A<>C channel monitor, generates a PaymentForwarded event, and + // queues a fulfill message for A. + if let MessageSendEvent::UpdateHTLCs { ref updates, .. } = events_d[fulfill_idx_c] { + nodes[2] + .node + .handle_update_fulfill_htlc(node_d_id, updates.update_fulfill_htlcs[0].clone()); + expect_payment_forwarded!(nodes[2], nodes[0], nodes[3], Some(expected_fee), false, false); + check_added_monitors(&nodes[2], 1); + + // C has a pending fulfill to send to A; retrieve it before the C<>D dance + let c_fulfill = get_htlc_update_msgs(&nodes[2], &node_a_id); + + do_commitment_signed_dance(&nodes[2], &nodes[3], &updates.commitment_signed, false, false); + + // Forward the fulfill from C to A + nodes[0] + .node + .handle_update_fulfill_htlc(node_c_id, c_fulfill.update_fulfill_htlcs[0].clone()); + do_commitment_signed_dance( + &nodes[0], + &nodes[2], + &c_fulfill.commitment_signed, + false, + false, + ); + } + + let payment_sent_a = nodes[0].node.get_and_clear_pending_events(); + check_added_monitors(&nodes[0], 1); + + assert_eq!(payment_sent_a.len(), 2, "{payment_sent_a:?}"); + if let Event::PaymentSent { payment_id, payment_hash, amount_msat, .. } = &payment_sent_a[0] { + assert_eq!(*payment_id, Some(payment_id_a)); + assert_eq!(*payment_hash, invoice.payment_hash()); + assert_eq!(*amount_msat, Some(node_a_payment_amt)); + } else { + panic!("{payment_sent_a:?}"); + } + if let Event::PaymentPathSuccessful { payment_id, .. } = &payment_sent_a[1] { + assert_eq!(*payment_id, payment_id_a); + } else { + panic!("{payment_sent_a:?}"); + } + + // Handle fulfill from D to B + if let MessageSendEvent::UpdateHTLCs { ref updates, .. } = events_d[fulfill_idx_b] { + nodes[1] + .node + .handle_update_fulfill_htlc(node_d_id, updates.update_fulfill_htlcs[0].clone()); + do_commitment_signed_dance(&nodes[1], &nodes[3], &updates.commitment_signed, false, false); + } + + let payment_sent_b = nodes[1].node.get_and_clear_pending_events(); + check_added_monitors(&nodes[1], 1); + + assert_eq!(payment_sent_b.len(), 2, "{payment_sent_b:?}"); + if let Event::PaymentSent { payment_id, payment_hash, amount_msat, .. } = &payment_sent_b[0] { + assert_eq!(*payment_id, Some(payment_id_b)); + assert_eq!(*payment_hash, invoice.payment_hash()); + assert_eq!(*amount_msat, Some(node_b_payment_amt)); + } else { + panic!("{payment_sent_b:?}"); + } + if let Event::PaymentPathSuccessful { payment_id, .. } = &payment_sent_b[1] { + assert_eq!(*payment_id, payment_id_b); + } else { + panic!("{payment_sent_b:?}"); + } +} From 372168d1e9e6f083c64b188d167a51e41e2c4ff4 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Wed, 11 Feb 2026 21:49:03 +0000 Subject: [PATCH 077/627] Add a pending changelog entry to note new backwards incompat --- pending_changelog/4373.txt | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 pending_changelog/4373.txt diff --git a/pending_changelog/4373.txt b/pending_changelog/4373.txt new file mode 100644 index 00000000000..e606063f93c --- /dev/null +++ b/pending_changelog/4373.txt @@ -0,0 +1,4 @@ +## Backwards Compat + * Setting `OptionalBolt11PaymentParams::declared_total_mpp_value_override` or + `RecipientOnionFields::total_mpp_amount_msat` for a payment will break + downgrade to 0.2 until the payment completes. From ed10776076da52ef560c841647ac235fdef782da Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Fri, 13 Feb 2026 20:49:45 +0000 Subject: [PATCH 078/627] Require `RecipientOnionFields` in the claimable HTLC pipeline We added `RecipientOnionFields` in the `ClaimablePayment`/`ClaimingPayment` structs in 0.0.115/0.0.124, always writing them for new HTLCs. As of 0.1, we do not support upgrading from 0.0.123 or earlier with pending HTLCs to forward or claim. Thus, we already don't support upgrading in cases where no `RecipientOnionFields` is set and we can thus go ahead and mark it as non-`Option`al. Further, there's some super ancient upgrade logic in `ChannelManager` deserialization we can remove at the same time. --- lightning/src/ln/channelmanager.rs | 116 ++++++++--------------------- 1 file changed, 33 insertions(+), 83 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 4ac15da712d..a436a92506e 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -1106,7 +1106,7 @@ struct ClaimingPayment { receiver_node_id: PublicKey, htlcs: Vec, sender_intended_value: Option, - onion_fields: Option, + onion_fields: RecipientOnionFields, payment_id: Option, /// When we claim and generate a [`Event::PaymentClaimed`], we want to block any /// payment-preimage-removing RAA [`ChannelMonitorUpdate`]s until the [`Event::PaymentClaimed`] @@ -1125,13 +1125,14 @@ impl_writeable_tlv_based!(ClaimingPayment, { (4, receiver_node_id, required), (5, htlcs, optional_vec), (7, sender_intended_value, option), - (9, onion_fields, (option: ReadableArgs, amount_msat.0.unwrap())), + // onion_fields was added (and always set for new payments) in 0.0.124 + (9, onion_fields, (required: ReadableArgs, amount_msat.0.unwrap())), (11, payment_id, option), }); struct ClaimablePayment { purpose: events::PaymentPurpose, - onion_fields: Option, + onion_fields: RecipientOnionFields, htlcs: Vec, } @@ -1254,12 +1255,11 @@ impl ClaimablePayments { } } - if let Some(RecipientOnionFields { custom_tlvs, .. }) = &payment.onion_fields { - if !custom_tlvs_known && custom_tlvs.iter().any(|(typ, _)| typ % 2 == 0) { - log_info!(logger, "Rejecting payment with payment hash {} as we cannot accept payment with unknown even TLVs: {}", - &payment_hash, log_iter!(custom_tlvs.iter().map(|(typ, _)| typ).filter(|typ| *typ % 2 == 0))); - return Err(payment.htlcs); - } + let custom_tlvs = &payment.onion_fields.custom_tlvs; + if !custom_tlvs_known && custom_tlvs.iter().any(|(typ, _)| typ % 2 == 0) { + log_info!(logger, "Rejecting payment with payment hash {} as we cannot accept payment with unknown even TLVs: {}", + &payment_hash, log_iter!(custom_tlvs.iter().map(|(typ, _)| typ).filter(|typ| *typ % 2 == 0))); + return Err(payment.htlcs); } let payment_id = payment.inbound_payment_id(inbound_payment_id_secret); @@ -8083,7 +8083,9 @@ impl< .or_insert_with(|| { committed_to_claimable = true; ClaimablePayment { - purpose: $purpose.clone(), htlcs: Vec::new(), onion_fields: None, + purpose: $purpose.clone(), + htlcs: Vec::new(), + onion_fields: onion_fields.clone(), } }); if $purpose != claimable_payment.purpose { @@ -8091,12 +8093,10 @@ impl< log_trace!(self.logger, "Failing new {} HTLC with payment_hash {} as we already had an existing {} HTLC with the same payment hash", log_keysend(is_keysend), &payment_hash, log_keysend(!is_keysend)); fail_htlc!(claimable_htlc, payment_hash); } - if let Some(earlier_fields) = &mut claimable_payment.onion_fields { - if earlier_fields.check_merge(&mut onion_fields).is_err() { - fail_htlc!(claimable_htlc, payment_hash); - } - } else { - claimable_payment.onion_fields = Some(onion_fields); + let onions_compatible = + claimable_payment.onion_fields.check_merge(&mut onion_fields); + if onions_compatible.is_err() { + fail_htlc!(claimable_htlc, payment_hash); } let mut total_value = claimable_htlc.sender_intended_value; let mut earliest_expiry = claimable_htlc.cltv_expiry; @@ -8142,7 +8142,7 @@ impl< counterparty_skimmed_fee_msat, receiving_channel_ids: claimable_payment.receiving_channel_ids(), claim_deadline: Some(earliest_expiry - HTLC_FAIL_BACK_BUFFER), - onion_fields: claimable_payment.onion_fields.clone(), + onion_fields: Some(claimable_payment.onion_fields.clone()), payment_id: Some(payment_id), }, None)); payment_claimable_generated = true; @@ -9866,7 +9866,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ receiver_node_id: Some(receiver_node_id), htlcs, sender_intended_total_msat, - onion_fields, + onion_fields: Some(onion_fields), payment_id, }; let action = if let Some((outpoint, counterparty_node_id, channel_id)) = @@ -17341,7 +17341,7 @@ impl< let pending_outbound_payments = self.pending_outbound_payments.pending_outbound_payments.lock().unwrap(); let mut htlc_purposes: Vec<&events::PaymentPurpose> = Vec::new(); - let mut htlc_onion_fields: Vec<&_> = Vec::new(); + let mut htlc_onion_fields: Vec> = Vec::new(); (claimable_payments.claimable_payments.len() as u64).write(writer)?; for (payment_hash, payment) in claimable_payments.claimable_payments.iter() { payment_hash.write(writer)?; @@ -17350,7 +17350,7 @@ impl< htlc.write(writer)?; } htlc_purposes.push(&payment.purpose); - htlc_onion_fields.push(&payment.onion_fields); + htlc_onion_fields.push(Some(&payment.onion_fields)); } let mut monitor_update_blocked_actions_per_peer = None; @@ -17621,25 +17621,18 @@ pub(super) struct ChannelManagerData { } /// Arguments for deserializing [`ChannelManagerData`]. -struct ChannelManagerDataReadArgs< - 'a, - ES: EntropySource, - NS: NodeSigner, - SP: SignerProvider, - L: Logger, -> { +struct ChannelManagerDataReadArgs<'a, ES: EntropySource, SP: SignerProvider, L: Logger> { entropy_source: &'a ES, - node_signer: &'a NS, signer_provider: &'a SP, config: UserConfig, logger: &'a L, } -impl<'a, ES: EntropySource, NS: NodeSigner, SP: SignerProvider, L: Logger> - ReadableArgs> for ChannelManagerData +impl<'a, ES: EntropySource, SP: SignerProvider, L: Logger> + ReadableArgs> for ChannelManagerData { fn read( - reader: &mut R, args: ChannelManagerDataReadArgs<'a, ES, NS, SP, L>, + reader: &mut R, args: ChannelManagerDataReadArgs<'a, ES, SP, L>, ) -> Result { let version = read_ver_prefix!(reader, SERIALIZATION_VERSION); @@ -17866,10 +17859,7 @@ impl<'a, ES: EntropySource, NS: NodeSigner, SP: SignerProvider, L: Logger> // Resolve events_override: if present, it replaces pending_events. let pending_events_read = events_override.unwrap_or(pending_events_read); - // Combine claimable_htlcs_list with their purposes and onion fields. For very old data - // (pre-0.0.107) that lacks purposes, reconstruct them from legacy hop data. - let expanded_inbound_key = args.node_signer.get_expanded_key(); - + // Combine claimable_htlcs_list with their purposes and onion fields. let mut claimable_payments = hash_map_with_capacity(claimable_htlcs_list.len()); if let Some(purposes) = claimable_htlc_purposes { if purposes.len() != claimable_htlcs_list.len() { @@ -17892,9 +17882,9 @@ impl<'a, ES: EntropySource, NS: NodeSigner, SP: SignerProvider, L: Logger> return Err(DecodeError::InvalidValue); } onion.0.total_mpp_amount_msat = htlcs_total_msat; - Some(onion.0) + onion.0 } else { - None + return Err(DecodeError::InvalidValue); }; let claimable = ClaimablePayment { purpose, htlcs, onion_fields }; let existing_payment = claimable_payments.insert(payment_hash, claimable); @@ -17902,54 +17892,15 @@ impl<'a, ES: EntropySource, NS: NodeSigner, SP: SignerProvider, L: Logger> return Err(DecodeError::InvalidValue); } } - } else { - for (purpose, (payment_hash, htlcs)) in - purposes.into_iter().zip(claimable_htlcs_list.into_iter()) - { - let claimable = ClaimablePayment { purpose, htlcs, onion_fields: None }; - let existing_payment = claimable_payments.insert(payment_hash, claimable); - if existing_payment.is_some() { - return Err(DecodeError::InvalidValue); - } - } + } else if !purposes.is_empty() || !claimable_htlcs_list.is_empty() { + // `amountless_claimable_htlc_onion_fields` was first written in LDK 0.0.115. We + // haven't supported upgrade from 0.0.115 with pending HTLCs since 0.1. + return Err(DecodeError::InvalidValue); } } else { // LDK versions prior to 0.0.107 did not write a `pending_htlc_purposes`, but do // include a `_legacy_hop_data` in the `OnionPayload`. - for (payment_hash, htlcs) in claimable_htlcs_list.into_iter() { - if htlcs.is_empty() { - return Err(DecodeError::InvalidValue); - } - let purpose = match &htlcs[0].onion_payload { - OnionPayload::Invoice { _legacy_hop_data } => { - if let Some(hop_data) = _legacy_hop_data { - events::PaymentPurpose::Bolt11InvoicePayment { - payment_preimage: match inbound_payment::verify( - payment_hash, - &hop_data, - 0, - &expanded_inbound_key, - &args.logger, - ) { - Ok((payment_preimage, _)) => payment_preimage, - Err(()) => { - log_error!(args.logger, "Failed to read claimable payment data for HTLC with payment hash {} - was not a pending inbound payment and didn't match our payment key", &payment_hash); - return Err(DecodeError::InvalidValue); - }, - }, - payment_secret: hop_data.payment_secret, - } - } else { - return Err(DecodeError::InvalidValue); - } - }, - OnionPayload::Spontaneous(payment_preimage) => { - events::PaymentPurpose::SpontaneousPayment(*payment_preimage) - }, - }; - claimable_payments - .insert(payment_hash, ClaimablePayment { purpose, htlcs, onion_fields: None }); - } + return Err(DecodeError::InvalidValue); } Ok(ChannelManagerData { @@ -18222,7 +18173,6 @@ impl< reader, ChannelManagerDataReadArgs { entropy_source: &args.entropy_source, - node_signer: &args.node_signer, signer_provider: &args.signer_provider, config: args.config.clone(), logger: &args.logger, @@ -19891,7 +19841,7 @@ impl< amount_msat: claimable_amt_msat, htlcs, sender_intended_total_msat, - onion_fields: payment.onion_fields, + onion_fields: Some(payment.onion_fields), payment_id: Some(payment_id), }, // Note that we don't bother adding a EventCompletionAction here to From a28e7e63a97b36bf3a2310da642a24d90b6a4e7b Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Fri, 13 Feb 2026 21:49:02 +0000 Subject: [PATCH 079/627] Drop `total_msat` from individual `ClaimableHTLC`s Now that we have `total_mpp_amount_msat` in the now-required `RecipientOnionFields` in `ClaimablePayment`s, the `total_msat` field in `ClaimableHTLC` is redundant. Given it was already awkward that we stored it in *each` `ClaimableHTLC` despite it being required to match in all of them, its good to drop it. --- lightning/src/ln/channelmanager.rs | 138 ++++++++++++----------------- 1 file changed, 58 insertions(+), 80 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index a436a92506e..80e4578746d 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -539,8 +539,6 @@ struct ClaimableHTLC { /// The total value received for a payment (sum of all MPP parts if the payment is a MPP). /// Gets set to the amount reported when pushing [`Event::PaymentClaimable`]. total_value_received: Option, - /// The sender intended sum total of all MPP parts specified in the onion - total_msat: u64, /// The extra fee our counterparty skimmed off the top of this HTLC. counterparty_skimmed_fee_msat: Option, } @@ -1272,7 +1270,7 @@ impl ClaimablePayments { }) .or_insert_with(|| { let htlcs = payment.htlcs.iter().map(events::ClaimedHTLC::from).collect(); - let sender_intended_value = payment.htlcs.first().map(|htlc| htlc.total_msat); + let sender_intended_value = payment.onion_fields.total_mpp_amount_msat; // Pick an "arbitrary" channel to block RAAs on until the `PaymentSent` // event is processed, specifically the last channel to get claimed. let durable_preimage_channel = payment.htlcs.last().map_or(None, |htlc| { @@ -1288,7 +1286,7 @@ impl ClaimablePayments { payment_purpose: payment.purpose, receiver_node_id, htlcs, - sender_intended_value, + sender_intended_value: Some(sender_intended_value), onion_fields: payment.onion_fields, payment_id: Some(payment_id), durable_preimage_channel, @@ -8013,11 +8011,6 @@ impl< sender_intended_value: outgoing_amt_msat, timer_ticks: 0, total_value_received: None, - total_msat: if let Some(data) = &payment_data { - data.total_msat - } else { - outgoing_amt_msat - }, cltv_expiry, onion_payload, counterparty_skimmed_fee_msat: skimmed_fee_msat, @@ -8098,27 +8091,25 @@ impl< if onions_compatible.is_err() { fail_htlc!(claimable_htlc, payment_hash); } - let mut total_value = claimable_htlc.sender_intended_value; + let mut total_intended_recvd_value = + claimable_htlc.sender_intended_value; let mut earliest_expiry = claimable_htlc.cltv_expiry; for htlc in claimable_payment.htlcs.iter() { - total_value += htlc.sender_intended_value; + total_intended_recvd_value += htlc.sender_intended_value; earliest_expiry = cmp::min(earliest_expiry, htlc.cltv_expiry); - if htlc.total_msat != claimable_htlc.total_msat { - log_trace!(self.logger, "Failing HTLCs with payment_hash {} as the HTLCs had inconsistent total values (eg {} and {})", - &payment_hash, claimable_htlc.total_msat, htlc.total_msat); - total_value = msgs::MAX_VALUE_MSAT; - } - if total_value >= msgs::MAX_VALUE_MSAT { break; } + if total_intended_recvd_value >= msgs::MAX_VALUE_MSAT { break; } } + let total_mpp_value = + claimable_payment.onion_fields.total_mpp_amount_msat; // The condition determining whether an MPP is complete must // match exactly the condition used in `timer_tick_occurred` - if total_value >= msgs::MAX_VALUE_MSAT { + if total_intended_recvd_value >= msgs::MAX_VALUE_MSAT { fail_htlc!(claimable_htlc, payment_hash); - } else if total_value - claimable_htlc.sender_intended_value >= claimable_htlc.total_msat { + } else if total_intended_recvd_value - claimable_htlc.sender_intended_value >= total_mpp_value { log_trace!(self.logger, "Failing HTLC with payment_hash {} as payment is already claimable", &payment_hash); fail_htlc!(claimable_htlc, payment_hash); - } else if total_value >= claimable_htlc.total_msat { + } else if total_intended_recvd_value >= total_mpp_value { #[allow(unused_assignments)] { committed_to_claimable = true; } @@ -8129,8 +8120,8 @@ impl< .for_each(|htlc| htlc.total_value_received = Some(amount_msat)); let counterparty_skimmed_fee_msat = claimable_payment.htlcs.iter() .map(|htlc| htlc.counterparty_skimmed_fee_msat.unwrap_or(0)).sum(); - debug_assert!(total_value.saturating_sub(amount_msat) <= - counterparty_skimmed_fee_msat); + debug_assert!(total_intended_recvd_value.saturating_sub(amount_msat) + <= counterparty_skimmed_fee_msat); claimable_payment.htlcs.sort(); let payment_id = claimable_payment.inbound_payment_id(&self.inbound_payment_id_secret); @@ -8592,9 +8583,10 @@ impl< // In this case we're not going to handle any timeouts of the parts here. // This condition determining whether the MPP is complete here must match // exactly the condition used in `process_pending_htlc_forwards`. - let htlc_total_msat = + let total_intended_recvd_value = payment.htlcs.iter().map(|h| h.sender_intended_value).sum(); - if payment.htlcs[0].total_msat <= htlc_total_msat { + let total_mpp_value = payment.onion_fields.total_mpp_amount_msat; + if total_mpp_value <= total_intended_recvd_value { return true; } else if payment.htlcs.iter_mut().any(|htlc| { htlc.timer_ticks += 1; @@ -9009,20 +9001,11 @@ impl< // amount we told the user in the last `PaymentClaimable`. We also do a sanity-check that // the MPP parts all have the same `total_msat`. let mut claimable_amt_msat = 0; - let mut prev_total_msat = None; let mut expected_amt_msat = None; let mut valid_mpp = true; let mut errs = Vec::new(); let per_peer_state = self.per_peer_state.read().unwrap(); for htlc in sources.iter() { - if prev_total_msat.is_some() && prev_total_msat != Some(htlc.total_msat) { - log_error!(self.logger, "Somehow ended up with an MPP payment with different expected total amounts - this should not be reachable!"); - debug_assert!(false); - valid_mpp = false; - break; - } - prev_total_msat = Some(htlc.total_msat); - if expected_amt_msat.is_some() && expected_amt_msat != htlc.total_value_received { log_error!(self.logger, "Somehow ended up with an MPP payment with different received total amounts - this should not be reachable!"); debug_assert!(false); @@ -17014,33 +16997,33 @@ impl_writeable_tlv_based!(HTLCPreviousHopData, { (13, trampoline_shared_secret, option), }); -impl Writeable for ClaimableHTLC { - fn write(&self, writer: &mut W) -> Result<(), io::Error> { - let (payment_data, keysend_preimage) = match &self.onion_payload { - OnionPayload::Invoice { _legacy_hop_data } => (_legacy_hop_data.as_ref(), None), - OnionPayload::Spontaneous(preimage) => (None, Some(preimage)), - }; - write_tlv_fields!(writer, { - (0, self.prev_hop, required), - (1, self.total_msat, required), - (2, self.value, required), - (3, self.sender_intended_value, required), - (4, payment_data, option), - (5, self.total_value_received, option), - (6, self.cltv_expiry, required), - (8, keysend_preimage, option), - (10, self.counterparty_skimmed_fee_msat, option), - }); - Ok(()) - } +fn write_claimable_htlc( + htlc: &ClaimableHTLC, total_mpp_value_msat: u64, writer: &mut W, +) -> Result<(), io::Error> { + let (payment_data, keysend_preimage) = match &htlc.onion_payload { + OnionPayload::Invoice { _legacy_hop_data } => (_legacy_hop_data.as_ref(), None), + OnionPayload::Spontaneous(preimage) => (None, Some(preimage)), + }; + write_tlv_fields!(writer, { + (0, htlc.prev_hop, required), + (1, total_mpp_value_msat, required), + (2, htlc.value, required), + (3, htlc.sender_intended_value, required), + (4, payment_data, option), + (5, htlc.total_value_received, option), + (6, htlc.cltv_expiry, required), + (8, keysend_preimage, option), + (10, htlc.counterparty_skimmed_fee_msat, option), + }); + Ok(()) } -impl Readable for ClaimableHTLC { +impl Readable for (ClaimableHTLC, u64) { #[rustfmt::skip] fn read(reader: &mut R) -> Result { _init_and_read_len_prefixed_tlv_fields!(reader, { (0, prev_hop, required), - (1, total_msat, option), + (1, total_msat, required), // Added and always written in 0.0.107 (2, value_ser, required), (3, sender_intended_value, option), (4, payment_data_opt, option), @@ -17056,32 +17039,20 @@ impl Readable for ClaimableHTLC { if payment_data.is_some() { return Err(DecodeError::InvalidValue) } - if total_msat.is_none() { - total_msat = Some(value); - } OnionPayload::Spontaneous(p) }, - None => { - if total_msat.is_none() { - if payment_data.is_none() { - return Err(DecodeError::InvalidValue) - } - total_msat = Some(payment_data.as_ref().unwrap().total_msat); - } - OnionPayload::Invoice { _legacy_hop_data: payment_data } - }, + None => OnionPayload::Invoice { _legacy_hop_data: payment_data }, }; - Ok(Self { + Ok((ClaimableHTLC { prev_hop: prev_hop.0.unwrap(), timer_ticks: 0, value, sender_intended_value: sender_intended_value.unwrap_or(value), total_value_received, - total_msat: total_msat.unwrap(), onion_payload, cltv_expiry: cltv_expiry.0.unwrap(), counterparty_skimmed_fee_msat, - }) + }, total_msat.0.expect("required field"))) } } @@ -17347,7 +17318,7 @@ impl< payment_hash.write(writer)?; (payment.htlcs.len() as u64).write(writer)?; for htlc in payment.htlcs.iter() { - htlc.write(writer)?; + write_claimable_htlc(&htlc, payment.onion_fields.total_mpp_amount_msat, writer)?; } htlc_purposes.push(&payment.purpose); htlc_onion_fields.push(Some(&payment.onion_fields)); @@ -17687,10 +17658,20 @@ impl<'a, ES: EntropySource, SP: SignerProvider, L: Logger> previous_hops_len as usize, MAX_ALLOC_SIZE / mem::size_of::(), )); + let mut total_mpp_value_msat = None; for _ in 0..previous_hops_len { - previous_hops.push(::read(reader)?); + let (htlc, total_mpp_value_msat_read) = + <(ClaimableHTLC, u64) as Readable>::read(reader)?; + if total_mpp_value_msat.is_some() + && total_mpp_value_msat != Some(total_mpp_value_msat_read) + { + return Err(DecodeError::InvalidValue); + } + total_mpp_value_msat = Some(total_mpp_value_msat_read); + previous_hops.push(htlc); } - claimable_htlcs_list.push((payment_hash, previous_hops)); + let total_mpp_value_msat = total_mpp_value_msat.ok_or(DecodeError::InvalidValue)?; + claimable_htlcs_list.push((payment_hash, previous_hops, total_mpp_value_msat)); } let peer_count: u64 = Readable::read(reader)?; @@ -17869,19 +17850,17 @@ impl<'a, ES: EntropySource, SP: SignerProvider, L: Logger> if onion_fields.len() != claimable_htlcs_list.len() { return Err(DecodeError::InvalidValue); } - for (purpose, (onion, (payment_hash, htlcs))) in purposes + for (purpose, (onion, (payment_hash, htlcs, total_mpp_value_msat))) in purposes .into_iter() .zip(onion_fields.into_iter().zip(claimable_htlcs_list.into_iter())) { - let htlcs_total_msat = - htlcs.first().ok_or(DecodeError::InvalidValue)?.total_msat; let onion_fields = if let Some(mut onion) = onion { if onion.0.total_mpp_amount_msat != 0 - && onion.0.total_mpp_amount_msat != htlcs_total_msat + && onion.0.total_mpp_amount_msat != total_mpp_value_msat { return Err(DecodeError::InvalidValue); } - onion.0.total_mpp_amount_msat = htlcs_total_msat; + onion.0.total_mpp_amount_msat = total_mpp_value_msat; onion.0 } else { return Err(DecodeError::InvalidValue); @@ -19831,8 +19810,7 @@ impl< let payment_id = payment.inbound_payment_id(&inbound_payment_id_secret.unwrap()); let htlcs = payment.htlcs.iter().map(events::ClaimedHTLC::from).collect(); - let sender_intended_total_msat = - payment.htlcs.first().map(|htlc| htlc.total_msat); + let sender_intended_total_msat = payment.onion_fields.total_mpp_amount_msat; pending_events.push_back(( events::Event::PaymentClaimed { receiver_node_id, @@ -19840,7 +19818,7 @@ impl< purpose: payment.purpose, amount_msat: claimable_amt_msat, htlcs, - sender_intended_total_msat, + sender_intended_total_msat: Some(sender_intended_total_msat), onion_fields: Some(payment.onion_fields), payment_id: Some(payment_id), }, From d2e2fbdeea964d86f082814be551ff40adad40f1 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Sun, 24 Aug 2025 02:17:33 +0000 Subject: [PATCH 080/627] Simplify calculation of the biggest HTLC value that can be sent next This commit has no functional changes. --- lightning/src/ln/channel.rs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 7943ed98719..2416981664d 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -5938,16 +5938,12 @@ impl ChannelContext { // We will first subtract the fee as if we were above-dust. Then, if the resulting // value ends up being below dust, we have this fee available again. In that case, // match the value to right-below-dust. - let mut capacity_minus_commitment_fee_msat: i64 = available_capacity_msat as i64 - - max_reserved_commit_tx_fee_msat as i64; - if capacity_minus_commitment_fee_msat < (real_dust_limit_timeout_sat as i64) * 1000 { - let one_htlc_difference_msat = max_reserved_commit_tx_fee_msat - min_reserved_commit_tx_fee_msat; - debug_assert!(one_htlc_difference_msat != 0); - capacity_minus_commitment_fee_msat += one_htlc_difference_msat as i64; - capacity_minus_commitment_fee_msat = cmp::min(real_dust_limit_timeout_sat as i64 * 1000 - 1, capacity_minus_commitment_fee_msat); - available_capacity_msat = cmp::max(0, cmp::min(capacity_minus_commitment_fee_msat, available_capacity_msat as i64)) as u64; + let capacity_minus_max_commitment_fee_msat = available_capacity_msat.saturating_sub(max_reserved_commit_tx_fee_msat); + if capacity_minus_max_commitment_fee_msat < real_dust_limit_timeout_sat * 1000 { + let capacity_minus_min_commitment_fee_msat = available_capacity_msat.saturating_sub(min_reserved_commit_tx_fee_msat); + available_capacity_msat = cmp::min(real_dust_limit_timeout_sat * 1000 - 1, capacity_minus_min_commitment_fee_msat); } else { - available_capacity_msat = capacity_minus_commitment_fee_msat as u64; + available_capacity_msat = capacity_minus_max_commitment_fee_msat; } } else { // If the channel is inbound (i.e. counterparty pays the fee), we need to make sure From 4760f868deffd25a31e93da09c132fee2967ebd5 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Tue, 3 Feb 2026 03:34:22 +0000 Subject: [PATCH 081/627] Introduce `TxBuilder::get_channel_stats` This commit moves the previous `TxBuilder::get_next_commitment_stats` method to a private function, and then calls this function in `TxBuilder::get_channel_stats`. Similar to the previous `TxBuilder::get_next_commitment_stats` method, `TxBuilder::get_channel_stats` fails if any party cannot afford the HTLCs outbound from said party, and the anchors if they are the funder. Aside from the API changes on `TxBuilder`, there are no functional changes in this commit. --- lightning/src/ln/channel.rs | 121 +++++++++-------- lightning/src/sign/tx_builder.rs | 223 +++++++++++++++++-------------- 2 files changed, 189 insertions(+), 155 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 2416981664d..509202b3426 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -71,7 +71,7 @@ use crate::ln::types::ChannelId; use crate::offers::static_invoice::StaticInvoice; use crate::routing::gossip::NodeId; use crate::sign::ecdsa::EcdsaChannelSigner; -use crate::sign::tx_builder::{HTLCAmountDirection, NextCommitmentStats, SpecTxBuilder, TxBuilder}; +use crate::sign::tx_builder::{ChannelStats, HTLCAmountDirection, SpecTxBuilder, TxBuilder}; use crate::sign::{ChannelSigner, EntropySource, NodeSigner, Recipient, SignerProvider}; use crate::types::features::{ChannelTypeFeatures, InitFeatures}; use crate::types::payment::{PaymentHash, PaymentPreimage}; @@ -4862,7 +4862,7 @@ impl ChannelContext { &self, funding: &FundingScope, htlc_candidate: Option, include_counterparty_unknown_htlcs: bool, addl_nondust_htlc_count: usize, feerate_per_kw: u32, dust_exposure_limiting_feerate: Option, - ) -> Result { + ) -> Result { let next_commitment_htlcs = self.get_next_commitment_htlcs( true, htlc_candidate, @@ -4870,7 +4870,7 @@ impl ChannelContext { ); let next_value_to_self_msat = self.get_next_commitment_value_to_self_msat(true, funding); - let ret = SpecTxBuilder {}.get_next_commitment_stats( + let local_stats = SpecTxBuilder {}.get_channel_stats( true, funding.is_outbound(), funding.get_value_satoshis(), @@ -4888,12 +4888,12 @@ impl ChannelContext { if addl_nondust_htlc_count == 0 { *funding.next_local_fee.lock().unwrap() = PredictedNextFee { predicted_feerate: feerate_per_kw, - predicted_nondust_htlc_count: ret.nondust_htlc_count, - predicted_fee_sat: ret.commit_tx_fee_sat, + predicted_nondust_htlc_count: local_stats.commitment_stats.nondust_htlc_count, + predicted_fee_sat: local_stats.commitment_stats.commit_tx_fee_sat, }; } else { let predicted_stats = SpecTxBuilder {} - .get_next_commitment_stats( + .get_channel_stats( true, funding.is_outbound(), funding.get_value_satoshis(), @@ -4905,7 +4905,8 @@ impl ChannelContext { self.holder_dust_limit_satoshis, funding.get_channel_type(), ) - .expect("Balance after HTLCs and anchors exhausted on local commitment"); + .expect("Balance after HTLCs and anchors exhausted on local commitment") + .commitment_stats; *funding.next_local_fee.lock().unwrap() = PredictedNextFee { predicted_feerate: feerate_per_kw, predicted_nondust_htlc_count: predicted_stats.nondust_htlc_count, @@ -4914,14 +4915,14 @@ impl ChannelContext { } } - Ok(ret) + Ok(local_stats) } fn get_next_remote_commitment_stats( &self, funding: &FundingScope, htlc_candidate: Option, include_counterparty_unknown_htlcs: bool, addl_nondust_htlc_count: usize, feerate_per_kw: u32, dust_exposure_limiting_feerate: Option, - ) -> Result { + ) -> Result { let next_commitment_htlcs = self.get_next_commitment_htlcs( false, htlc_candidate, @@ -4929,7 +4930,7 @@ impl ChannelContext { ); let next_value_to_self_msat = self.get_next_commitment_value_to_self_msat(false, funding); - let ret = SpecTxBuilder {}.get_next_commitment_stats( + let remote_stats = SpecTxBuilder {}.get_channel_stats( false, funding.is_outbound(), funding.get_value_satoshis(), @@ -4947,12 +4948,12 @@ impl ChannelContext { if addl_nondust_htlc_count == 0 { *funding.next_remote_fee.lock().unwrap() = PredictedNextFee { predicted_feerate: feerate_per_kw, - predicted_nondust_htlc_count: ret.nondust_htlc_count, - predicted_fee_sat: ret.commit_tx_fee_sat, + predicted_nondust_htlc_count: remote_stats.commitment_stats.nondust_htlc_count, + predicted_fee_sat: remote_stats.commitment_stats.commit_tx_fee_sat, }; } else { let predicted_stats = SpecTxBuilder {} - .get_next_commitment_stats( + .get_channel_stats( false, funding.is_outbound(), funding.get_value_satoshis(), @@ -4964,7 +4965,8 @@ impl ChannelContext { self.counterparty_dust_limit_satoshis, funding.get_channel_type(), ) - .expect("Balance after HTLCs and anchors exhausted on remote commitment"); + .expect("Balance after HTLCs and anchors exhausted on remote commitment") + .commitment_stats; *funding.next_remote_fee.lock().unwrap() = PredictedNextFee { predicted_feerate: feerate_per_kw, predicted_nondust_htlc_count: predicted_stats.nondust_htlc_count, @@ -4973,7 +4975,7 @@ impl ChannelContext { } } - Ok(ret) + Ok(remote_stats) } fn validate_update_add_htlc( @@ -4993,7 +4995,7 @@ impl ChannelContext { let include_counterparty_unknown_htlcs = false; // Don't include the extra fee spike buffer HTLC in calculations let fee_spike_buffer_htlc = 0; - let next_remote_commitment_stats = self + let remote_stats = self .get_next_remote_commitment_stats( funding, Some(HTLCAmountDirection { outbound: false, amount_msat: msg.amount_msat }), @@ -5006,7 +5008,7 @@ impl ChannelContext { ChannelError::close(String::from("Remote HTLC add would overdraw remaining funds")) })?; - if next_remote_commitment_stats.inbound_htlcs_count + if remote_stats.commitment_stats.inbound_htlcs_count > self.holder_max_accepted_htlcs as usize { return Err(ChannelError::close(format!( @@ -5014,7 +5016,7 @@ impl ChannelContext { self.holder_max_accepted_htlcs, ))); } - if next_remote_commitment_stats.inbound_htlcs_value_msat + if remote_stats.commitment_stats.inbound_htlcs_value_msat > self.holder_max_htlc_value_in_flight_msat { return Err(ChannelError::close(format!( @@ -5042,16 +5044,17 @@ impl ChannelContext { let remote_commit_tx_fee_msat = if funding.is_outbound() { 0 } else { - next_remote_commitment_stats.commit_tx_fee_sat * 1000 + remote_stats.commitment_stats.commit_tx_fee_sat * 1000 }; - if next_remote_commitment_stats.counterparty_balance_before_fee_msat + if remote_stats.commitment_stats.counterparty_balance_before_fee_msat < remote_commit_tx_fee_msat { return Err(ChannelError::close( "Remote HTLC add would not leave enough to pay for fees".to_owned(), )); }; - if next_remote_commitment_stats + if remote_stats + .commitment_stats .counterparty_balance_before_fee_msat .saturating_sub(remote_commit_tx_fee_msat) < funding.holder_selected_channel_reserve_satoshis * 1000 @@ -5063,7 +5066,7 @@ impl ChannelContext { } if funding.is_outbound() { - let next_local_commitment_stats = self + let local_stats = self .get_next_local_commitment_stats( funding, Some(HTLCAmountDirection { outbound: false, amount_msat: msg.amount_msat }), @@ -5078,9 +5081,9 @@ impl ChannelContext { )) })?; // Check that they won't violate our local required channel reserve by adding this HTLC. - if next_local_commitment_stats.holder_balance_before_fee_msat + if local_stats.commitment_stats.holder_balance_before_fee_msat < funding.counterparty_selected_channel_reserve_satoshis.unwrap() * 1000 - + next_local_commitment_stats.commit_tx_fee_sat * 1000 + + local_stats.commitment_stats.commit_tx_fee_sat * 1000 { return Err(ChannelError::close( "Cannot accept HTLC that would put our balance under counterparty-announced channel reserve value".to_owned() @@ -5101,7 +5104,7 @@ impl ChannelContext { // Do not include outbound update_add_htlc's in the holding cell, or those which haven't yet been ACK'ed // by the counterparty (ie. LocalAnnounced HTLCs) let include_counterparty_unknown_htlcs = false; - let next_local_commitment_stats = self + let local_stats = self .get_next_local_commitment_stats( funding, None, @@ -5116,7 +5119,8 @@ impl ChannelContext { )) })?; - next_local_commitment_stats + local_stats + .commitment_stats .get_holder_counterparty_balances_incl_fee_msat() .and_then(|(_, counterparty_balance_incl_fee_msat)| { counterparty_balance_incl_fee_msat @@ -5127,7 +5131,7 @@ impl ChannelContext { ChannelError::close("Funding remote cannot afford proposed new fee".to_owned()) })?; - let next_remote_commitment_stats = self + let remote_stats = self .get_next_remote_commitment_stats( funding, None, @@ -5144,21 +5148,21 @@ impl ChannelContext { let max_dust_htlc_exposure_msat = self.get_max_dust_htlc_exposure_msat(dust_exposure_limiting_feerate); - if next_local_commitment_stats.dust_exposure_msat > max_dust_htlc_exposure_msat { + if local_stats.commitment_stats.dust_exposure_msat > max_dust_htlc_exposure_msat { return Err(ChannelError::close( format!( "Peer sent update_fee with a feerate ({}) which may over-expose us to dust-in-flight on our own transactions (totaling {} msat)", new_feerate_per_kw, - next_local_commitment_stats.dust_exposure_msat, + local_stats.commitment_stats.dust_exposure_msat, ) )); } - if next_remote_commitment_stats.dust_exposure_msat > max_dust_htlc_exposure_msat { + if remote_stats.commitment_stats.dust_exposure_msat > max_dust_htlc_exposure_msat { return Err(ChannelError::close( format!( "Peer sent update_fee with a feerate ({}) which may over-expose us to dust-in-flight on our counterparty's transactions (totaling {} msat)", new_feerate_per_kw, - next_remote_commitment_stats.dust_exposure_msat, + remote_stats.commitment_stats.dust_exposure_msat, ) )); } @@ -5304,7 +5308,7 @@ impl ChannelContext { // Include outbound update_add_htlc's in the holding cell, and those which haven't yet been ACK'ed by // the counterparty (ie. LocalAnnounced HTLCs) let include_counterparty_unknown_htlcs = true; - let next_remote_commitment_stats = if let Ok(stats) = self.get_next_remote_commitment_stats( + let remote_stats = if let Ok(stats) = self.get_next_remote_commitment_stats( funding, None, include_counterparty_unknown_htlcs, @@ -5322,8 +5326,8 @@ impl ChannelContext { }; // Note that `stats.commit_tx_fee_sat` accounts for any HTLCs that transition from non-dust to dust // under a higher feerate (in the case where HTLC-transactions pay endogenous fees). - if next_remote_commitment_stats.holder_balance_before_fee_msat - < next_remote_commitment_stats.commit_tx_fee_sat * 1000 + if remote_stats.commitment_stats.holder_balance_before_fee_msat + < remote_stats.commitment_stats.commit_tx_fee_sat * 1000 + funding.counterparty_selected_channel_reserve_satoshis.unwrap() * 1000 { //TODO: auto-close after a number of failures? @@ -5335,7 +5339,7 @@ impl ChannelContext { // `feerate_per_kw`. let max_dust_htlc_exposure_msat = self.get_max_dust_htlc_exposure_msat(dust_exposure_limiting_feerate); - if next_remote_commitment_stats.dust_exposure_msat > max_dust_htlc_exposure_msat { + if remote_stats.commitment_stats.dust_exposure_msat > max_dust_htlc_exposure_msat { log_debug!( logger, "Cannot afford to send new feerate at {} without infringing max dust htlc exposure", @@ -5344,7 +5348,7 @@ impl ChannelContext { return false; } - let next_local_commitment_stats = if let Ok(stats) = self.get_next_local_commitment_stats( + let local_stats = if let Ok(stats) = self.get_next_local_commitment_stats( funding, None, include_counterparty_unknown_htlcs, @@ -5360,7 +5364,7 @@ impl ChannelContext { ); return false; }; - if next_local_commitment_stats.dust_exposure_msat > max_dust_htlc_exposure_msat { + if local_stats.commitment_stats.dust_exposure_msat > max_dust_htlc_exposure_msat { log_debug!( logger, "Cannot afford to send new feerate at {} without infringing max dust htlc exposure", @@ -5390,7 +5394,7 @@ impl ChannelContext { cmp::max(self.feerate_per_kw, self.pending_update_fee.map(|(fee, _)| fee).unwrap_or(0)); // A `None` `HTLCCandidate` is used as in this case because we're already accounting for // the incoming HTLC as it has been fully committed by both sides. - let next_local_commitment_stats = self + let local_stats = self .get_next_local_commitment_stats( funding, None, @@ -5403,7 +5407,7 @@ impl ChannelContext { log_trace!(logger, "Attempting to fail HTLC due to balance after HTLCs and anchors exhausted on local commitment"); LocalHTLCFailureReason::ChannelBalanceOverdrawn })?; - let next_remote_commitment_stats = self + let remote_stats = self .get_next_remote_commitment_stats( funding, None, @@ -5419,22 +5423,22 @@ impl ChannelContext { let max_dust_htlc_exposure_msat = self.get_max_dust_htlc_exposure_msat(dust_exposure_limiting_feerate); - if next_remote_commitment_stats.dust_exposure_msat > max_dust_htlc_exposure_msat { + if remote_stats.commitment_stats.dust_exposure_msat > max_dust_htlc_exposure_msat { // Note that the total dust exposure includes both the dust HTLCs and the excess mining fees of // the counterparty commitment transaction log_info!( logger, "Cannot accept value that would put our total dust exposure at {} over the limit {} on counterparty commitment tx", - next_remote_commitment_stats.dust_exposure_msat, + remote_stats.commitment_stats.dust_exposure_msat, max_dust_htlc_exposure_msat, ); return Err(LocalHTLCFailureReason::DustLimitCounterparty); } - if next_local_commitment_stats.dust_exposure_msat > max_dust_htlc_exposure_msat { + if local_stats.commitment_stats.dust_exposure_msat > max_dust_htlc_exposure_msat { log_info!( logger, "Cannot accept value that would put our exposure to dust HTLCs at {} over the limit {} on holder commitment tx", - next_local_commitment_stats.dust_exposure_msat, + local_stats.commitment_stats.dust_exposure_msat, max_dust_htlc_exposure_msat, ); return Err(LocalHTLCFailureReason::DustLimitHolder); @@ -5442,14 +5446,15 @@ impl ChannelContext { if !funding.is_outbound() { let mut remote_fee_incl_fee_spike_buffer_htlc_msat = - next_remote_commitment_stats.commit_tx_fee_sat * 1000; + remote_stats.commitment_stats.commit_tx_fee_sat * 1000; // Note that with anchor outputs we are no longer as sensitive to fee spikes, so we don't need // to account for them. if !funding.get_channel_type().supports_anchors_zero_fee_htlc_tx() { remote_fee_incl_fee_spike_buffer_htlc_msat *= FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE; } - if next_remote_commitment_stats + if remote_stats + .commitment_stats .counterparty_balance_before_fee_msat .saturating_sub(funding.holder_selected_channel_reserve_satoshis * 1000) < remote_fee_incl_fee_spike_buffer_htlc_msat @@ -5571,7 +5576,7 @@ impl ChannelContext { let value_to_self_msat = (funding.value_to_self_msat + value_to_self_claimed_msat).checked_sub(value_to_remote_claimed_msat).unwrap(); - let (tx, stats) = SpecTxBuilder {}.build_commitment_transaction( + let (tx, _stats) = SpecTxBuilder {}.build_commitment_transaction( local, commitment_number, per_commitment_point, @@ -5587,7 +5592,7 @@ impl ChannelContext { { let PredictedNextFee { predicted_feerate, predicted_nondust_htlc_count, predicted_fee_sat } = if local { *funding.next_local_fee.lock().unwrap() } else { *funding.next_remote_fee.lock().unwrap() }; if predicted_feerate == tx.negotiated_feerate_per_kw() && predicted_nondust_htlc_count == tx.nondust_htlcs().len() { - assert_eq!(predicted_fee_sat, stats.commit_tx_fee_sat); + assert_eq!(predicted_fee_sat, _stats.commit_tx_fee_sat); } } #[cfg(debug_assertions)] @@ -5600,19 +5605,19 @@ impl ChannelContext { funding.counterparty_prev_commitment_tx_balance.lock().unwrap() }; - if stats.local_balance_before_fee_msat / 1000 < funding.counterparty_selected_channel_reserve_satoshis.unwrap() { + if _stats.local_balance_before_fee_msat / 1000 < funding.counterparty_selected_channel_reserve_satoshis.unwrap() { // If the local balance is below the reserve on this new commitment, it MUST be // greater than or equal to the one on the previous commitment. - debug_assert!(broadcaster_prev_commitment_balance.0 <= stats.local_balance_before_fee_msat); + debug_assert!(broadcaster_prev_commitment_balance.0 <= _stats.local_balance_before_fee_msat); } - broadcaster_prev_commitment_balance.0 = stats.local_balance_before_fee_msat; + broadcaster_prev_commitment_balance.0 = _stats.local_balance_before_fee_msat; - if stats.remote_balance_before_fee_msat / 1000 < funding.holder_selected_channel_reserve_satoshis { + if _stats.remote_balance_before_fee_msat / 1000 < funding.holder_selected_channel_reserve_satoshis { // If the remote balance is below the reserve on this new commitment, it MUST be // greater than or equal to the one on the previous commitment. - debug_assert!(broadcaster_prev_commitment_balance.1 <= stats.remote_balance_before_fee_msat); + debug_assert!(broadcaster_prev_commitment_balance.1 <= _stats.remote_balance_before_fee_msat); } - broadcaster_prev_commitment_balance.1 = stats.remote_balance_before_fee_msat; + broadcaster_prev_commitment_balance.1 = _stats.remote_balance_before_fee_msat; } // This populates the HTLC-source table with the indices from the HTLCs in the commitment @@ -12704,7 +12709,7 @@ where // We are not interested in dust exposure let dust_exposure_limiting_feerate = None; - let local_commitment_stats = self + let local_stats = self .context .get_next_local_commitment_stats( funding, @@ -12716,11 +12721,12 @@ where ) .map_err(|()| "Balance after HTLCs and anchors exhausted on local commitment")?; let (holder_balance_on_local_msat, counterparty_balance_on_local_msat) = - local_commitment_stats + local_stats + .commitment_stats .get_holder_counterparty_balances_incl_fee_msat() .map_err(|()| "Channel funder cannot afford the fee on local commitment")?; - let remote_commitment_stats = self + let remote_stats = self .context .get_next_remote_commitment_stats( funding, @@ -12732,7 +12738,8 @@ where ) .map_err(|()| "Balance after HTLCs and anchors exhausted on remote commitment")?; let (holder_balance_on_remote_msat, counterparty_balance_on_remote_msat) = - remote_commitment_stats + remote_stats + .commitment_stats .get_holder_counterparty_balances_incl_fee_msat() .map_err(|()| "Channel funder cannot afford the fee on remote commitment")?; diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index 27b8b1a9a2b..d004cc90171 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -45,6 +45,10 @@ pub(crate) struct NextCommitmentStats { pub extra_accepted_htlc_dust_exposure_msat: u64, } +pub(crate) struct ChannelStats { + pub commitment_stats: NextCommitmentStats, +} + impl NextCommitmentStats { pub(crate) fn get_holder_counterparty_balances_incl_fee_msat(&self) -> Result<(u64, u64), ()> { if self.is_outbound_from_holder { @@ -153,14 +157,120 @@ fn get_dust_buffer_feerate(feerate_per_kw: u32) -> u32 { cmp::max(feerate_per_kw.saturating_add(2530), feerate_plus_quarter.unwrap_or(u32::MAX)) } +fn get_next_commitment_stats( + local: bool, is_outbound_from_holder: bool, channel_value_satoshis: u64, + value_to_holder_msat: u64, next_commitment_htlcs: &[HTLCAmountDirection], + addl_nondust_htlc_count: usize, feerate_per_kw: u32, + dust_exposure_limiting_feerate: Option, broadcaster_dust_limit_satoshis: u64, + channel_type: &ChannelTypeFeatures, +) -> Result { + let excess_feerate = + feerate_per_kw.saturating_sub(dust_exposure_limiting_feerate.unwrap_or(feerate_per_kw)); + if channel_type.supports_anchor_zero_fee_commitments() { + debug_assert_eq!(feerate_per_kw, 0); + debug_assert_eq!(excess_feerate, 0); + debug_assert_eq!(addl_nondust_htlc_count, 0); + } + + // Calculate inbound htlc count + let inbound_htlcs_count = + next_commitment_htlcs.iter().filter(|htlc| !htlc.outbound).count(); + + // Calculate balances after htlcs + let value_to_counterparty_msat = + (channel_value_satoshis * 1000).checked_sub(value_to_holder_msat).ok_or(())?; + let outbound_htlcs_value_msat: u64 = next_commitment_htlcs + .iter() + .filter_map(|htlc| htlc.outbound.then_some(htlc.amount_msat)) + .sum(); + let inbound_htlcs_value_msat: u64 = next_commitment_htlcs + .iter() + .filter_map(|htlc| (!htlc.outbound).then_some(htlc.amount_msat)) + .sum(); + let value_to_holder_after_htlcs_msat = + value_to_holder_msat.checked_sub(outbound_htlcs_value_msat).ok_or(())?; + let value_to_counterparty_after_htlcs_msat = + value_to_counterparty_msat.checked_sub(inbound_htlcs_value_msat).ok_or(())?; + + // Subtract the anchors from the channel funder + let (holder_balance_before_fee_msat, counterparty_balance_before_fee_msat) = + subtract_addl_outputs( + is_outbound_from_holder, + value_to_holder_after_htlcs_msat, + value_to_counterparty_after_htlcs_msat, + channel_type, + )?; + + // Increment the feerate by a buffer to calculate dust exposure + let dust_buffer_feerate = get_dust_buffer_feerate(feerate_per_kw); + + // Calculate fees on commitment transaction + let nondust_htlc_count = next_commitment_htlcs + .iter() + .filter(|htlc| { + !htlc.is_dust(local, feerate_per_kw, broadcaster_dust_limit_satoshis, channel_type) + }) + .count(); + let commit_tx_fee_sat = commit_tx_fee_sat( + feerate_per_kw, + nondust_htlc_count + addl_nondust_htlc_count, + channel_type, + ); + + // Calculate dust exposure on commitment transaction + let dust_exposure_msat = next_commitment_htlcs + .iter() + .filter_map(|htlc| { + htlc.is_dust( + local, + dust_buffer_feerate, + broadcaster_dust_limit_satoshis, + channel_type, + ) + .then_some(htlc.amount_msat) + }) + .sum(); + + // Add any excess fees to dust exposure on counterparty transactions + let (dust_exposure_msat, extra_accepted_htlc_dust_exposure_msat) = if local { + (dust_exposure_msat, dust_exposure_msat) + } else { + let (excess_fees_msat, extra_accepted_htlc_excess_fees_msat) = + commit_plus_htlc_tx_fees_msat( + local, + &next_commitment_htlcs, + dust_buffer_feerate, + excess_feerate, + broadcaster_dust_limit_satoshis, + channel_type, + ); + ( + dust_exposure_msat + excess_fees_msat, + dust_exposure_msat + extra_accepted_htlc_excess_fees_msat, + ) + }; + + Ok(NextCommitmentStats { + is_outbound_from_holder, + inbound_htlcs_count, + inbound_htlcs_value_msat, + holder_balance_before_fee_msat, + counterparty_balance_before_fee_msat, + nondust_htlc_count: nondust_htlc_count + addl_nondust_htlc_count, + commit_tx_fee_sat, + dust_exposure_msat, + extra_accepted_htlc_dust_exposure_msat, + }) +} + pub(crate) trait TxBuilder { - fn get_next_commitment_stats( + fn get_channel_stats( &self, local: bool, is_outbound_from_holder: bool, channel_value_satoshis: u64, value_to_holder_msat: u64, next_commitment_htlcs: &[HTLCAmountDirection], addl_nondust_htlc_count: usize, feerate_per_kw: u32, dust_exposure_limiting_feerate: Option, broadcaster_dust_limit_satoshis: u64, channel_type: &ChannelTypeFeatures, - ) -> Result; + ) -> Result; fn commit_tx_fee_sat( &self, feerate_per_kw: u32, nondust_htlc_count: usize, channel_type: &ChannelTypeFeatures, ) -> u64; @@ -179,110 +289,27 @@ pub(crate) trait TxBuilder { pub(crate) struct SpecTxBuilder {} impl TxBuilder for SpecTxBuilder { - fn get_next_commitment_stats( + fn get_channel_stats( &self, local: bool, is_outbound_from_holder: bool, channel_value_satoshis: u64, value_to_holder_msat: u64, next_commitment_htlcs: &[HTLCAmountDirection], addl_nondust_htlc_count: usize, feerate_per_kw: u32, dust_exposure_limiting_feerate: Option, broadcaster_dust_limit_satoshis: u64, channel_type: &ChannelTypeFeatures, - ) -> Result { - let excess_feerate = - feerate_per_kw.saturating_sub(dust_exposure_limiting_feerate.unwrap_or(feerate_per_kw)); - if channel_type.supports_anchor_zero_fee_commitments() { - debug_assert_eq!(feerate_per_kw, 0); - debug_assert_eq!(excess_feerate, 0); - debug_assert_eq!(addl_nondust_htlc_count, 0); - } - - // Calculate inbound htlc count - let inbound_htlcs_count = - next_commitment_htlcs.iter().filter(|htlc| !htlc.outbound).count(); - - // Calculate balances after htlcs - let value_to_counterparty_msat = - (channel_value_satoshis * 1000).checked_sub(value_to_holder_msat).ok_or(())?; - let outbound_htlcs_value_msat: u64 = next_commitment_htlcs - .iter() - .filter_map(|htlc| htlc.outbound.then_some(htlc.amount_msat)) - .sum(); - let inbound_htlcs_value_msat: u64 = next_commitment_htlcs - .iter() - .filter_map(|htlc| (!htlc.outbound).then_some(htlc.amount_msat)) - .sum(); - let value_to_holder_after_htlcs_msat = - value_to_holder_msat.checked_sub(outbound_htlcs_value_msat).ok_or(())?; - let value_to_counterparty_after_htlcs_msat = - value_to_counterparty_msat.checked_sub(inbound_htlcs_value_msat).ok_or(())?; - - // Subtract the anchors from the channel funder - let (holder_balance_before_fee_msat, counterparty_balance_before_fee_msat) = - subtract_addl_outputs( - is_outbound_from_holder, - value_to_holder_after_htlcs_msat, - value_to_counterparty_after_htlcs_msat, - channel_type, - )?; - - // Increment the feerate by a buffer to calculate dust exposure - let dust_buffer_feerate = get_dust_buffer_feerate(feerate_per_kw); - - // Calculate fees on commitment transaction - let nondust_htlc_count = next_commitment_htlcs - .iter() - .filter(|htlc| { - !htlc.is_dust(local, feerate_per_kw, broadcaster_dust_limit_satoshis, channel_type) - }) - .count(); - let commit_tx_fee_sat = commit_tx_fee_sat( + ) -> Result { + let commitment_stats = get_next_commitment_stats( + local, + is_outbound_from_holder, + channel_value_satoshis, + value_to_holder_msat, + next_commitment_htlcs, + addl_nondust_htlc_count, feerate_per_kw, - nondust_htlc_count + addl_nondust_htlc_count, + dust_exposure_limiting_feerate, + broadcaster_dust_limit_satoshis, channel_type, - ); + )?; - // Calculate dust exposure on commitment transaction - let dust_exposure_msat = next_commitment_htlcs - .iter() - .filter_map(|htlc| { - htlc.is_dust( - local, - dust_buffer_feerate, - broadcaster_dust_limit_satoshis, - channel_type, - ) - .then_some(htlc.amount_msat) - }) - .sum(); - - // Add any excess fees to dust exposure on counterparty transactions - let (dust_exposure_msat, extra_accepted_htlc_dust_exposure_msat) = if local { - (dust_exposure_msat, dust_exposure_msat) - } else { - let (excess_fees_msat, extra_accepted_htlc_excess_fees_msat) = - commit_plus_htlc_tx_fees_msat( - local, - &next_commitment_htlcs, - dust_buffer_feerate, - excess_feerate, - broadcaster_dust_limit_satoshis, - channel_type, - ); - ( - dust_exposure_msat + excess_fees_msat, - dust_exposure_msat + extra_accepted_htlc_excess_fees_msat, - ) - }; - - Ok(NextCommitmentStats { - is_outbound_from_holder, - inbound_htlcs_count, - inbound_htlcs_value_msat, - holder_balance_before_fee_msat, - counterparty_balance_before_fee_msat, - nondust_htlc_count: nondust_htlc_count + addl_nondust_htlc_count, - commit_tx_fee_sat, - dust_exposure_msat, - extra_accepted_htlc_dust_exposure_msat, - }) + Ok(ChannelStats { commitment_stats }) } fn commit_tx_fee_sat( &self, feerate_per_kw: u32, nondust_htlc_count: usize, channel_type: &ChannelTypeFeatures, From a4bf94a4e96aa208d439670488c4fc5e7d95310e Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Tue, 3 Feb 2026 04:32:41 +0000 Subject: [PATCH 082/627] Delete `TxBuilder::commit_tx_fee_sat` Move calls to `TxBuilder::commit_tx_fee_sat` in `new_for_inbound_channel` and `new_for_outbound_channel` to `ChannelContext::get_next_{*}_commitment_stats`, and set the parameters such that the exact same behavior is maintained. We also replace calls to `TxBuilder::commit_tx_fee_sat` in `get_pending_htlc_stats`, `next_local_commit_tx_fee_msat`, and `next_remote_commit_tx_fee_msat` with `chan_utils::commit_tx_fee_sat`. All three functions get deleted in an upcoming commit, so we accept this temporary use of the `chan_utils::commit_tx_fee_sat` function. --- lightning/src/ln/channel.rs | 86 ++++++++++++++++++++------------ lightning/src/sign/tx_builder.rs | 11 +--- 2 files changed, 54 insertions(+), 43 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 509202b3426..d09e117041a 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -3731,23 +3731,6 @@ impl ChannelContext { debug_assert!(our_funding_satoshis == 0 || msg_push_msat == 0); let value_to_self_msat = our_funding_satoshis * 1000 + msg_push_msat; - // check if the funder's amount for the initial commitment tx is sufficient - // for full fee payment plus a few HTLCs to ensure the channel will be useful. - let funders_amount_msat = open_channel_fields.funding_satoshis * 1000 - msg_push_msat; - let commit_tx_fee_sat = SpecTxBuilder {}.commit_tx_fee_sat(open_channel_fields.commitment_feerate_sat_per_1000_weight, MIN_AFFORDABLE_HTLC_COUNT, &channel_type); - // Subtract any non-HTLC outputs from the remote balance - let (_, remote_balance_before_fee_msat) = SpecTxBuilder {}.subtract_non_htlc_outputs(false, value_to_self_msat, funders_amount_msat, &channel_type); - if remote_balance_before_fee_msat / 1000 < commit_tx_fee_sat { - return Err(ChannelError::close(format!("Funding amount ({} sats) can't even pay fee for initial commitment transaction fee of {} sats.", funders_amount_msat / 1000, commit_tx_fee_sat))); - } - - let to_remote_satoshis = remote_balance_before_fee_msat / 1000 - commit_tx_fee_sat; - // While it's reasonable for us to not meet the channel reserve initially (if they don't - // want to push much to us), our counterparty should always have more than our reserve. - if to_remote_satoshis < holder_selected_channel_reserve_satoshis { - return Err(ChannelError::close("Insufficient funding amount for initial reserve".to_owned())); - } - let counterparty_shutdown_scriptpubkey = if their_features.supports_upfront_shutdown_script() { match &open_channel_fields.shutdown_scriptpubkey { &Some(ref script) => { @@ -3948,6 +3931,33 @@ impl ChannelContext { interactive_tx_signing_session: None, }; + // check if the funder's amount for the initial commitment tx is sufficient + // for full fee payment plus a few HTLCs to ensure the channel will be useful. + let funders_amount_msat = funding.get_value_satoshis() * 1000 - funding.get_value_to_self_msat(); + let htlc_candidate = None; + let include_counterparty_unknown_htlcs = false; + let addl_nondust_htlc_count = MIN_AFFORDABLE_HTLC_COUNT; + let dust_exposure_limiting_feerate = channel_context.get_dust_exposure_limiting_feerate(&fee_estimator, funding.get_channel_type()); + let remote_stats = channel_context.get_next_remote_commitment_stats( + &funding, + htlc_candidate, + include_counterparty_unknown_htlcs, + addl_nondust_htlc_count, + channel_context.feerate_per_kw, + dust_exposure_limiting_feerate + ).map_err(|()| ChannelError::close(format!("Funding amount ({} sats) can't even pay fee for two anchors on the initial commitment transaction", funders_amount_msat / 1000)))?; + + if remote_stats.commitment_stats.counterparty_balance_before_fee_msat / 1000 < remote_stats.commitment_stats.commit_tx_fee_sat { + return Err(ChannelError::close(format!("Funding amount ({} sats) can't even pay fee for initial commitment transaction fee of {} sats.", funders_amount_msat / 1000, remote_stats.commitment_stats.commit_tx_fee_sat))); + } + + let to_remote_satoshis = remote_stats.commitment_stats.counterparty_balance_before_fee_msat / 1000 - remote_stats.commitment_stats.commit_tx_fee_sat; + // While it's reasonable for us to not meet the channel reserve initially (if they don't + // want to push much to us), our counterparty should always have more than our reserve. + if to_remote_satoshis < funding.holder_selected_channel_reserve_satoshis { + return Err(ChannelError::close("Insufficient funding amount for initial reserve".to_owned())); + } + Ok((funding, channel_context)) } @@ -3998,17 +4008,6 @@ impl ChannelContext { ); let value_to_self_msat = channel_value_satoshis * 1000 - push_msat; - let commit_tx_fee_sat = SpecTxBuilder {}.commit_tx_fee_sat(commitment_feerate, MIN_AFFORDABLE_HTLC_COUNT, &channel_type); - // Subtract any non-HTLC outputs from the local balance - let (local_balance_before_fee_msat, _) = SpecTxBuilder {}.subtract_non_htlc_outputs( - true, - value_to_self_msat, - push_msat, - &channel_type, - ); - if local_balance_before_fee_msat / 1000 < commit_tx_fee_sat { - return Err(APIError::APIMisuseError{ err: format!("Funding amount ({}) can't even pay fee for initial commitment transaction fee of {}.", value_to_self_msat / 1000, commit_tx_fee_sat) }); - } let mut secp_ctx = Secp256k1::new(); secp_ctx.seeded_randomize(&entropy_source.get_secure_random_bytes()); @@ -4182,6 +4181,23 @@ impl ChannelContext { interactive_tx_signing_session: None, }; + let htlc_candidate = None; + let include_counterparty_unknown_htlcs = false; + let addl_nondust_htlc_count = MIN_AFFORDABLE_HTLC_COUNT; + let dust_exposure_limiting_feerate = channel_context.get_dust_exposure_limiting_feerate(&fee_estimator, funding.get_channel_type()); + let local_stats = channel_context.get_next_local_commitment_stats( + &funding, + htlc_candidate, + include_counterparty_unknown_htlcs, + addl_nondust_htlc_count, + channel_context.feerate_per_kw, + dust_exposure_limiting_feerate, + ).map_err(|()| APIError::APIMisuseError { err: format!("Funding amount ({} sats) can't even pay fee for two anchors on the initial commitment transaction", funding.get_value_to_self_msat() / 1000)})?; + + if local_stats.commitment_stats.holder_balance_before_fee_msat / 1000 < local_stats.commitment_stats.commit_tx_fee_sat { + return Err(APIError::APIMisuseError{ err: format!("Funding amount ({}) can't even pay fee for initial commitment transaction fee of {}.", funding.get_value_to_self_msat() / 1000, local_stats.commitment_stats.commit_tx_fee_sat) }); + } + Ok((funding, channel_context)) } @@ -5771,10 +5787,10 @@ impl ChannelContext { } let extra_nondust_htlc_on_counterparty_tx_dust_exposure_msat = excess_feerate_opt.map(|excess_feerate| { - let extra_htlc_commit_tx_fee_sat = SpecTxBuilder {}.commit_tx_fee_sat(excess_feerate, on_counterparty_tx_accepted_nondust_htlcs + 1 + on_counterparty_tx_offered_nondust_htlcs, funding.get_channel_type()); + let extra_htlc_commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(excess_feerate, on_counterparty_tx_accepted_nondust_htlcs + 1 + on_counterparty_tx_offered_nondust_htlcs, funding.get_channel_type()); let extra_htlc_htlc_tx_fees_sat = chan_utils::htlc_tx_fees_sat(excess_feerate, on_counterparty_tx_accepted_nondust_htlcs + 1, on_counterparty_tx_offered_nondust_htlcs, funding.get_channel_type()); - let commit_tx_fee_sat = SpecTxBuilder {}.commit_tx_fee_sat(excess_feerate, on_counterparty_tx_accepted_nondust_htlcs + on_counterparty_tx_offered_nondust_htlcs, funding.get_channel_type()); + let commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(excess_feerate, on_counterparty_tx_accepted_nondust_htlcs + on_counterparty_tx_offered_nondust_htlcs, funding.get_channel_type()); let htlc_tx_fees_sat = chan_utils::htlc_tx_fees_sat(excess_feerate, on_counterparty_tx_accepted_nondust_htlcs, on_counterparty_tx_offered_nondust_htlcs, funding.get_channel_type()); let extra_htlc_dust_exposure = on_counterparty_tx_dust_exposure_msat + (extra_htlc_commit_tx_fee_sat + extra_htlc_htlc_tx_fees_sat) * 1000; @@ -6112,7 +6128,7 @@ impl ChannelContext { } let num_htlcs = included_htlcs + addl_htlcs; - SpecTxBuilder {}.commit_tx_fee_sat(context.feerate_per_kw, num_htlcs, funding.get_channel_type()) * 1000 + chan_utils::commit_tx_fee_sat(context.feerate_per_kw, num_htlcs, funding.get_channel_type()) * 1000 } /// Get the commitment tx fee for the remote's next commitment transaction based on the number of @@ -6189,7 +6205,7 @@ impl ChannelContext { } let num_htlcs = included_htlcs + addl_htlcs; - SpecTxBuilder {}.commit_tx_fee_sat(context.feerate_per_kw, num_htlcs, funding.get_channel_type()) * 1000 + chan_utils::commit_tx_fee_sat(context.feerate_per_kw, num_htlcs, funding.get_channel_type()) * 1000 } #[rustfmt::skip] @@ -17088,7 +17104,7 @@ mod tests { ChannelPublicKeys, CounterpartyChannelTransactionParameters, HolderCommitmentTransaction, }; - use crate::ln::channel::HTLCOutputInCommitment; + use crate::ln::channel::{HTLCOutputInCommitment, PredictedNextFee}; use crate::ln::channel_keys::{DelayedPaymentBasepoint, HtlcBasepoint}; use crate::sign::{ecdsa::EcdsaChannelSigner, ChannelDerivationParameters, HTLCDescriptor}; use crate::sync::Arc; @@ -17217,6 +17233,8 @@ mod tests { macro_rules! test_commitment { ( $counterparty_sig_hex: expr, $sig_hex: expr, $tx_hex: expr, $($remain:tt)* ) => { chan.funding.channel_transaction_parameters.channel_type_features = ChannelTypeFeatures::only_static_remote_key(); + chan.funding.next_local_fee = Mutex::new(PredictedNextFee::default()); + chan.funding.next_remote_fee = Mutex::new(PredictedNextFee::default()); test_commitment_common!(chan, logger, secp_ctx, signer, holder_pubkeys, per_commitment_point, $counterparty_sig_hex, $sig_hex, $tx_hex, &ChannelTypeFeatures::only_static_remote_key(), $($remain)*); }; } @@ -17224,6 +17242,8 @@ mod tests { macro_rules! test_commitment_with_anchors { ( $counterparty_sig_hex: expr, $sig_hex: expr, $tx_hex: expr, $($remain:tt)* ) => { chan.funding.channel_transaction_parameters.channel_type_features = ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies(); + chan.funding.next_local_fee = Mutex::new(PredictedNextFee::default()); + chan.funding.next_remote_fee = Mutex::new(PredictedNextFee::default()); test_commitment_common!(chan, logger, secp_ctx, signer, holder_pubkeys, per_commitment_point, $counterparty_sig_hex, $sig_hex, $tx_hex, &ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies(), $($remain)*); }; } diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index d004cc90171..3b34fb8eb8d 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -169,7 +169,6 @@ fn get_next_commitment_stats( if channel_type.supports_anchor_zero_fee_commitments() { debug_assert_eq!(feerate_per_kw, 0); debug_assert_eq!(excess_feerate, 0); - debug_assert_eq!(addl_nondust_htlc_count, 0); } // Calculate inbound htlc count @@ -271,9 +270,6 @@ pub(crate) trait TxBuilder { dust_exposure_limiting_feerate: Option, broadcaster_dust_limit_satoshis: u64, channel_type: &ChannelTypeFeatures, ) -> Result; - fn commit_tx_fee_sat( - &self, feerate_per_kw: u32, nondust_htlc_count: usize, channel_type: &ChannelTypeFeatures, - ) -> u64; fn subtract_non_htlc_outputs( &self, is_outbound_from_holder: bool, value_to_self_after_htlcs: u64, value_to_remote_after_htlcs: u64, channel_type: &ChannelTypeFeatures, @@ -311,11 +307,6 @@ impl TxBuilder for SpecTxBuilder { Ok(ChannelStats { commitment_stats }) } - fn commit_tx_fee_sat( - &self, feerate_per_kw: u32, nondust_htlc_count: usize, channel_type: &ChannelTypeFeatures, - ) -> u64 { - commit_tx_fee_sat(feerate_per_kw, nondust_htlc_count, channel_type) - } fn subtract_non_htlc_outputs( &self, is_outbound_from_holder: bool, value_to_self_after_htlcs: u64, value_to_remote_after_htlcs: u64, channel_type: &ChannelTypeFeatures, @@ -399,7 +390,7 @@ impl TxBuilder for SpecTxBuilder { // The value going to each party MUST be 0 or positive, even if all HTLCs pending in the // commitment clear by failure. - let commit_tx_fee_sat = self.commit_tx_fee_sat( + let commit_tx_fee_sat = commit_tx_fee_sat( feerate_per_kw, htlcs_in_tx.len(), &channel_parameters.channel_type_features, From 75d4a6bf8bbfe2855a3524f22295935be5cc3270 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Tue, 3 Feb 2026 04:39:25 +0000 Subject: [PATCH 083/627] Delete `TxBuilder::subtract_non_htlc_outputs` We make temporary use of the raw `tx_builder::saturating_sub_anchor_outputs` function in `get_available_balances_for_scope`. This ok because we move most of the `get_available_balances_for_scope` function to the `TxBuilder::get_channel_stats` call in an upcoming commit. Again, no functional change is introduced in this commit. --- lightning/src/ln/channel.rs | 9 ++-- lightning/src/sign/tx_builder.rs | 88 ++++++++++++++++---------------- 2 files changed, 49 insertions(+), 48 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index d09e117041a..104e61a1415 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -71,7 +71,10 @@ use crate::ln::types::ChannelId; use crate::offers::static_invoice::StaticInvoice; use crate::routing::gossip::NodeId; use crate::sign::ecdsa::EcdsaChannelSigner; -use crate::sign::tx_builder::{ChannelStats, HTLCAmountDirection, SpecTxBuilder, TxBuilder}; +use crate::sign::tx_builder::{ + saturating_sub_anchor_outputs, ChannelStats, HTLCAmountDirection, SpecTxBuilder, + TxBuilder, +}; use crate::sign::{ChannelSigner, EntropySource, NodeSigner, Recipient, SignerProvider}; use crate::types::features::{ChannelTypeFeatures, InitFeatures}; use crate::types::payment::{PaymentHash, PaymentPreimage}; @@ -5914,8 +5917,8 @@ impl ChannelContext { ); let htlc_stats = context.get_pending_htlc_stats(funding, None, dust_exposure_limiting_feerate); - // Subtract any non-HTLC outputs from the local and remote balances - let (local_balance_before_fee_msat, remote_balance_before_fee_msat) = SpecTxBuilder {}.subtract_non_htlc_outputs( + // Subtract anchor outputs from the local and remote balances + let (local_balance_before_fee_msat, remote_balance_before_fee_msat) = saturating_sub_anchor_outputs( funding.is_outbound(), funding.value_to_self_msat.saturating_sub(htlc_stats.pending_outbound_htlcs_value_msat), (funding.get_value_satoshis() * 1000).checked_sub(funding.value_to_self_msat).unwrap().saturating_sub(htlc_stats.pending_inbound_htlcs_value_msat), diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index 3b34fb8eb8d..9c2942fb10e 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -117,7 +117,7 @@ fn commit_plus_htlc_tx_fees_msat( (total_fees_msat, extra_accepted_htlc_total_fees_msat) } -fn subtract_addl_outputs( +pub(crate) fn checked_sub_anchor_outputs( is_outbound_from_holder: bool, value_to_self_after_htlcs_msat: u64, value_to_remote_after_htlcs_msat: u64, channel_type: &ChannelTypeFeatures, ) -> Result<(u64, u64), ()> { @@ -127,13 +127,6 @@ fn subtract_addl_outputs( 0 }; - // We MUST use checked subs here, as the funder's balance is not guaranteed to be greater - // than or equal to `total_anchors_sat`. - // - // This is because when the remote party sends an `update_fee` message, we build the new - // commitment transaction *before* checking whether the remote party's balance is enough to - // cover the total anchor sum. - if is_outbound_from_holder { Ok(( value_to_self_after_htlcs_msat.checked_sub(total_anchors_sat * 1000).ok_or(())?, @@ -147,6 +140,29 @@ fn subtract_addl_outputs( } } +pub(crate) fn saturating_sub_anchor_outputs( + is_outbound_from_holder: bool, value_to_self_after_htlcs: u64, + value_to_remote_after_htlcs: u64, channel_type: &ChannelTypeFeatures, +) -> (u64, u64) { + let total_anchors_sat = if channel_type.supports_anchors_zero_fee_htlc_tx() { + ANCHOR_OUTPUT_VALUE_SATOSHI * 2 + } else { + 0 + }; + + if is_outbound_from_holder { + ( + value_to_self_after_htlcs.saturating_sub(total_anchors_sat * 1000), + value_to_remote_after_htlcs, + ) + } else { + ( + value_to_self_after_htlcs, + value_to_remote_after_htlcs.saturating_sub(total_anchors_sat * 1000), + ) + } +} + fn get_dust_buffer_feerate(feerate_per_kw: u32) -> u32 { // When calculating our exposure to dust HTLCs, we assume that the channel feerate // may, at any point, increase by at least 10 sat/vB (i.e 2530 sat/kWU) or 25%, @@ -192,8 +208,16 @@ fn get_next_commitment_stats( value_to_counterparty_msat.checked_sub(inbound_htlcs_value_msat).ok_or(())?; // Subtract the anchors from the channel funder + + // We MUST use checked subs here, as the funder's balance is not guaranteed to be greater + // than or equal to `total_anchors_sat`. + // + // This is because when the remote party sends an `update_fee` message, we build the new + // commitment transaction *before* checking whether the remote party's balance is enough to + // cover the total anchor sum. + let (holder_balance_before_fee_msat, counterparty_balance_before_fee_msat) = - subtract_addl_outputs( + checked_sub_anchor_outputs( is_outbound_from_holder, value_to_holder_after_htlcs_msat, value_to_counterparty_after_htlcs_msat, @@ -270,10 +294,6 @@ pub(crate) trait TxBuilder { dust_exposure_limiting_feerate: Option, broadcaster_dust_limit_satoshis: u64, channel_type: &ChannelTypeFeatures, ) -> Result; - fn subtract_non_htlc_outputs( - &self, is_outbound_from_holder: bool, value_to_self_after_htlcs: u64, - value_to_remote_after_htlcs: u64, channel_type: &ChannelTypeFeatures, - ) -> (u64, u64); fn build_commitment_transaction( &self, local: bool, commitment_number: u64, per_commitment_point: &PublicKey, channel_parameters: &ChannelTransactionParameters, secp_ctx: &Secp256k1, @@ -307,36 +327,6 @@ impl TxBuilder for SpecTxBuilder { Ok(ChannelStats { commitment_stats }) } - fn subtract_non_htlc_outputs( - &self, is_outbound_from_holder: bool, value_to_self_after_htlcs: u64, - value_to_remote_after_htlcs: u64, channel_type: &ChannelTypeFeatures, - ) -> (u64, u64) { - let total_anchors_sat = if channel_type.supports_anchors_zero_fee_htlc_tx() { - ANCHOR_OUTPUT_VALUE_SATOSHI * 2 - } else { - 0 - }; - - let mut local_balance_before_fee_msat = value_to_self_after_htlcs; - let mut remote_balance_before_fee_msat = value_to_remote_after_htlcs; - - // We MUST use saturating subs here, as the funder's balance is not guaranteed to be greater - // than or equal to `total_anchors_sat`. - // - // This is because when the remote party sends an `update_fee` message, we build the new - // commitment transaction *before* checking whether the remote party's balance is enough to - // cover the total anchor sum. - - if is_outbound_from_holder { - local_balance_before_fee_msat = - local_balance_before_fee_msat.saturating_sub(total_anchors_sat * 1000); - } else { - remote_balance_before_fee_msat = - remote_balance_before_fee_msat.saturating_sub(total_anchors_sat * 1000); - } - - (local_balance_before_fee_msat, remote_balance_before_fee_msat) - } fn build_commitment_transaction( &self, local: bool, commitment_number: u64, per_commitment_point: &PublicKey, channel_parameters: &ChannelTransactionParameters, secp_ctx: &Secp256k1, @@ -402,8 +392,16 @@ impl TxBuilder for SpecTxBuilder { .unwrap() .checked_sub(remote_htlc_total_msat) .unwrap(); - let (local_balance_before_fee_msat, remote_balance_before_fee_msat) = self - .subtract_non_htlc_outputs( + + // We MUST use saturating subs here, as the funder's balance is not guaranteed to be greater + // than or equal to `total_anchors_sat`. + // + // This is because when the remote party sends an `update_fee` message, we build the new + // commitment transaction *before* checking whether the remote party's balance is enough to + // cover the total anchor sum. + + let (local_balance_before_fee_msat, remote_balance_before_fee_msat) = + saturating_sub_anchor_outputs( channel_parameters.is_outbound_from_holder, value_to_self_after_htlcs_msat, value_to_remote_after_htlcs_msat, From 69e2d8cf95dcc562a7984130754aab8edd8a5e25 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Mon, 23 Feb 2026 13:41:26 +0100 Subject: [PATCH 084/627] Clean up fuzz crate: use panic=abort, add stdin_fuzz support, remove dylib This commit makes three related changes to the fuzz infrastructure: 1. Set panic=abort on both dev and release profiles, and remove the dylib crate type (keeping rlib and staticlib). The dylib crate type was added in e28fd78e6 (2019) for a C-callable harness that was never implemented. Removing dylib is what enables panic=abort, since Rust forces panic=unwind on dylib crates. staticlib is retained as it is compatible with panic=abort. 2. Add stdin_fuzz support: a new Stdout logger, updated target template so that stdin_fuzz calls _test() with Stdout logging, and updated README with usage instructions. This allows reproducing crashes via piped input, useful for git bisect and AI-assisted debugging. 3. Update all fuzzer frontends (AFL, honggfuzz, libfuzzer, stdin_fuzz) to call the _test() functions directly instead of going through the _run() C wrappers. Co-Authored-By: Claude Opus 4.6 --- fuzz/Cargo.toml | 4 +++- fuzz/README.md | 10 ++++++++++ fuzz/src/bin/base32_target.rs | 11 ++++++----- fuzz/src/bin/bech32_parse_target.rs | 11 ++++++----- fuzz/src/bin/bolt11_deser_target.rs | 11 ++++++----- fuzz/src/bin/chanmon_consistency_target.rs | 11 ++++++----- fuzz/src/bin/chanmon_deser_target.rs | 11 ++++++----- fuzz/src/bin/feature_flags_target.rs | 11 ++++++----- fuzz/src/bin/fromstr_to_netaddress_target.rs | 11 ++++++----- fuzz/src/bin/fs_store_target.rs | 11 ++++++----- fuzz/src/bin/full_stack_target.rs | 11 ++++++----- fuzz/src/bin/indexedmap_target.rs | 11 ++++++----- fuzz/src/bin/invoice_deser_target.rs | 11 ++++++----- fuzz/src/bin/invoice_request_deser_target.rs | 11 ++++++----- fuzz/src/bin/lsps_message_target.rs | 11 ++++++----- fuzz/src/bin/msg_accept_channel_target.rs | 11 ++++++----- fuzz/src/bin/msg_accept_channel_v2_target.rs | 11 ++++++----- fuzz/src/bin/msg_announcement_signatures_target.rs | 11 ++++++----- fuzz/src/bin/msg_blinded_message_path_target.rs | 11 ++++++----- fuzz/src/bin/msg_channel_announcement_target.rs | 11 ++++++----- fuzz/src/bin/msg_channel_details_target.rs | 11 ++++++----- fuzz/src/bin/msg_channel_ready_target.rs | 11 ++++++----- fuzz/src/bin/msg_channel_reestablish_target.rs | 11 ++++++----- fuzz/src/bin/msg_channel_update_target.rs | 11 ++++++----- fuzz/src/bin/msg_closing_complete_target.rs | 11 ++++++----- fuzz/src/bin/msg_closing_sig_target.rs | 11 ++++++----- fuzz/src/bin/msg_closing_signed_target.rs | 11 ++++++----- fuzz/src/bin/msg_commitment_signed_target.rs | 11 ++++++----- fuzz/src/bin/msg_decoded_onion_error_packet_target.rs | 11 ++++++----- fuzz/src/bin/msg_error_message_target.rs | 11 ++++++----- fuzz/src/bin/msg_funding_created_target.rs | 11 ++++++----- fuzz/src/bin/msg_funding_signed_target.rs | 11 ++++++----- fuzz/src/bin/msg_gossip_timestamp_filter_target.rs | 11 ++++++----- fuzz/src/bin/msg_init_target.rs | 11 ++++++----- fuzz/src/bin/msg_node_announcement_target.rs | 11 ++++++----- fuzz/src/bin/msg_open_channel_target.rs | 11 ++++++----- fuzz/src/bin/msg_open_channel_v2_target.rs | 11 ++++++----- fuzz/src/bin/msg_ping_target.rs | 11 ++++++----- fuzz/src/bin/msg_pong_target.rs | 11 ++++++----- fuzz/src/bin/msg_query_channel_range_target.rs | 11 ++++++----- fuzz/src/bin/msg_query_short_channel_ids_target.rs | 11 ++++++----- fuzz/src/bin/msg_reply_channel_range_target.rs | 11 ++++++----- .../src/bin/msg_reply_short_channel_ids_end_target.rs | 11 ++++++----- fuzz/src/bin/msg_revoke_and_ack_target.rs | 11 ++++++----- fuzz/src/bin/msg_shutdown_target.rs | 11 ++++++----- fuzz/src/bin/msg_splice_ack_target.rs | 11 ++++++----- fuzz/src/bin/msg_splice_init_target.rs | 11 ++++++----- fuzz/src/bin/msg_splice_locked_target.rs | 11 ++++++----- fuzz/src/bin/msg_stfu_target.rs | 11 ++++++----- fuzz/src/bin/msg_tx_abort_target.rs | 11 ++++++----- fuzz/src/bin/msg_tx_ack_rbf_target.rs | 11 ++++++----- fuzz/src/bin/msg_tx_add_input_target.rs | 11 ++++++----- fuzz/src/bin/msg_tx_add_output_target.rs | 11 ++++++----- fuzz/src/bin/msg_tx_complete_target.rs | 11 ++++++----- fuzz/src/bin/msg_tx_init_rbf_target.rs | 11 ++++++----- fuzz/src/bin/msg_tx_remove_input_target.rs | 11 ++++++----- fuzz/src/bin/msg_tx_remove_output_target.rs | 11 ++++++----- fuzz/src/bin/msg_tx_signatures_target.rs | 11 ++++++----- fuzz/src/bin/msg_update_add_htlc_target.rs | 11 ++++++----- fuzz/src/bin/msg_update_fail_htlc_target.rs | 11 ++++++----- fuzz/src/bin/msg_update_fail_malformed_htlc_target.rs | 11 ++++++----- fuzz/src/bin/msg_update_fee_target.rs | 11 ++++++----- fuzz/src/bin/msg_update_fulfill_htlc_target.rs | 11 ++++++----- fuzz/src/bin/offer_deser_target.rs | 11 ++++++----- fuzz/src/bin/onion_hop_data_target.rs | 11 ++++++----- fuzz/src/bin/onion_message_target.rs | 11 ++++++----- fuzz/src/bin/peer_crypt_target.rs | 11 ++++++----- fuzz/src/bin/process_network_graph_target.rs | 11 ++++++----- fuzz/src/bin/process_onion_failure_target.rs | 11 ++++++----- fuzz/src/bin/refund_deser_target.rs | 11 ++++++----- fuzz/src/bin/router_target.rs | 11 ++++++----- fuzz/src/bin/static_invoice_deser_target.rs | 11 ++++++----- fuzz/src/bin/target_template.txt | 11 ++++++----- fuzz/src/bin/zbase32_target.rs | 11 ++++++----- fuzz/src/utils/test_logger.rs | 7 +++++++ 75 files changed, 452 insertions(+), 361 deletions(-) diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 86ad12e8961..5bf899f34b1 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -39,6 +39,7 @@ cc = "1.0" members = ["."] [profile.release] +panic = "abort" lto = true codegen-units = 1 debug-assertions = true @@ -46,12 +47,13 @@ overflow-checks = true # When testing a large fuzz corpus, -O1 offers a nice speedup [profile.dev] +panic = "abort" opt-level = 1 [lib] name = "lightning_fuzz" path = "src/lib.rs" -crate-type = ["rlib", "dylib", "staticlib"] +crate-type = ["rlib", "staticlib"] [lints.rust.unexpected_cfgs] level = "forbid" diff --git a/fuzz/README.md b/fuzz/README.md index cfdab4940bc..0516ca7d7ea 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -148,6 +148,16 @@ mv hfuzz_workspace/fuzz_target/SIGABRT.PC.7ffff7e21ce1.STACK.[…].fuzz ./test_c This will reproduce the failing fuzz input and yield a usable stack trace. +Alternatively, you can use the `stdin_fuzz` feature to pipe the crash input directly without +creating test case files on disk: + +```shell +echo -ne '\x2d\x31\x36\x38\x37\x34\x09\x01...' | RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz" cargo run --features stdin_fuzz --bin full_stack_target +``` + +Panics will abort the process directly (the crate uses `panic = "abort"`), resulting in a +non-zero exit code. Piping via stdin is useful for reproducing crashes during `git bisect` or +when working with AI agents that can construct and pipe byte sequences directly. ## How do I add a new fuzz test? diff --git a/fuzz/src/bin/base32_target.rs b/fuzz/src/bin/base32_target.rs index 7937f30855c..5f168fdcea3 100644 --- a/fuzz/src/bin/base32_target.rs +++ b/fuzz/src/bin/base32_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::base32::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - base32_run(data.as_ptr(), data.len()); + base32_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - base32_run(data.as_ptr(), data.len()); + base32_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - base32_run(data.as_ptr(), data.len()); + base32_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - base32_run(data.as_ptr(), data.len()); + base32_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - base32_run(data.as_ptr(), data.len()); + base32_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/bech32_parse_target.rs b/fuzz/src/bin/bech32_parse_target.rs index 62f588d3169..ad2f6653843 100644 --- a/fuzz/src/bin/bech32_parse_target.rs +++ b/fuzz/src/bin/bech32_parse_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::bech32_parse::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - bech32_parse_run(data.as_ptr(), data.len()); + bech32_parse_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - bech32_parse_run(data.as_ptr(), data.len()); + bech32_parse_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - bech32_parse_run(data.as_ptr(), data.len()); + bech32_parse_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - bech32_parse_run(data.as_ptr(), data.len()); + bech32_parse_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - bech32_parse_run(data.as_ptr(), data.len()); + bech32_parse_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/bolt11_deser_target.rs b/fuzz/src/bin/bolt11_deser_target.rs index f79140ae5eb..9e2f33d92cc 100644 --- a/fuzz/src/bin/bolt11_deser_target.rs +++ b/fuzz/src/bin/bolt11_deser_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::bolt11_deser::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - bolt11_deser_run(data.as_ptr(), data.len()); + bolt11_deser_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - bolt11_deser_run(data.as_ptr(), data.len()); + bolt11_deser_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - bolt11_deser_run(data.as_ptr(), data.len()); + bolt11_deser_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - bolt11_deser_run(data.as_ptr(), data.len()); + bolt11_deser_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - bolt11_deser_run(data.as_ptr(), data.len()); + bolt11_deser_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/chanmon_consistency_target.rs b/fuzz/src/bin/chanmon_consistency_target.rs index c4788b0c1b2..a729e3df1d6 100644 --- a/fuzz/src/bin/chanmon_consistency_target.rs +++ b/fuzz/src/bin/chanmon_consistency_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::chanmon_consistency::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - chanmon_consistency_run(data.as_ptr(), data.len()); + chanmon_consistency_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - chanmon_consistency_run(data.as_ptr(), data.len()); + chanmon_consistency_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - chanmon_consistency_run(data.as_ptr(), data.len()); + chanmon_consistency_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - chanmon_consistency_run(data.as_ptr(), data.len()); + chanmon_consistency_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - chanmon_consistency_run(data.as_ptr(), data.len()); + chanmon_consistency_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/chanmon_deser_target.rs b/fuzz/src/bin/chanmon_deser_target.rs index e58b8030217..a2f109f17c8 100644 --- a/fuzz/src/bin/chanmon_deser_target.rs +++ b/fuzz/src/bin/chanmon_deser_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::chanmon_deser::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - chanmon_deser_run(data.as_ptr(), data.len()); + chanmon_deser_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - chanmon_deser_run(data.as_ptr(), data.len()); + chanmon_deser_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - chanmon_deser_run(data.as_ptr(), data.len()); + chanmon_deser_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - chanmon_deser_run(data.as_ptr(), data.len()); + chanmon_deser_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - chanmon_deser_run(data.as_ptr(), data.len()); + chanmon_deser_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/feature_flags_target.rs b/fuzz/src/bin/feature_flags_target.rs index 1be8fd12e8c..2d23f96b4e6 100644 --- a/fuzz/src/bin/feature_flags_target.rs +++ b/fuzz/src/bin/feature_flags_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::feature_flags::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - feature_flags_run(data.as_ptr(), data.len()); + feature_flags_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - feature_flags_run(data.as_ptr(), data.len()); + feature_flags_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - feature_flags_run(data.as_ptr(), data.len()); + feature_flags_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - feature_flags_run(data.as_ptr(), data.len()); + feature_flags_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - feature_flags_run(data.as_ptr(), data.len()); + feature_flags_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/fromstr_to_netaddress_target.rs b/fuzz/src/bin/fromstr_to_netaddress_target.rs index d86d521c762..fd34e029722 100644 --- a/fuzz/src/bin/fromstr_to_netaddress_target.rs +++ b/fuzz/src/bin/fromstr_to_netaddress_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::fromstr_to_netaddress::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - fromstr_to_netaddress_run(data.as_ptr(), data.len()); + fromstr_to_netaddress_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - fromstr_to_netaddress_run(data.as_ptr(), data.len()); + fromstr_to_netaddress_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - fromstr_to_netaddress_run(data.as_ptr(), data.len()); + fromstr_to_netaddress_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - fromstr_to_netaddress_run(data.as_ptr(), data.len()); + fromstr_to_netaddress_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - fromstr_to_netaddress_run(data.as_ptr(), data.len()); + fromstr_to_netaddress_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/fs_store_target.rs b/fuzz/src/bin/fs_store_target.rs index 804b09a84cf..8942ebea7f7 100644 --- a/fuzz/src/bin/fs_store_target.rs +++ b/fuzz/src/bin/fs_store_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::fs_store::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - fs_store_run(data.as_ptr(), data.len()); + fs_store_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - fs_store_run(data.as_ptr(), data.len()); + fs_store_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - fs_store_run(data.as_ptr(), data.len()); + fs_store_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - fs_store_run(data.as_ptr(), data.len()); + fs_store_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - fs_store_run(data.as_ptr(), data.len()); + fs_store_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/full_stack_target.rs b/fuzz/src/bin/full_stack_target.rs index 33bac418f38..a0be19786b5 100644 --- a/fuzz/src/bin/full_stack_target.rs +++ b/fuzz/src/bin/full_stack_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::full_stack::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - full_stack_run(data.as_ptr(), data.len()); + full_stack_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - full_stack_run(data.as_ptr(), data.len()); + full_stack_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - full_stack_run(data.as_ptr(), data.len()); + full_stack_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - full_stack_run(data.as_ptr(), data.len()); + full_stack_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - full_stack_run(data.as_ptr(), data.len()); + full_stack_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/indexedmap_target.rs b/fuzz/src/bin/indexedmap_target.rs index 3830e6a24a6..51d135b372e 100644 --- a/fuzz/src/bin/indexedmap_target.rs +++ b/fuzz/src/bin/indexedmap_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::indexedmap::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - indexedmap_run(data.as_ptr(), data.len()); + indexedmap_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - indexedmap_run(data.as_ptr(), data.len()); + indexedmap_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - indexedmap_run(data.as_ptr(), data.len()); + indexedmap_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - indexedmap_run(data.as_ptr(), data.len()); + indexedmap_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - indexedmap_run(data.as_ptr(), data.len()); + indexedmap_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/invoice_deser_target.rs b/fuzz/src/bin/invoice_deser_target.rs index ed79d246a58..bcdbecd0706 100644 --- a/fuzz/src/bin/invoice_deser_target.rs +++ b/fuzz/src/bin/invoice_deser_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::invoice_deser::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - invoice_deser_run(data.as_ptr(), data.len()); + invoice_deser_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - invoice_deser_run(data.as_ptr(), data.len()); + invoice_deser_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - invoice_deser_run(data.as_ptr(), data.len()); + invoice_deser_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - invoice_deser_run(data.as_ptr(), data.len()); + invoice_deser_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - invoice_deser_run(data.as_ptr(), data.len()); + invoice_deser_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/invoice_request_deser_target.rs b/fuzz/src/bin/invoice_request_deser_target.rs index 47fd3361fcc..f6eee60f142 100644 --- a/fuzz/src/bin/invoice_request_deser_target.rs +++ b/fuzz/src/bin/invoice_request_deser_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::invoice_request_deser::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - invoice_request_deser_run(data.as_ptr(), data.len()); + invoice_request_deser_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - invoice_request_deser_run(data.as_ptr(), data.len()); + invoice_request_deser_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - invoice_request_deser_run(data.as_ptr(), data.len()); + invoice_request_deser_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - invoice_request_deser_run(data.as_ptr(), data.len()); + invoice_request_deser_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - invoice_request_deser_run(data.as_ptr(), data.len()); + invoice_request_deser_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/lsps_message_target.rs b/fuzz/src/bin/lsps_message_target.rs index 7ba7469ebc0..4c6a0f45655 100644 --- a/fuzz/src/bin/lsps_message_target.rs +++ b/fuzz/src/bin/lsps_message_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::lsps_message::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - lsps_message_run(data.as_ptr(), data.len()); + lsps_message_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - lsps_message_run(data.as_ptr(), data.len()); + lsps_message_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - lsps_message_run(data.as_ptr(), data.len()); + lsps_message_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - lsps_message_run(data.as_ptr(), data.len()); + lsps_message_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - lsps_message_run(data.as_ptr(), data.len()); + lsps_message_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_accept_channel_target.rs b/fuzz/src/bin/msg_accept_channel_target.rs index 0b5fa27bcc7..aa3b6768eba 100644 --- a/fuzz/src/bin/msg_accept_channel_target.rs +++ b/fuzz/src/bin/msg_accept_channel_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_accept_channel::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_accept_channel_run(data.as_ptr(), data.len()); + msg_accept_channel_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_accept_channel_run(data.as_ptr(), data.len()); + msg_accept_channel_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_accept_channel_run(data.as_ptr(), data.len()); + msg_accept_channel_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_accept_channel_run(data.as_ptr(), data.len()); + msg_accept_channel_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_accept_channel_run(data.as_ptr(), data.len()); + msg_accept_channel_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_accept_channel_v2_target.rs b/fuzz/src/bin/msg_accept_channel_v2_target.rs index efb02c7acf4..469ae98a410 100644 --- a/fuzz/src/bin/msg_accept_channel_v2_target.rs +++ b/fuzz/src/bin/msg_accept_channel_v2_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_accept_channel_v2::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_accept_channel_v2_run(data.as_ptr(), data.len()); + msg_accept_channel_v2_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_accept_channel_v2_run(data.as_ptr(), data.len()); + msg_accept_channel_v2_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_accept_channel_v2_run(data.as_ptr(), data.len()); + msg_accept_channel_v2_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_accept_channel_v2_run(data.as_ptr(), data.len()); + msg_accept_channel_v2_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_accept_channel_v2_run(data.as_ptr(), data.len()); + msg_accept_channel_v2_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_announcement_signatures_target.rs b/fuzz/src/bin/msg_announcement_signatures_target.rs index 684f1361f38..f53aae636d5 100644 --- a/fuzz/src/bin/msg_announcement_signatures_target.rs +++ b/fuzz/src/bin/msg_announcement_signatures_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_announcement_signatures::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_announcement_signatures_run(data.as_ptr(), data.len()); + msg_announcement_signatures_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_announcement_signatures_run(data.as_ptr(), data.len()); + msg_announcement_signatures_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_announcement_signatures_run(data.as_ptr(), data.len()); + msg_announcement_signatures_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_announcement_signatures_run(data.as_ptr(), data.len()); + msg_announcement_signatures_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_announcement_signatures_run(data.as_ptr(), data.len()); + msg_announcement_signatures_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_blinded_message_path_target.rs b/fuzz/src/bin/msg_blinded_message_path_target.rs index 5b8ec215bc4..4159e1c6499 100644 --- a/fuzz/src/bin/msg_blinded_message_path_target.rs +++ b/fuzz/src/bin/msg_blinded_message_path_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_blinded_message_path::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_blinded_message_path_run(data.as_ptr(), data.len()); + msg_blinded_message_path_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_blinded_message_path_run(data.as_ptr(), data.len()); + msg_blinded_message_path_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_blinded_message_path_run(data.as_ptr(), data.len()); + msg_blinded_message_path_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_blinded_message_path_run(data.as_ptr(), data.len()); + msg_blinded_message_path_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_blinded_message_path_run(data.as_ptr(), data.len()); + msg_blinded_message_path_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_channel_announcement_target.rs b/fuzz/src/bin/msg_channel_announcement_target.rs index 8f326790e0a..31cb61165b0 100644 --- a/fuzz/src/bin/msg_channel_announcement_target.rs +++ b/fuzz/src/bin/msg_channel_announcement_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_channel_announcement::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_channel_announcement_run(data.as_ptr(), data.len()); + msg_channel_announcement_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_channel_announcement_run(data.as_ptr(), data.len()); + msg_channel_announcement_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_channel_announcement_run(data.as_ptr(), data.len()); + msg_channel_announcement_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_channel_announcement_run(data.as_ptr(), data.len()); + msg_channel_announcement_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_channel_announcement_run(data.as_ptr(), data.len()); + msg_channel_announcement_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_channel_details_target.rs b/fuzz/src/bin/msg_channel_details_target.rs index 34f51a30bde..618c5d6d297 100644 --- a/fuzz/src/bin/msg_channel_details_target.rs +++ b/fuzz/src/bin/msg_channel_details_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_channel_details::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_channel_details_run(data.as_ptr(), data.len()); + msg_channel_details_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_channel_details_run(data.as_ptr(), data.len()); + msg_channel_details_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_channel_details_run(data.as_ptr(), data.len()); + msg_channel_details_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_channel_details_run(data.as_ptr(), data.len()); + msg_channel_details_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_channel_details_run(data.as_ptr(), data.len()); + msg_channel_details_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_channel_ready_target.rs b/fuzz/src/bin/msg_channel_ready_target.rs index 76733dbecfe..eacacf10193 100644 --- a/fuzz/src/bin/msg_channel_ready_target.rs +++ b/fuzz/src/bin/msg_channel_ready_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_channel_ready::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_channel_ready_run(data.as_ptr(), data.len()); + msg_channel_ready_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_channel_ready_run(data.as_ptr(), data.len()); + msg_channel_ready_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_channel_ready_run(data.as_ptr(), data.len()); + msg_channel_ready_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_channel_ready_run(data.as_ptr(), data.len()); + msg_channel_ready_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_channel_ready_run(data.as_ptr(), data.len()); + msg_channel_ready_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_channel_reestablish_target.rs b/fuzz/src/bin/msg_channel_reestablish_target.rs index cdb4f1048e3..9ed4a5d1ad3 100644 --- a/fuzz/src/bin/msg_channel_reestablish_target.rs +++ b/fuzz/src/bin/msg_channel_reestablish_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_channel_reestablish::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_channel_reestablish_run(data.as_ptr(), data.len()); + msg_channel_reestablish_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_channel_reestablish_run(data.as_ptr(), data.len()); + msg_channel_reestablish_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_channel_reestablish_run(data.as_ptr(), data.len()); + msg_channel_reestablish_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_channel_reestablish_run(data.as_ptr(), data.len()); + msg_channel_reestablish_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_channel_reestablish_run(data.as_ptr(), data.len()); + msg_channel_reestablish_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_channel_update_target.rs b/fuzz/src/bin/msg_channel_update_target.rs index 0b567c18b81..56ffeff2c4d 100644 --- a/fuzz/src/bin/msg_channel_update_target.rs +++ b/fuzz/src/bin/msg_channel_update_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_channel_update::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_channel_update_run(data.as_ptr(), data.len()); + msg_channel_update_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_channel_update_run(data.as_ptr(), data.len()); + msg_channel_update_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_channel_update_run(data.as_ptr(), data.len()); + msg_channel_update_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_channel_update_run(data.as_ptr(), data.len()); + msg_channel_update_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_channel_update_run(data.as_ptr(), data.len()); + msg_channel_update_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_closing_complete_target.rs b/fuzz/src/bin/msg_closing_complete_target.rs index d097e0f6b81..3d8b1375266 100644 --- a/fuzz/src/bin/msg_closing_complete_target.rs +++ b/fuzz/src/bin/msg_closing_complete_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_closing_complete::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_closing_complete_run(data.as_ptr(), data.len()); + msg_closing_complete_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_closing_complete_run(data.as_ptr(), data.len()); + msg_closing_complete_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_closing_complete_run(data.as_ptr(), data.len()); + msg_closing_complete_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_closing_complete_run(data.as_ptr(), data.len()); + msg_closing_complete_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_closing_complete_run(data.as_ptr(), data.len()); + msg_closing_complete_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_closing_sig_target.rs b/fuzz/src/bin/msg_closing_sig_target.rs index 67150cef167..8bd8e30b50f 100644 --- a/fuzz/src/bin/msg_closing_sig_target.rs +++ b/fuzz/src/bin/msg_closing_sig_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_closing_sig::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_closing_sig_run(data.as_ptr(), data.len()); + msg_closing_sig_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_closing_sig_run(data.as_ptr(), data.len()); + msg_closing_sig_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_closing_sig_run(data.as_ptr(), data.len()); + msg_closing_sig_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_closing_sig_run(data.as_ptr(), data.len()); + msg_closing_sig_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_closing_sig_run(data.as_ptr(), data.len()); + msg_closing_sig_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_closing_signed_target.rs b/fuzz/src/bin/msg_closing_signed_target.rs index 1634b109da9..68ed7239693 100644 --- a/fuzz/src/bin/msg_closing_signed_target.rs +++ b/fuzz/src/bin/msg_closing_signed_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_closing_signed::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_closing_signed_run(data.as_ptr(), data.len()); + msg_closing_signed_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_closing_signed_run(data.as_ptr(), data.len()); + msg_closing_signed_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_closing_signed_run(data.as_ptr(), data.len()); + msg_closing_signed_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_closing_signed_run(data.as_ptr(), data.len()); + msg_closing_signed_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_closing_signed_run(data.as_ptr(), data.len()); + msg_closing_signed_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_commitment_signed_target.rs b/fuzz/src/bin/msg_commitment_signed_target.rs index 0c00a4ceb5a..bac1912c616 100644 --- a/fuzz/src/bin/msg_commitment_signed_target.rs +++ b/fuzz/src/bin/msg_commitment_signed_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_commitment_signed::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_commitment_signed_run(data.as_ptr(), data.len()); + msg_commitment_signed_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_commitment_signed_run(data.as_ptr(), data.len()); + msg_commitment_signed_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_commitment_signed_run(data.as_ptr(), data.len()); + msg_commitment_signed_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_commitment_signed_run(data.as_ptr(), data.len()); + msg_commitment_signed_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_commitment_signed_run(data.as_ptr(), data.len()); + msg_commitment_signed_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_decoded_onion_error_packet_target.rs b/fuzz/src/bin/msg_decoded_onion_error_packet_target.rs index 93f3c66b207..546acafd089 100644 --- a/fuzz/src/bin/msg_decoded_onion_error_packet_target.rs +++ b/fuzz/src/bin/msg_decoded_onion_error_packet_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_decoded_onion_error_packet::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_decoded_onion_error_packet_run(data.as_ptr(), data.len()); + msg_decoded_onion_error_packet_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_decoded_onion_error_packet_run(data.as_ptr(), data.len()); + msg_decoded_onion_error_packet_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_decoded_onion_error_packet_run(data.as_ptr(), data.len()); + msg_decoded_onion_error_packet_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_decoded_onion_error_packet_run(data.as_ptr(), data.len()); + msg_decoded_onion_error_packet_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_decoded_onion_error_packet_run(data.as_ptr(), data.len()); + msg_decoded_onion_error_packet_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_error_message_target.rs b/fuzz/src/bin/msg_error_message_target.rs index 4840e2bdfe9..f020c4532b3 100644 --- a/fuzz/src/bin/msg_error_message_target.rs +++ b/fuzz/src/bin/msg_error_message_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_error_message::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_error_message_run(data.as_ptr(), data.len()); + msg_error_message_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_error_message_run(data.as_ptr(), data.len()); + msg_error_message_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_error_message_run(data.as_ptr(), data.len()); + msg_error_message_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_error_message_run(data.as_ptr(), data.len()); + msg_error_message_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_error_message_run(data.as_ptr(), data.len()); + msg_error_message_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_funding_created_target.rs b/fuzz/src/bin/msg_funding_created_target.rs index f8884116710..cfa74aca486 100644 --- a/fuzz/src/bin/msg_funding_created_target.rs +++ b/fuzz/src/bin/msg_funding_created_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_funding_created::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_funding_created_run(data.as_ptr(), data.len()); + msg_funding_created_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_funding_created_run(data.as_ptr(), data.len()); + msg_funding_created_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_funding_created_run(data.as_ptr(), data.len()); + msg_funding_created_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_funding_created_run(data.as_ptr(), data.len()); + msg_funding_created_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_funding_created_run(data.as_ptr(), data.len()); + msg_funding_created_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_funding_signed_target.rs b/fuzz/src/bin/msg_funding_signed_target.rs index 42d0316dc9a..de5f3b22300 100644 --- a/fuzz/src/bin/msg_funding_signed_target.rs +++ b/fuzz/src/bin/msg_funding_signed_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_funding_signed::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_funding_signed_run(data.as_ptr(), data.len()); + msg_funding_signed_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_funding_signed_run(data.as_ptr(), data.len()); + msg_funding_signed_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_funding_signed_run(data.as_ptr(), data.len()); + msg_funding_signed_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_funding_signed_run(data.as_ptr(), data.len()); + msg_funding_signed_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_funding_signed_run(data.as_ptr(), data.len()); + msg_funding_signed_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_gossip_timestamp_filter_target.rs b/fuzz/src/bin/msg_gossip_timestamp_filter_target.rs index 0a47f773114..4fd905b3edd 100644 --- a/fuzz/src/bin/msg_gossip_timestamp_filter_target.rs +++ b/fuzz/src/bin/msg_gossip_timestamp_filter_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_gossip_timestamp_filter::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_gossip_timestamp_filter_run(data.as_ptr(), data.len()); + msg_gossip_timestamp_filter_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_gossip_timestamp_filter_run(data.as_ptr(), data.len()); + msg_gossip_timestamp_filter_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_gossip_timestamp_filter_run(data.as_ptr(), data.len()); + msg_gossip_timestamp_filter_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_gossip_timestamp_filter_run(data.as_ptr(), data.len()); + msg_gossip_timestamp_filter_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_gossip_timestamp_filter_run(data.as_ptr(), data.len()); + msg_gossip_timestamp_filter_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_init_target.rs b/fuzz/src/bin/msg_init_target.rs index db0c8a8894f..9d2bc346304 100644 --- a/fuzz/src/bin/msg_init_target.rs +++ b/fuzz/src/bin/msg_init_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_init::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_init_run(data.as_ptr(), data.len()); + msg_init_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_init_run(data.as_ptr(), data.len()); + msg_init_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_init_run(data.as_ptr(), data.len()); + msg_init_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_init_run(data.as_ptr(), data.len()); + msg_init_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_init_run(data.as_ptr(), data.len()); + msg_init_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_node_announcement_target.rs b/fuzz/src/bin/msg_node_announcement_target.rs index 1c20a999aaa..820fea1adca 100644 --- a/fuzz/src/bin/msg_node_announcement_target.rs +++ b/fuzz/src/bin/msg_node_announcement_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_node_announcement::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_node_announcement_run(data.as_ptr(), data.len()); + msg_node_announcement_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_node_announcement_run(data.as_ptr(), data.len()); + msg_node_announcement_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_node_announcement_run(data.as_ptr(), data.len()); + msg_node_announcement_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_node_announcement_run(data.as_ptr(), data.len()); + msg_node_announcement_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_node_announcement_run(data.as_ptr(), data.len()); + msg_node_announcement_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_open_channel_target.rs b/fuzz/src/bin/msg_open_channel_target.rs index fc6df814dd1..fbfd0938924 100644 --- a/fuzz/src/bin/msg_open_channel_target.rs +++ b/fuzz/src/bin/msg_open_channel_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_open_channel::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_open_channel_run(data.as_ptr(), data.len()); + msg_open_channel_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_open_channel_run(data.as_ptr(), data.len()); + msg_open_channel_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_open_channel_run(data.as_ptr(), data.len()); + msg_open_channel_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_open_channel_run(data.as_ptr(), data.len()); + msg_open_channel_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_open_channel_run(data.as_ptr(), data.len()); + msg_open_channel_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_open_channel_v2_target.rs b/fuzz/src/bin/msg_open_channel_v2_target.rs index 732daed18c3..8c46c4c09df 100644 --- a/fuzz/src/bin/msg_open_channel_v2_target.rs +++ b/fuzz/src/bin/msg_open_channel_v2_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_open_channel_v2::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_open_channel_v2_run(data.as_ptr(), data.len()); + msg_open_channel_v2_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_open_channel_v2_run(data.as_ptr(), data.len()); + msg_open_channel_v2_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_open_channel_v2_run(data.as_ptr(), data.len()); + msg_open_channel_v2_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_open_channel_v2_run(data.as_ptr(), data.len()); + msg_open_channel_v2_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_open_channel_v2_run(data.as_ptr(), data.len()); + msg_open_channel_v2_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_ping_target.rs b/fuzz/src/bin/msg_ping_target.rs index bb1a59b9bad..52cd3d941ab 100644 --- a/fuzz/src/bin/msg_ping_target.rs +++ b/fuzz/src/bin/msg_ping_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_ping::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_ping_run(data.as_ptr(), data.len()); + msg_ping_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_ping_run(data.as_ptr(), data.len()); + msg_ping_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_ping_run(data.as_ptr(), data.len()); + msg_ping_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_ping_run(data.as_ptr(), data.len()); + msg_ping_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_ping_run(data.as_ptr(), data.len()); + msg_ping_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_pong_target.rs b/fuzz/src/bin/msg_pong_target.rs index 7a97d93e785..da9e9cc2b89 100644 --- a/fuzz/src/bin/msg_pong_target.rs +++ b/fuzz/src/bin/msg_pong_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_pong::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_pong_run(data.as_ptr(), data.len()); + msg_pong_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_pong_run(data.as_ptr(), data.len()); + msg_pong_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_pong_run(data.as_ptr(), data.len()); + msg_pong_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_pong_run(data.as_ptr(), data.len()); + msg_pong_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_pong_run(data.as_ptr(), data.len()); + msg_pong_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_query_channel_range_target.rs b/fuzz/src/bin/msg_query_channel_range_target.rs index 4fd3260db0a..e177b23072f 100644 --- a/fuzz/src/bin/msg_query_channel_range_target.rs +++ b/fuzz/src/bin/msg_query_channel_range_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_query_channel_range::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_query_channel_range_run(data.as_ptr(), data.len()); + msg_query_channel_range_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_query_channel_range_run(data.as_ptr(), data.len()); + msg_query_channel_range_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_query_channel_range_run(data.as_ptr(), data.len()); + msg_query_channel_range_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_query_channel_range_run(data.as_ptr(), data.len()); + msg_query_channel_range_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_query_channel_range_run(data.as_ptr(), data.len()); + msg_query_channel_range_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_query_short_channel_ids_target.rs b/fuzz/src/bin/msg_query_short_channel_ids_target.rs index 63f8c48fb3b..53ca822bb21 100644 --- a/fuzz/src/bin/msg_query_short_channel_ids_target.rs +++ b/fuzz/src/bin/msg_query_short_channel_ids_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_query_short_channel_ids::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_query_short_channel_ids_run(data.as_ptr(), data.len()); + msg_query_short_channel_ids_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_query_short_channel_ids_run(data.as_ptr(), data.len()); + msg_query_short_channel_ids_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_query_short_channel_ids_run(data.as_ptr(), data.len()); + msg_query_short_channel_ids_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_query_short_channel_ids_run(data.as_ptr(), data.len()); + msg_query_short_channel_ids_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_query_short_channel_ids_run(data.as_ptr(), data.len()); + msg_query_short_channel_ids_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_reply_channel_range_target.rs b/fuzz/src/bin/msg_reply_channel_range_target.rs index 8e5ce619fa4..2a776eaabf7 100644 --- a/fuzz/src/bin/msg_reply_channel_range_target.rs +++ b/fuzz/src/bin/msg_reply_channel_range_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_reply_channel_range::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_reply_channel_range_run(data.as_ptr(), data.len()); + msg_reply_channel_range_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_reply_channel_range_run(data.as_ptr(), data.len()); + msg_reply_channel_range_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_reply_channel_range_run(data.as_ptr(), data.len()); + msg_reply_channel_range_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_reply_channel_range_run(data.as_ptr(), data.len()); + msg_reply_channel_range_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_reply_channel_range_run(data.as_ptr(), data.len()); + msg_reply_channel_range_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_reply_short_channel_ids_end_target.rs b/fuzz/src/bin/msg_reply_short_channel_ids_end_target.rs index 9b9b528abe5..02ffd90ebcb 100644 --- a/fuzz/src/bin/msg_reply_short_channel_ids_end_target.rs +++ b/fuzz/src/bin/msg_reply_short_channel_ids_end_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_reply_short_channel_ids_end::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_reply_short_channel_ids_end_run(data.as_ptr(), data.len()); + msg_reply_short_channel_ids_end_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_reply_short_channel_ids_end_run(data.as_ptr(), data.len()); + msg_reply_short_channel_ids_end_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_reply_short_channel_ids_end_run(data.as_ptr(), data.len()); + msg_reply_short_channel_ids_end_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_reply_short_channel_ids_end_run(data.as_ptr(), data.len()); + msg_reply_short_channel_ids_end_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_reply_short_channel_ids_end_run(data.as_ptr(), data.len()); + msg_reply_short_channel_ids_end_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_revoke_and_ack_target.rs b/fuzz/src/bin/msg_revoke_and_ack_target.rs index 1f401dae773..0a20ea7586c 100644 --- a/fuzz/src/bin/msg_revoke_and_ack_target.rs +++ b/fuzz/src/bin/msg_revoke_and_ack_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_revoke_and_ack::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_revoke_and_ack_run(data.as_ptr(), data.len()); + msg_revoke_and_ack_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_revoke_and_ack_run(data.as_ptr(), data.len()); + msg_revoke_and_ack_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_revoke_and_ack_run(data.as_ptr(), data.len()); + msg_revoke_and_ack_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_revoke_and_ack_run(data.as_ptr(), data.len()); + msg_revoke_and_ack_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_revoke_and_ack_run(data.as_ptr(), data.len()); + msg_revoke_and_ack_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_shutdown_target.rs b/fuzz/src/bin/msg_shutdown_target.rs index c29bb93bb0b..ed26a25949c 100644 --- a/fuzz/src/bin/msg_shutdown_target.rs +++ b/fuzz/src/bin/msg_shutdown_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_shutdown::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_shutdown_run(data.as_ptr(), data.len()); + msg_shutdown_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_shutdown_run(data.as_ptr(), data.len()); + msg_shutdown_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_shutdown_run(data.as_ptr(), data.len()); + msg_shutdown_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_shutdown_run(data.as_ptr(), data.len()); + msg_shutdown_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_shutdown_run(data.as_ptr(), data.len()); + msg_shutdown_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_splice_ack_target.rs b/fuzz/src/bin/msg_splice_ack_target.rs index 9957a85552f..0a1f13b7e08 100644 --- a/fuzz/src/bin/msg_splice_ack_target.rs +++ b/fuzz/src/bin/msg_splice_ack_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_splice_ack::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_splice_ack_run(data.as_ptr(), data.len()); + msg_splice_ack_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_splice_ack_run(data.as_ptr(), data.len()); + msg_splice_ack_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_splice_ack_run(data.as_ptr(), data.len()); + msg_splice_ack_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_splice_ack_run(data.as_ptr(), data.len()); + msg_splice_ack_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_splice_ack_run(data.as_ptr(), data.len()); + msg_splice_ack_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_splice_init_target.rs b/fuzz/src/bin/msg_splice_init_target.rs index 83df6454623..9a7bc60ebda 100644 --- a/fuzz/src/bin/msg_splice_init_target.rs +++ b/fuzz/src/bin/msg_splice_init_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_splice_init::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_splice_init_run(data.as_ptr(), data.len()); + msg_splice_init_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_splice_init_run(data.as_ptr(), data.len()); + msg_splice_init_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_splice_init_run(data.as_ptr(), data.len()); + msg_splice_init_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_splice_init_run(data.as_ptr(), data.len()); + msg_splice_init_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_splice_init_run(data.as_ptr(), data.len()); + msg_splice_init_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_splice_locked_target.rs b/fuzz/src/bin/msg_splice_locked_target.rs index d9dfcf956be..0f9b0a2ed60 100644 --- a/fuzz/src/bin/msg_splice_locked_target.rs +++ b/fuzz/src/bin/msg_splice_locked_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_splice_locked::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_splice_locked_run(data.as_ptr(), data.len()); + msg_splice_locked_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_splice_locked_run(data.as_ptr(), data.len()); + msg_splice_locked_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_splice_locked_run(data.as_ptr(), data.len()); + msg_splice_locked_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_splice_locked_run(data.as_ptr(), data.len()); + msg_splice_locked_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_splice_locked_run(data.as_ptr(), data.len()); + msg_splice_locked_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_stfu_target.rs b/fuzz/src/bin/msg_stfu_target.rs index bdef12d4c32..d6b898ba11b 100644 --- a/fuzz/src/bin/msg_stfu_target.rs +++ b/fuzz/src/bin/msg_stfu_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_stfu::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_stfu_run(data.as_ptr(), data.len()); + msg_stfu_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_stfu_run(data.as_ptr(), data.len()); + msg_stfu_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_stfu_run(data.as_ptr(), data.len()); + msg_stfu_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_stfu_run(data.as_ptr(), data.len()); + msg_stfu_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_stfu_run(data.as_ptr(), data.len()); + msg_stfu_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_tx_abort_target.rs b/fuzz/src/bin/msg_tx_abort_target.rs index 76f098b1e2c..3b824095062 100644 --- a/fuzz/src/bin/msg_tx_abort_target.rs +++ b/fuzz/src/bin/msg_tx_abort_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_tx_abort::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_tx_abort_run(data.as_ptr(), data.len()); + msg_tx_abort_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_tx_abort_run(data.as_ptr(), data.len()); + msg_tx_abort_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_tx_abort_run(data.as_ptr(), data.len()); + msg_tx_abort_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_abort_run(data.as_ptr(), data.len()); + msg_tx_abort_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_tx_abort_run(data.as_ptr(), data.len()); + msg_tx_abort_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_tx_ack_rbf_target.rs b/fuzz/src/bin/msg_tx_ack_rbf_target.rs index 1f549a5703f..d4905a5ce14 100644 --- a/fuzz/src/bin/msg_tx_ack_rbf_target.rs +++ b/fuzz/src/bin/msg_tx_ack_rbf_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_tx_ack_rbf::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_tx_ack_rbf_run(data.as_ptr(), data.len()); + msg_tx_ack_rbf_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_tx_ack_rbf_run(data.as_ptr(), data.len()); + msg_tx_ack_rbf_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_tx_ack_rbf_run(data.as_ptr(), data.len()); + msg_tx_ack_rbf_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_ack_rbf_run(data.as_ptr(), data.len()); + msg_tx_ack_rbf_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_tx_ack_rbf_run(data.as_ptr(), data.len()); + msg_tx_ack_rbf_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_tx_add_input_target.rs b/fuzz/src/bin/msg_tx_add_input_target.rs index 9b7e1cfe7e6..627797fdc6f 100644 --- a/fuzz/src/bin/msg_tx_add_input_target.rs +++ b/fuzz/src/bin/msg_tx_add_input_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_tx_add_input::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_tx_add_input_run(data.as_ptr(), data.len()); + msg_tx_add_input_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_tx_add_input_run(data.as_ptr(), data.len()); + msg_tx_add_input_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_tx_add_input_run(data.as_ptr(), data.len()); + msg_tx_add_input_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_add_input_run(data.as_ptr(), data.len()); + msg_tx_add_input_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_tx_add_input_run(data.as_ptr(), data.len()); + msg_tx_add_input_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_tx_add_output_target.rs b/fuzz/src/bin/msg_tx_add_output_target.rs index b8ad29581bc..be301558f6f 100644 --- a/fuzz/src/bin/msg_tx_add_output_target.rs +++ b/fuzz/src/bin/msg_tx_add_output_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_tx_add_output::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_tx_add_output_run(data.as_ptr(), data.len()); + msg_tx_add_output_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_tx_add_output_run(data.as_ptr(), data.len()); + msg_tx_add_output_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_tx_add_output_run(data.as_ptr(), data.len()); + msg_tx_add_output_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_add_output_run(data.as_ptr(), data.len()); + msg_tx_add_output_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_tx_add_output_run(data.as_ptr(), data.len()); + msg_tx_add_output_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_tx_complete_target.rs b/fuzz/src/bin/msg_tx_complete_target.rs index 28b295b0d25..12abb32e020 100644 --- a/fuzz/src/bin/msg_tx_complete_target.rs +++ b/fuzz/src/bin/msg_tx_complete_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_tx_complete::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_tx_complete_run(data.as_ptr(), data.len()); + msg_tx_complete_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_tx_complete_run(data.as_ptr(), data.len()); + msg_tx_complete_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_tx_complete_run(data.as_ptr(), data.len()); + msg_tx_complete_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_complete_run(data.as_ptr(), data.len()); + msg_tx_complete_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_tx_complete_run(data.as_ptr(), data.len()); + msg_tx_complete_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_tx_init_rbf_target.rs b/fuzz/src/bin/msg_tx_init_rbf_target.rs index 24fa793315d..6ede611b2ae 100644 --- a/fuzz/src/bin/msg_tx_init_rbf_target.rs +++ b/fuzz/src/bin/msg_tx_init_rbf_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_tx_init_rbf::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_tx_init_rbf_run(data.as_ptr(), data.len()); + msg_tx_init_rbf_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_tx_init_rbf_run(data.as_ptr(), data.len()); + msg_tx_init_rbf_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_tx_init_rbf_run(data.as_ptr(), data.len()); + msg_tx_init_rbf_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_init_rbf_run(data.as_ptr(), data.len()); + msg_tx_init_rbf_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_tx_init_rbf_run(data.as_ptr(), data.len()); + msg_tx_init_rbf_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_tx_remove_input_target.rs b/fuzz/src/bin/msg_tx_remove_input_target.rs index abe4190a354..a508497e151 100644 --- a/fuzz/src/bin/msg_tx_remove_input_target.rs +++ b/fuzz/src/bin/msg_tx_remove_input_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_tx_remove_input::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_tx_remove_input_run(data.as_ptr(), data.len()); + msg_tx_remove_input_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_tx_remove_input_run(data.as_ptr(), data.len()); + msg_tx_remove_input_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_tx_remove_input_run(data.as_ptr(), data.len()); + msg_tx_remove_input_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_remove_input_run(data.as_ptr(), data.len()); + msg_tx_remove_input_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_tx_remove_input_run(data.as_ptr(), data.len()); + msg_tx_remove_input_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_tx_remove_output_target.rs b/fuzz/src/bin/msg_tx_remove_output_target.rs index 3d084e0048d..993ddb044b2 100644 --- a/fuzz/src/bin/msg_tx_remove_output_target.rs +++ b/fuzz/src/bin/msg_tx_remove_output_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_tx_remove_output::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_tx_remove_output_run(data.as_ptr(), data.len()); + msg_tx_remove_output_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_tx_remove_output_run(data.as_ptr(), data.len()); + msg_tx_remove_output_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_tx_remove_output_run(data.as_ptr(), data.len()); + msg_tx_remove_output_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_remove_output_run(data.as_ptr(), data.len()); + msg_tx_remove_output_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_tx_remove_output_run(data.as_ptr(), data.len()); + msg_tx_remove_output_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_tx_signatures_target.rs b/fuzz/src/bin/msg_tx_signatures_target.rs index fa3b966b478..8054d4241ee 100644 --- a/fuzz/src/bin/msg_tx_signatures_target.rs +++ b/fuzz/src/bin/msg_tx_signatures_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_tx_signatures::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_tx_signatures_run(data.as_ptr(), data.len()); + msg_tx_signatures_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_tx_signatures_run(data.as_ptr(), data.len()); + msg_tx_signatures_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_tx_signatures_run(data.as_ptr(), data.len()); + msg_tx_signatures_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_signatures_run(data.as_ptr(), data.len()); + msg_tx_signatures_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_tx_signatures_run(data.as_ptr(), data.len()); + msg_tx_signatures_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_update_add_htlc_target.rs b/fuzz/src/bin/msg_update_add_htlc_target.rs index f3c25a37524..258dd2445f2 100644 --- a/fuzz/src/bin/msg_update_add_htlc_target.rs +++ b/fuzz/src/bin/msg_update_add_htlc_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_update_add_htlc::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_update_add_htlc_run(data.as_ptr(), data.len()); + msg_update_add_htlc_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_update_add_htlc_run(data.as_ptr(), data.len()); + msg_update_add_htlc_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_update_add_htlc_run(data.as_ptr(), data.len()); + msg_update_add_htlc_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_update_add_htlc_run(data.as_ptr(), data.len()); + msg_update_add_htlc_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_update_add_htlc_run(data.as_ptr(), data.len()); + msg_update_add_htlc_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_update_fail_htlc_target.rs b/fuzz/src/bin/msg_update_fail_htlc_target.rs index 9698ae92cfe..b4ae4e52e1e 100644 --- a/fuzz/src/bin/msg_update_fail_htlc_target.rs +++ b/fuzz/src/bin/msg_update_fail_htlc_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_update_fail_htlc::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_update_fail_htlc_run(data.as_ptr(), data.len()); + msg_update_fail_htlc_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_update_fail_htlc_run(data.as_ptr(), data.len()); + msg_update_fail_htlc_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_update_fail_htlc_run(data.as_ptr(), data.len()); + msg_update_fail_htlc_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_update_fail_htlc_run(data.as_ptr(), data.len()); + msg_update_fail_htlc_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_update_fail_htlc_run(data.as_ptr(), data.len()); + msg_update_fail_htlc_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_update_fail_malformed_htlc_target.rs b/fuzz/src/bin/msg_update_fail_malformed_htlc_target.rs index b7f511c5ff5..fb5325d54f7 100644 --- a/fuzz/src/bin/msg_update_fail_malformed_htlc_target.rs +++ b/fuzz/src/bin/msg_update_fail_malformed_htlc_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_update_fail_malformed_htlc::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_update_fail_malformed_htlc_run(data.as_ptr(), data.len()); + msg_update_fail_malformed_htlc_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_update_fail_malformed_htlc_run(data.as_ptr(), data.len()); + msg_update_fail_malformed_htlc_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_update_fail_malformed_htlc_run(data.as_ptr(), data.len()); + msg_update_fail_malformed_htlc_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_update_fail_malformed_htlc_run(data.as_ptr(), data.len()); + msg_update_fail_malformed_htlc_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_update_fail_malformed_htlc_run(data.as_ptr(), data.len()); + msg_update_fail_malformed_htlc_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_update_fee_target.rs b/fuzz/src/bin/msg_update_fee_target.rs index b021107f150..d8e9a26dc08 100644 --- a/fuzz/src/bin/msg_update_fee_target.rs +++ b/fuzz/src/bin/msg_update_fee_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_update_fee::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_update_fee_run(data.as_ptr(), data.len()); + msg_update_fee_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_update_fee_run(data.as_ptr(), data.len()); + msg_update_fee_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_update_fee_run(data.as_ptr(), data.len()); + msg_update_fee_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_update_fee_run(data.as_ptr(), data.len()); + msg_update_fee_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_update_fee_run(data.as_ptr(), data.len()); + msg_update_fee_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/msg_update_fulfill_htlc_target.rs b/fuzz/src/bin/msg_update_fulfill_htlc_target.rs index d87cd5bd490..cec5ccfc1fe 100644 --- a/fuzz/src/bin/msg_update_fulfill_htlc_target.rs +++ b/fuzz/src/bin/msg_update_fulfill_htlc_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::msg_targets::msg_update_fulfill_htlc::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - msg_update_fulfill_htlc_run(data.as_ptr(), data.len()); + msg_update_fulfill_htlc_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - msg_update_fulfill_htlc_run(data.as_ptr(), data.len()); + msg_update_fulfill_htlc_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - msg_update_fulfill_htlc_run(data.as_ptr(), data.len()); + msg_update_fulfill_htlc_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_update_fulfill_htlc_run(data.as_ptr(), data.len()); + msg_update_fulfill_htlc_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - msg_update_fulfill_htlc_run(data.as_ptr(), data.len()); + msg_update_fulfill_htlc_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/offer_deser_target.rs b/fuzz/src/bin/offer_deser_target.rs index 51cdb09adec..d788a8b04c9 100644 --- a/fuzz/src/bin/offer_deser_target.rs +++ b/fuzz/src/bin/offer_deser_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::offer_deser::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - offer_deser_run(data.as_ptr(), data.len()); + offer_deser_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - offer_deser_run(data.as_ptr(), data.len()); + offer_deser_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - offer_deser_run(data.as_ptr(), data.len()); + offer_deser_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - offer_deser_run(data.as_ptr(), data.len()); + offer_deser_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - offer_deser_run(data.as_ptr(), data.len()); + offer_deser_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/onion_hop_data_target.rs b/fuzz/src/bin/onion_hop_data_target.rs index 50d98043d05..1677d075ebd 100644 --- a/fuzz/src/bin/onion_hop_data_target.rs +++ b/fuzz/src/bin/onion_hop_data_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::onion_hop_data::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - onion_hop_data_run(data.as_ptr(), data.len()); + onion_hop_data_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - onion_hop_data_run(data.as_ptr(), data.len()); + onion_hop_data_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - onion_hop_data_run(data.as_ptr(), data.len()); + onion_hop_data_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - onion_hop_data_run(data.as_ptr(), data.len()); + onion_hop_data_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - onion_hop_data_run(data.as_ptr(), data.len()); + onion_hop_data_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/onion_message_target.rs b/fuzz/src/bin/onion_message_target.rs index 7bb09477ec5..ff5feec3fb4 100644 --- a/fuzz/src/bin/onion_message_target.rs +++ b/fuzz/src/bin/onion_message_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::onion_message::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - onion_message_run(data.as_ptr(), data.len()); + onion_message_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - onion_message_run(data.as_ptr(), data.len()); + onion_message_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - onion_message_run(data.as_ptr(), data.len()); + onion_message_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - onion_message_run(data.as_ptr(), data.len()); + onion_message_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - onion_message_run(data.as_ptr(), data.len()); + onion_message_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/peer_crypt_target.rs b/fuzz/src/bin/peer_crypt_target.rs index 0ba0252c963..6b21d8e6e5a 100644 --- a/fuzz/src/bin/peer_crypt_target.rs +++ b/fuzz/src/bin/peer_crypt_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::peer_crypt::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - peer_crypt_run(data.as_ptr(), data.len()); + peer_crypt_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - peer_crypt_run(data.as_ptr(), data.len()); + peer_crypt_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - peer_crypt_run(data.as_ptr(), data.len()); + peer_crypt_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - peer_crypt_run(data.as_ptr(), data.len()); + peer_crypt_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - peer_crypt_run(data.as_ptr(), data.len()); + peer_crypt_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/process_network_graph_target.rs b/fuzz/src/bin/process_network_graph_target.rs index 4ce10e6d4df..26306648151 100644 --- a/fuzz/src/bin/process_network_graph_target.rs +++ b/fuzz/src/bin/process_network_graph_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::process_network_graph::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - process_network_graph_run(data.as_ptr(), data.len()); + process_network_graph_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - process_network_graph_run(data.as_ptr(), data.len()); + process_network_graph_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - process_network_graph_run(data.as_ptr(), data.len()); + process_network_graph_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - process_network_graph_run(data.as_ptr(), data.len()); + process_network_graph_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - process_network_graph_run(data.as_ptr(), data.len()); + process_network_graph_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/process_onion_failure_target.rs b/fuzz/src/bin/process_onion_failure_target.rs index 1d2cdb28593..4c613a055b1 100644 --- a/fuzz/src/bin/process_onion_failure_target.rs +++ b/fuzz/src/bin/process_onion_failure_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::process_onion_failure::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - process_onion_failure_run(data.as_ptr(), data.len()); + process_onion_failure_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - process_onion_failure_run(data.as_ptr(), data.len()); + process_onion_failure_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - process_onion_failure_run(data.as_ptr(), data.len()); + process_onion_failure_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - process_onion_failure_run(data.as_ptr(), data.len()); + process_onion_failure_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - process_onion_failure_run(data.as_ptr(), data.len()); + process_onion_failure_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/refund_deser_target.rs b/fuzz/src/bin/refund_deser_target.rs index fea8a9c4c6d..c61c4f7a5d9 100644 --- a/fuzz/src/bin/refund_deser_target.rs +++ b/fuzz/src/bin/refund_deser_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::refund_deser::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - refund_deser_run(data.as_ptr(), data.len()); + refund_deser_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - refund_deser_run(data.as_ptr(), data.len()); + refund_deser_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - refund_deser_run(data.as_ptr(), data.len()); + refund_deser_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - refund_deser_run(data.as_ptr(), data.len()); + refund_deser_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - refund_deser_run(data.as_ptr(), data.len()); + refund_deser_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/router_target.rs b/fuzz/src/bin/router_target.rs index 0ebec549455..73d6d1b3f7b 100644 --- a/fuzz/src/bin/router_target.rs +++ b/fuzz/src/bin/router_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::router::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - router_run(data.as_ptr(), data.len()); + router_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - router_run(data.as_ptr(), data.len()); + router_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - router_run(data.as_ptr(), data.len()); + router_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - router_run(data.as_ptr(), data.len()); + router_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - router_run(data.as_ptr(), data.len()); + router_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/static_invoice_deser_target.rs b/fuzz/src/bin/static_invoice_deser_target.rs index 573f0aa0b22..59b854486ac 100644 --- a/fuzz/src/bin/static_invoice_deser_target.rs +++ b/fuzz/src/bin/static_invoice_deser_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::static_invoice_deser::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - static_invoice_deser_run(data.as_ptr(), data.len()); + static_invoice_deser_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - static_invoice_deser_run(data.as_ptr(), data.len()); + static_invoice_deser_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - static_invoice_deser_run(data.as_ptr(), data.len()); + static_invoice_deser_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - static_invoice_deser_run(data.as_ptr(), data.len()); + static_invoice_deser_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - static_invoice_deser_run(data.as_ptr(), data.len()); + static_invoice_deser_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/target_template.txt b/fuzz/src/bin/target_template.txt index e828aa998b1..b085ae7ad7b 100644 --- a/fuzz/src/bin/target_template.txt +++ b/fuzz/src/bin/target_template.txt @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::TARGET_MOD::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - TARGET_NAME_run(data.as_ptr(), data.len()); + TARGET_NAME_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - TARGET_NAME_run(data.as_ptr(), data.len()); + TARGET_NAME_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - TARGET_NAME_run(data.as_ptr(), data.len()); + TARGET_NAME_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - TARGET_NAME_run(data.as_ptr(), data.len()); + TARGET_NAME_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - TARGET_NAME_run(data.as_ptr(), data.len()); + TARGET_NAME_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/bin/zbase32_target.rs b/fuzz/src/bin/zbase32_target.rs index 35aa53d1fff..c17ea0ae8b5 100644 --- a/fuzz/src/bin/zbase32_target.rs +++ b/fuzz/src/bin/zbase32_target.rs @@ -24,13 +24,14 @@ compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); extern crate lightning_fuzz; use lightning_fuzz::zbase32::*; +use lightning_fuzz::utils::test_logger; #[cfg(feature = "afl")] #[macro_use] extern crate afl; #[cfg(feature = "afl")] fn main() { fuzz!(|data| { - zbase32_run(data.as_ptr(), data.len()); + zbase32_test(&data, test_logger::DevNull {}); }); } @@ -40,7 +41,7 @@ fn main() { fn main() { loop { fuzz!(|data| { - zbase32_run(data.as_ptr(), data.len()); + zbase32_test(&data, test_logger::DevNull {}); }); } } @@ -49,7 +50,7 @@ fn main() { #[macro_use] extern crate libfuzzer_sys; #[cfg(feature = "libfuzzer_fuzz")] fuzz_target!(|data: &[u8]| { - zbase32_run(data.as_ptr(), data.len()); + zbase32_test(data, test_logger::DevNull {}); }); #[cfg(feature = "stdin_fuzz")] @@ -58,7 +59,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - zbase32_run(data.as_ptr(), data.len()); + zbase32_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); } #[test] @@ -70,7 +71,7 @@ fn run_test_cases() { use std::sync::{atomic, Arc}; { let data: Vec = vec![0]; - zbase32_run(data.as_ptr(), data.len()); + zbase32_test(&data, test_logger::DevNull {}); } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); diff --git a/fuzz/src/utils/test_logger.rs b/fuzz/src/utils/test_logger.rs index 8f38d08a035..193ccc06a54 100644 --- a/fuzz/src/utils/test_logger.rs +++ b/fuzz/src/utils/test_logger.rs @@ -21,6 +21,13 @@ impl Output for DevNull { fn locked_write(&self, _data: &[u8]) {} } #[derive(Clone)] +pub struct Stdout {} +impl Output for Stdout { + fn locked_write(&self, data: &[u8]) { + std::io::stdout().write_all(data).unwrap(); + } +} +#[derive(Clone)] pub struct StringBuffer(Arc>); impl Output for StringBuffer { fn locked_write(&self, data: &[u8]) { From 42ca4b79df68cdb91cc3e812bf9e320ba0459037 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Mon, 23 Feb 2026 11:37:47 -0500 Subject: [PATCH 085/627] Rename to StaticInvoice::held_htlc_available_paths Otherwise, Bolt12Invoice::message_paths and StaticInvoice::message_paths would have the same name but return completely different kinds of paths, which is inconsistent. Claude'd. --- lightning/src/offers/flow.rs | 2 +- lightning/src/offers/invoice.rs | 18 ++++----- lightning/src/offers/static_invoice.rs | 54 +++++++++++++++----------- 3 files changed, 42 insertions(+), 32 deletions(-) diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index efd53035158..6e7293cee6b 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -1270,7 +1270,7 @@ impl OffersMessageFlow { let message = AsyncPaymentsMessage::HeldHtlcAvailable(HeldHtlcAvailable {}); enqueue_onion_message_with_reply_paths( message, - invoice.message_paths(), + invoice.held_htlc_available_paths(), reply_paths, &mut pending_async_payments_messages, ); diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index 8d83225f117..fd77595ca7d 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -1428,7 +1428,7 @@ impl InvoiceFields { fallbacks: self.fallbacks.as_ref(), features, node_id: Some(&self.signing_pubkey), - message_paths: None, + held_htlc_available_paths: None, }, ExperimentalInvoiceTlvStreamRef { #[cfg(test)] @@ -1511,7 +1511,7 @@ tlv_stream!(InvoiceTlvStream, InvoiceTlvStreamRef<'a>, INVOICE_TYPES, { (174, features: (Bolt12InvoiceFeatures, WithoutLength)), (176, node_id: PublicKey), // Only present in `StaticInvoice`s. - (236, message_paths: (Vec, WithoutLength)), + (236, held_htlc_available_paths: (Vec, WithoutLength)), }); /// Valid type range for experimental invoice TLV records. @@ -1700,7 +1700,7 @@ impl TryFrom for InvoiceContents { fallbacks, features, node_id, - message_paths, + held_htlc_available_paths, }, experimental_offer_tlv_stream, experimental_invoice_request_tlv_stream, @@ -1710,7 +1710,7 @@ impl TryFrom for InvoiceContents { }, ) = tlv_stream; - if message_paths.is_some() { + if held_htlc_available_paths.is_some() { return Err(Bolt12SemanticError::UnexpectedPaths); } @@ -2037,7 +2037,7 @@ mod tests { fallbacks: None, features: None, node_id: Some(&recipient_pubkey()), - message_paths: None, + held_htlc_available_paths: None, }, SignatureTlvStreamRef { signature: Some(&invoice.signature()) }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, @@ -2140,7 +2140,7 @@ mod tests { fallbacks: None, features: None, node_id: Some(&recipient_pubkey()), - message_paths: None, + held_htlc_available_paths: None, }, SignatureTlvStreamRef { signature: Some(&invoice.signature()) }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, @@ -3558,7 +3558,7 @@ mod tests { } #[test] - fn fails_parsing_invoice_with_message_paths() { + fn fails_parsing_invoice_with_held_htlc_available_paths() { let expanded_key = ExpandedKey::new([42; 32]); let entropy = FixedEntropy {}; let nonce = Nonce::from_entropy_source(&entropy); @@ -3590,8 +3590,8 @@ mod tests { ); let mut tlv_stream = invoice.as_tlv_stream(); - let message_paths = vec![blinded_path]; - tlv_stream.3.message_paths = Some(&message_paths); + let held_htlc_available_paths = vec![blinded_path]; + tlv_stream.3.held_htlc_available_paths = Some(&held_htlc_available_paths); match Bolt12Invoice::try_from(tlv_stream.to_bytes()) { Ok(_) => panic!("expected error"), diff --git a/lightning/src/offers/static_invoice.rs b/lightning/src/offers/static_invoice.rs index 77f486a6a06..c8afb7cfc12 100644 --- a/lightning/src/offers/static_invoice.rs +++ b/lightning/src/offers/static_invoice.rs @@ -99,7 +99,7 @@ struct InvoiceContents { fallbacks: Option>, features: Bolt12InvoiceFeatures, signing_pubkey: PublicKey, - message_paths: Vec, + held_htlc_available_paths: Vec, #[cfg(test)] experimental_baz: Option, } @@ -122,14 +122,17 @@ impl<'a> StaticInvoiceBuilder<'a> { /// overridden by [`StaticInvoiceBuilder::relative_expiry`]. pub fn for_offer_using_derived_keys( offer: &'a Offer, payment_paths: Vec, - message_paths: Vec, created_at: Duration, expanded_key: &ExpandedKey, - nonce: Nonce, secp_ctx: &Secp256k1, + held_htlc_available_paths: Vec, created_at: Duration, + expanded_key: &ExpandedKey, nonce: Nonce, secp_ctx: &Secp256k1, ) -> Result { if offer.chains().len() > 1 { return Err(Bolt12SemanticError::UnexpectedChain); } - if payment_paths.is_empty() || message_paths.is_empty() || offer.paths().is_empty() { + if payment_paths.is_empty() + || held_htlc_available_paths.is_empty() + || offer.paths().is_empty() + { return Err(Bolt12SemanticError::MissingPaths); } @@ -147,8 +150,13 @@ impl<'a> StaticInvoiceBuilder<'a> { return Err(Bolt12SemanticError::InvalidSigningPubkey); } - let invoice = - InvoiceContents::new(offer, payment_paths, message_paths, created_at, signing_pubkey); + let invoice = InvoiceContents::new( + offer, + payment_paths, + held_htlc_available_paths, + created_at, + signing_pubkey, + ); Ok(Self { offer_bytes: &offer.bytes, invoice, keys }) } @@ -264,8 +272,8 @@ macro_rules! invoice_accessors { ($self: ident, $contents: expr) => { /// Paths to the recipient for indicating that a held HTLC is available to claim when they next /// come online. - pub fn message_paths(&$self) -> &[BlindedMessagePath] { - $contents.message_paths() + pub fn held_htlc_available_paths(&$self) -> &[BlindedMessagePath] { + $contents.held_htlc_available_paths() } /// The quantity of items supported, from [`Offer::supported_quantity`]. @@ -438,12 +446,13 @@ impl InvoiceContents { fn new( offer: &Offer, payment_paths: Vec, - message_paths: Vec, created_at: Duration, signing_pubkey: PublicKey, + held_htlc_available_paths: Vec, created_at: Duration, + signing_pubkey: PublicKey, ) -> Self { Self { offer: offer.contents.clone(), payment_paths, - message_paths, + held_htlc_available_paths, created_at, relative_expiry: None, fallbacks: None, @@ -465,7 +474,7 @@ impl InvoiceContents { let invoice = InvoiceTlvStreamRef { paths: Some(Iterable(self.payment_paths.iter().map(|path| path.inner_blinded_path()))), - message_paths: Some(self.message_paths.as_ref()), + held_htlc_available_paths: Some(self.held_htlc_available_paths.as_ref()), blindedpay: Some(Iterable(self.payment_paths.iter().map(|path| &path.payinfo))), created_at: Some(self.created_at.as_secs()), relative_expiry: self.relative_expiry.map(|duration| duration.as_secs() as u32), @@ -519,8 +528,8 @@ impl InvoiceContents { self.offer.paths() } - fn message_paths(&self) -> &[BlindedMessagePath] { - &self.message_paths[..] + fn held_htlc_available_paths(&self) -> &[BlindedMessagePath] { + &self.held_htlc_available_paths[..] } fn supported_quantity(&self) -> Quantity { @@ -670,7 +679,7 @@ impl TryFrom for InvoiceContents { fallbacks, features, node_id, - message_paths, + held_htlc_available_paths, payment_hash, amount, }, @@ -689,7 +698,8 @@ impl TryFrom for InvoiceContents { } let payment_paths = construct_payment_paths(blindedpay, paths)?; - let message_paths = message_paths.ok_or(Bolt12SemanticError::MissingPaths)?; + let held_htlc_available_paths = + held_htlc_available_paths.ok_or(Bolt12SemanticError::MissingPaths)?; let created_at = match created_at { None => return Err(Bolt12SemanticError::MissingCreationTime), @@ -713,7 +723,7 @@ impl TryFrom for InvoiceContents { Ok(InvoiceContents { offer: OfferContents::try_from((offer_tlv_stream, experimental_offer_tlv_stream))?, payment_paths, - message_paths, + held_htlc_available_paths, created_at, relative_expiry, fallbacks, @@ -875,7 +885,7 @@ mod tests { assert_eq!(invoice.offer_features(), &OfferFeatures::empty()); assert_eq!(invoice.absolute_expiry(), None); assert_eq!(invoice.offer_message_paths(), &[blinded_path()]); - assert_eq!(invoice.message_paths(), &[blinded_path()]); + assert_eq!(invoice.held_htlc_available_paths(), &[blinded_path()]); assert_eq!(invoice.issuer(), None); assert_eq!(invoice.supported_quantity(), Quantity::One); assert_ne!(invoice.signing_pubkey(), recipient_pubkey()); @@ -921,7 +931,7 @@ mod tests { fallbacks: None, features: None, node_id: Some(&signing_pubkey), - message_paths: Some(&paths), + held_htlc_available_paths: Some(&paths), }, SignatureTlvStreamRef { signature: Some(&invoice.signature()) }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, @@ -1318,10 +1328,10 @@ mod tests { }, } - // Error if message paths are missing. - let missing_message_paths_invoice = invoice(); - let mut tlv_stream = missing_message_paths_invoice.as_tlv_stream(); - tlv_stream.1.message_paths = None; + // Error if held_htlc_available_paths are missing. + let missing_held_htlc_available_paths_invoice = invoice(); + let mut tlv_stream = missing_held_htlc_available_paths_invoice.as_tlv_stream(); + tlv_stream.1.held_htlc_available_paths = None; match StaticInvoice::try_from(tlv_stream_to_bytes(&tlv_stream)) { Ok(_) => panic!("expected error"), Err(e) => { From 1322b341bff53b2d42aa999954b6e6d61272e95e Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Fri, 20 Feb 2026 11:59:28 -0800 Subject: [PATCH 086/627] Merge initial and retry stfu send paths In 15b04b5, we fixed a case in `FundedChannel::try_send_stfu` where we'd send `stfu` unnecessarily for a new splice while one is already pending. The same case also existed in `FundedChannel::send_stfu`, but was not fixed. There's no good reason for both of these methods to exist, so we merge them into one as `FundedChannel::try_send_stfu`. We also add a test that reproduces the `FundedChannel::send_stfu` issue to ensure it's fixed and does not regress. --- lightning/src/ln/channel.rs | 135 +++++++++++++---------------- lightning/src/ln/channelmanager.rs | 16 ++-- lightning/src/ln/splicing_tests.rs | 43 +++++++++ 3 files changed, 106 insertions(+), 88 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 7943ed98719..35d5864ada5 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -80,7 +80,7 @@ use crate::util::config::{ MaxDustHTLCExposure, UserConfig, }; use crate::util::errors::APIError; -use crate::util::logger::{Logger, Record, WithContext}; +use crate::util::logger::{Level as LoggerLevel, Logger, Record, WithContext}; use crate::util::scid_utils::{block_from_scid, scid_from_parts}; use crate::util::ser::{Readable, ReadableArgs, RequiredWrapper, Writeable, Writer}; use crate::util::wallet_utils::Input; @@ -13410,65 +13410,19 @@ where ); return Err(action); } + // Since we don't have a pending quiescent action, we should never be in a state where we + // sent `stfu` without already having become quiescent. + debug_assert!(!self.context.channel_state.is_awaiting_quiescence()); + debug_assert!(!self.context.channel_state.is_local_stfu_sent()); self.quiescent_action = Some(action); - if self.context.channel_state.is_quiescent() - || self.context.channel_state.is_awaiting_quiescence() - || self.context.channel_state.is_local_stfu_sent() - { - log_debug!(logger, "Channel is either pending quiescence or already quiescent"); + if self.context.channel_state.is_quiescent() { + log_debug!(logger, "Channel is already quiescent"); return Ok(None); } self.context.channel_state.set_awaiting_quiescence(); - if self.context.is_live() { - match self.send_stfu(logger) { - Ok(stfu) => Ok(Some(stfu)), - Err(e) => { - log_debug!(logger, "{e}"); - Ok(None) - }, - } - } else { - log_debug!(logger, "Waiting for peer reconnection to send stfu"); - Ok(None) - } - } - - // Assumes we are either awaiting quiescence or our counterparty has requested quiescence. - #[rustfmt::skip] - pub fn send_stfu(&mut self, logger: &L) -> Result { - debug_assert!(!self.context.channel_state.is_local_stfu_sent()); - debug_assert!( - self.context.channel_state.is_awaiting_quiescence() - || self.context.channel_state.is_remote_stfu_sent() - ); - debug_assert!(self.context.is_live()); - - if self.context.is_waiting_on_peer_pending_channel_update() - || self.context.is_monitor_or_signer_pending_channel_update() - { - return Err("We cannot send `stfu` while state machine is pending") - } - - let initiator = if self.context.channel_state.is_remote_stfu_sent() { - // We may have also attempted to initiate quiescence. - self.context.channel_state.clear_awaiting_quiescence(); - self.context.channel_state.clear_remote_stfu_sent(); - self.context.channel_state.set_quiescent(); - // We are sending an stfu in response to our couterparty's stfu, but had not yet sent - // our own stfu (even if `awaiting_quiescence` was set). Thus, the counterparty is the - // initiator and they can do "something fundamental". - false - } else { - log_debug!(logger, "Sending stfu as quiescence initiator"); - debug_assert!(self.context.channel_state.is_awaiting_quiescence()); - self.context.channel_state.clear_awaiting_quiescence(); - self.context.channel_state.set_local_stfu_sent(); - true - }; - - Ok(msgs::Stfu { channel_id: self.context.channel_id, initiator }) + Ok(self.try_send_stfu(false, logger)) } #[rustfmt::skip] @@ -13505,10 +13459,7 @@ where self.context.channel_state.set_remote_stfu_sent(); log_debug!(logger, "Received counterparty stfu proposing quiescence"); - return self - .send_stfu(logger) - .map(|stfu| Some(StfuResponse::Stfu(stfu))) - .map_err(|e| ChannelError::Ignore(e.to_owned())); + return Ok(self.try_send_stfu(false, logger).map(|stfu| StfuResponse::Stfu(stfu))) } // We already sent `stfu` and are now processing theirs. It may be in response to ours, or @@ -13610,17 +13561,30 @@ where Ok(None) } - pub fn try_send_stfu( - &mut self, logger: &L, - ) -> Result, ChannelError> { + pub fn try_send_stfu(&mut self, is_retry: bool, logger: &L) -> Option { // We must never see both stfu flags set, we always set the quiescent flag instead. debug_assert!( !(self.context.channel_state.is_local_stfu_sent() && self.context.channel_state.is_remote_stfu_sent()) ); + // We only need to send `stfu` when we're awaiting quiescence and haven't sent it yet, or + // in response to a counterparty one. + if self.context.channel_state.is_local_stfu_sent() + || self.context.channel_state.is_quiescent() + { + return None; + } + if !self.context.channel_state.is_awaiting_quiescence() + && !self.context.channel_state.is_remote_stfu_sent() + { + return None; + } + + let logger_level = if is_retry { LoggerLevel::Trace } else { LoggerLevel::Debug }; if !self.context.is_live() { - return Ok(None); + log_given_level!(logger, logger_level, "Waiting for peer reconnection to send stfu"); + return None; } if let Some(action) = self.quiescent_action.as_ref() { @@ -13630,27 +13594,44 @@ where let has_splice_action = matches!(action, QuiescentAction::Splice { .. }) || matches!(action, QuiescentAction::LegacySplice(_)); if has_splice_action && self.pending_splice.is_some() { - return Ok(None); + log_given_level!( + logger, + logger_level, + "Waiting for pending splice to lock before sending stfu for new splice" + ); + return None; } } - // We need to send our `stfu`, either because we're trying to initiate quiescence, or the - // counterparty is and we've yet to send ours. - if self.context.channel_state.is_awaiting_quiescence() - || (self.context.channel_state.is_remote_stfu_sent() - && !self.context.channel_state.is_local_stfu_sent()) + if self.context.is_waiting_on_peer_pending_channel_update() + || self.context.is_monitor_or_signer_pending_channel_update() { - return self - .send_stfu(logger) - .map(|stfu| Some(stfu)) - .map_err(|e| ChannelError::Ignore(e.to_owned())); + log_given_level!( + logger, + logger_level, + "Waiting for state machine pending changes to complete before sending stfu" + ); + return None; } - // We're either: - // - already quiescent - // - in a state where quiescence is not possible - // - not currently trying to become quiescent - Ok(None) + let initiator = if self.context.channel_state.is_remote_stfu_sent() { + // We may have also attempted to initiate quiescence. + self.context.channel_state.clear_awaiting_quiescence(); + self.context.channel_state.clear_remote_stfu_sent(); + self.context.channel_state.set_quiescent(); + // We are sending an stfu in response to our counterparty's stfu, but had not yet sent + // our own stfu (even if `awaiting_quiescence` was set). Thus, the counterparty is the + // initiator and they can do "something fundamental". + false + } else { + log_debug!(logger, "Sending stfu as quiescence initiator"); + debug_assert!(self.context.channel_state.is_awaiting_quiescence()); + self.context.channel_state.clear_awaiting_quiescence(); + self.context.channel_state.set_local_stfu_sent(); + true + }; + + Some(msgs::Stfu { channel_id: self.context.channel_id, initiator }) } #[cfg(any(test, fuzzing, feature = "_test_utils"))] diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 08cbb6f6bf7..5cb7d362579 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -13342,17 +13342,11 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let logger = WithContext::from( &self.logger, Some(*counterparty_node_id), Some(*channel_id), None ); - match funded_chan.try_send_stfu(&&logger) { - Ok(None) => {}, - Ok(Some(stfu)) => { - pending_msg_events.push(MessageSendEvent::SendStfu { - node_id: chan.context().get_counterparty_node_id(), - msg: stfu, - }); - }, - Err(e) => { - log_debug!(logger, "Could not advance quiescence handshake: {}", e); - } + if let Some(stfu) = funded_chan.try_send_stfu(true, &&logger) { + pending_msg_events.push(MessageSendEvent::SendStfu { + node_id: chan.context().get_counterparty_node_id(), + msg: stfu, + }); } } } diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 92a298f6ef1..ed111269fc7 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -1145,6 +1145,49 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { ); } +#[test] +fn test_initiating_splice_holds_stfu_with_pending_splice() { + // Test that we don't send stfu too early for a new splice while we're already pending one. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let config = test_default_channel_config(); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_0_id = nodes[0].node.get_our_node_id(); + provide_utxo_reserves(&nodes, 2, Amount::ONE_BTC); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + // Have both nodes attempt a splice, but only node 0 will call back and negotiate the splice. + let value_added = Amount::from_sat(10_000); + let funding_contribution_0 = initiate_splice_in(&nodes[0], &nodes[1], channel_id, value_added); + + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = nodes[1].node.splice_channel(&channel_id, &node_0_id, feerate).unwrap(); + + let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution_0); + + // With the splice negotiated, have node 1 call back. This will queue the quiescent action, but + // it shouldn't send stfu yet as there's a pending splice. + let wallet = WalletSync::new(Arc::clone(&nodes[1].wallet_source), &nodes[1].logger); + let funding_contribution = funding_template.splice_in_sync(value_added, &wallet).unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_0_id, funding_contribution.clone(), None) + .unwrap(); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + + mine_transaction(&nodes[0], &splice_tx); + mine_transaction(&nodes[1], &splice_tx); + let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], 5); + assert!( + matches!(stfu, Some(MessageSendEvent::SendStfu { node_id, .. }) if node_id == node_0_id) + ); +} + #[cfg(test)] #[derive(PartialEq)] enum SpliceStatus { From 7e8337a41720741b0fc6c998f1421e58a159705f Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Fri, 20 Feb 2026 14:27:24 -0800 Subject: [PATCH 087/627] Remove awaiting quiescence channel state flag With the introduction of `QuiescentAction`, the flag has essentially become duplicate state, so we opt to remove it in favor of just checking whether we have a pending `FundedChannel::quiescent_action`. Since the quiescent flags are never persisted, we can simply remove it and update the other flags, freeing up a bit for future use. --- lightning/src/ln/channel.rs | 70 ++++------------------------ lightning/src/ln/quiescence_tests.rs | 5 ++ 2 files changed, 14 insertions(+), 61 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 35d5864ada5..033e2ac9aff 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -679,10 +679,9 @@ mod state_flags { pub const LOCAL_SHUTDOWN_SENT: u32 = 1 << 11; pub const SHUTDOWN_COMPLETE: u32 = 1 << 12; pub const WAITING_FOR_BATCH: u32 = 1 << 13; - pub const AWAITING_QUIESCENCE: u32 = 1 << 14; - pub const LOCAL_STFU_SENT: u32 = 1 << 15; - pub const REMOTE_STFU_SENT: u32 = 1 << 16; - pub const QUIESCENT: u32 = 1 << 17; + pub const LOCAL_STFU_SENT: u32 = 1 << 14; + pub const REMOTE_STFU_SENT: u32 = 1 << 15; + pub const QUIESCENT: u32 = 1 << 16; } define_state_flags!( @@ -749,13 +748,8 @@ define_state_flags!( implicit ACK, so instead we have to hold them away temporarily to be sent later.", AWAITING_REMOTE_REVOKE, state_flags::AWAITING_REMOTE_REVOKE, is_awaiting_remote_revoke, set_awaiting_remote_revoke, clear_awaiting_remote_revoke), - ("Indicates a local request has been made for the channel to become quiescent. Both nodes \ - must send `stfu` for the channel to become quiescent. This flag will be cleared and we \ - will no longer attempt quiescence if either node requests a shutdown.", - AWAITING_QUIESCENCE, state_flags::AWAITING_QUIESCENCE, - is_awaiting_quiescence, set_awaiting_quiescence, clear_awaiting_quiescence), ("Indicates we have sent a `stfu` message to the counterparty. This message can only be sent \ - if either `AWAITING_QUIESCENCE` or `REMOTE_STFU_SENT` is set. Shutdown requests are \ + if `REMOTE_STFU_SENT` is set, or a `QuiescentAction` is pending. Shutdown requests are \ rejected if this flag is set.", LOCAL_STFU_SENT, state_flags::LOCAL_STFU_SENT, is_local_stfu_sent, set_local_stfu_sent, clear_local_stfu_sent), @@ -950,12 +944,6 @@ impl ChannelState { clear_awaiting_remote_revoke, ChannelReady ); - impl_state_flag!( - is_awaiting_quiescence, - set_awaiting_quiescence, - clear_awaiting_quiescence, - ChannelReady - ); impl_state_flag!(is_local_stfu_sent, set_local_stfu_sent, clear_local_stfu_sent, ChannelReady); impl_state_flag!( is_remote_stfu_sent, @@ -1750,10 +1738,6 @@ where let splice_funding_failed = if let ChannelPhase::Funded(chan) = &mut self.phase { // Reset any quiescence-related state as it is implicitly terminated once disconnected. if matches!(chan.context.channel_state, ChannelState::ChannelReady(_)) { - if chan.quiescent_action.is_some() { - // If we were trying to get quiescent, try again after reconnection. - chan.context.channel_state.set_awaiting_quiescence(); - } chan.context.channel_state.clear_local_stfu_sent(); chan.context.channel_state.clear_remote_stfu_sent(); if chan.should_reset_pending_splice_state(false) { @@ -7088,7 +7072,6 @@ where } else { match self.quiescent_action.take() { Some(QuiescentAction::LegacySplice(instructions)) => { - self.context.channel_state.clear_awaiting_quiescence(); let (inputs, outputs) = instructions.into_contributed_inputs_and_outputs(); Some(SpliceFundingFailed { funding_txo: None, @@ -7098,7 +7081,6 @@ where }) }, Some(QuiescentAction::Splice { contribution, .. }) => { - self.context.channel_state.clear_awaiting_quiescence(); let (inputs, outputs) = contribution.into_contributed_inputs_and_outputs(); Some(SpliceFundingFailed { funding_txo: None, @@ -10747,11 +10729,6 @@ where // From here on out, we may not fail! self.context.channel_state.set_remote_shutdown_sent(); - if self.context.channel_state.is_awaiting_quiescence() { - // We haven't been able to send `stfu` yet, and there's no point in attempting - // quiescence anymore since the counterparty wishes to close the channel. - self.context.channel_state.clear_awaiting_quiescence(); - } self.context.update_time_counter += 1; let monitor_update = if update_shutdown_script { @@ -11526,17 +11503,6 @@ where let announcement_sigs = self.get_announcement_sigs(node_signer, chain_hash, user_config, block_height, logger); - if let Some(quiescent_action) = self.quiescent_action.as_ref() { - // TODO(splicing): If we didn't win quiescence, then we can contribute as an acceptor - // instead of waiting for the splice to lock. - if matches!( - quiescent_action, - QuiescentAction::Splice { .. } | QuiescentAction::LegacySplice(_) - ) { - self.context.channel_state.set_awaiting_quiescence(); - } - } - Some(SpliceFundingPromotion { funding_txo, monitor_update, @@ -13314,9 +13280,6 @@ where // From here on out, we may not fail! self.context.target_closing_feerate_sats_per_kw = target_feerate_sats_per_kw; self.context.channel_state.set_local_shutdown_sent(); - if self.context.channel_state.is_awaiting_quiescence() { - self.context.channel_state.clear_awaiting_quiescence(); - } self.context.local_initiated_shutdown = Some(()); self.context.update_time_counter += 1; @@ -13412,7 +13375,6 @@ where } // Since we don't have a pending quiescent action, we should never be in a state where we // sent `stfu` without already having become quiescent. - debug_assert!(!self.context.channel_state.is_awaiting_quiescence()); debug_assert!(!self.context.channel_state.is_local_stfu_sent()); self.quiescent_action = Some(action); @@ -13421,7 +13383,6 @@ where return Ok(None); } - self.context.channel_state.set_awaiting_quiescence(); Ok(self.try_send_stfu(false, logger)) } @@ -13570,13 +13531,11 @@ where // We only need to send `stfu` when we're awaiting quiescence and haven't sent it yet, or // in response to a counterparty one. - if self.context.channel_state.is_local_stfu_sent() - || self.context.channel_state.is_quiescent() - { + if self.quiescent_action.is_none() && !self.context.channel_state.is_remote_stfu_sent() { return None; } - if !self.context.channel_state.is_awaiting_quiescence() - && !self.context.channel_state.is_remote_stfu_sent() + if self.context.channel_state.is_local_stfu_sent() + || self.context.channel_state.is_quiescent() { return None; } @@ -13615,18 +13574,13 @@ where } let initiator = if self.context.channel_state.is_remote_stfu_sent() { - // We may have also attempted to initiate quiescence. - self.context.channel_state.clear_awaiting_quiescence(); + // Since we may have also attempted to initiate quiescence but the counterparty + // initiated first, we'll retry after we're no longer quiescent. self.context.channel_state.clear_remote_stfu_sent(); self.context.channel_state.set_quiescent(); - // We are sending an stfu in response to our counterparty's stfu, but had not yet sent - // our own stfu (even if `awaiting_quiescence` was set). Thus, the counterparty is the - // initiator and they can do "something fundamental". false } else { log_debug!(logger, "Sending stfu as quiescence initiator"); - debug_assert!(self.context.channel_state.is_awaiting_quiescence()); - self.context.channel_state.clear_awaiting_quiescence(); self.context.channel_state.set_local_stfu_sent(); true }; @@ -13639,7 +13593,6 @@ where pub fn exit_quiescence(&mut self) -> bool { // Make sure we either finished the quiescence handshake and are quiescent, or we never // attempted to initiate quiescence at all. - debug_assert!(!self.context.channel_state.is_awaiting_quiescence()); debug_assert!(!self.context.channel_state.is_local_stfu_sent()); debug_assert!(!self.context.channel_state.is_remote_stfu_sent()); @@ -14744,11 +14697,6 @@ impl Writeable for FundedChannel { match channel_state { ChannelState::AwaitingChannelReady(_) => {}, ChannelState::ChannelReady(_) => { - if self.quiescent_action.is_some() { - // If we're trying to get quiescent to do something, try again when we - // reconnect to the peer. - channel_state.set_awaiting_quiescence(); - } channel_state.clear_local_stfu_sent(); channel_state.clear_remote_stfu_sent(); if self.should_reset_pending_splice_state(false) diff --git a/lightning/src/ln/quiescence_tests.rs b/lightning/src/ln/quiescence_tests.rs index d972fb6a5c5..56dc4d42797 100644 --- a/lightning/src/ln/quiescence_tests.rs +++ b/lightning/src/ln/quiescence_tests.rs @@ -35,6 +35,11 @@ fn test_quiescence_tie() { assert!(nodes[0].node.exit_quiescence(&nodes[1].node.get_our_node_id(), &chan_id).unwrap()); assert!(nodes[1].node.exit_quiescence(&nodes[0].node.get_our_node_id(), &chan_id).unwrap()); + + // Since node 1 lost the tie, they'll attempt quiescence again. + let stfu = + get_event_msg!(nodes[1], MessageSendEvent::SendStfu, nodes[0].node.get_our_node_id()); + assert!(stfu.initiator); } #[test] From 1e571322e6ec0de56cc7f97fe60a7eda2a25e981 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Mon, 23 Feb 2026 15:04:49 -0800 Subject: [PATCH 088/627] Abandon pending quiescent action upon shutdown initiation Otherwise, now that we no longer have the awaiting quiescence state flag, we may end up sending `stfu` for a channel we intend to close. --- lightning/src/ln/channel.rs | 78 ++++++++++++++++++------------ lightning/src/ln/channelmanager.rs | 62 +++++++++++++++++------- lightning/src/ln/splicing_tests.rs | 60 +++++++++++++++++++++++ 3 files changed, 153 insertions(+), 47 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 033e2ac9aff..fee74aada0d 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -7065,37 +7065,41 @@ where shutdown_result } + fn abandon_quiescent_action(&mut self) -> Option { + match self.quiescent_action.take() { + Some(QuiescentAction::LegacySplice(instructions)) => { + let (inputs, outputs) = instructions.into_contributed_inputs_and_outputs(); + Some(SpliceFundingFailed { + funding_txo: None, + channel_type: None, + contributed_inputs: inputs, + contributed_outputs: outputs, + }) + }, + Some(QuiescentAction::Splice { contribution, .. }) => { + let (inputs, outputs) = contribution.into_contributed_inputs_and_outputs(); + Some(SpliceFundingFailed { + funding_txo: None, + channel_type: None, + contributed_inputs: inputs, + contributed_outputs: outputs, + }) + }, + #[cfg(any(test, fuzzing, feature = "_test_utils"))] + Some(quiescent_action) => { + self.quiescent_action = Some(quiescent_action); + None + }, + None => None, + } + } + fn maybe_fail_splice_negotiation(&mut self) -> Option { if matches!(self.context.channel_state, ChannelState::ChannelReady(_)) { if self.should_reset_pending_splice_state(false) { self.reset_pending_splice_state() } else { - match self.quiescent_action.take() { - Some(QuiescentAction::LegacySplice(instructions)) => { - let (inputs, outputs) = instructions.into_contributed_inputs_and_outputs(); - Some(SpliceFundingFailed { - funding_txo: None, - channel_type: None, - contributed_inputs: inputs, - contributed_outputs: outputs, - }) - }, - Some(QuiescentAction::Splice { contribution, .. }) => { - let (inputs, outputs) = contribution.into_contributed_inputs_and_outputs(); - Some(SpliceFundingFailed { - funding_txo: None, - channel_type: None, - contributed_inputs: inputs, - contributed_outputs: outputs, - }) - }, - #[cfg(any(test, fuzzing, feature = "_test_utils"))] - Some(quiescent_action) => { - self.quiescent_action = Some(quiescent_action); - None - }, - None => None, - } + self.abandon_quiescent_action() } } else { None @@ -10638,7 +10642,12 @@ where &mut self, logger: &L, signer_provider: &SP, their_features: &InitFeatures, msg: &msgs::Shutdown, ) -> Result< - (Option, Option, Vec<(HTLCSource, PaymentHash)>), + ( + Option, + Option, + Vec<(HTLCSource, PaymentHash)>, + Option, + ), ChannelError, > { if self.context.channel_state.is_peer_disconnected() { @@ -10779,7 +10788,9 @@ where self.context.channel_state.set_local_shutdown_sent(); self.context.update_time_counter += 1; - Ok((shutdown, monitor_update, dropped_outbound_htlcs)) + let splice_funding_failed = self.abandon_quiescent_action(); + + Ok((shutdown, monitor_update, dropped_outbound_htlcs, splice_funding_failed)) } fn build_signed_closing_transaction( @@ -13206,7 +13217,12 @@ where target_feerate_sats_per_kw: Option, override_shutdown_script: Option, logger: &L, ) -> Result< - (msgs::Shutdown, Option, Vec<(HTLCSource, PaymentHash)>), + ( + msgs::Shutdown, + Option, + Vec<(HTLCSource, PaymentHash)>, + Option, + ), APIError, > { let logger = WithChannelContext::from(logger, &self.context, None); @@ -13328,7 +13344,9 @@ where "we can't both complete shutdown and return a monitor update" ); - Ok((shutdown, monitor_update, dropped_outbound_htlcs)) + let splice_funding_failed = self.abandon_quiescent_action(); + + Ok((shutdown, monitor_update, dropped_outbound_htlcs, splice_funding_failed)) } // Miscellaneous utilities diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 5cb7d362579..0e764d6cc9b 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -3901,15 +3901,31 @@ impl< if let Some(chan) = chan_entry.get_mut().as_funded_mut() { let funding_txo_opt = chan.funding.get_funding_txo(); let their_features = &peer_state.latest_features; - let (shutdown_msg, mut monitor_update_opt, htlcs) = chan.get_shutdown( - &self.signer_provider, - their_features, - target_feerate_sats_per_1000_weight, - override_shutdown_script, - &self.logger, - )?; + let (shutdown_msg, mut monitor_update_opt, htlcs, splice_funding_failed) = + chan.get_shutdown( + &self.signer_provider, + their_features, + target_feerate_sats_per_1000_weight, + override_shutdown_script, + &self.logger, + )?; failed_htlcs = htlcs; + if let Some(splice_funding_failed) = splice_funding_failed { + self.pending_events.lock().unwrap().push_back(( + events::Event::SpliceFailed { + channel_id: *chan_id, + counterparty_node_id: *counterparty_node_id, + user_channel_id: chan.context().get_user_id(), + abandoned_funding_txo: splice_funding_failed.funding_txo, + channel_type: splice_funding_failed.channel_type, + contributed_inputs: splice_funding_failed.contributed_inputs, + contributed_outputs: splice_funding_failed.contributed_outputs, + }, + None, + )); + } + // We can send the `shutdown` message before updating the `ChannelMonitor` // here as we don't need the monitor update to complete until we send a // `shutdown_signed`, which we'll delay if we're pending a monitor update. @@ -11779,19 +11795,31 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } let funding_txo_opt = chan.funding.get_funding_txo(); - let (shutdown, monitor_update_opt, htlcs) = try_channel_entry!( - self, - peer_state, - chan.shutdown( - &self.logger, - &self.signer_provider, - &peer_state.latest_features, - &msg - ), - chan_entry + let res = chan.shutdown( + &self.logger, + &self.signer_provider, + &peer_state.latest_features, + &msg, ); + let (shutdown, monitor_update_opt, htlcs, splice_funding_failed) = + try_channel_entry!(self, peer_state, res, chan_entry); dropped_htlcs = htlcs; + if let Some(splice_funding_failed) = splice_funding_failed { + self.pending_events.lock().unwrap().push_back(( + events::Event::SpliceFailed { + channel_id: msg.channel_id, + counterparty_node_id: *counterparty_node_id, + user_channel_id: chan.context().get_user_id(), + abandoned_funding_txo: splice_funding_failed.funding_txo, + channel_type: splice_funding_failed.channel_type, + contributed_inputs: splice_funding_failed.contributed_inputs, + contributed_outputs: splice_funding_failed.contributed_outputs, + }, + None, + )); + } + if let Some(msg) = shutdown { // We can send the `shutdown` message before updating the `ChannelMonitor` // here as we don't need the monitor update to complete until we send a diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index ed111269fc7..fc18a9ec766 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -2406,6 +2406,66 @@ fn fail_quiescent_action_on_channel_close() { check_added_monitors(&nodes[0], 1); } +#[test] +fn abandon_splice_quiescent_action_on_shutdown() { + do_abandon_splice_quiescent_action_on_shutdown(true); + do_abandon_splice_quiescent_action_on_shutdown(false); +} + +#[cfg(test)] +fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + provide_utxo_reserves(&nodes, 1, Amount::ONE_BTC); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_capacity = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0); + + // Since we cannot close after having sent `stfu`, send an HTLC so that when we attempt to + // splice, the `stfu` message is held back. + let (route, payment_hash, _payment_preimage, payment_secret) = + get_route_and_payment_hash!(&nodes[0], &nodes[1], 1_000_000); + let onion = RecipientOnionFields::secret_only(payment_secret); + let payment_id = PaymentId(payment_hash.0); + nodes[0].node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); + let update = get_htlc_update_msgs(&nodes[0], &node_id_1); + check_added_monitors(&nodes[0], 1); + + nodes[1].node.handle_update_add_htlc(node_id_0, &update.update_add_htlcs[0]); + nodes[1].node.handle_commitment_signed(node_id_0, &update.commitment_signed[0]); + check_added_monitors(&nodes[1], 1); + let (revoke_and_ack, _) = get_revoke_commit_msgs(&nodes[1], &node_id_0); + + nodes[0].node.handle_revoke_and_ack(node_id_1, &revoke_and_ack); + check_added_monitors(&nodes[0], 1); + + // Attempt the splice. `stfu` should not go out yet as the state machine is pending. + let splice_in_amount = initial_channel_capacity / 2; + let _ = + initiate_splice_in(&nodes[0], &nodes[1], channel_id, Amount::from_sat(splice_in_amount)); + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + + // Close the channel. We should see a `SpliceFailed` event for the pending splice + // `QuiescentAction`. + let (closer_node, closee_node) = + if local_shutdown { (&nodes[0], &nodes[1]) } else { (&nodes[1], &nodes[0]) }; + let closer_node_id = closer_node.node.get_our_node_id(); + let closee_node_id = closee_node.node.get_our_node_id(); + + closer_node.node.close_channel(&channel_id, &closee_node_id).unwrap(); + let shutdown = get_event_msg!(closer_node, MessageSendEvent::SendShutdown, closee_node_id); + closee_node.node.handle_shutdown(closer_node_id, &shutdown); + + let _ = get_event!(nodes[0], Event::SpliceFailed); + let _ = get_event_msg!(closee_node, MessageSendEvent::SendShutdown, closer_node_id); +} + #[cfg(test)] fn do_test_splice_with_inflight_htlc_forward_and_resolution(expire_scid_pre_forward: bool) { // Test that we are still able to forward and resolve HTLCs while the original SCIDs contained From 58018e0c06043e4217b1487b713b2e3748d4eae4 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Thu, 19 Feb 2026 12:31:57 +0100 Subject: [PATCH 089/627] chanmon_consistency: assert claimed payments result in PaymentSent Add an invariant to the settlement phase: every payment that a receiver claimed (via claim_funds) must result in a PaymentSent event at the sender. This catches bugs where a claimed payment's preimage fails to propagate back to the sender. To support this, change resolved_payments from Vec to HashMap>, storing Some(hash) for PaymentSent and None for PaymentFailed/probes. Co-Authored-By: Claude Opus 4.6 --- fuzz/src/chanmon_consistency.rs | 35 ++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 21623fdba1e..573a2fd707f 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1356,7 +1356,9 @@ pub fn do_test( let mut node_c_ser = nodes[2].encode(); let pending_payments = RefCell::new([Vec::new(), Vec::new(), Vec::new()]); - let resolved_payments = RefCell::new([Vec::new(), Vec::new(), Vec::new()]); + let resolved_payments: RefCell<[HashMap>; 3]> = + RefCell::new([new_hash_map(), new_hash_map(), new_hash_map()]); + let claimed_payment_hashes: RefCell> = RefCell::new(HashSet::new()); macro_rules! test_return { () => {{ @@ -1864,18 +1866,19 @@ pub fn do_test( nodes[$node].fail_htlc_backwards(&payment_hash); } else { nodes[$node].claim_funds(PaymentPreimage(payment_hash.0)); + claimed_payment_hashes.borrow_mut().insert(payment_hash); } } }, - events::Event::PaymentSent { payment_id, .. } => { + events::Event::PaymentSent { payment_id, payment_hash, .. } => { let sent_id = payment_id.unwrap(); let idx_opt = pending_payments[$node].iter().position(|id| *id == sent_id); if let Some(idx) = idx_opt { pending_payments[$node].remove(idx); - resolved_payments[$node].push(sent_id); + resolved_payments[$node].insert(sent_id, Some(payment_hash)); } else { - assert!(resolved_payments[$node].contains(&sent_id)); + assert!(resolved_payments[$node].contains_key(&sent_id)); } }, // Even though we don't explicitly send probes, because probes are @@ -1887,9 +1890,9 @@ pub fn do_test( pending_payments[$node].iter().position(|id| *id == payment_id); if let Some(idx) = idx_opt { pending_payments[$node].remove(idx); - resolved_payments[$node].push(payment_id); + resolved_payments[$node].insert(payment_id, None); } else { - assert!(resolved_payments[$node].contains(&payment_id)); + assert!(resolved_payments[$node].contains_key(&payment_id)); } }, events::Event::PaymentFailed { payment_id, .. } @@ -1898,11 +1901,11 @@ pub fn do_test( pending_payments[$node].iter().position(|id| *id == payment_id); if let Some(idx) = idx_opt { pending_payments[$node].remove(idx); - resolved_payments[$node].push(payment_id); - } else if !resolved_payments[$node].contains(&payment_id) { + resolved_payments[$node].insert(payment_id, None); + } else if !resolved_payments[$node].contains_key(&payment_id) { // Payment failed immediately on send, so it was never added to // pending_payments. Add it to resolved_payments to track it. - resolved_payments[$node].push(payment_id); + resolved_payments[$node].insert(payment_id, None); } }, events::Event::PaymentClaimed { .. } => {}, @@ -2705,6 +2708,20 @@ pub fn do_test( ); } + // Verify that every payment claimed by a receiver resulted in a + // PaymentSent event at the sender. + let resolved = resolved_payments.borrow(); + for hash in claimed_payment_hashes.borrow().iter() { + let found = resolved.iter().any(|node_resolved| { + node_resolved.values().any(|h| h.as_ref() == Some(hash)) + }); + assert!( + found, + "Payment {:?} was claimed by receiver but sender never got PaymentSent", + hash + ); + } + // Finally, make sure that at least one end of each channel can make a substantial payment for &chan_id in &chan_ab_ids { assert!( From 37e75e7ab369162b81ec2bc4556a0d228899da18 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Sun, 8 Feb 2026 23:43:07 +0000 Subject: [PATCH 090/627] Use HTLC CLTV instead of onion CLTV values for payment claim timer When we receive an HTLC as a part of a claim, we validate that the CLTV on the HTLC is >= the CLTV that the sender requested we receive, but then we use the CLTV value that the sender requested we receive as the deadline to claim the HTLC anyway. This isn't generally all that interesting (they're always the same unless the previous-hop node gave us "free CLTV"), but for trampoline payments where we're both a trampoline hop and the blinded intro point and the recipient, it means we end up allowing ourselves less claim time than we actually have. Instead, here, we just use the actual HTLC CLTV deadline. --- lightning/src/ln/blinded_payment_tests.rs | 15 +++++---------- lightning/src/ln/onion_payment.rs | 6 +++--- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index e8469cade60..b945b8949d8 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -981,11 +981,11 @@ fn do_multi_hop_receiver_fail(check: ReceiveCheckFail) { }; let amt_msat = 5000; - let excess_final_cltv_delta_opt = if check == ReceiveCheckFail::ProcessPendingHTLCsCheck { - // Set the final CLTV expiry too low to trigger the failure in process_pending_htlc_forwards. - Some(TEST_FINAL_CLTV as u16 - 2) + let required_final_cltv = if check == ReceiveCheckFail::ProcessPendingHTLCsCheck { + // Set the final CLTV required much too high to trigger the failure in process_pending_htlc_forwards. + Some((TEST_FINAL_CLTV as u16) * 10) } else { None }; - let (_, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[2], Some(amt_msat), excess_final_cltv_delta_opt); + let (_, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[2], Some(amt_msat), required_final_cltv); let mut route_params = get_blinded_route_parameters(amt_msat, payment_secret, 1, 1_0000_0000, nodes.iter().skip(1).map(|n| n.node.get_our_node_id()).collect(), &[&chan_upd_1_2], &chanmon_cfgs[2].keys_manager); @@ -993,11 +993,7 @@ fn do_multi_hop_receiver_fail(check: ReceiveCheckFail) { route_params.payment_params.max_path_length = 17; let route = if check == ReceiveCheckFail::ProcessPendingHTLCsCheck { - let mut route = get_route(&nodes[0], &route_params).unwrap(); - // Set the final CLTV expiry too low to trigger the failure in process_pending_htlc_forwards. - route.paths[0].hops.last_mut().map(|h| h.cltv_expiry_delta += excess_final_cltv_delta_opt.unwrap() as u32); - route.paths[0].blinded_tail.as_mut().map(|bt| bt.excess_final_cltv_expiry_delta = excess_final_cltv_delta_opt.unwrap() as u32); - route + get_route(&nodes[0], &route_params).unwrap() } else if check == ReceiveCheckFail::PaymentConstraints { // Create a blinded path where the receiver's encrypted payload has an htlc_minimum_msat that is // violated by `amt_msat`, and stick it in the route_params without changing the corresponding @@ -1115,7 +1111,6 @@ fn do_multi_hop_receiver_fail(check: ReceiveCheckFail) { check_added_monitors(&nodes[2], 1); }, ReceiveCheckFail::ProcessPendingHTLCsCheck => { - assert_eq!(payment_event_1_2.msgs[0].cltv_expiry, nodes[0].best_block_info().1 + 1 + excess_final_cltv_delta_opt.unwrap() as u32 + TEST_FINAL_CLTV); nodes[2].node.handle_update_add_htlc(nodes[1].node.get_our_node_id(), &payment_event_1_2.msgs[0]); check_added_monitors(&nodes[2], 0); do_commitment_signed_dance(&nodes[2], &nodes[1], &payment_event_1_2.commitment_msg, true, true); diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index def4a1861c4..5111f6982fe 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -438,7 +438,7 @@ pub(super) fn create_recv_pending_htlc_info( payment_data, payment_preimage, payment_metadata, - incoming_cltv_expiry: onion_cltv_expiry, + incoming_cltv_expiry: cltv_expiry, custom_tlvs, requires_blinded_error, has_recipient_created_payment_secret, @@ -450,7 +450,7 @@ pub(super) fn create_recv_pending_htlc_info( payment_data: data, payment_metadata, payment_context, - incoming_cltv_expiry: onion_cltv_expiry, + incoming_cltv_expiry: cltv_expiry, phantom_shared_secret, trampoline_shared_secret, custom_tlvs, @@ -842,7 +842,7 @@ mod tests { PendingHTLCRouting::ReceiveKeysend { payment_preimage, payment_data, incoming_cltv_expiry, .. } => { assert_eq!(payment_preimage, preimage); assert_eq!(peeled2.outgoing_amt_msat, recipient_amount); - assert_eq!(incoming_cltv_expiry, peeled2.outgoing_cltv_value); + assert_eq!(incoming_cltv_expiry, msg.cltv_expiry); let msgs::FinalOnionHopData{total_msat, payment_secret} = payment_data.unwrap(); assert_eq!(total_msat, total_amt_msat); assert_eq!(payment_secret, pay_secret); From 4867c309385e2db7f5210ac14757c0c2146db5cb Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Sun, 8 Feb 2026 23:45:27 +0000 Subject: [PATCH 091/627] Fix trampoline onion encoding to match doc-declared CLTV rules The docs for `RouteHop::cltv_expiry_delta` claim that it includes any trampoline hops, but the way we actually implemented onion building it did not. Because the docs described a simpler and more backwards-compatible API, we update the onion-building logic to match rather than updating the docs. --- lightning/src/ln/blinded_payment_tests.rs | 74 +++++++++++++---------- lightning/src/ln/functional_test_utils.rs | 15 ++++- lightning/src/ln/onion_route_tests.rs | 22 +++---- lightning/src/ln/onion_utils.rs | 33 ++++------ lightning/src/routing/router.rs | 4 +- 5 files changed, 81 insertions(+), 67 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index b945b8949d8..e148ce2c474 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -1852,7 +1852,7 @@ fn test_combined_trampoline_onion_creation_vectors() { short_channel_id: (572330 << 40) + (42 << 16) + 2821, channel_features: ChannelFeatures::empty(), fee_msat: 153_000, - cltv_expiry_delta: 0, + cltv_expiry_delta: 24 + 36, maybe_announced_channel: false, }, ], @@ -1947,7 +1947,7 @@ fn test_trampoline_inbound_payment_decoding() { short_channel_id: (572330 << 40) + (42 << 16) + 2821, channel_features: ChannelFeatures::empty(), fee_msat: 150_153_000, - cltv_expiry_delta: 0, + cltv_expiry_delta: 24 + 36, maybe_announced_channel: false, }, ], @@ -2115,7 +2115,7 @@ fn test_trampoline_forward_payload_encoded_as_receive() { blinded_path::utils::construct_blinded_hops( &secp_ctx, path.into_iter(), &trampoline_session_priv, ) - }; + }; let route = Route { paths: vec![Path { @@ -2138,7 +2138,7 @@ fn test_trampoline_forward_payload_encoded_as_receive() { short_channel_id: bob_carol_scid, channel_features: ChannelFeatures::empty(), fee_msat: 0, - cltv_expiry_delta: 48, + cltv_expiry_delta: 24 + 39, maybe_announced_channel: false, } ], @@ -2149,7 +2149,7 @@ fn test_trampoline_forward_payload_encoded_as_receive() { pubkey: carol_node_id, node_features: Features::empty(), fee_msat: amt_msat, - cltv_expiry_delta: 24, + cltv_expiry_delta: 24 + 39, }, ], hops: carol_blinded_hops, @@ -2176,7 +2176,7 @@ fn test_trampoline_forward_payload_encoded_as_receive() { }); let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(amt_msat); - let (mut trampoline_payloads, outer_total_msat, outer_starting_htlc_offset) = onion_utils::build_trampoline_onion_payloads(&blinded_tail, &recipient_onion_fields, 32, &None).unwrap(); + let (mut trampoline_payloads, outer_total_msat) = onion_utils::build_trampoline_onion_payloads(&blinded_tail, &recipient_onion_fields, 32, &None).unwrap(); // pop the last dummy hop trampoline_payloads.pop(); @@ -2191,7 +2191,7 @@ fn test_trampoline_forward_payload_encoded_as_receive() { ).unwrap(); let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(outer_total_msat); - let (outer_payloads, _, _) = onion_utils::test_build_onion_payloads(&route.paths[0], &recipient_onion_fields, outer_starting_htlc_offset, &None, None, Some(trampoline_packet)).unwrap(); + let (outer_payloads, _, _) = onion_utils::test_build_onion_payloads(&route.paths[0], &recipient_onion_fields, 32, &None, None, Some(trampoline_packet)).unwrap(); let outer_onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route.clone().paths[0], &outer_session_priv); let outer_packet = onion_utils::construct_onion_packet( outer_payloads, @@ -2304,7 +2304,7 @@ fn do_test_trampoline_single_hop_receive(success: bool) { short_channel_id: bob_carol_scid, channel_features: ChannelFeatures::empty(), fee_msat: 0, - cltv_expiry_delta: 48, + cltv_expiry_delta: 104 + 39, maybe_announced_channel: false, } ], @@ -2315,7 +2315,7 @@ fn do_test_trampoline_single_hop_receive(success: bool) { pubkey: carol_node_id, node_features: Features::empty(), fee_msat: amt_msat, - cltv_expiry_delta: 104, + cltv_expiry_delta: 104 + 39, }, ], hops: blinded_path.blinded_hops().to_vec(), @@ -2423,8 +2423,8 @@ fn test_trampoline_blinded_receive() { /// Creates a blinded tail where Carol receives via a blinded path. fn create_blinded_tail( secp_ctx: &Secp256k1, override_random_bytes: [u8; 32], carol_node_id: PublicKey, - carol_auth_key: ReceiveAuthKey, trampoline_cltv_expiry_delta: u32, final_value_msat: u64, - payment_secret: PaymentSecret, + carol_auth_key: ReceiveAuthKey, trampoline_cltv_expiry_delta: u32, + excess_final_cltv_delta: u32, final_value_msat: u64, payment_secret: PaymentSecret, ) -> BlindedTail { let outer_session_priv = SecretKey::from_slice(&override_random_bytes).unwrap(); let trampoline_session_priv = onion_utils::compute_trampoline_session_priv(&outer_session_priv); @@ -2455,11 +2455,11 @@ fn create_blinded_tail( pubkey: carol_node_id, node_features: Features::empty(), fee_msat: final_value_msat, - cltv_expiry_delta: trampoline_cltv_expiry_delta, + cltv_expiry_delta: trampoline_cltv_expiry_delta + excess_final_cltv_delta, }], hops: carol_blinded_hops, blinding_point: carol_blinding_point, - excess_final_cltv_expiry_delta: 39, + excess_final_cltv_expiry_delta: excess_final_cltv_delta, final_value_msat, } } @@ -2468,8 +2468,9 @@ fn create_blinded_tail( // payloads that send to unblinded receives and invalid payloads. fn replacement_onion( test_case: TrampolineTestCase, secp_ctx: &Secp256k1, override_random_bytes: [u8; 32], - route: Route, original_amt_msat: u64, starting_htlc_offset: u32, original_trampoline_cltv: u32, - payment_hash: PaymentHash, payment_secret: PaymentSecret, blinded: bool, + route: Route, original_amt_msat: u64, starting_htlc_offset: u32, excess_final_cltv: u32, + original_trampoline_cltv: u32, payment_hash: PaymentHash, payment_secret: PaymentSecret, + blinded: bool, ) -> msgs::OnionPacket { let outer_session_priv = SecretKey::from_slice(&override_random_bytes[..]).unwrap(); let trampoline_session_priv = onion_utils::compute_trampoline_session_priv(&outer_session_priv); @@ -2480,8 +2481,8 @@ fn replacement_onion( // Rebuild our trampoline packet from the original route. If we want to test Carol receiving // as an unblinded trampoline hop, we switch out her inner trampoline onion with a direct // receive payload because LDK doesn't support unblinded trampoline receives. - let (trampoline_packet, outer_total_msat, outer_starting_htlc_offset) = { - let (mut trampoline_payloads, outer_total_msat, outer_starting_htlc_offset) = + let (trampoline_packet, outer_total_msat) = { + let (mut trampoline_payloads, outer_total_msat) = onion_utils::build_trampoline_onion_payloads( &blinded_tail, &recipient_onion_fields, @@ -2497,7 +2498,9 @@ fn replacement_onion( total_msat: original_amt_msat, }), sender_intended_htlc_amt_msat: original_amt_msat, - cltv_expiry_height: original_trampoline_cltv + starting_htlc_offset, + cltv_expiry_height: original_trampoline_cltv + + starting_htlc_offset + + excess_final_cltv, }]; } @@ -2515,7 +2518,7 @@ fn replacement_onion( ) .unwrap(); - (trampoline_packet, outer_total_msat, outer_starting_htlc_offset) + (trampoline_packet, outer_total_msat) }; // Use a different session key to construct the replacement onion packet. Note that the @@ -2524,7 +2527,7 @@ fn replacement_onion( let (mut outer_payloads, _, _) = onion_utils::test_build_onion_payloads( &route.paths[0], &recipient_onion_fields, - outer_starting_htlc_offset, + starting_htlc_offset, &None, None, Some(trampoline_packet), @@ -2542,7 +2545,7 @@ fn replacement_onion( .. } => { *amt_to_forward = test_case.outer_onion_amt(original_amt_msat); - let outer_cltv = original_trampoline_cltv + starting_htlc_offset; + let outer_cltv = original_trampoline_cltv + starting_htlc_offset + excess_final_cltv; *outgoing_cltv_value = test_case.outer_onion_cltv(outer_cltv); }, _ => panic!("final payload is not trampoline entrypoint"), @@ -2577,11 +2580,9 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { let alice_bob_chan = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); let bob_carol_chan = create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0); + let starting_htlc_offset = (TOTAL_NODE_COUNT as u32) * CHAN_CONFIRM_DEPTH + 1; for i in 0..TOTAL_NODE_COUNT { - connect_blocks( - &nodes[i], - (TOTAL_NODE_COUNT as u32) * CHAN_CONFIRM_DEPTH + 1 - nodes[i].best_block_info().1, - ); + connect_blocks(&nodes[i], starting_htlc_offset - nodes[i].best_block_info().1); } let alice_node_id = nodes[0].node.get_our_node_id(); @@ -2592,8 +2593,11 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { let bob_carol_scid = get_scid_from_channel_id(&nodes[1], bob_carol_chan.2); let original_amt_msat = 1000; - let original_trampoline_cltv = 72; - let starting_htlc_offset = 32; + // Note that for TrampolineTestCase::OuterCLTVLessThanTrampoline to work properly, + // (starting_htlc_offset + excess_final_cltv) / 2 < (starting_htlc_offset + excess_final_cltv + original_trampoline_cltv) + // otherwise dividing the CLTV value by 2 won't kick us under the outer trampoline CLTV. + let original_trampoline_cltv = 42; + let excess_final_cltv = 70; let (payment_preimage, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[2], Some(original_amt_msat), None); @@ -2620,7 +2624,7 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { short_channel_id: bob_carol_scid, channel_features: ChannelFeatures::empty(), fee_msat: 0, - cltv_expiry_delta: 48, + cltv_expiry_delta: original_trampoline_cltv + excess_final_cltv, maybe_announced_channel: false, }, ], @@ -2633,6 +2637,7 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { carol_node_id, nodes[2].keys_manager.get_receive_auth_key(), original_trampoline_cltv, + excess_final_cltv, original_amt_msat, payment_secret, )), @@ -2675,6 +2680,7 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { original_amt_msat, starting_htlc_offset, original_trampoline_cltv, + excess_final_cltv, payment_hash, payment_secret, blinded, @@ -2691,8 +2697,9 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { ); let amt_bytes = test_case.outer_onion_amt(original_amt_msat).to_be_bytes(); - let cltv_bytes = - test_case.outer_onion_cltv(original_trampoline_cltv + starting_htlc_offset).to_be_bytes(); + let cltv_bytes = test_case + .outer_onion_cltv(original_trampoline_cltv + starting_htlc_offset + excess_final_cltv) + .to_be_bytes(); let payment_failure = test_case.payment_failed_conditions(&amt_bytes, &cltv_bytes).map(|p| { if blinded { PaymentFailedConditions::new() @@ -2706,7 +2713,8 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { .without_claimable_event() .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }) } else { - args.with_payment_secret(payment_secret) + let htlc_cltv = starting_htlc_offset + original_trampoline_cltv + excess_final_cltv; + args.with_payment_secret(payment_secret).with_payment_claimable_cltv(htlc_cltv) }; do_pass_along_path(args); @@ -2792,7 +2800,7 @@ fn test_trampoline_forward_rejection() { short_channel_id: bob_carol_scid, channel_features: ChannelFeatures::empty(), fee_msat: 0, - cltv_expiry_delta: 48, + cltv_expiry_delta: 24 + 24 + 39, maybe_announced_channel: false, } ], @@ -2811,7 +2819,7 @@ fn test_trampoline_forward_rejection() { pubkey: alice_node_id, node_features: Features::empty(), fee_msat: amt_msat, - cltv_expiry_delta: 24, + cltv_expiry_delta: 24 + 39, }, ], hops: vec![BlindedHop{ diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 16616e5077c..680a0d98d1b 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -11,7 +11,7 @@ //! nodes for functional tests. use crate::blinded_path::payment::DummyTlvs; -use crate::chain::channelmonitor::ChannelMonitor; +use crate::chain::channelmonitor::{ChannelMonitor, HTLC_FAIL_BACK_BUFFER}; use crate::chain::transaction::OutPoint; use crate::chain::{BestBlock, ChannelMonitorUpdateStatus, Confirm, Listen, Watch}; use crate::events::bump_transaction::sync::BumpTransactionEventHandlerSync; @@ -3490,6 +3490,7 @@ pub struct PassAlongPathArgs<'a, 'b, 'c, 'd> { pub custom_tlvs: Vec<(u64, Vec)>, pub payment_metadata: Option>, pub expected_failure: Option, + pub payment_claimable_cltv: Option, } impl<'a, 'b, 'c, 'd> PassAlongPathArgs<'a, 'b, 'c, 'd> { @@ -3512,6 +3513,7 @@ impl<'a, 'b, 'c, 'd> PassAlongPathArgs<'a, 'b, 'c, 'd> { custom_tlvs: Vec::new(), payment_metadata: None, expected_failure: None, + payment_claimable_cltv: None, } } pub fn without_clearing_recipient_events(mut self) -> Self { @@ -3552,6 +3554,10 @@ impl<'a, 'b, 'c, 'd> PassAlongPathArgs<'a, 'b, 'c, 'd> { self.dummy_tlvs = dummy_tlvs.to_vec(); self } + pub fn with_payment_claimable_cltv(mut self, cltv: u32) -> Self { + self.payment_claimable_cltv = Some(cltv); + self + } } pub fn do_pass_along_path<'a, 'b, 'c>(args: PassAlongPathArgs) -> Option { @@ -3570,6 +3576,7 @@ pub fn do_pass_along_path<'a, 'b, 'c>(args: PassAlongPathArgs) -> Option custom_tlvs, payment_metadata, expected_failure, + payment_claimable_cltv, } = args; let mut payment_event = SendEvent::from_event(ev); @@ -3685,6 +3692,12 @@ pub fn do_pass_along_path<'a, 'b, 'c>(args: PassAlongPathArgs) -> Option assert_eq!(*user_chan_id, Some(chan.user_channel_id)); } assert!(claim_deadline.unwrap() > node.best_block_info().1); + if let Some(expected_cltv) = payment_claimable_cltv { + assert_eq!( + claim_deadline.unwrap(), + expected_cltv - HTLC_FAIL_BACK_BUFFER, + ); + } }, _ => panic!("Unexpected event"), } diff --git a/lightning/src/ln/onion_route_tests.rs b/lightning/src/ln/onion_route_tests.rs index ceb930014ff..019d8faf98c 100644 --- a/lightning/src/ln/onion_route_tests.rs +++ b/lightning/src/ln/onion_route_tests.rs @@ -1918,7 +1918,7 @@ fn test_trampoline_onion_payload_assembly_values() { short_channel_id: (572330 << 40) + (42 << 16) + 2821, channel_features: ChannelFeatures::empty(), fee_msat: 153_000, - cltv_expiry_delta: 0, + cltv_expiry_delta: 36 + 24, // Last hop should include the CLTV of the trampoline hops maybe_announced_channel: false, }, ], @@ -1974,17 +1974,15 @@ fn test_trampoline_onion_payload_assembly_values() { SecretKey::from_slice(&>::from_hex(SECRET_HEX).unwrap()).unwrap().secret_bytes(), ); let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret, amt_msat); - let (trampoline_payloads, outer_total_msat, outer_starting_htlc_offset) = - onion_utils::build_trampoline_onion_payloads( - &path.blinded_tail.as_ref().unwrap(), - &recipient_onion_fields, - cur_height, - &None, - ) - .unwrap(); + let (trampoline_payloads, outer_total_msat) = onion_utils::build_trampoline_onion_payloads( + &path.blinded_tail.as_ref().unwrap(), + &recipient_onion_fields, + cur_height, + &None, + ) + .unwrap(); assert_eq!(trampoline_payloads.len(), 3); assert_eq!(outer_total_msat, 150_153_000); - assert_eq!(outer_starting_htlc_offset, 800_060); let trampoline_carol_payload = &trampoline_payloads[0]; let trampoline_dave_payload = &trampoline_payloads[1]; @@ -2042,7 +2040,7 @@ fn test_trampoline_onion_payload_assembly_values() { let (outer_payloads, total_msat, total_htlc_offset) = test_build_onion_payloads( &path, &recipient_onion_fields, - outer_starting_htlc_offset, + cur_height, &None, None, Some(trampoline_packet), @@ -2067,7 +2065,7 @@ fn test_trampoline_onion_payload_assembly_values() { outer_bob_payload { assert_eq!(amt_to_forward, &150_153_000); - assert_eq!(outgoing_cltv_value, &800_084); + assert_eq!(outgoing_cltv_value, &800_060); } else { panic!("Bob payload must be Forward"); } diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index a95012dc7f2..5c003680ed1 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -416,7 +416,7 @@ pub(super) fn construct_trampoline_onion_keys( pub(super) fn build_trampoline_onion_payloads<'a>( blinded_tail: &'a BlindedTail, recipient_onion: &'a RecipientOnionFields, starting_htlc_offset: u32, keysend_preimage: &Option, -) -> Result<(Vec>, u64, u32), APIError> { +) -> Result<(Vec>, u64), APIError> { let mut res: Vec = Vec::with_capacity(blinded_tail.trampoline_hops.len() + blinded_tail.hops.len()); let blinded_tail_with_hop_iter = BlindedTailDetails::DirectEntry { @@ -426,7 +426,7 @@ pub(super) fn build_trampoline_onion_payloads<'a>( excess_final_cltv_expiry_delta: blinded_tail.excess_final_cltv_expiry_delta, }; - let (value_msat, cltv) = build_onion_payloads_callback( + let (value_msat, _) = build_onion_payloads_callback( blinded_tail.trampoline_hops.iter(), Some(blinded_tail_with_hop_iter), recipient_onion, @@ -438,7 +438,7 @@ pub(super) fn build_trampoline_onion_payloads<'a>( PayloadCallbackAction::PushFront => res.insert(0, payload), }, )?; - Ok((res, value_msat, cltv)) + Ok((res, value_msat)) } /// returns the hop data, as well as the first-hop value_msat and CLTV value we should send. @@ -539,11 +539,7 @@ where // exactly as it should be (and the next hop isn't trying to probe to find out if we're // the intended recipient). let value_msat = if cur_value_msat == 0 { hop.fee_msat() } else { cur_value_msat }; - let cltv = if cur_cltv == starting_htlc_offset { - hop.cltv_expiry_delta().saturating_add(starting_htlc_offset) - } else { - cur_cltv - }; + let cltv = hop.cltv_expiry_delta().saturating_add(cur_cltv); if idx == 0 { match blinded_tail.take() { Some(BlindedTailDetails::DirectEntry { @@ -591,7 +587,7 @@ where PayloadCallbackAction::PushBack, OP::new_trampoline_entry( final_value_msat + hop.fee_msat(), - cur_cltv, + cltv, &recipient_onion, trampoline_packet, )?, @@ -610,7 +606,7 @@ where err: "Next hop ID must be known for non-final hops".to_string(), })?, value_msat, - cltv, + cur_cltv, ); callback(PayloadCallbackAction::PushFront, payload); } @@ -2638,8 +2634,6 @@ pub(crate) fn create_payment_onion_internal( prng_seed: [u8; 32], trampoline_session_priv_override: Option, trampoline_prng_seed_override: Option<[u8; 32]>, ) -> Result<(msgs::OnionPacket, u64, u32), APIError> { - let mut outer_starting_htlc_offset = cur_block_height; - // If we're paying to a recipient through a trampoline, we use the `payment_secret` provided in // `recipient_onion` as the MPP identifier for the trampoline entry point, allowing it to // detect when when it has received all the MPP parts. @@ -2661,13 +2655,12 @@ pub(crate) fn create_payment_onion_internal( if !blinded_tail.trampoline_hops.is_empty() { let trampoline_payloads; let outer_total_msat; - (trampoline_payloads, outer_total_msat, outer_starting_htlc_offset) = - build_trampoline_onion_payloads( - &blinded_tail, - recipient_onion, - cur_block_height, - keysend_preimage, - )?; + (trampoline_payloads, outer_total_msat) = build_trampoline_onion_payloads( + &blinded_tail, + recipient_onion, + cur_block_height, + keysend_preimage, + )?; trampoline_outer_onion.total_mpp_amount_msat = outer_total_msat; let trampoline_session_priv = trampoline_session_priv_override @@ -2698,7 +2691,7 @@ pub(crate) fn create_payment_onion_internal( let (onion_payloads, htlc_msat, htlc_cltv) = build_onion_payloads( &path, outer_onion, - outer_starting_htlc_offset, + cur_block_height, keysend_preimage, invoice_request, trampoline_packet_option, diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 75c6a05a86d..ee08f9edca9 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -512,6 +512,7 @@ pub struct RouteHop { /// to reach this node. pub channel_features: ChannelFeatures, /// The fee taken on this hop (for paying for the use of the *next* channel in the path). + /// /// If this is the last hop in [`Path::hops`]: /// * if we're sending to a [`BlindedPaymentPath`], this is the fee paid for use of the entire /// blinded path (including any Trampoline hops) @@ -557,8 +558,9 @@ pub struct TrampolineHop { /// the entire blinded path. pub fee_msat: u64, /// The CLTV delta added for this hop. + /// /// If this is the last Trampoline hop within [`BlindedTail`], this is the CLTV delta for the entire - /// blinded path. + /// blinded path (including the [`BlindedTail::excess_final_cltv_expiry_delta`]). pub cltv_expiry_delta: u32, } From ec8580b0df2e9cb35b9b6fd62e0b40dce25df0c1 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 9 Feb 2026 02:00:11 +0000 Subject: [PATCH 092/627] Clarify CLTV value selection in the first blinded hop marginally --- lightning/src/ln/onion_utils.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 5c003680ed1..a74d5fe11d3 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -559,7 +559,7 @@ where OP::new_blinded_receive( final_value_msat, recipient_onion.total_mpp_amount_msat, - cur_cltv + excess_final_cltv_expiry_delta, + starting_htlc_offset + excess_final_cltv_expiry_delta, &blinded_hop.encrypted_payload, blinding_point.take(), *keysend_preimage, From 5ce6e42b03b192fe2a3f52c9be6d2709f9257c16 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 9 Feb 2026 02:00:37 +0000 Subject: [PATCH 093/627] Add a `Path::total_cltv_expiry_delta` accessor --- lightning/src/ln/onion_utils.rs | 1 + lightning/src/routing/router.rs | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index a74d5fe11d3..ffb4f4cfa99 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -2696,6 +2696,7 @@ pub(crate) fn create_payment_onion_internal( invoice_request, trampoline_packet_option, )?; + debug_assert_eq!(htlc_cltv - cur_block_height, path.total_cltv_expiry_delta()); let onion_keys = construct_onion_keys(&secp_ctx, &path, session_priv); let onion_packet = construct_onion_packet(onion_payloads, onion_keys, prng_seed, payment_hash) diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index ee08f9edca9..97f9871444d 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -644,6 +644,12 @@ impl Path { } } + /// Gets the total CLTV expiry delta which will be added to the current block height (plus some + /// extra headroom) when sending the HTLC + pub fn total_cltv_expiry_delta(&self) -> u32 { + self.hops.iter().map(|hop| hop.cltv_expiry_delta).sum() + } + /// True if this [`Path`] has at least one Trampoline hop. pub fn has_trampoline_hops(&self) -> bool { self.blinded_tail.as_ref().is_some_and(|bt| !bt.trampoline_hops.is_empty()) From 54be6eff97e7f9f199c4dfbeca07c39f373b7976 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 9 Feb 2026 02:00:58 +0000 Subject: [PATCH 094/627] Validate CLTV somewhat in `Route::debug_assert_route_meets_params` Now that we've cleaned up trampoline CLTV building and added `Path::total_cltv_expiry_delta`, we can use both to do some basic validation of CLTV values on blinded tails in `Route::debug_assert_route_meets_params` --- lightning/src/ln/htlc_reserve_unit_tests.rs | 3 +- lightning/src/ln/onion_utils.rs | 11 ++++-- lightning/src/routing/router.rs | 43 +++++++++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/lightning/src/ln/htlc_reserve_unit_tests.rs b/lightning/src/ln/htlc_reserve_unit_tests.rs index 6f02c936cff..d88b9a2dc3f 100644 --- a/lightning/src/ln/htlc_reserve_unit_tests.rs +++ b/lightning/src/ln/htlc_reserve_unit_tests.rs @@ -1429,9 +1429,10 @@ pub fn test_update_add_htlc_bolt2_sender_cltv_expiry_too_high() { let _chan = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1000000, 0); - let payment_params = PaymentParameters::from_node_id(node_b_id, 0) + let mut payment_params = PaymentParameters::from_node_id(node_b_id, 0) .with_bolt11_features(nodes[1].node.bolt11_invoice_features()) .unwrap(); + payment_params.max_total_cltv_expiry_delta = 500000001; let (mut route, our_payment_hash, _, our_payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[1], payment_params, 100000000); route.paths[0].hops.last_mut().unwrap().cltv_expiry_delta = 500000001; diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index ffb4f4cfa99..099690ed33e 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -539,8 +539,8 @@ where // exactly as it should be (and the next hop isn't trying to probe to find out if we're // the intended recipient). let value_msat = if cur_value_msat == 0 { hop.fee_msat() } else { cur_value_msat }; - let cltv = hop.cltv_expiry_delta().saturating_add(cur_cltv); if idx == 0 { + let declared_incoming_cltv = hop.cltv_expiry_delta().saturating_add(cur_cltv); match blinded_tail.take() { Some(BlindedTailDetails::DirectEntry { blinding_point, @@ -587,7 +587,7 @@ where PayloadCallbackAction::PushBack, OP::new_trampoline_entry( final_value_msat + hop.fee_msat(), - cltv, + declared_incoming_cltv, &recipient_onion, trampoline_packet, )?, @@ -596,7 +596,12 @@ where None => { callback( PayloadCallbackAction::PushBack, - OP::new_receive(&recipient_onion, *keysend_preimage, value_msat, cltv)?, + OP::new_receive( + &recipient_onion, + *keysend_preimage, + value_msat, + declared_incoming_cltv, + )?, ); }, } diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 97f9871444d..90697ad246e 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -725,6 +725,17 @@ impl Route { return Err(()); } + let total_cltv_delta = path.total_cltv_expiry_delta(); + if total_cltv_delta > route_params.payment_params.max_total_cltv_expiry_delta { + let err = format!( + "Path had a total CLTV of {total_cltv_delta} which is greater than the maximum we're allowed {}", + route_params.payment_params.max_total_cltv_expiry_delta, + ); + debug_assert!(false, "{}", err); + log_error!(logger, "{}", err); + return Err(()); + } + if path.hops.len() > route_params.payment_params.max_path_length.into() { let err = format!( "Path had a length of {}, which is greater than the maximum we're allowed ({})", @@ -737,6 +748,38 @@ impl Route { // This is a bug, but there's not a material safety risk to making this // payment, so we don't bother to error here. } + + if let Some(tail) = &path.blinded_tail { + let trampoline_cltv_sum: u32 = + tail.trampoline_hops.iter().map(|hop| hop.cltv_expiry_delta).sum(); + let last_hop_cltv_delta = path.hops.last().unwrap().cltv_expiry_delta; + if trampoline_cltv_sum > last_hop_cltv_delta { + let err = format!( + "Path had a total trampoline CLTV of {trampoline_cltv_sum}, which is less than the total last-hop CLTV delta of {last_hop_cltv_delta}" + ); + debug_assert!(false, "{}", err); + log_error!(logger, "{}", err); + } + let last_trampoline_cltv_opt = + tail.trampoline_hops.last().map(|h| h.cltv_expiry_delta); + let last_trampoline_cltv = last_trampoline_cltv_opt.unwrap_or(u32::MAX); + if tail.excess_final_cltv_expiry_delta > last_trampoline_cltv { + let err = format!( + "Last trampoline CLTV of {last_trampoline_cltv} is less than the excess blinded path cltv of {}", + tail.excess_final_cltv_expiry_delta + ); + debug_assert!(false, "{}", err); + log_error!(logger, "{}", err); + } + if tail.excess_final_cltv_expiry_delta > last_hop_cltv_delta { + let err = format!( + "Last path hop CLTV of {last_hop_cltv_delta} is less than the excess blinded path cltv of {}", + tail.excess_final_cltv_expiry_delta + ); + debug_assert!(false, "{}", err); + log_error!(logger, "{}", err); + } + } } // Test that we don't contain any "extra" MPP parts - while we're allowed to overshoot From e39437db94a50f46fb0a10a6e54e2862c2a757ff Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 10 Feb 2026 12:57:40 +0000 Subject: [PATCH 095/627] Rename `starting_htlc_offset` `cur_block_height` in onion building Now that we are consistently using the `RouteHop::cltv_expiry_delta` as the last hop's starting CLTV rather than summing trampoline hops, `starting_htlc_offset` is a bit confusing - its actually always the current block height. Thus, here we rename it. --- lightning/src/ln/onion_utils.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 099690ed33e..9b1b009e93a 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -415,7 +415,7 @@ pub(super) fn construct_trampoline_onion_keys( pub(super) fn build_trampoline_onion_payloads<'a>( blinded_tail: &'a BlindedTail, recipient_onion: &'a RecipientOnionFields, - starting_htlc_offset: u32, keysend_preimage: &Option, + cur_block_height: u32, keysend_preimage: &Option, ) -> Result<(Vec>, u64), APIError> { let mut res: Vec = Vec::with_capacity(blinded_tail.trampoline_hops.len() + blinded_tail.hops.len()); @@ -430,7 +430,7 @@ pub(super) fn build_trampoline_onion_payloads<'a>( blinded_tail.trampoline_hops.iter(), Some(blinded_tail_with_hop_iter), recipient_onion, - starting_htlc_offset, + cur_block_height, keysend_preimage, None, |action, payload| match action { @@ -444,14 +444,14 @@ pub(super) fn build_trampoline_onion_payloads<'a>( /// returns the hop data, as well as the first-hop value_msat and CLTV value we should send. #[cfg(any(test, feature = "_externalize_tests"))] pub(crate) fn test_build_onion_payloads<'a>( - path: &'a Path, recipient_onion: &'a RecipientOnionFields, starting_htlc_offset: u32, + path: &'a Path, recipient_onion: &'a RecipientOnionFields, cur_block_height: u32, keysend_preimage: &Option, invoice_request: Option<&'a InvoiceRequest>, trampoline_packet: Option, ) -> Result<(Vec>, u64, u32), APIError> { build_onion_payloads( path, recipient_onion, - starting_htlc_offset, + cur_block_height, keysend_preimage, invoice_request, trampoline_packet, @@ -460,7 +460,7 @@ pub(crate) fn test_build_onion_payloads<'a>( /// returns the hop data, as well as the first-hop value_msat and CLTV value we should send. fn build_onion_payloads<'a>( - path: &'a Path, recipient_onion: &'a RecipientOnionFields, starting_htlc_offset: u32, + path: &'a Path, recipient_onion: &'a RecipientOnionFields, cur_block_height: u32, keysend_preimage: &Option, invoice_request: Option<&'a InvoiceRequest>, trampoline_packet: Option, ) -> Result<(Vec>, u64, u32), APIError> { @@ -490,7 +490,7 @@ fn build_onion_payloads<'a>( path.hops.iter(), blinded_tail_with_hop_iter, recipient_onion, - starting_htlc_offset, + cur_block_height, keysend_preimage, invoice_request, |action, payload| match action { @@ -520,7 +520,7 @@ enum PayloadCallbackAction { } fn build_onion_payloads_callback<'a, 'b, H, B, F, OP>( hops: H, mut blinded_tail: Option>, - recipient_onion: &'a RecipientOnionFields, starting_htlc_offset: u32, + recipient_onion: &'a RecipientOnionFields, cur_block_height: u32, keysend_preimage: &Option, invoice_request: Option<&'a InvoiceRequest>, mut callback: F, ) -> Result<(u64, u32), APIError> @@ -531,7 +531,7 @@ where OP: OnionPayload<'a, 'b, ReceiveType = OP>, { let mut cur_value_msat = 0u64; - let mut cur_cltv = starting_htlc_offset; + let mut cur_cltv = cur_block_height; let mut last_hop_id = None; for (idx, hop) in hops.rev().enumerate() { @@ -559,7 +559,7 @@ where OP::new_blinded_receive( final_value_msat, recipient_onion.total_mpp_amount_msat, - starting_htlc_offset + excess_final_cltv_expiry_delta, + cur_block_height + excess_final_cltv_expiry_delta, &blinded_hop.encrypted_payload, blinding_point.take(), *keysend_preimage, From 7e413a72174971ff41aae6910f38b0790f46ca2d Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 24 Feb 2026 12:29:30 -0600 Subject: [PATCH 096/627] Fix silent merge conflict RecipientOnionFields::secret_only requires an amount now, but when do_abandon_splice_quiescent_action_on_shutdown was introduced it was based on an earlier commit. --- lightning/src/ln/splicing_tests.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 1e92fb216e1..1e9ecf9678a 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -2429,9 +2429,10 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) { // Since we cannot close after having sent `stfu`, send an HTLC so that when we attempt to // splice, the `stfu` message is held back. + let payment_amount = 1_000_000; let (route, payment_hash, _payment_preimage, payment_secret) = - get_route_and_payment_hash!(&nodes[0], &nodes[1], 1_000_000); - let onion = RecipientOnionFields::secret_only(payment_secret); + get_route_and_payment_hash!(&nodes[0], &nodes[1], payment_amount); + let onion = RecipientOnionFields::secret_only(payment_secret, payment_amount); let payment_id = PaymentId(payment_hash.0); nodes[0].node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); let update = get_htlc_update_msgs(&nodes[0], &node_id_1); From 9c7783b32706c93e4262e8ed5931c5fdefa56858 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 6 Feb 2026 15:04:12 -0600 Subject: [PATCH 097/627] Add expect_splice_failed_events helper Add a helper function to assert that SpliceFailed events contain the expected channel_id and contributed inputs/outputs. This ensures that tests verify the contributions match what was originally provided. Co-Authored-By: Claude Opus 4.5 --- lightning/src/ln/functional_test_utils.rs | 21 ++++++++++++- lightning/src/ln/splicing_tests.rs | 38 +++++++++-------------- 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 16616e5077c..91e05d23fa1 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -27,7 +27,7 @@ use crate::ln::channelmanager::{ AChannelManager, ChainParameters, ChannelManager, ChannelManagerReadArgs, PaymentId, RAACommitmentOrder, MIN_CLTV_EXPIRY_DELTA, }; -use crate::ln::funding::FundingTxInput; +use crate::ln::funding::{FundingContribution, FundingTxInput}; use crate::ln::msgs::{self, OpenChannel}; use crate::ln::msgs::{ BaseMessageHandler, ChannelMessageHandler, MessageSendEvent, RoutingMessageHandler, @@ -3232,6 +3232,25 @@ pub fn expect_splice_pending_event<'a, 'b, 'c, 'd>( } } +#[cfg(any(test, ldk_bench, feature = "_test_utils"))] +pub fn expect_splice_failed_events<'a, 'b, 'c, 'd>( + node: &'a Node<'b, 'c, 'd>, expected_channel_id: &ChannelId, + funding_contribution: FundingContribution, +) { + let events = node.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match &events[0] { + Event::SpliceFailed { channel_id, contributed_inputs, contributed_outputs, .. } => { + assert_eq!(*expected_channel_id, *channel_id); + let (expected_inputs, expected_outputs) = + funding_contribution.into_contributed_inputs_and_outputs(); + assert_eq!(*contributed_inputs, expected_inputs); + assert_eq!(*contributed_outputs, expected_outputs); + }, + _ => panic!("Unexpected event"), + } +} + pub fn expect_probe_successful_events( node: &Node, mut probe_results: Vec<(PaymentHash, PaymentId)>, ) { diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 1e9ecf9678a..0c7df06defb 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -548,7 +548,8 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { value: Amount::from_sat(1_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]; - let _ = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); + let funding_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); // Attempt a splice negotiation that only goes up to receiving `splice_init`. Reconnecting // should implicitly abort the negotiation and reset the splice state such that we're able to @@ -586,14 +587,15 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { nodes[1].node.peer_disconnected(node_id_0); } - let _event = get_event!(nodes[0], Event::SpliceFailed); + expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); reconnect_args.send_channel_ready = (true, true); reconnect_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_args); - let _ = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); + let funding_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); // Attempt a splice negotiation that ends mid-construction of the funding transaction. // Reconnecting should implicitly abort the negotiation and reset the splice state such that @@ -636,14 +638,15 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { nodes[1].node.peer_disconnected(node_id_0); } - let _event = get_event!(nodes[0], Event::SpliceFailed); + expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); reconnect_args.send_channel_ready = (true, true); reconnect_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_args); - let _ = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); + let funding_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); // Attempt a splice negotiation that ends before the initial `commitment_signed` messages are // exchanged. The node missing the other's `commitment_signed` upon reconnecting should @@ -717,7 +720,7 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { let tx_abort = get_event_msg!(nodes[0], MessageSendEvent::SendTxAbort, node_id_1); nodes[1].node.handle_tx_abort(node_id_0, &tx_abort); - let _event = get_event!(nodes[0], Event::SpliceFailed); + expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); // Attempt a splice negotiation that completes, (i.e. `tx_signatures` are exchanged). Reconnecting // should not abort the negotiation or reset the splice state. @@ -778,7 +781,8 @@ fn test_config_reject_inbound_splices() { value: Amount::from_sat(1_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]; - let _ = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); + let funding_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); let stfu = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); nodes[1].node.handle_stfu(node_id_0, &stfu); @@ -799,7 +803,7 @@ fn test_config_reject_inbound_splices() { nodes[0].node.peer_disconnected(node_id_1); nodes[1].node.peer_disconnected(node_id_0); - let _event = get_event!(nodes[0], Event::SpliceFailed); + expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); reconnect_args.send_channel_ready = (true, true); @@ -2035,14 +2039,7 @@ fn fail_splice_on_interactive_tx_error() { get_event_msg!(acceptor, MessageSendEvent::SendTxComplete, node_id_initiator); initiator.node.handle_tx_add_input(node_id_acceptor, &tx_add_input); - let event = get_event!(initiator, Event::SpliceFailed); - match event { - Event::SpliceFailed { contributed_inputs, .. } => { - assert_eq!(contributed_inputs.len(), 1); - assert_eq!(contributed_inputs[0], funding_contribution.into_tx_parts().0[0].outpoint()); - }, - _ => panic!("Expected Event::SpliceFailed"), - } + expect_splice_failed_events(initiator, &channel_id, funding_contribution); // We exit quiescence upon sending `tx_abort`, so we should see the holding cell be immediately // freed. @@ -2113,14 +2110,7 @@ fn fail_splice_on_tx_abort() { let tx_abort = get_event_msg!(acceptor, MessageSendEvent::SendTxAbort, node_id_initiator); initiator.node.handle_tx_abort(node_id_acceptor, &tx_abort); - let event = get_event!(initiator, Event::SpliceFailed); - match event { - Event::SpliceFailed { contributed_inputs, .. } => { - assert_eq!(contributed_inputs.len(), 1); - assert_eq!(contributed_inputs[0], funding_contribution.into_tx_parts().0[0].outpoint()); - }, - _ => panic!("Expected Event::SpliceFailed"), - } + expect_splice_failed_events(initiator, &channel_id, funding_contribution); // We exit quiescence upon receiving `tx_abort`, so we should see our `tx_abort` echo and the // holding cell be immediately freed. From a4f2ea27005209f5f81d22f7885a8f769eb37a73 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 20 Feb 2026 16:11:52 -0600 Subject: [PATCH 098/627] Extract contributed_{inputs|outputs} iterators The following types have methods for returning contributed inputs and outputs: - FundingNegotiationContext - InteractiveTxConstructor - InteractiveTxSigningSession - ConstructedTransaction Having iterators for these can avoid allocations, which is useful for filtering contributed input and outputs when producing DiscardFunding events. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 13 +++++--- lightning/src/ln/interactivetxs.rs | 48 ++++++++++++++++++------------ 2 files changed, 38 insertions(+), 23 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index fee74aada0d..905adb158f2 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -6833,11 +6833,16 @@ impl FundingNegotiationContext { (contributed_inputs, contributed_outputs) } + fn contributed_inputs(&self) -> impl Iterator + '_ { + self.our_funding_inputs.iter().map(|input| input.utxo.outpoint) + } + + fn contributed_outputs(&self) -> impl Iterator + '_ { + self.our_funding_outputs.iter() + } + fn to_contributed_inputs_and_outputs(&self) -> (Vec, Vec) { - let contributed_inputs = - self.our_funding_inputs.iter().map(|input| input.utxo.outpoint).collect(); - let contributed_outputs = self.our_funding_outputs.clone(); - (contributed_inputs, contributed_outputs) + (self.contributed_inputs().collect(), self.contributed_outputs().cloned().collect()) } } diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index c5db1bcbe8a..7e7a9fb609c 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -361,9 +361,8 @@ impl ConstructedTransaction { NegotiationError { reason, contributed_inputs, contributed_outputs } } - fn to_contributed_inputs_and_outputs(&self) -> (Vec, Vec) { - let contributed_inputs = self - .tx + fn contributed_inputs(&self) -> impl Iterator + '_ { + self.tx .input .iter() .zip(self.input_metadata.iter()) @@ -375,20 +374,21 @@ impl ConstructedTransaction { .unwrap_or(true) }) .map(|(_, (txin, _))| txin.previous_output) - .collect(); + } - let contributed_outputs = self - .tx + fn contributed_outputs(&self) -> impl Iterator + '_ { + self.tx .output .iter() .zip(self.output_metadata.iter()) .enumerate() .filter(|(_, (_, output))| output.is_local(self.holder_is_initiator)) .filter(|(index, _)| *index != self.shared_output_index as usize) - .map(|(_, (txout, _))| txout.clone()) - .collect(); + .map(|(_, (txout, _))| txout) + } - (contributed_inputs, contributed_outputs) + fn to_contributed_inputs_and_outputs(&self) -> (Vec, Vec) { + (self.contributed_inputs().collect(), self.contributed_outputs().cloned().collect()) } fn into_contributed_inputs_and_outputs(self) -> (Vec, Vec) { @@ -899,8 +899,16 @@ impl InteractiveTxSigningSession { self.unsigned_tx.into_negotiation_error(reason) } + pub(super) fn contributed_inputs(&self) -> impl Iterator + '_ { + self.unsigned_tx.contributed_inputs() + } + + pub(super) fn contributed_outputs(&self) -> impl Iterator + '_ { + self.unsigned_tx.contributed_outputs() + } + pub(super) fn to_contributed_inputs_and_outputs(&self) -> (Vec, Vec) { - self.unsigned_tx.to_contributed_inputs_and_outputs() + (self.contributed_inputs().collect(), self.contributed_outputs().cloned().collect()) } pub(super) fn into_contributed_inputs_and_outputs(self) -> (Vec, Vec) { @@ -2149,20 +2157,22 @@ impl InteractiveTxConstructor { (contributed_inputs, contributed_outputs) } - pub(super) fn to_contributed_inputs_and_outputs(&self) -> (Vec, Vec) { - let contributed_inputs = self - .inputs_to_contribute + pub(super) fn contributed_inputs(&self) -> impl Iterator + '_ { + self.inputs_to_contribute .iter() .filter(|(_, input)| !input.is_shared()) .map(|(_, input)| input.tx_in().previous_output) - .collect(); - let contributed_outputs = self - .outputs_to_contribute + } + + pub(super) fn contributed_outputs(&self) -> impl Iterator + '_ { + self.outputs_to_contribute .iter() .filter(|(_, output)| !output.is_shared()) - .map(|(_, output)| output.tx_out().clone()) - .collect(); - (contributed_inputs, contributed_outputs) + .map(|(_, output)| output.tx_out()) + } + + pub(super) fn to_contributed_inputs_and_outputs(&self) -> (Vec, Vec) { + (self.contributed_inputs().collect(), self.contributed_outputs().cloned().collect()) } pub fn is_initiator(&self) -> bool { From 9901ee9cd7f9da32ba37eb3efabe670a2029b71b Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 5 Feb 2026 14:06:07 -0600 Subject: [PATCH 099/627] Split DiscardFunding from SpliceFailed event When a splice fails, users need to reclaim UTXOs they contributed to the funding transaction. Previously, the contributed inputs and outputs were included in the SpliceFailed event. This commit splits them into a separate DiscardFunding event with a new FundingInfo::Contribution variant, providing a consistent interface for UTXO cleanup across all funding failure scenarios. Changes: - Add FundingInfo::Contribution variant to hold inputs/outputs for DiscardFunding events - Remove contributed_inputs/outputs fields from SpliceFailed event - Add QuiescentError enum for better error handling in funding_contributed - Emit DiscardFunding on all funding_contributed error paths - Filter duplicate inputs/outputs when contribution overlaps existing pending contribution - Return Err(APIError) from funding_contributed on all error cases - Add comprehensive test coverage for funding_contributed error paths Co-Authored-By: Claude Opus 4.6 --- lightning/src/events/mod.rs | 23 +- lightning/src/ln/channel.rs | 114 ++++-- lightning/src/ln/channelmanager.rs | 229 ++++++++--- lightning/src/ln/functional_test_utils.rs | 46 ++- lightning/src/ln/funding.rs | 26 ++ lightning/src/ln/splicing_tests.rs | 476 +++++++++++++++++++++- 6 files changed, 812 insertions(+), 102 deletions(-) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 1f030aac40d..3f6bb0efb01 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -77,6 +77,13 @@ pub enum FundingInfo { /// The outpoint of the funding outpoint: transaction::OutPoint, }, + /// The contributions used for a dual funding or splice funding transaction. + Contribution { + /// UTXOs spent as inputs contributed to the funding transaction. + inputs: Vec, + /// Outputs contributed to the funding transaction. + outputs: Vec, + }, } impl_writeable_tlv_based_enum!(FundingInfo, @@ -85,6 +92,10 @@ impl_writeable_tlv_based_enum!(FundingInfo, }, (1, OutPoint) => { (1, outpoint, required) + }, + (2, Contribution) => { + (1, inputs, optional_vec), + (3, outputs, optional_vec), } ); @@ -1561,10 +1572,6 @@ pub enum Event { abandoned_funding_txo: Option, /// The features that this channel will operate with, if available. channel_type: Option, - /// UTXOs spent as inputs contributed to the splice transaction. - contributed_inputs: Vec, - /// Outputs contributed to the splice transaction. - contributed_outputs: Vec, }, /// Used to indicate to the user that they can abandon the funding transaction and recycle the /// inputs for another purpose. @@ -2326,8 +2333,6 @@ impl Writeable for Event { ref counterparty_node_id, ref abandoned_funding_txo, ref channel_type, - ref contributed_inputs, - ref contributed_outputs, } => { 52u8.write(writer)?; write_tlv_fields!(writer, { @@ -2336,8 +2341,6 @@ impl Writeable for Event { (5, user_channel_id, required), (7, counterparty_node_id, required), (9, abandoned_funding_txo, option), - (11, *contributed_inputs, optional_vec), - (13, *contributed_outputs, optional_vec), }); }, // Note that, going forward, all new events must only write data inside of @@ -2965,8 +2968,6 @@ impl MaybeReadable for Event { (5, user_channel_id, required), (7, counterparty_node_id, required), (9, abandoned_funding_txo, option), - (11, contributed_inputs, optional_vec), - (13, contributed_outputs, optional_vec), }); Ok(Some(Event::SpliceFailed { @@ -2975,8 +2976,6 @@ impl MaybeReadable for Event { counterparty_node_id: counterparty_node_id.0.unwrap(), abandoned_funding_txo, channel_type, - contributed_inputs: contributed_inputs.unwrap_or_default(), - contributed_outputs: contributed_outputs.unwrap_or_default(), })) }; f() diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 905adb158f2..cd98ed70a43 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -3050,6 +3050,35 @@ pub(crate) enum QuiescentAction { DoNothing, } +pub(super) enum QuiescentError { + DoNothing, + DiscardFunding { inputs: Vec, outputs: Vec }, + FailSplice(SpliceFundingFailed), +} + +impl From for QuiescentError { + fn from(action: QuiescentAction) -> Self { + match action { + QuiescentAction::LegacySplice(_) => { + debug_assert!(false); + QuiescentError::DoNothing + }, + QuiescentAction::Splice { contribution, .. } => { + let (contributed_inputs, contributed_outputs) = + contribution.into_contributed_inputs_and_outputs(); + return QuiescentError::FailSplice(SpliceFundingFailed { + funding_txo: None, + channel_type: None, + contributed_inputs, + contributed_outputs, + }); + }, + #[cfg(any(test, fuzzing, feature = "_test_utils"))] + QuiescentAction::DoNothing => QuiescentError::DoNothing, + } + } +} + pub(crate) enum StfuResponse { Stfu(msgs::Stfu), SpliceInit(msgs::SpliceInit), @@ -12215,9 +12244,58 @@ where pub fn funding_contributed( &mut self, contribution: FundingContribution, locktime: LockTime, logger: &L, - ) -> Result, SpliceFundingFailed> { + ) -> Result, QuiescentError> { debug_assert!(contribution.is_splice()); + if let Some(QuiescentAction::Splice { contribution: existing, .. }) = &self.quiescent_action + { + return match contribution.into_unique_contributions( + existing.contributed_inputs(), + existing.contributed_outputs(), + ) { + None => Err(QuiescentError::DoNothing), + Some((inputs, outputs)) => Err(QuiescentError::DiscardFunding { inputs, outputs }), + }; + } + + let initiated_funding_negotiation = self + .pending_splice + .as_ref() + .and_then(|pending_splice| pending_splice.funding_negotiation.as_ref()) + .filter(|funding_negotiation| funding_negotiation.is_initiator()); + + if let Some(funding_negotiation) = initiated_funding_negotiation { + let unique_contributions = match funding_negotiation { + FundingNegotiation::AwaitingAck { context, .. } => contribution + .into_unique_contributions( + context.contributed_inputs(), + context.contributed_outputs(), + ), + FundingNegotiation::ConstructingTransaction { + interactive_tx_constructor, .. + } => contribution.into_unique_contributions( + interactive_tx_constructor.contributed_inputs(), + interactive_tx_constructor.contributed_outputs(), + ), + FundingNegotiation::AwaitingSignatures { .. } => { + let session = self + .context + .interactive_tx_signing_session + .as_ref() + .expect("pending splice awaiting signatures"); + contribution.into_unique_contributions( + session.contributed_inputs(), + session.contributed_outputs(), + ) + }, + }; + + return match unique_contributions { + None => Err(QuiescentError::DoNothing), + Some((inputs, outputs)) => Err(QuiescentError::DiscardFunding { inputs, outputs }), + }; + } + if let Err(e) = contribution.validate().and_then(|()| { // For splice-out, our_funding_contribution is adjusted to cover fees if there // aren't any inputs. @@ -12229,37 +12307,15 @@ where let (contributed_inputs, contributed_outputs) = contribution.into_contributed_inputs_and_outputs(); - return Err(SpliceFundingFailed { + return Err(QuiescentError::FailSplice(SpliceFundingFailed { funding_txo: None, channel_type: None, contributed_inputs, contributed_outputs, - }); + })); } - self.propose_quiescence(logger, QuiescentAction::Splice { contribution, locktime }).map_err( - |action| { - // FIXME: Any better way to do this? - if let QuiescentAction::Splice { contribution, .. } = action { - let (contributed_inputs, contributed_outputs) = - contribution.into_contributed_inputs_and_outputs(); - SpliceFundingFailed { - funding_txo: None, - channel_type: None, - contributed_inputs, - contributed_outputs, - } - } else { - debug_assert!(false); - SpliceFundingFailed { - funding_txo: None, - channel_type: None, - contributed_inputs: vec![], - contributed_outputs: vec![], - } - } - }, - ) + self.propose_quiescence(logger, QuiescentAction::Splice { contribution, locktime }) } fn send_splice_init(&mut self, instructions: SpliceInstructions) -> msgs::SpliceInit { @@ -13382,19 +13438,19 @@ where #[rustfmt::skip] pub fn propose_quiescence( &mut self, logger: &L, action: QuiescentAction, - ) -> Result, QuiescentAction> { + ) -> Result, QuiescentError> { log_debug!(logger, "Attempting to initiate quiescence"); if !self.context.is_usable() { log_debug!(logger, "Channel is not in a usable state to propose quiescence"); - return Err(action); + return Err(action.into()); } if self.quiescent_action.is_some() { log_debug!( logger, "Channel already has a pending quiescent action and cannot start another", ); - return Err(action); + return Err(action.into()); } // Since we don't have a pending quiescent action, we should never be in a state where we // sent `stfu` without already having become quiescent. diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index fec6d82e091..d7c1b6000bf 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -57,11 +57,12 @@ use crate::events::{FundingInfo, PaidBolt12Invoice}; use crate::ln::chan_utils::selected_commitment_sat_per_1000_weight; #[cfg(any(test, fuzzing, feature = "_test_utils"))] use crate::ln::channel::QuiescentAction; +use crate::ln::channel::QuiescentError; use crate::ln::channel::{ self, hold_time_since, Channel, ChannelError, ChannelUpdateStatus, DisconnectResult, FundedChannel, FundingTxSigned, InboundV1Channel, InteractiveTxMsgError, OutboundHop, - OutboundV1Channel, PendingV2Channel, ReconnectionMsg, ShutdownResult, StfuResponse, - UpdateFulfillCommitFetch, WithChannelContext, + OutboundV1Channel, PendingV2Channel, ReconnectionMsg, ShutdownResult, SpliceFundingFailed, + StfuResponse, UpdateFulfillCommitFetch, WithChannelContext, }; use crate::ln::channel_state::ChannelDetails; use crate::ln::funding::{FundingContribution, FundingTemplate}; @@ -3925,15 +3926,24 @@ impl< failed_htlcs = htlcs; if let Some(splice_funding_failed) = splice_funding_failed { - self.pending_events.lock().unwrap().push_back(( + let mut pending_events = self.pending_events.lock().unwrap(); + pending_events.push_back(( events::Event::SpliceFailed { channel_id: *chan_id, counterparty_node_id: *counterparty_node_id, user_channel_id: chan.context().get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type, - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, + }, + None, + )); + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: *chan_id, + funding_info: FundingInfo::Contribution { + inputs: splice_funding_failed.contributed_inputs, + outputs: splice_funding_failed.contributed_outputs, + }, }, None, )); @@ -4236,8 +4246,16 @@ impl< user_channel_id: shutdown_res.user_channel_id, abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type, - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, + }, + None, + )); + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: shutdown_res.channel_id, + funding_info: FundingInfo::Contribution { + inputs: splice_funding_failed.contributed_inputs, + outputs: splice_funding_failed.contributed_outputs, + }, }, None, )); @@ -4757,8 +4775,16 @@ impl< user_channel_id: chan.context.get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type, - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, + }, + None, + )); + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: *channel_id, + funding_info: FundingInfo::Contribution { + inputs: splice_funding_failed.contributed_inputs, + outputs: splice_funding_failed.contributed_outputs, + }, }, None, )); @@ -6418,13 +6444,27 @@ impl< /// Used after [`ChannelManager::splice_channel`] by constructing a [`FundingContribution`] /// from the returned [`FundingTemplate`] and passing it here. /// + /// # Arguments + /// + /// An optional `locktime` for the funding transaction may be specified. If not given, the + /// current best block height is used. + /// + /// # Events + /// /// Calling this method will commence the process of creating a new funding transaction for the /// channel. An [`Event::FundingTransactionReadyForSigning`] will be generated once the /// transaction is successfully constructed interactively with the counterparty. - /// If unsuccessful, an [`Event::SpliceFailed`] will be surfaced instead. /// - /// An optional `locktime` for the funding transaction may be specified. If not given, the - /// current best block height is used. + /// If unsuccessful, an [`Event::SpliceFailed`] will be produced if there aren't any earlier + /// splice attempts for the channel outstanding (i.e., haven't yet produced either + /// [`Event::SplicePending`] or [`Event::SpliceFailed`]). + /// + /// If unsuccessful, an [`Event::DiscardFunding`] will be produced for any contributions + /// passed in that are not found in any outstanding attempts for the channel. If there are no + /// such contributions, then the [`Event::DiscardFunding`] will not be produced since these + /// contributions must not be reused yet. + /// + /// # Errors /// /// Returns [`ChannelUnavailable`] when a channel is not found or an incorrect /// `counterparty_node_id` is provided. @@ -6440,12 +6480,22 @@ impl< ) -> Result<(), APIError> { let mut result = Ok(()); PersistenceNotifierGuard::optionally_notify(self, || { + let push_discard_funding = |contribution: FundingContribution| { + let (inputs, outputs) = contribution.into_contributed_inputs_and_outputs(); + self.pending_events.lock().unwrap().push_back(( + events::Event::DiscardFunding { + channel_id: *channel_id, + funding_info: FundingInfo::Contribution { inputs, outputs }, + }, + None, + )); + }; + let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex_opt = per_peer_state.get(counterparty_node_id); if peer_state_mutex_opt.is_none() { - result = Err(APIError::ChannelUnavailable { - err: format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}") - }); + push_discard_funding(contribution); + result = Err(APIError::no_such_peer(counterparty_node_id)); return NotifyOption::SkipPersistNoEvents; } @@ -6469,28 +6519,69 @@ impl< ); } }, - Err(splice_funding_failed) => { + Err(QuiescentError::DoNothing) => { + result = Err(APIError::APIMisuseError { + err: format!( + "Duplicate funding contribution for channel {}", + channel_id + ), + }); + }, + Err(QuiescentError::DiscardFunding { inputs, outputs }) => { + self.pending_events.lock().unwrap().push_back(( + events::Event::DiscardFunding { + channel_id: *channel_id, + funding_info: FundingInfo::Contribution { inputs, outputs }, + }, + None, + )); + result = Err(APIError::APIMisuseError { + err: format!( + "Channel {} already has a pending funding contribution", + channel_id + ), + }); + }, + Err(QuiescentError::FailSplice(SpliceFundingFailed { + funding_txo, + channel_type, + contributed_inputs, + contributed_outputs, + })) => { let pending_events = &mut self.pending_events.lock().unwrap(); pending_events.push_back(( events::Event::SpliceFailed { channel_id: *channel_id, counterparty_node_id: *counterparty_node_id, user_channel_id: channel.context().get_user_id(), - abandoned_funding_txo: splice_funding_failed.funding_txo, - channel_type: splice_funding_failed.channel_type.clone(), - contributed_inputs: splice_funding_failed - .contributed_inputs, - contributed_outputs: splice_funding_failed - .contributed_outputs, + abandoned_funding_txo: funding_txo, + channel_type, }, None, )); + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: *channel_id, + funding_info: FundingInfo::Contribution { + inputs: contributed_inputs, + outputs: contributed_outputs, + }, + }, + None, + )); + result = Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot accept funding contribution", + channel_id + ), + }); }, } return NotifyOption::DoPersist; }, None => { + push_discard_funding(contribution); result = Err(APIError::APIMisuseError { err: format!( "Channel with id {} not expecting funding contribution", @@ -6501,12 +6592,9 @@ impl< }, }, None => { - result = Err(APIError::ChannelUnavailable { - err: format!( - "Channel with id {} not found for the passed counterparty node_id {}", - channel_id, counterparty_node_id - ), - }); + push_discard_funding(contribution); + result = + Err(APIError::no_such_channel_for_peer(channel_id, counterparty_node_id)); return NotifyOption::SkipPersistNoEvents; }, } @@ -11369,8 +11457,16 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ user_channel_id: channel.context().get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type.clone(), - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, + }, + None, + )); + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id, + funding_info: FundingInfo::Contribution { + inputs: splice_funding_failed.contributed_inputs, + outputs: splice_funding_failed.contributed_outputs, + }, }, None, )); @@ -11520,8 +11616,16 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ user_channel_id: chan.context().get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type.clone(), - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, + }, + None, + )); + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: msg.channel_id, + funding_info: FundingInfo::Contribution { + inputs: splice_funding_failed.contributed_inputs, + outputs: splice_funding_failed.contributed_outputs, + }, }, None, )); @@ -11682,8 +11786,16 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ user_channel_id: chan_entry.get().context().get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type, - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, + }, + None, + )); + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: msg.channel_id, + funding_info: FundingInfo::Contribution { + inputs: splice_funding_failed.contributed_inputs, + outputs: splice_funding_failed.contributed_outputs, + }, }, None, )); @@ -11814,15 +11926,24 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ dropped_htlcs = htlcs; if let Some(splice_funding_failed) = splice_funding_failed { - self.pending_events.lock().unwrap().push_back(( + let mut pending_events = self.pending_events.lock().unwrap(); + pending_events.push_back(( events::Event::SpliceFailed { channel_id: msg.channel_id, counterparty_node_id: *counterparty_node_id, user_channel_id: chan.context().get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type, - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, + }, + None, + )); + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: msg.channel_id, + funding_info: FundingInfo::Contribution { + inputs: splice_funding_failed.contributed_inputs, + outputs: splice_funding_failed.contributed_outputs, + }, }, None, )); @@ -13424,7 +13545,10 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }); notify = NotifyOption::SkipPersistHandleEvents; }, - Err(action) => log_trace!(logger, "Failed to propose quiescence for: {:?}", action), + Err(e) => { + debug_assert!(matches!(e, QuiescentError::DoNothing)); + log_trace!(logger, "Failed to propose quiescence"); + }, } } else { result = Err(APIError::APIMisuseError { @@ -14779,8 +14903,13 @@ impl< user_channel_id: chan.context().get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type, - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, + }); + splice_failed_events.push(events::Event::DiscardFunding { + channel_id: chan.context().channel_id(), + funding_info: FundingInfo::Contribution { + inputs: splice_funding_failed.contributed_inputs, + outputs: splice_funding_failed.contributed_outputs, + }, }); } @@ -17379,9 +17508,9 @@ impl< let our_pending_intercepts = self.pending_intercepted_htlcs.lock().unwrap(); // Since some FundingNegotiation variants are not persisted, any splice in such state must - // be failed upon reload. However, as the necessary information for the SpliceFailed event - // is not persisted, the event itself needs to be persisted even though it hasn't been - // emitted yet. These are removed after the events are written. + // be failed upon reload. However, as the necessary information for the SpliceFailed and + // DiscardFunding events is not persisted, the events need to be persisted even though they + // haven't been emitted yet. These are removed after the events are written. let mut events = self.pending_events.lock().unwrap(); let event_count = events.len(); for peer_state in peer_states.iter() { @@ -17394,8 +17523,16 @@ impl< user_channel_id: chan.context.get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type, - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, + }, + None, + )); + events.push_back(( + events::Event::DiscardFunding { + channel_id: chan.context().channel_id(), + funding_info: FundingInfo::Contribution { + inputs: splice_funding_failed.contributed_inputs, + outputs: splice_funding_failed.contributed_outputs, + }, }, None, )); @@ -17518,7 +17655,7 @@ impl< (21, WithoutLength(&self.flow.writeable_async_receive_offer_cache()), required), }); - // Remove the SpliceFailed events added earlier. + // Remove the SpliceFailed and DiscardFunding events added earlier. events.truncate(event_count); Ok(()) diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 91e05d23fa1..35138c14fa1 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -17,8 +17,8 @@ use crate::chain::{BestBlock, ChannelMonitorUpdateStatus, Confirm, Listen, Watch use crate::events::bump_transaction::sync::BumpTransactionEventHandlerSync; use crate::events::bump_transaction::BumpTransactionEvent; use crate::events::{ - ClaimedHTLC, ClosureReason, Event, HTLCHandlingFailureType, PaidBolt12Invoice, PathFailure, - PaymentFailureReason, PaymentPurpose, + ClaimedHTLC, ClosureReason, Event, FundingInfo, HTLCHandlingFailureType, PaidBolt12Invoice, + PathFailure, PaymentFailureReason, PaymentPurpose, }; use crate::ln::chan_utils::{ commitment_tx_base_weight, COMMITMENT_TX_WEIGHT_PER_HTLC, TRUC_MAX_WEIGHT, @@ -3236,16 +3236,48 @@ pub fn expect_splice_pending_event<'a, 'b, 'c, 'd>( pub fn expect_splice_failed_events<'a, 'b, 'c, 'd>( node: &'a Node<'b, 'c, 'd>, expected_channel_id: &ChannelId, funding_contribution: FundingContribution, +) { + let events = node.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2); + match &events[0] { + Event::SpliceFailed { channel_id, .. } => { + assert_eq!(*expected_channel_id, *channel_id); + }, + _ => panic!("Unexpected event"), + } + match &events[1] { + Event::DiscardFunding { funding_info, .. } => { + if let FundingInfo::Contribution { inputs, outputs } = &funding_info { + let (expected_inputs, expected_outputs) = + funding_contribution.into_contributed_inputs_and_outputs(); + assert_eq!(*inputs, expected_inputs); + assert_eq!(*outputs, expected_outputs); + } else { + panic!("Expected FundingInfo::Contribution"); + } + }, + _ => panic!("Unexpected event"), + } +} + +#[cfg(any(test, ldk_bench, feature = "_test_utils"))] +pub fn expect_discard_funding_event<'a, 'b, 'c, 'd>( + node: &'a Node<'b, 'c, 'd>, expected_channel_id: &ChannelId, + funding_contribution: FundingContribution, ) { let events = node.node.get_and_clear_pending_events(); assert_eq!(events.len(), 1); match &events[0] { - Event::SpliceFailed { channel_id, contributed_inputs, contributed_outputs, .. } => { + Event::DiscardFunding { channel_id, funding_info } => { assert_eq!(*expected_channel_id, *channel_id); - let (expected_inputs, expected_outputs) = - funding_contribution.into_contributed_inputs_and_outputs(); - assert_eq!(*contributed_inputs, expected_inputs); - assert_eq!(*contributed_outputs, expected_outputs); + if let FundingInfo::Contribution { inputs, outputs } = &funding_info { + let (expected_inputs, expected_outputs) = + funding_contribution.into_contributed_inputs_and_outputs(); + assert_eq!(*inputs, expected_inputs); + assert_eq!(*outputs, expected_outputs); + } else { + panic!("Expected FundingInfo::Contribution"); + } }, _ => panic!("Unexpected event"), } diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 935703ce817..dc29b23b1e3 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -305,6 +305,14 @@ impl FundingContribution { self.is_splice } + pub(super) fn contributed_inputs(&self) -> impl Iterator + '_ { + self.inputs.iter().map(|input| input.utxo.outpoint) + } + + pub(super) fn contributed_outputs(&self) -> impl Iterator + '_ { + self.outputs.iter().chain(self.change_output.iter()) + } + pub(super) fn into_tx_parts(self) -> (Vec, Vec) { let FundingContribution { inputs, mut outputs, change_output, .. } = self; @@ -321,6 +329,24 @@ impl FundingContribution { (inputs.into_iter().map(|input| input.utxo.outpoint).collect(), outputs) } + pub(super) fn into_unique_contributions<'a>( + self, existing_inputs: impl Iterator, + existing_outputs: impl Iterator, + ) -> Option<(Vec, Vec)> { + let (mut inputs, mut outputs) = self.into_contributed_inputs_and_outputs(); + for existing in existing_inputs { + inputs.retain(|input| *input != existing); + } + for existing in existing_outputs { + outputs.retain(|output| *output != *existing); + } + if inputs.is_empty() && outputs.is_empty() { + None + } else { + Some((inputs, outputs)) + } + } + /// Validates that the funding inputs are suitable for use in the interactive transaction /// protocol, checking prevtx sizes and input sufficiency. pub fn validate(&self) -> Result<(), String> { diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 0c7df06defb..ab890fdbab7 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -29,9 +29,12 @@ use crate::util::wallet_utils::{WalletSourceSync, WalletSync}; use crate::sync::Arc; +use bitcoin::hashes::Hash; use bitcoin::secp256k1::ecdsa::Signature; -use bitcoin::secp256k1::PublicKey; -use bitcoin::{Amount, FeeRate, OutPoint as BitcoinOutPoint, ScriptBuf, Transaction, TxOut}; +use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use bitcoin::{ + Amount, FeeRate, OutPoint as BitcoinOutPoint, ScriptBuf, Transaction, TxOut, WPubkeyHash, +}; #[test] fn test_splicing_not_supported_api_error() { @@ -2151,7 +2154,7 @@ fn fail_splice_on_tx_complete_error() { value: Amount::from_sat(1_000), script_pubkey: acceptor.wallet_source.get_change_script().unwrap(), }]; - let _ = initiate_splice_out(initiator, acceptor, channel_id, outputs); + let funding_contribution = initiate_splice_out(initiator, acceptor, channel_id, outputs); let _ = complete_splice_handshake(initiator, acceptor); // Queue an outgoing HTLC to the holding cell. It should be freed once we exit quiescence. @@ -2205,7 +2208,8 @@ fn fail_splice_on_tx_complete_error() { }; initiator.node.handle_tx_abort(node_id_acceptor, tx_abort); - let _ = get_event!(initiator, Event::SpliceFailed); + expect_splice_failed_events(initiator, &channel_id, funding_contribution); + let tx_abort = get_event_msg!(initiator, MessageSendEvent::SendTxAbort, node_id_acceptor); acceptor.node.handle_tx_abort(node_id_initiator, &tx_abort); @@ -2339,7 +2343,7 @@ fn fail_splice_on_channel_close() { &nodes[0], &[ExpectedCloseEvent { channel_id: Some(channel_id), - discard_funding: false, + discard_funding: true, splice_failed: true, channel_funding_txo: None, user_channel_id: Some(42), @@ -2385,7 +2389,7 @@ fn fail_quiescent_action_on_channel_close() { &nodes[0], &[ExpectedCloseEvent { channel_id: Some(channel_id), - discard_funding: false, + discard_funding: true, splice_failed: true, channel_funding_txo: None, user_channel_id: Some(42), @@ -2438,7 +2442,7 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) { // Attempt the splice. `stfu` should not go out yet as the state machine is pending. let splice_in_amount = initial_channel_capacity / 2; - let _ = + let funding_contribution = initiate_splice_in(&nodes[0], &nodes[1], channel_id, Amount::from_sat(splice_in_amount)); assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); @@ -2453,7 +2457,7 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) { let shutdown = get_event_msg!(closer_node, MessageSendEvent::SendShutdown, closee_node_id); closee_node.node.handle_shutdown(closer_node_id, &shutdown); - let _ = get_event!(nodes[0], Event::SpliceFailed); + expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); let _ = get_event_msg!(closee_node, MessageSendEvent::SendShutdown, closer_node_id); } @@ -2890,3 +2894,459 @@ fn test_splice_balance_falls_below_reserve() { // Final sanity check: send a payment using the new spliced capacity. let _ = send_payment(&nodes[0], &[&nodes[1]], 1_000_000); } + +#[test] +fn test_funding_contributed_counterparty_not_found() { + // Tests that calling funding_contributed with an unknown counterparty_node_id returns + // ChannelUnavailable and emits a DiscardFunding event. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_1 = nodes[1].node.get_our_node_id(); + + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 50_000_000); + + let splice_in_amount = Amount::from_sat(20_000); + provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); + + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let funding_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + + // Use a fake/unknown public key as counterparty + let fake_node_id = + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[42; 32]).unwrap()); + + assert_eq!( + nodes[0].node.funding_contributed( + &channel_id, + &fake_node_id, + funding_contribution.clone(), + None + ), + Err(APIError::no_such_peer(&fake_node_id)), + ); + + expect_discard_funding_event(&nodes[0], &channel_id, funding_contribution); +} + +#[test] +fn test_funding_contributed_channel_not_found() { + // Tests that calling funding_contributed with an unknown channel_id returns + // ChannelUnavailable and emits a DiscardFunding event. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_1 = nodes[1].node.get_our_node_id(); + + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 50_000_000); + + let splice_in_amount = Amount::from_sat(20_000); + provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); + + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let funding_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + + // Use a random/unknown channel_id + let fake_channel_id = ChannelId::from_bytes([42; 32]); + + assert_eq!( + nodes[0].node.funding_contributed( + &fake_channel_id, + &node_id_1, + funding_contribution.clone(), + None + ), + Err(APIError::no_such_channel_for_peer(&fake_channel_id, &node_id_1)), + ); + + expect_discard_funding_event(&nodes[0], &fake_channel_id, funding_contribution); +} + +#[test] +fn test_funding_contributed_splice_already_pending() { + // Tests that calling funding_contributed when there's already a pending splice + // contribution returns Err(APIMisuseError) and emits a DiscardFunding event containing only the + // inputs/outputs that are NOT already in the existing contribution. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_1 = nodes[1].node.get_our_node_id(); + + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); + + let splice_in_amount = Amount::from_sat(20_000); + provide_utxo_reserves(&nodes, 2, splice_in_amount * 2); + + // Use splice_in_and_out with an output so we can test output filtering + let first_splice_out = TxOut { + value: Amount::from_sat(5_000), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_raw_hash(Hash::all_zeros())), + }; + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let first_contribution = funding_template + .splice_in_and_out_sync(splice_in_amount, vec![first_splice_out.clone()], &wallet) + .unwrap(); + + // Initiate a second splice with a DIFFERENT output to test that different outputs + // are included in DiscardFunding (not filtered out) + let second_splice_out = TxOut { + value: Amount::from_sat(6_000), // Different amount + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_raw_hash(Hash::all_zeros())), + }; + + // Clear UTXOs and add a LARGER one for the second contribution to ensure + // the change output will be different from the first contribution's change + // + // FIXME: Should we actually not consider the change value given DiscardFunding is meant to + // reclaim the change script pubkey? But that means for other cases we'd need to track which + // output is for change later in the pipeline. + nodes[0].wallet_source.clear_utxos(); + provide_utxo_reserves(&nodes, 1, splice_in_amount * 3); + + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let second_contribution = funding_template + .splice_in_and_out_sync(splice_in_amount, vec![second_splice_out.clone()], &wallet) + .unwrap(); + + // First funding_contributed - this sets up the quiescent action + nodes[0].node.funding_contributed(&channel_id, &node_id_1, first_contribution, None).unwrap(); + + // Drain the pending stfu message + let _ = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + + // Second funding_contributed with a different contribution - this should trigger + // DiscardFunding because there's already a pending quiescent action (splice contribution). + // Only inputs/outputs NOT in the existing contribution should be discarded. + let (expected_inputs, expected_outputs) = + second_contribution.clone().into_contributed_inputs_and_outputs(); + + // Returns Err(APIMisuseError) and emits DiscardFunding for the non-duplicate parts of the second contribution + assert_eq!( + nodes[0].node.funding_contributed(&channel_id, &node_id_1, second_contribution, None), + Err(APIError::APIMisuseError { + err: format!("Channel {} already has a pending funding contribution", channel_id), + }) + ); + + // The second contribution has different outputs (second_splice_out differs from first_splice_out), + // so those outputs should NOT be filtered out - they should appear in DiscardFunding. + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match &events[0] { + Event::DiscardFunding { channel_id: event_channel_id, funding_info } => { + assert_eq!(event_channel_id, &channel_id); + if let FundingInfo::Contribution { inputs, outputs } = funding_info { + // The input is different, so it should be in the discard event + assert_eq!(*inputs, expected_inputs); + // The splice-out output is different (6000 vs 5000), so it should be in discard event + assert!(expected_outputs.contains(&second_splice_out)); + assert!(!expected_outputs.contains(&first_splice_out)); + // The different outputs should NOT be filtered out + assert_eq!(*outputs, expected_outputs); + } else { + panic!("Expected FundingInfo::Contribution"); + } + }, + _ => panic!("Expected DiscardFunding event"), + } +} + +#[test] +fn test_funding_contributed_duplicate_contribution_no_event() { + // Tests that calling funding_contributed with the exact same contribution twice + // returns Err(APIMisuseError) and emits no events on the second call (DoNothing path). + // This tests the case where all inputs/outputs in the second contribution + // are already present in the existing contribution. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_1 = nodes[1].node.get_our_node_id(); + + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); + + let splice_in_amount = Amount::from_sat(20_000); + provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); + + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + + // First funding_contributed - this sets up the quiescent action + nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution.clone(), None).unwrap(); + + // Drain the pending stfu message + let _ = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + + // Second funding_contributed with the SAME contribution (same inputs/outputs) + // This should trigger the DoNothing path because all inputs/outputs are duplicates. + // Returns Err(APIMisuseError) and emits NO events. + assert_eq!( + nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution, None), + Err(APIError::APIMisuseError { + err: format!("Duplicate funding contribution for channel {}", channel_id), + }) + ); + + // Verify no events were emitted - the duplicate contribution is silently ignored + let events = nodes[0].node.get_and_clear_pending_events(); + assert!(events.is_empty(), "Expected no events for duplicate contribution, got {:?}", events); +} + +#[test] +fn test_funding_contributed_active_funding_negotiation() { + do_test_funding_contributed_active_funding_negotiation(0); // AwaitingAck + do_test_funding_contributed_active_funding_negotiation(1); // ConstructingTransaction + do_test_funding_contributed_active_funding_negotiation(2); // AwaitingSignatures +} + +#[cfg(test)] +fn do_test_funding_contributed_active_funding_negotiation(state: u8) { + // Tests that calling funding_contributed when a splice is already being actively negotiated + // (pending_splice.funding_negotiation exists and is_initiator()) returns Err(APIMisuseError) + // and emits SpliceFailed + DiscardFunding events for non-duplicate contributions, or + // returns Err(APIMisuseError) with no events for duplicate contributions. + // + // State 0: AwaitingAck (splice_init sent, splice_ack not yet received) + // State 1: ConstructingTransaction (splice handshake complete, interactive TX in progress) + // State 2: AwaitingSignatures (interactive TX complete, awaiting signing) + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); + + let splice_in_amount = Amount::from_sat(20_000); + provide_utxo_reserves(&nodes, 2, splice_in_amount * 2); + + // Build first contribution + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let first_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + + // Build second contribution with different UTXOs so inputs/outputs don't overlap + nodes[0].wallet_source.clear_utxos(); + provide_utxo_reserves(&nodes, 1, splice_in_amount * 3); + + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let second_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + + // First funding_contributed - sets up the quiescent action and queues STFU + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, first_contribution.clone(), None) + .unwrap(); + + // Complete the STFU exchange. This consumes the quiescent_action and creates + // FundingNegotiation::AwaitingAck with splice_init queued. + let stfu_init = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_init); + let stfu_ack = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_ack); + + // Drain the splice_init from the initiator's pending message events + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + + if state >= 1 { + // Process splice_init/ack to move to ConstructingTransaction + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); + + if state == 2 { + // Complete interactive TX negotiation to move to AwaitingSignatures + let new_funding_script = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + first_contribution.clone(), + new_funding_script, + ); + + // Drain the FundingTransactionReadyForSigning event from the initiator + let _ = get_event!(nodes[0], Event::FundingTransactionReadyForSigning); + } + } + + // Call funding_contributed with a different contribution (non-overlapping inputs/outputs). + // This hits the funding_negotiation path and returns DiscardFunding. + let (expected_inputs, expected_outputs) = + second_contribution.clone().into_contributed_inputs_and_outputs(); + assert_eq!( + nodes[0].node.funding_contributed(&channel_id, &node_id_1, second_contribution, None), + Err(APIError::APIMisuseError { + err: format!("Channel {} already has a pending funding contribution", channel_id), + }) + ); + + // Assert DiscardFunding event with the non-duplicate inputs/outputs + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1, "{events:?}"); + match &events[0] { + Event::DiscardFunding { channel_id: event_channel_id, funding_info } => { + assert_eq!(*event_channel_id, channel_id); + if let FundingInfo::Contribution { inputs, outputs } = funding_info { + assert_eq!(*inputs, expected_inputs); + assert_eq!(*outputs, expected_outputs); + } else { + panic!("Expected FundingInfo::Contribution"); + } + }, + _ => panic!("Expected DiscardFunding event, got {:?}", events[1]), + } + + // Also test the DoNothing path: call funding_contributed with the same contribution + // as the existing negotiation. All inputs/outputs are duplicates, so no events. + assert_eq!( + nodes[0].node.funding_contributed(&channel_id, &node_id_1, first_contribution, None), + Err(APIError::APIMisuseError { + err: format!("Duplicate funding contribution for channel {}", channel_id), + }) + ); + + let events = nodes[0].node.get_and_clear_pending_events(); + assert!(events.is_empty(), "Expected no events for duplicate contribution, got {:?}", events); + + // Cleanup: drain leftover message events from the in-progress splice negotiation + if state == 1 { + // Initiator has its first interactive TX message queued after handle_splice_ack + let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + assert!(matches!(msg_events[0], MessageSendEvent::SendTxAddInput { .. })); + } + if state == 2 { + // Acceptor (no contribution) auto-signed and sent commitment_signed + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + assert!(matches!(msg_events[0], MessageSendEvent::UpdateHTLCs { .. })); + } +} + +#[test] +fn test_funding_contributed_channel_shutdown() { + // Tests that calling funding_contributed after initiating channel shutdown returns Err(APIMisuseError) + // and emits both SpliceFailed and DiscardFunding events. The channel is no longer usable + // after shutdown is initiated, so quiescence cannot be proposed. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_1 = nodes[1].node.get_our_node_id(); + + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); + + let splice_in_amount = Amount::from_sat(20_000); + provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); + + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let funding_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + + // Initiate channel shutdown - this makes is_usable() return false + nodes[0].node.close_channel(&channel_id, &node_id_1).unwrap(); + + // Drain the pending shutdown message + let _ = get_event_msg!(nodes[0], MessageSendEvent::SendShutdown, node_id_1); + + // Now call funding_contributed - this should trigger FailSplice because + // propose_quiescence() will fail when is_usable() returns false. + // Returns Err(APIMisuseError) and emits both SpliceFailed and DiscardFunding. + assert_eq!( + nodes[0].node.funding_contributed( + &channel_id, + &node_id_1, + funding_contribution.clone(), + None + ), + Err(APIError::APIMisuseError { + err: format!("Channel {} cannot accept funding contribution", channel_id), + }) + ); + + expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); +} + +#[test] +fn test_funding_contributed_unfunded_channel() { + // Tests that calling funding_contributed on an unfunded channel returns APIMisuseError + // and emits a DiscardFunding event. The channel exists but is not yet funded. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_1 = nodes[1].node.get_our_node_id(); + + // Create a funded channel for the splice operation + let (_, _, funded_channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); + + // Create an unfunded channel (after open/accept but before funding tx) + let unfunded_channel_id = exchange_open_accept_chan(&nodes[0], &nodes[1], 50_000, 0); + + // Drain the FundingGenerationReady event for the unfunded channel + let _ = get_event!(nodes[0], Event::FundingGenerationReady); + + let splice_in_amount = Amount::from_sat(20_000); + provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); + + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = + nodes[0].node.splice_channel(&funded_channel_id, &node_id_1, feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let funding_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + + // Call funding_contributed with the unfunded channel's ID instead of the funded one. + // Returns APIMisuseError because the channel is not funded. + assert_eq!( + nodes[0].node.funding_contributed( + &unfunded_channel_id, + &node_id_1, + funding_contribution.clone(), + None + ), + Err(APIError::APIMisuseError { + err: format!( + "Channel with id {} not expecting funding contribution", + unfunded_channel_id + ), + }) + ); + + expect_discard_funding_event(&nodes[0], &unfunded_channel_id, funding_contribution); +} From c9c01592249d230cc15f3e2519cdb13ed293fb92 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 23 Feb 2026 15:41:51 -0600 Subject: [PATCH 100/627] Print unexpected events upon assertion failure --- lightning/src/ln/functional_test_utils.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 35138c14fa1..82c4ac80797 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -1088,7 +1088,8 @@ pub fn get_warning_msg(node: &Node, recipient: &PublicKey) -> msgs::WarningMessa macro_rules! get_event { ($node: expr, $event_type: path) => {{ let mut events = $node.node.get_and_clear_pending_events(); - assert_eq!(events.len(), 1); + assert!(!events.is_empty(), "Expected an event"); + assert_eq!(events.len(), 1, "Unexpected events {events:?}"); let ev = events.pop().unwrap(); match ev { $event_type { .. } => ev, From 24062c076263ef7439e2d35280c3616c921268ca Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 20 Feb 2026 14:30:32 -0600 Subject: [PATCH 101/627] Add pending changelog for SpliceFailed / DiscardFunding split Co-Authored-By: Claude Opus 4.6 --- .../4388-splice-failed-discard-funding.txt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 pending_changelog/4388-splice-failed-discard-funding.txt diff --git a/pending_changelog/4388-splice-failed-discard-funding.txt b/pending_changelog/4388-splice-failed-discard-funding.txt new file mode 100644 index 00000000000..64fc4ab4e26 --- /dev/null +++ b/pending_changelog/4388-splice-failed-discard-funding.txt @@ -0,0 +1,21 @@ +# API Updates + + * `Event::SpliceFailed` no longer carries `contributed_inputs` or `contributed_outputs` fields. + Instead, a separate `Event::DiscardFunding` event with `FundingInfo::Contribution` is emitted + for UTXO cleanup. + + * `Event::DiscardFunding` with `FundingInfo::Contribution` is also emitted without a + corresponding `Event::SpliceFailed` when `ChannelManager::funding_contributed` returns an + error (e.g., channel or peer not found, wrong channel state, duplicate contribution). + +# Backwards Compatibility + + * Older serializations that included `contributed_inputs` and `contributed_outputs` in + `SpliceFailed` will have those fields silently ignored on deserialization (they were odd TLV + fields). A `DiscardFunding` event will not be produced when reading these older serializations. + +# Forward Compatibility + + * Downgrading will not set the removed `contributed_inputs`/`contributed_outputs` fields on + `SpliceFailed`, so older code expecting those fields will see empty vectors for splice + failures. From 22b056bac868f3eb6a0c09caacbee10ac0bdd40f Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Sun, 24 Aug 2025 23:22:51 +0000 Subject: [PATCH 102/627] Create `ChannelConstraints` to hold parameters for balance calculations In an upcoming commit, we move `get_available_balances_for_scope` behind `TxBuilder::get_channel_stats`, and pass channel parameters relevant to balance calculations in `TxBuilder::get_channel_stats` via `ChannelConstraints`. There are no functional changes in this commit. --- lightning/src/ln/channel.rs | 41 ++++++++++++++++++++++---------- lightning/src/sign/tx_builder.rs | 11 +++++++++ 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 104e61a1415..fac0c3890b5 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -72,8 +72,8 @@ use crate::offers::static_invoice::StaticInvoice; use crate::routing::gossip::NodeId; use crate::sign::ecdsa::EcdsaChannelSigner; use crate::sign::tx_builder::{ - saturating_sub_anchor_outputs, ChannelStats, HTLCAmountDirection, SpecTxBuilder, - TxBuilder, + saturating_sub_anchor_outputs, ChannelConstraints, ChannelStats, HTLCAmountDirection, + SpecTxBuilder, TxBuilder, }; use crate::sign::{ChannelSigner, EntropySource, NodeSigner, Recipient, SignerProvider}; use crate::types::features::{ChannelTypeFeatures, InitFeatures}; @@ -5904,11 +5904,28 @@ impl ChannelContext { outbound_details } + fn get_channel_constraints(&self, funding: &FundingScope) -> ChannelConstraints { + ChannelConstraints { + holder_dust_limit_satoshis: self.holder_dust_limit_satoshis, + counterparty_selected_channel_reserve_satoshis: funding + .counterparty_selected_channel_reserve_satoshis + .unwrap_or(0), + counterparty_dust_limit_satoshis: self.counterparty_dust_limit_satoshis, + holder_selected_channel_reserve_satoshis: funding + .holder_selected_channel_reserve_satoshis, + counterparty_htlc_minimum_msat: self.counterparty_htlc_minimum_msat, + counterparty_max_accepted_htlcs: self.counterparty_max_accepted_htlcs as u64, + counterparty_max_htlc_value_in_flight_msat: self + .counterparty_max_htlc_value_in_flight_msat, + } + } + #[rustfmt::skip] fn get_available_balances_for_scope( &self, funding: &FundingScope, fee_estimator: &LowerBoundedFeeEstimator, ) -> AvailableBalances { let context = &self; + let channel_constraints = self.get_channel_constraints(funding); // Note that we have to handle overflow due to the case mentioned in the docs in general // here. @@ -5927,7 +5944,7 @@ impl ChannelContext { let outbound_capacity_msat = local_balance_before_fee_msat .saturating_sub( - funding.counterparty_selected_channel_reserve_satoshis.unwrap_or(0) * 1000); + channel_constraints.counterparty_selected_channel_reserve_satoshis * 1000); let mut available_capacity_msat = outbound_capacity_msat; let (real_htlc_success_tx_fee_sat, real_htlc_timeout_tx_fee_sat) = second_stage_tx_fees_sat( @@ -5948,7 +5965,7 @@ impl ChannelContext { Some(()) }; - let real_dust_limit_timeout_sat = real_htlc_timeout_tx_fee_sat + context.holder_dust_limit_satoshis; + let real_dust_limit_timeout_sat = real_htlc_timeout_tx_fee_sat + channel_constraints.holder_dust_limit_satoshis; let htlc_above_dust = HTLCCandidate::new(real_dust_limit_timeout_sat * 1000, HTLCInitiator::LocalOffered); let mut max_reserved_commit_tx_fee_msat = context.next_local_commit_tx_fee_msat(&funding, htlc_above_dust, fee_spike_buffer_htlc); let htlc_dust = HTLCCandidate::new(real_dust_limit_timeout_sat * 1000 - 1, HTLCInitiator::LocalOffered); @@ -5972,11 +5989,11 @@ impl ChannelContext { } else { // If the channel is inbound (i.e. counterparty pays the fee), we need to make sure // sending a new HTLC won't reduce their balance below our reserve threshold. - let real_dust_limit_success_sat = real_htlc_success_tx_fee_sat + context.counterparty_dust_limit_satoshis; + let real_dust_limit_success_sat = real_htlc_success_tx_fee_sat + channel_constraints.counterparty_dust_limit_satoshis; let htlc_above_dust = HTLCCandidate::new(real_dust_limit_success_sat * 1000, HTLCInitiator::LocalOffered); let max_reserved_commit_tx_fee_msat = context.next_remote_commit_tx_fee_msat(funding, Some(htlc_above_dust), None); - let holder_selected_chan_reserve_msat = funding.holder_selected_channel_reserve_satoshis * 1000; + let holder_selected_chan_reserve_msat = channel_constraints.holder_selected_channel_reserve_satoshis * 1000; if remote_balance_before_fee_msat < max_reserved_commit_tx_fee_msat + holder_selected_chan_reserve_msat { // If another HTLC's fee would reduce the remote's balance below the reserve limit // we've selected for them, we can only send dust HTLCs. @@ -5984,7 +6001,7 @@ impl ChannelContext { } } - let mut next_outbound_htlc_minimum_msat = context.counterparty_htlc_minimum_msat; + let mut next_outbound_htlc_minimum_msat = channel_constraints.counterparty_htlc_minimum_msat; // If we get close to our maximum dust exposure, we end up in a situation where we can send // between zero and the remaining dust exposure limit remaining OR above the dust limit. @@ -5998,8 +6015,8 @@ impl ChannelContext { let (buffer_htlc_success_tx_fee_sat, buffer_htlc_timeout_tx_fee_sat) = second_stage_tx_fees_sat( funding.get_channel_type(), dust_buffer_feerate, ); - let buffer_dust_limit_success_sat = buffer_htlc_success_tx_fee_sat + context.counterparty_dust_limit_satoshis; - let buffer_dust_limit_timeout_sat = buffer_htlc_timeout_tx_fee_sat + context.holder_dust_limit_satoshis; + let buffer_dust_limit_success_sat = buffer_htlc_success_tx_fee_sat + channel_constraints.counterparty_dust_limit_satoshis; + let buffer_dust_limit_timeout_sat = buffer_htlc_timeout_tx_fee_sat + channel_constraints.holder_dust_limit_satoshis; if let Some(extra_htlc_dust_exposure) = htlc_stats.extra_nondust_htlc_on_counterparty_tx_dust_exposure_msat { if extra_htlc_dust_exposure > max_dust_htlc_exposure_msat { @@ -6033,15 +6050,15 @@ impl ChannelContext { } available_capacity_msat = cmp::min(available_capacity_msat, - context.counterparty_max_htlc_value_in_flight_msat - htlc_stats.pending_outbound_htlcs_value_msat); + channel_constraints.counterparty_max_htlc_value_in_flight_msat - htlc_stats.pending_outbound_htlcs_value_msat); - if htlc_stats.pending_outbound_htlcs + 1 > context.counterparty_max_accepted_htlcs as usize { + if htlc_stats.pending_outbound_htlcs + 1 > channel_constraints.counterparty_max_accepted_htlcs as usize { available_capacity_msat = 0; } #[allow(deprecated)] // TODO: Remove once balance_msat is removed. AvailableBalances { - inbound_capacity_msat: remote_balance_before_fee_msat.saturating_sub(funding.holder_selected_channel_reserve_satoshis * 1000), + inbound_capacity_msat: remote_balance_before_fee_msat.saturating_sub(channel_constraints.holder_selected_channel_reserve_satoshis * 1000), outbound_capacity_msat, next_outbound_htlc_limit_msat: available_capacity_msat, next_outbound_htlc_minimum_msat, diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index 9c2942fb10e..6dae14b5fcc 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -173,6 +173,17 @@ fn get_dust_buffer_feerate(feerate_per_kw: u32) -> u32 { cmp::max(feerate_per_kw.saturating_add(2530), feerate_plus_quarter.unwrap_or(u32::MAX)) } +#[derive(Clone, Copy, Debug)] +pub(crate) struct ChannelConstraints { + pub holder_dust_limit_satoshis: u64, + pub counterparty_selected_channel_reserve_satoshis: u64, + pub counterparty_dust_limit_satoshis: u64, + pub holder_selected_channel_reserve_satoshis: u64, + pub counterparty_htlc_minimum_msat: u64, + pub counterparty_max_htlc_value_in_flight_msat: u64, + pub counterparty_max_accepted_htlcs: u64, +} + fn get_next_commitment_stats( local: bool, is_outbound_from_holder: bool, channel_value_satoshis: u64, value_to_holder_msat: u64, next_commitment_htlcs: &[HTLCAmountDirection], From f607ff89d187a732ef3332a53d3bc5de63b22ef8 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Tue, 3 Feb 2026 05:12:35 +0000 Subject: [PATCH 103/627] Extract dust exposure calculation in `tx_builder` to its own function This snippet is currently used in `tx_builder::get_next_commitent_stats`, and will be used in an upcoming commit in `get_available_balances_for_scope`. There are no functional changes in this commit, as the `extra_accepted_htlc_dust_exposure` member of `NextCommitmentStats` was not used. --- lightning/src/sign/tx_builder.rs | 92 ++++++++++++++++++-------------- 1 file changed, 52 insertions(+), 40 deletions(-) diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index 6dae14b5fcc..cf063834e5c 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -42,7 +42,6 @@ pub(crate) struct NextCommitmentStats { pub nondust_htlc_count: usize, pub commit_tx_fee_sat: u64, pub dust_exposure_msat: u64, - pub extra_accepted_htlc_dust_exposure_msat: u64, } pub(crate) struct ChannelStats { @@ -184,6 +183,50 @@ pub(crate) struct ChannelConstraints { pub counterparty_max_accepted_htlcs: u64, } +pub(crate) fn get_dust_exposure_stats( + local: bool, commitment_htlcs: &[HTLCAmountDirection], feerate_per_kw: u32, + dust_exposure_limiting_feerate: Option, broadcaster_dust_limit_satoshis: u64, + channel_type: &ChannelTypeFeatures, +) -> (u64, Option) { + let excess_feerate = + feerate_per_kw.saturating_sub(dust_exposure_limiting_feerate.unwrap_or(feerate_per_kw)); + if channel_type.supports_anchor_zero_fee_commitments() { + debug_assert_eq!(feerate_per_kw, 0); + debug_assert_eq!(excess_feerate, 0); + } + + // Increment the feerate by a buffer to calculate dust exposure + let dust_buffer_feerate = get_dust_buffer_feerate(feerate_per_kw); + + // Calculate dust exposure on commitment transaction + let dust_exposure_msat = commitment_htlcs + .iter() + .filter_map(|htlc| { + htlc.is_dust(local, dust_buffer_feerate, broadcaster_dust_limit_satoshis, channel_type) + .then_some(htlc.amount_msat) + }) + .sum(); + + if local || excess_feerate == 0 { + (dust_exposure_msat, None) + } else { + // Add any excess fees to dust exposure on counterparty transactions + let (excess_fees_msat, extra_accepted_htlc_excess_fees_msat) = + commit_plus_htlc_tx_fees_msat( + local, + &commitment_htlcs, + dust_buffer_feerate, + excess_feerate, + broadcaster_dust_limit_satoshis, + channel_type, + ); + ( + dust_exposure_msat + excess_fees_msat, + Some(dust_exposure_msat + extra_accepted_htlc_excess_fees_msat), + ) + } +} + fn get_next_commitment_stats( local: bool, is_outbound_from_holder: bool, channel_value_satoshis: u64, value_to_holder_msat: u64, next_commitment_htlcs: &[HTLCAmountDirection], @@ -191,11 +234,8 @@ fn get_next_commitment_stats( dust_exposure_limiting_feerate: Option, broadcaster_dust_limit_satoshis: u64, channel_type: &ChannelTypeFeatures, ) -> Result { - let excess_feerate = - feerate_per_kw.saturating_sub(dust_exposure_limiting_feerate.unwrap_or(feerate_per_kw)); if channel_type.supports_anchor_zero_fee_commitments() { debug_assert_eq!(feerate_per_kw, 0); - debug_assert_eq!(excess_feerate, 0); } // Calculate inbound htlc count @@ -235,9 +275,6 @@ fn get_next_commitment_stats( channel_type, )?; - // Increment the feerate by a buffer to calculate dust exposure - let dust_buffer_feerate = get_dust_buffer_feerate(feerate_per_kw); - // Calculate fees on commitment transaction let nondust_htlc_count = next_commitment_htlcs .iter() @@ -251,38 +288,14 @@ fn get_next_commitment_stats( channel_type, ); - // Calculate dust exposure on commitment transaction - let dust_exposure_msat = next_commitment_htlcs - .iter() - .filter_map(|htlc| { - htlc.is_dust( - local, - dust_buffer_feerate, - broadcaster_dust_limit_satoshis, - channel_type, - ) - .then_some(htlc.amount_msat) - }) - .sum(); - - // Add any excess fees to dust exposure on counterparty transactions - let (dust_exposure_msat, extra_accepted_htlc_dust_exposure_msat) = if local { - (dust_exposure_msat, dust_exposure_msat) - } else { - let (excess_fees_msat, extra_accepted_htlc_excess_fees_msat) = - commit_plus_htlc_tx_fees_msat( - local, - &next_commitment_htlcs, - dust_buffer_feerate, - excess_feerate, - broadcaster_dust_limit_satoshis, - channel_type, - ); - ( - dust_exposure_msat + excess_fees_msat, - dust_exposure_msat + extra_accepted_htlc_excess_fees_msat, - ) - }; + let (dust_exposure_msat, _extra_accepted_htlc_dust_exposure_msat) = get_dust_exposure_stats( + local, + next_commitment_htlcs, + feerate_per_kw, + dust_exposure_limiting_feerate, + broadcaster_dust_limit_satoshis, + channel_type, + ); Ok(NextCommitmentStats { is_outbound_from_holder, @@ -293,7 +306,6 @@ fn get_next_commitment_stats( nondust_htlc_count: nondust_htlc_count + addl_nondust_htlc_count, commit_tx_fee_sat, dust_exposure_msat, - extra_accepted_htlc_dust_exposure_msat, }) } From e41259a4cd8ffa99259aeb0ea555091f87c29be2 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Tue, 3 Feb 2026 05:10:46 +0000 Subject: [PATCH 104/627] Rewrite `get_available_balances_for_scope` using `tx_builder` functions We no longer make use of `get_pending_htlc_stats`, `get_dust_buffer_feerate`, `next_local_commit_tx_fee_msat`, and `next_remote_commit_tx_fee_msat` in the `channel` module, and instead make use of tooling from the `tx_builder` module. `HTLCStats::pending_outbound_htlcs` and `HTLCStats::pending_outbound_htlcs_value_msat` are now calculated in `get_available_balances_for_scope`, and do not include outbound HTLCs in states `AwaitingRemoteRevokeToRemove` and `AwaitingRemovedRemoteRevoke`. `HTLCStats::pending_inbound_htlcs_value_msat` is now calculated in `get_available_balances_for_scope`, and does not include inbound HTLCs in state `LocalRemoved`. To determine whether a HTLC is dust for the purpose of calculating total dust exposure, we now refer only to `ChannelContext::feerate_per_kw`, and ignore any upcoming fee updates stored in `pending_update_fee`. The same applies for dust exposure due to excess fees; we ignore any fee updates in `ChannelContext::pending_update_fee`, and only refer to `ChannelContext::feerate_per_kw`. For outbound feerate updates, this is ok because all such updates first get placed in the holding cell. We validate dust exposure again upon freeing the feerate update from the holding cell, and immediately generate the corresponding commitment. For inbound feerate updates, it is possible that the peer sends us a feerate update that is in excess of our dust exposure limiting feerate, at the same time that we send non-dust HTLCs that exhaust the max dust exposure at the new feerate. This leads to a channel force-close when the peer sends us their commitment signed including the HTLCs and the new feerate. Similar to the `HTLCStats` members above, when calculating dust exposure on both holder and counterparty transactions in `get_available_balances_for_scope`, we now do not include inbound HTLCs in states `LocalRemoved`, and outbound HTLCs in states `AwaitingRemoteRevokeToRemove` and `AwaitingRemovedRemoteRevoke`. In the case where `is_outbound_from_holder` is true, `max_reserved_commit_tx_fee_msat` and `min_reserved_commit_tx_fee_msat` now do not include pending inbound HTLCs in state `LocalRemoved`. In the case where `is_outbound_from_holder` is false, `max_reserved_commit_tx_fee_msat` now also includes outbound HTLCs in the holding cell, and does not include inbound HTLCs in state `LocalRemoved`. These fee values are also the result of the feerate getting multiplied by the fee spike buffer increase multiple, instead of the final commitment transaction fee getting multiplied by that multiple. This results in higher values, as we multiply before the rounding down to the nearest satoshi. This reduces the set of HTLC additions we would send. Finally, these values also account for any non-dust HTLCs that transition to dust at the higher feerate, resulting in lower values. This increases the set of HTLC additions we would send, and previous versions of LDK will fail only the single HTLC and not the channel in case we breach their buffer. --- lightning/src/ln/channel.rs | 160 +++++++++++++++++++++---------- lightning/src/sign/tx_builder.rs | 4 +- 2 files changed, 112 insertions(+), 52 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index fac0c3890b5..88cb39943f9 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -39,11 +39,12 @@ use crate::chain::BestBlock; use crate::events::{ClosureReason, FundingInfo}; use crate::ln::chan_utils; use crate::ln::chan_utils::{ - get_commitment_transaction_number_obscure_factor, max_htlcs, second_stage_tx_fees_sat, - selected_commitment_sat_per_1000_weight, ChannelPublicKeys, ChannelTransactionParameters, - ClosingTransaction, CommitmentTransaction, CounterpartyChannelTransactionParameters, - CounterpartyCommitmentSecrets, HTLCOutputInCommitment, HolderCommitmentTransaction, - EMPTY_SCRIPT_SIG_WEIGHT, FUNDING_TRANSACTION_WITNESS_WEIGHT, + commit_tx_fee_sat, get_commitment_transaction_number_obscure_factor, max_htlcs, + second_stage_tx_fees_sat, selected_commitment_sat_per_1000_weight, ChannelPublicKeys, + ChannelTransactionParameters, ClosingTransaction, CommitmentTransaction, + CounterpartyChannelTransactionParameters, CounterpartyCommitmentSecrets, + HTLCOutputInCommitment, HolderCommitmentTransaction, EMPTY_SCRIPT_SIG_WEIGHT, + FUNDING_TRANSACTION_WITNESS_WEIGHT, }; use crate::ln::channel_state::{ ChannelShutdownState, CounterpartyForwardingInfo, InboundHTLCDetails, InboundHTLCStateDetails, @@ -72,8 +73,8 @@ use crate::offers::static_invoice::StaticInvoice; use crate::routing::gossip::NodeId; use crate::sign::ecdsa::EcdsaChannelSigner; use crate::sign::tx_builder::{ - saturating_sub_anchor_outputs, ChannelConstraints, ChannelStats, HTLCAmountDirection, - SpecTxBuilder, TxBuilder, + get_dust_buffer_feerate, get_dust_exposure_stats, saturating_sub_anchor_outputs, + ChannelConstraints, ChannelStats, HTLCAmountDirection, SpecTxBuilder, TxBuilder, }; use crate::sign::{ChannelSigner, EntropySource, NodeSigner, Recipient, SignerProvider}; use crate::types::features::{ChannelTypeFeatures, InitFeatures}; @@ -5924,34 +5925,106 @@ impl ChannelContext { fn get_available_balances_for_scope( &self, funding: &FundingScope, fee_estimator: &LowerBoundedFeeEstimator, ) -> AvailableBalances { - let context = &self; - let channel_constraints = self.get_channel_constraints(funding); - // Note that we have to handle overflow due to the case mentioned in the docs in general - // here. + let local = false; + let htlc_candidate = None; + let include_counterparty_unknown_htlcs = true; + let pending_htlcs = self.get_next_commitment_htlcs(local, htlc_candidate, include_counterparty_unknown_htlcs); let dust_exposure_limiting_feerate = self.get_dust_exposure_limiting_feerate( &fee_estimator, funding.get_channel_type(), ); - let htlc_stats = context.get_pending_htlc_stats(funding, None, dust_exposure_limiting_feerate); + let max_dust_htlc_exposure_msat = self.get_max_dust_htlc_exposure_msat(dust_exposure_limiting_feerate); + + let is_outbound_from_holder = funding.is_outbound(); + let channel_value_satoshis = funding.get_value_satoshis(); + let value_to_holder_msat = funding.get_value_to_self_msat(); + let pending_htlcs = &pending_htlcs; + let feerate_per_kw = self.feerate_per_kw; + let channel_constraints = self.get_channel_constraints(funding); + let channel_type = funding.get_channel_type(); + + let fee_spike_buffer_htlc = + if channel_type.supports_anchor_zero_fee_commitments() { 0 } else { 1 }; + + let local_feerate = feerate_per_kw + * if is_outbound_from_holder && !channel_type.supports_anchors_zero_fee_htlc_tx() { + crate::ln::channel::FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 + } else { + 1 + }; + + let local_nondust_htlc_count = pending_htlcs + .iter() + .filter(|htlc| { + !htlc.is_dust( + true, + local_feerate, + channel_constraints.holder_dust_limit_satoshis, + channel_type, + ) + }) + .count(); + let local_max_commit_tx_fee_sat = commit_tx_fee_sat( + local_feerate, + local_nondust_htlc_count + fee_spike_buffer_htlc + 1, + channel_type, + ); + let local_min_commit_tx_fee_sat = commit_tx_fee_sat( + local_feerate, + local_nondust_htlc_count + fee_spike_buffer_htlc, + channel_type, + ); + let (local_dust_exposure_msat, _) = get_dust_exposure_stats( + true, + pending_htlcs, + feerate_per_kw, + dust_exposure_limiting_feerate, + channel_constraints.holder_dust_limit_satoshis, + channel_type, + ); + let remote_nondust_htlc_count = pending_htlcs + .iter() + .filter(|htlc| { + !htlc.is_dust( + false, + feerate_per_kw, + channel_constraints.counterparty_dust_limit_satoshis, + channel_type, + ) + }) + .count(); + let remote_commit_tx_fee_sat = + commit_tx_fee_sat(feerate_per_kw, remote_nondust_htlc_count + 1, channel_type); + let (remote_dust_exposure_msat, extra_htlc_remote_dust_exposure_msat) = get_dust_exposure_stats( + false, + pending_htlcs, + feerate_per_kw, + dust_exposure_limiting_feerate, + channel_constraints.counterparty_dust_limit_satoshis, + channel_type, + ); - // Subtract anchor outputs from the local and remote balances + let outbound_htlcs_value_msat: u64 = + pending_htlcs.iter().filter_map(|htlc| htlc.outbound.then_some(htlc.amount_msat)).sum(); + let inbound_htlcs_value_msat: u64 = + pending_htlcs.iter().filter_map(|htlc| (!htlc.outbound).then_some(htlc.amount_msat)).sum(); let (local_balance_before_fee_msat, remote_balance_before_fee_msat) = saturating_sub_anchor_outputs( - funding.is_outbound(), - funding.value_to_self_msat.saturating_sub(htlc_stats.pending_outbound_htlcs_value_msat), - (funding.get_value_satoshis() * 1000).checked_sub(funding.value_to_self_msat).unwrap().saturating_sub(htlc_stats.pending_inbound_htlcs_value_msat), - funding.get_channel_type(), + is_outbound_from_holder, + value_to_holder_msat.saturating_sub(outbound_htlcs_value_msat), + (channel_value_satoshis * 1000).checked_sub(value_to_holder_msat).unwrap().saturating_sub(inbound_htlcs_value_msat), + &channel_type, ); let outbound_capacity_msat = local_balance_before_fee_msat - .saturating_sub( - channel_constraints.counterparty_selected_channel_reserve_satoshis * 1000); + .saturating_sub( + channel_constraints.counterparty_selected_channel_reserve_satoshis * 1000); let mut available_capacity_msat = outbound_capacity_msat; let (real_htlc_success_tx_fee_sat, real_htlc_timeout_tx_fee_sat) = second_stage_tx_fees_sat( - funding.get_channel_type(), context.feerate_per_kw, + channel_type, feerate_per_kw ); - if funding.is_outbound() { + if is_outbound_from_holder { // We should mind channel commit tx fee when computing how much of the available capacity // can be used in the next htlc. Mirrors the logic in send_htlc. // @@ -5959,22 +6032,10 @@ impl ChannelContext { // and the answer will in turn change the amount itself — making it a circular // dependency. // This complicates the computation around dust-values, up to the one-htlc-value. - let fee_spike_buffer_htlc = if funding.get_channel_type().supports_anchor_zero_fee_commitments() { - None - } else { - Some(()) - }; let real_dust_limit_timeout_sat = real_htlc_timeout_tx_fee_sat + channel_constraints.holder_dust_limit_satoshis; - let htlc_above_dust = HTLCCandidate::new(real_dust_limit_timeout_sat * 1000, HTLCInitiator::LocalOffered); - let mut max_reserved_commit_tx_fee_msat = context.next_local_commit_tx_fee_msat(&funding, htlc_above_dust, fee_spike_buffer_htlc); - let htlc_dust = HTLCCandidate::new(real_dust_limit_timeout_sat * 1000 - 1, HTLCInitiator::LocalOffered); - let mut min_reserved_commit_tx_fee_msat = context.next_local_commit_tx_fee_msat(&funding, htlc_dust, fee_spike_buffer_htlc); - - if !funding.get_channel_type().supports_anchors_zero_fee_htlc_tx() { - max_reserved_commit_tx_fee_msat *= FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE; - min_reserved_commit_tx_fee_msat *= FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE; - } + let max_reserved_commit_tx_fee_msat = local_max_commit_tx_fee_sat * 1000; + let min_reserved_commit_tx_fee_msat = local_min_commit_tx_fee_sat * 1000; // We will first subtract the fee as if we were above-dust. Then, if the resulting // value ends up being below dust, we have this fee available again. In that case, @@ -5990,8 +6051,7 @@ impl ChannelContext { // If the channel is inbound (i.e. counterparty pays the fee), we need to make sure // sending a new HTLC won't reduce their balance below our reserve threshold. let real_dust_limit_success_sat = real_htlc_success_tx_fee_sat + channel_constraints.counterparty_dust_limit_satoshis; - let htlc_above_dust = HTLCCandidate::new(real_dust_limit_success_sat * 1000, HTLCInitiator::LocalOffered); - let max_reserved_commit_tx_fee_msat = context.next_remote_commit_tx_fee_msat(funding, Some(htlc_above_dust), None); + let max_reserved_commit_tx_fee_msat = remote_commit_tx_fee_sat * 1000; let holder_selected_chan_reserve_msat = channel_constraints.holder_selected_channel_reserve_satoshis * 1000; if remote_balance_before_fee_msat < max_reserved_commit_tx_fee_msat + holder_selected_chan_reserve_msat { @@ -6009,35 +6069,35 @@ impl ChannelContext { // send above the dust limit (as the router can always overpay to meet the dust limit). let mut remaining_msat_below_dust_exposure_limit = None; let mut dust_exposure_dust_limit_msat = 0; - let max_dust_htlc_exposure_msat = context.get_max_dust_htlc_exposure_msat(dust_exposure_limiting_feerate); - let dust_buffer_feerate = self.get_dust_buffer_feerate(None); + let dust_buffer_feerate = get_dust_buffer_feerate(feerate_per_kw); let (buffer_htlc_success_tx_fee_sat, buffer_htlc_timeout_tx_fee_sat) = second_stage_tx_fees_sat( - funding.get_channel_type(), dust_buffer_feerate, + channel_type, dust_buffer_feerate ); let buffer_dust_limit_success_sat = buffer_htlc_success_tx_fee_sat + channel_constraints.counterparty_dust_limit_satoshis; let buffer_dust_limit_timeout_sat = buffer_htlc_timeout_tx_fee_sat + channel_constraints.holder_dust_limit_satoshis; - if let Some(extra_htlc_dust_exposure) = htlc_stats.extra_nondust_htlc_on_counterparty_tx_dust_exposure_msat { - if extra_htlc_dust_exposure > max_dust_htlc_exposure_msat { + if let Some(extra_htlc_remote_dust_exposure) = extra_htlc_remote_dust_exposure_msat { + if extra_htlc_remote_dust_exposure > max_dust_htlc_exposure_msat { // If adding an extra HTLC would put us over the dust limit in total fees, we cannot // send any non-dust HTLCs. available_capacity_msat = cmp::min(available_capacity_msat, buffer_dust_limit_success_sat * 1000); } } - if htlc_stats.on_counterparty_tx_dust_exposure_msat.saturating_add(buffer_dust_limit_success_sat * 1000) > max_dust_htlc_exposure_msat.saturating_add(1) { + if remote_dust_exposure_msat.saturating_add(buffer_dust_limit_success_sat * 1000) > max_dust_htlc_exposure_msat.saturating_add(1) { // Note that we don't use the `counterparty_tx_dust_exposure` (with // `htlc_dust_exposure_msat`) here as it only applies to non-dust HTLCs. remaining_msat_below_dust_exposure_limit = - Some(max_dust_htlc_exposure_msat.saturating_sub(htlc_stats.on_counterparty_tx_dust_exposure_msat)); + Some(max_dust_htlc_exposure_msat.saturating_sub(remote_dust_exposure_msat)); dust_exposure_dust_limit_msat = cmp::max(dust_exposure_dust_limit_msat, buffer_dust_limit_success_sat * 1000); } - if htlc_stats.on_holder_tx_dust_exposure_msat as i64 + buffer_dust_limit_timeout_sat as i64 * 1000 - 1 > max_dust_htlc_exposure_msat.try_into().unwrap_or(i64::max_value()) { + if local_dust_exposure_msat as i64 + buffer_dust_limit_timeout_sat as i64 * 1000 - 1 > max_dust_htlc_exposure_msat.try_into().unwrap_or(i64::max_value()) { remaining_msat_below_dust_exposure_limit = Some(cmp::min( remaining_msat_below_dust_exposure_limit.unwrap_or(u64::max_value()), - max_dust_htlc_exposure_msat.saturating_sub(htlc_stats.on_holder_tx_dust_exposure_msat))); + max_dust_htlc_exposure_msat.saturating_sub(local_dust_exposure_msat), + )); dust_exposure_dust_limit_msat = cmp::max(dust_exposure_dust_limit_msat, buffer_dust_limit_timeout_sat * 1000); } @@ -6050,14 +6110,14 @@ impl ChannelContext { } available_capacity_msat = cmp::min(available_capacity_msat, - channel_constraints.counterparty_max_htlc_value_in_flight_msat - htlc_stats.pending_outbound_htlcs_value_msat); + channel_constraints.counterparty_max_htlc_value_in_flight_msat - outbound_htlcs_value_msat); - if htlc_stats.pending_outbound_htlcs + 1 > channel_constraints.counterparty_max_accepted_htlcs as usize { + if pending_htlcs.iter().filter(|htlc| htlc.outbound).count() + 1 > channel_constraints.counterparty_max_accepted_htlcs as usize { available_capacity_msat = 0; } - #[allow(deprecated)] // TODO: Remove once balance_msat is removed. - AvailableBalances { + #[allow(deprecated)] // TODO: Remove once balance_msat is removed + crate::ln::channel::AvailableBalances { inbound_capacity_msat: remote_balance_before_fee_msat.saturating_sub(channel_constraints.holder_selected_channel_reserve_satoshis * 1000), outbound_capacity_msat, next_outbound_htlc_limit_msat: available_capacity_msat, diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index cf063834e5c..8282eb86ef2 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -21,7 +21,7 @@ pub(crate) struct HTLCAmountDirection { } impl HTLCAmountDirection { - fn is_dust( + pub(crate) fn is_dust( &self, local: bool, feerate_per_kw: u32, broadcaster_dust_limit_satoshis: u64, channel_type: &ChannelTypeFeatures, ) -> bool { @@ -162,7 +162,7 @@ pub(crate) fn saturating_sub_anchor_outputs( } } -fn get_dust_buffer_feerate(feerate_per_kw: u32) -> u32 { +pub(crate) fn get_dust_buffer_feerate(feerate_per_kw: u32) -> u32 { // When calculating our exposure to dust HTLCs, we assume that the channel feerate // may, at any point, increase by at least 10 sat/vB (i.e 2530 sat/kWU) or 25%, // whichever is higher. This ensures that we aren't suddenly exposed to significantly From 415ad542f1b0401b7a857df0f740daf8ffbc2573 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Tue, 3 Feb 2026 05:52:50 +0000 Subject: [PATCH 105/627] Move `ChannelContext::get_available_balances_for_scope` to `tx_builder` This is a direct code move to `tx_builder::get_available_balances`. --- lightning/src/ln/channel.rs | 214 +++---------------------------- lightning/src/sign/tx_builder.rs | 198 +++++++++++++++++++++++++++- 2 files changed, 211 insertions(+), 201 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 88cb39943f9..1a3c32a9a21 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -39,12 +39,11 @@ use crate::chain::BestBlock; use crate::events::{ClosureReason, FundingInfo}; use crate::ln::chan_utils; use crate::ln::chan_utils::{ - commit_tx_fee_sat, get_commitment_transaction_number_obscure_factor, max_htlcs, - second_stage_tx_fees_sat, selected_commitment_sat_per_1000_weight, ChannelPublicKeys, - ChannelTransactionParameters, ClosingTransaction, CommitmentTransaction, - CounterpartyChannelTransactionParameters, CounterpartyCommitmentSecrets, - HTLCOutputInCommitment, HolderCommitmentTransaction, EMPTY_SCRIPT_SIG_WEIGHT, - FUNDING_TRANSACTION_WITNESS_WEIGHT, + get_commitment_transaction_number_obscure_factor, max_htlcs, second_stage_tx_fees_sat, + selected_commitment_sat_per_1000_weight, ChannelPublicKeys, ChannelTransactionParameters, + ClosingTransaction, CommitmentTransaction, CounterpartyChannelTransactionParameters, + CounterpartyCommitmentSecrets, HTLCOutputInCommitment, HolderCommitmentTransaction, + EMPTY_SCRIPT_SIG_WEIGHT, FUNDING_TRANSACTION_WITNESS_WEIGHT, }; use crate::ln::channel_state::{ ChannelShutdownState, CounterpartyForwardingInfo, InboundHTLCDetails, InboundHTLCStateDetails, @@ -73,8 +72,8 @@ use crate::offers::static_invoice::StaticInvoice; use crate::routing::gossip::NodeId; use crate::sign::ecdsa::EcdsaChannelSigner; use crate::sign::tx_builder::{ - get_dust_buffer_feerate, get_dust_exposure_stats, saturating_sub_anchor_outputs, - ChannelConstraints, ChannelStats, HTLCAmountDirection, SpecTxBuilder, TxBuilder, + get_available_balances, ChannelConstraints, ChannelStats, HTLCAmountDirection, + SpecTxBuilder, TxBuilder, }; use crate::sign::{ChannelSigner, EntropySource, NodeSigner, Recipient, SignerProvider}; use crate::types::features::{ChannelTypeFeatures, InitFeatures}; @@ -1442,7 +1441,7 @@ impl HolderCommitmentPoint { #[cfg(any(fuzzing, test, feature = "_test_utils"))] pub const FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE: u64 = 2; #[cfg(not(any(fuzzing, test, feature = "_test_utils")))] -const FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE: u64 = 2; +pub(crate) const FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE: u64 = 2; /// If we fail to see a funding transaction confirmed on-chain within this many blocks after the /// channel creation on an inbound channel, we simply force-close and move on. @@ -5935,194 +5934,17 @@ impl ChannelContext { ); let max_dust_htlc_exposure_msat = self.get_max_dust_htlc_exposure_msat(dust_exposure_limiting_feerate); - let is_outbound_from_holder = funding.is_outbound(); - let channel_value_satoshis = funding.get_value_satoshis(); - let value_to_holder_msat = funding.get_value_to_self_msat(); - let pending_htlcs = &pending_htlcs; - let feerate_per_kw = self.feerate_per_kw; - let channel_constraints = self.get_channel_constraints(funding); - let channel_type = funding.get_channel_type(); - - let fee_spike_buffer_htlc = - if channel_type.supports_anchor_zero_fee_commitments() { 0 } else { 1 }; - - let local_feerate = feerate_per_kw - * if is_outbound_from_holder && !channel_type.supports_anchors_zero_fee_htlc_tx() { - crate::ln::channel::FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 - } else { - 1 - }; - - let local_nondust_htlc_count = pending_htlcs - .iter() - .filter(|htlc| { - !htlc.is_dust( - true, - local_feerate, - channel_constraints.holder_dust_limit_satoshis, - channel_type, - ) - }) - .count(); - let local_max_commit_tx_fee_sat = commit_tx_fee_sat( - local_feerate, - local_nondust_htlc_count + fee_spike_buffer_htlc + 1, - channel_type, - ); - let local_min_commit_tx_fee_sat = commit_tx_fee_sat( - local_feerate, - local_nondust_htlc_count + fee_spike_buffer_htlc, - channel_type, - ); - let (local_dust_exposure_msat, _) = get_dust_exposure_stats( - true, - pending_htlcs, - feerate_per_kw, - dust_exposure_limiting_feerate, - channel_constraints.holder_dust_limit_satoshis, - channel_type, - ); - let remote_nondust_htlc_count = pending_htlcs - .iter() - .filter(|htlc| { - !htlc.is_dust( - false, - feerate_per_kw, - channel_constraints.counterparty_dust_limit_satoshis, - channel_type, - ) - }) - .count(); - let remote_commit_tx_fee_sat = - commit_tx_fee_sat(feerate_per_kw, remote_nondust_htlc_count + 1, channel_type); - let (remote_dust_exposure_msat, extra_htlc_remote_dust_exposure_msat) = get_dust_exposure_stats( - false, - pending_htlcs, - feerate_per_kw, + get_available_balances( + funding.is_outbound(), + funding.get_value_satoshis(), + funding.get_value_to_self_msat(), + &pending_htlcs, + self.feerate_per_kw, dust_exposure_limiting_feerate, - channel_constraints.counterparty_dust_limit_satoshis, - channel_type, - ); - - let outbound_htlcs_value_msat: u64 = - pending_htlcs.iter().filter_map(|htlc| htlc.outbound.then_some(htlc.amount_msat)).sum(); - let inbound_htlcs_value_msat: u64 = - pending_htlcs.iter().filter_map(|htlc| (!htlc.outbound).then_some(htlc.amount_msat)).sum(); - let (local_balance_before_fee_msat, remote_balance_before_fee_msat) = saturating_sub_anchor_outputs( - is_outbound_from_holder, - value_to_holder_msat.saturating_sub(outbound_htlcs_value_msat), - (channel_value_satoshis * 1000).checked_sub(value_to_holder_msat).unwrap().saturating_sub(inbound_htlcs_value_msat), - &channel_type, - ); - - let outbound_capacity_msat = local_balance_before_fee_msat - .saturating_sub( - channel_constraints.counterparty_selected_channel_reserve_satoshis * 1000); - - let mut available_capacity_msat = outbound_capacity_msat; - let (real_htlc_success_tx_fee_sat, real_htlc_timeout_tx_fee_sat) = second_stage_tx_fees_sat( - channel_type, feerate_per_kw - ); - - if is_outbound_from_holder { - // We should mind channel commit tx fee when computing how much of the available capacity - // can be used in the next htlc. Mirrors the logic in send_htlc. - // - // The fee depends on whether the amount we will be sending is above dust or not, - // and the answer will in turn change the amount itself — making it a circular - // dependency. - // This complicates the computation around dust-values, up to the one-htlc-value. - - let real_dust_limit_timeout_sat = real_htlc_timeout_tx_fee_sat + channel_constraints.holder_dust_limit_satoshis; - let max_reserved_commit_tx_fee_msat = local_max_commit_tx_fee_sat * 1000; - let min_reserved_commit_tx_fee_msat = local_min_commit_tx_fee_sat * 1000; - - // We will first subtract the fee as if we were above-dust. Then, if the resulting - // value ends up being below dust, we have this fee available again. In that case, - // match the value to right-below-dust. - let capacity_minus_max_commitment_fee_msat = available_capacity_msat.saturating_sub(max_reserved_commit_tx_fee_msat); - if capacity_minus_max_commitment_fee_msat < real_dust_limit_timeout_sat * 1000 { - let capacity_minus_min_commitment_fee_msat = available_capacity_msat.saturating_sub(min_reserved_commit_tx_fee_msat); - available_capacity_msat = cmp::min(real_dust_limit_timeout_sat * 1000 - 1, capacity_minus_min_commitment_fee_msat); - } else { - available_capacity_msat = capacity_minus_max_commitment_fee_msat; - } - } else { - // If the channel is inbound (i.e. counterparty pays the fee), we need to make sure - // sending a new HTLC won't reduce their balance below our reserve threshold. - let real_dust_limit_success_sat = real_htlc_success_tx_fee_sat + channel_constraints.counterparty_dust_limit_satoshis; - let max_reserved_commit_tx_fee_msat = remote_commit_tx_fee_sat * 1000; - - let holder_selected_chan_reserve_msat = channel_constraints.holder_selected_channel_reserve_satoshis * 1000; - if remote_balance_before_fee_msat < max_reserved_commit_tx_fee_msat + holder_selected_chan_reserve_msat { - // If another HTLC's fee would reduce the remote's balance below the reserve limit - // we've selected for them, we can only send dust HTLCs. - available_capacity_msat = cmp::min(available_capacity_msat, real_dust_limit_success_sat * 1000 - 1); - } - } - - let mut next_outbound_htlc_minimum_msat = channel_constraints.counterparty_htlc_minimum_msat; - - // If we get close to our maximum dust exposure, we end up in a situation where we can send - // between zero and the remaining dust exposure limit remaining OR above the dust limit. - // Because we cannot express this as a simple min/max, we prefer to tell the user they can - // send above the dust limit (as the router can always overpay to meet the dust limit). - let mut remaining_msat_below_dust_exposure_limit = None; - let mut dust_exposure_dust_limit_msat = 0; - - let dust_buffer_feerate = get_dust_buffer_feerate(feerate_per_kw); - let (buffer_htlc_success_tx_fee_sat, buffer_htlc_timeout_tx_fee_sat) = second_stage_tx_fees_sat( - channel_type, dust_buffer_feerate - ); - let buffer_dust_limit_success_sat = buffer_htlc_success_tx_fee_sat + channel_constraints.counterparty_dust_limit_satoshis; - let buffer_dust_limit_timeout_sat = buffer_htlc_timeout_tx_fee_sat + channel_constraints.holder_dust_limit_satoshis; - - if let Some(extra_htlc_remote_dust_exposure) = extra_htlc_remote_dust_exposure_msat { - if extra_htlc_remote_dust_exposure > max_dust_htlc_exposure_msat { - // If adding an extra HTLC would put us over the dust limit in total fees, we cannot - // send any non-dust HTLCs. - available_capacity_msat = cmp::min(available_capacity_msat, buffer_dust_limit_success_sat * 1000); - } - } - - if remote_dust_exposure_msat.saturating_add(buffer_dust_limit_success_sat * 1000) > max_dust_htlc_exposure_msat.saturating_add(1) { - // Note that we don't use the `counterparty_tx_dust_exposure` (with - // `htlc_dust_exposure_msat`) here as it only applies to non-dust HTLCs. - remaining_msat_below_dust_exposure_limit = - Some(max_dust_htlc_exposure_msat.saturating_sub(remote_dust_exposure_msat)); - dust_exposure_dust_limit_msat = cmp::max(dust_exposure_dust_limit_msat, buffer_dust_limit_success_sat * 1000); - } - - if local_dust_exposure_msat as i64 + buffer_dust_limit_timeout_sat as i64 * 1000 - 1 > max_dust_htlc_exposure_msat.try_into().unwrap_or(i64::max_value()) { - remaining_msat_below_dust_exposure_limit = Some(cmp::min( - remaining_msat_below_dust_exposure_limit.unwrap_or(u64::max_value()), - max_dust_htlc_exposure_msat.saturating_sub(local_dust_exposure_msat), - )); - dust_exposure_dust_limit_msat = cmp::max(dust_exposure_dust_limit_msat, buffer_dust_limit_timeout_sat * 1000); - } - - if let Some(remaining_limit_msat) = remaining_msat_below_dust_exposure_limit { - if available_capacity_msat < dust_exposure_dust_limit_msat { - available_capacity_msat = cmp::min(available_capacity_msat, remaining_limit_msat); - } else { - next_outbound_htlc_minimum_msat = cmp::max(next_outbound_htlc_minimum_msat, dust_exposure_dust_limit_msat); - } - } - - available_capacity_msat = cmp::min(available_capacity_msat, - channel_constraints.counterparty_max_htlc_value_in_flight_msat - outbound_htlcs_value_msat); - - if pending_htlcs.iter().filter(|htlc| htlc.outbound).count() + 1 > channel_constraints.counterparty_max_accepted_htlcs as usize { - available_capacity_msat = 0; - } - - #[allow(deprecated)] // TODO: Remove once balance_msat is removed - crate::ln::channel::AvailableBalances { - inbound_capacity_msat: remote_balance_before_fee_msat.saturating_sub(channel_constraints.holder_selected_channel_reserve_satoshis * 1000), - outbound_capacity_msat, - next_outbound_htlc_limit_msat: available_capacity_msat, - next_outbound_htlc_minimum_msat, - } + max_dust_htlc_exposure_msat, + self.get_channel_constraints(funding), + funding.get_channel_type(), + ) } /// Get the commitment tx fee for the local's (i.e. our) next commitment transaction based on the diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index 8282eb86ef2..6c88a75a9d1 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -21,7 +21,7 @@ pub(crate) struct HTLCAmountDirection { } impl HTLCAmountDirection { - pub(crate) fn is_dust( + fn is_dust( &self, local: bool, feerate_per_kw: u32, broadcaster_dust_limit_satoshis: u64, channel_type: &ChannelTypeFeatures, ) -> bool { @@ -116,7 +116,7 @@ fn commit_plus_htlc_tx_fees_msat( (total_fees_msat, extra_accepted_htlc_total_fees_msat) } -pub(crate) fn checked_sub_anchor_outputs( +fn checked_sub_anchor_outputs( is_outbound_from_holder: bool, value_to_self_after_htlcs_msat: u64, value_to_remote_after_htlcs_msat: u64, channel_type: &ChannelTypeFeatures, ) -> Result<(u64, u64), ()> { @@ -139,7 +139,7 @@ pub(crate) fn checked_sub_anchor_outputs( } } -pub(crate) fn saturating_sub_anchor_outputs( +fn saturating_sub_anchor_outputs( is_outbound_from_holder: bool, value_to_self_after_htlcs: u64, value_to_remote_after_htlcs: u64, channel_type: &ChannelTypeFeatures, ) -> (u64, u64) { @@ -162,7 +162,7 @@ pub(crate) fn saturating_sub_anchor_outputs( } } -pub(crate) fn get_dust_buffer_feerate(feerate_per_kw: u32) -> u32 { +fn get_dust_buffer_feerate(feerate_per_kw: u32) -> u32 { // When calculating our exposure to dust HTLCs, we assume that the channel feerate // may, at any point, increase by at least 10 sat/vB (i.e 2530 sat/kWU) or 25%, // whichever is higher. This ensures that we aren't suddenly exposed to significantly @@ -183,7 +183,7 @@ pub(crate) struct ChannelConstraints { pub counterparty_max_accepted_htlcs: u64, } -pub(crate) fn get_dust_exposure_stats( +fn get_dust_exposure_stats( local: bool, commitment_htlcs: &[HTLCAmountDirection], feerate_per_kw: u32, dust_exposure_limiting_feerate: Option, broadcaster_dust_limit_satoshis: u64, channel_type: &ChannelTypeFeatures, @@ -309,6 +309,194 @@ fn get_next_commitment_stats( }) } +pub(crate) fn get_available_balances( + is_outbound_from_holder: bool, channel_value_satoshis: u64, value_to_holder_msat: u64, + pending_htlcs: &[HTLCAmountDirection], feerate_per_kw: u32, + dust_exposure_limiting_feerate: Option, max_dust_htlc_exposure_msat: u64, + channel_constraints: ChannelConstraints, channel_type: &ChannelTypeFeatures, +) -> crate::ln::channel::AvailableBalances { + let fee_spike_buffer_htlc = + if channel_type.supports_anchor_zero_fee_commitments() { 0 } else { 1 }; + + let local_feerate = feerate_per_kw + * if is_outbound_from_holder && !channel_type.supports_anchors_zero_fee_htlc_tx() { + crate::ln::channel::FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 + } else { + 1 + }; + + let local_nondust_htlc_count = pending_htlcs + .iter() + .filter(|htlc| { + !htlc.is_dust( + true, + local_feerate, + channel_constraints.holder_dust_limit_satoshis, + channel_type, + ) + }) + .count(); + let local_max_commit_tx_fee_sat = commit_tx_fee_sat( + local_feerate, + local_nondust_htlc_count + fee_spike_buffer_htlc + 1, + channel_type, + ); + let local_min_commit_tx_fee_sat = commit_tx_fee_sat( + local_feerate, + local_nondust_htlc_count + fee_spike_buffer_htlc, + channel_type, + ); + let (local_dust_exposure_msat, _) = get_dust_exposure_stats( + true, + pending_htlcs, + feerate_per_kw, + dust_exposure_limiting_feerate, + channel_constraints.holder_dust_limit_satoshis, + channel_type, + ); + let remote_nondust_htlc_count = pending_htlcs + .iter() + .filter(|htlc| { + !htlc.is_dust( + false, + feerate_per_kw, + channel_constraints.counterparty_dust_limit_satoshis, + channel_type, + ) + }) + .count(); + let remote_commit_tx_fee_sat = + commit_tx_fee_sat(feerate_per_kw, remote_nondust_htlc_count + 1, channel_type); + let (remote_dust_exposure_msat, extra_htlc_remote_dust_exposure_msat) = get_dust_exposure_stats( + false, + pending_htlcs, + feerate_per_kw, + dust_exposure_limiting_feerate, + channel_constraints.counterparty_dust_limit_satoshis, + channel_type, + ); + + let outbound_htlcs_value_msat: u64 = + pending_htlcs.iter().filter_map(|htlc| htlc.outbound.then_some(htlc.amount_msat)).sum(); + let inbound_htlcs_value_msat: u64 = + pending_htlcs.iter().filter_map(|htlc| (!htlc.outbound).then_some(htlc.amount_msat)).sum(); + let (local_balance_before_fee_msat, remote_balance_before_fee_msat) = saturating_sub_anchor_outputs( + is_outbound_from_holder, + value_to_holder_msat.saturating_sub(outbound_htlcs_value_msat), + (channel_value_satoshis * 1000).checked_sub(value_to_holder_msat).unwrap().saturating_sub(inbound_htlcs_value_msat), + &channel_type, + ); + + let outbound_capacity_msat = local_balance_before_fee_msat + .saturating_sub( + channel_constraints.counterparty_selected_channel_reserve_satoshis * 1000); + + let mut available_capacity_msat = outbound_capacity_msat; + let (real_htlc_success_tx_fee_sat, real_htlc_timeout_tx_fee_sat) = second_stage_tx_fees_sat( + channel_type, feerate_per_kw + ); + + if is_outbound_from_holder { + // We should mind channel commit tx fee when computing how much of the available capacity + // can be used in the next htlc. Mirrors the logic in send_htlc. + // + // The fee depends on whether the amount we will be sending is above dust or not, + // and the answer will in turn change the amount itself — making it a circular + // dependency. + // This complicates the computation around dust-values, up to the one-htlc-value. + + let real_dust_limit_timeout_sat = real_htlc_timeout_tx_fee_sat + channel_constraints.holder_dust_limit_satoshis; + let max_reserved_commit_tx_fee_msat = local_max_commit_tx_fee_sat * 1000; + let min_reserved_commit_tx_fee_msat = local_min_commit_tx_fee_sat * 1000; + + // We will first subtract the fee as if we were above-dust. Then, if the resulting + // value ends up being below dust, we have this fee available again. In that case, + // match the value to right-below-dust. + let capacity_minus_max_commitment_fee_msat = available_capacity_msat.saturating_sub(max_reserved_commit_tx_fee_msat); + if capacity_minus_max_commitment_fee_msat < real_dust_limit_timeout_sat * 1000 { + let capacity_minus_min_commitment_fee_msat = available_capacity_msat.saturating_sub(min_reserved_commit_tx_fee_msat); + available_capacity_msat = cmp::min(real_dust_limit_timeout_sat * 1000 - 1, capacity_minus_min_commitment_fee_msat); + } else { + available_capacity_msat = capacity_minus_max_commitment_fee_msat; + } + } else { + // If the channel is inbound (i.e. counterparty pays the fee), we need to make sure + // sending a new HTLC won't reduce their balance below our reserve threshold. + let real_dust_limit_success_sat = real_htlc_success_tx_fee_sat + channel_constraints.counterparty_dust_limit_satoshis; + let max_reserved_commit_tx_fee_msat = remote_commit_tx_fee_sat * 1000; + + let holder_selected_chan_reserve_msat = channel_constraints.holder_selected_channel_reserve_satoshis * 1000; + if remote_balance_before_fee_msat < max_reserved_commit_tx_fee_msat + holder_selected_chan_reserve_msat { + // If another HTLC's fee would reduce the remote's balance below the reserve limit + // we've selected for them, we can only send dust HTLCs. + available_capacity_msat = cmp::min(available_capacity_msat, real_dust_limit_success_sat * 1000 - 1); + } + } + + let mut next_outbound_htlc_minimum_msat = channel_constraints.counterparty_htlc_minimum_msat; + + // If we get close to our maximum dust exposure, we end up in a situation where we can send + // between zero and the remaining dust exposure limit remaining OR above the dust limit. + // Because we cannot express this as a simple min/max, we prefer to tell the user they can + // send above the dust limit (as the router can always overpay to meet the dust limit). + let mut remaining_msat_below_dust_exposure_limit = None; + let mut dust_exposure_dust_limit_msat = 0; + + let dust_buffer_feerate = get_dust_buffer_feerate(feerate_per_kw); + let (buffer_htlc_success_tx_fee_sat, buffer_htlc_timeout_tx_fee_sat) = second_stage_tx_fees_sat( + channel_type, dust_buffer_feerate + ); + let buffer_dust_limit_success_sat = buffer_htlc_success_tx_fee_sat + channel_constraints.counterparty_dust_limit_satoshis; + let buffer_dust_limit_timeout_sat = buffer_htlc_timeout_tx_fee_sat + channel_constraints.holder_dust_limit_satoshis; + + if let Some(extra_htlc_remote_dust_exposure) = extra_htlc_remote_dust_exposure_msat { + if extra_htlc_remote_dust_exposure > max_dust_htlc_exposure_msat { + // If adding an extra HTLC would put us over the dust limit in total fees, we cannot + // send any non-dust HTLCs. + available_capacity_msat = cmp::min(available_capacity_msat, buffer_dust_limit_success_sat * 1000); + } + } + + if remote_dust_exposure_msat.saturating_add(buffer_dust_limit_success_sat * 1000) > max_dust_htlc_exposure_msat.saturating_add(1) { + // Note that we don't use the `counterparty_tx_dust_exposure` (with + // `htlc_dust_exposure_msat`) here as it only applies to non-dust HTLCs. + remaining_msat_below_dust_exposure_limit = + Some(max_dust_htlc_exposure_msat.saturating_sub(remote_dust_exposure_msat)); + dust_exposure_dust_limit_msat = cmp::max(dust_exposure_dust_limit_msat, buffer_dust_limit_success_sat * 1000); + } + + if local_dust_exposure_msat as i64 + buffer_dust_limit_timeout_sat as i64 * 1000 - 1 > max_dust_htlc_exposure_msat.try_into().unwrap_or(i64::max_value()) { + remaining_msat_below_dust_exposure_limit = Some(cmp::min( + remaining_msat_below_dust_exposure_limit.unwrap_or(u64::max_value()), + max_dust_htlc_exposure_msat.saturating_sub(local_dust_exposure_msat), + )); + dust_exposure_dust_limit_msat = cmp::max(dust_exposure_dust_limit_msat, buffer_dust_limit_timeout_sat * 1000); + } + + if let Some(remaining_limit_msat) = remaining_msat_below_dust_exposure_limit { + if available_capacity_msat < dust_exposure_dust_limit_msat { + available_capacity_msat = cmp::min(available_capacity_msat, remaining_limit_msat); + } else { + next_outbound_htlc_minimum_msat = cmp::max(next_outbound_htlc_minimum_msat, dust_exposure_dust_limit_msat); + } + } + + available_capacity_msat = cmp::min(available_capacity_msat, + channel_constraints.counterparty_max_htlc_value_in_flight_msat - outbound_htlcs_value_msat); + + if pending_htlcs.iter().filter(|htlc| htlc.outbound).count() + 1 > channel_constraints.counterparty_max_accepted_htlcs as usize { + available_capacity_msat = 0; + } + + #[allow(deprecated)] // TODO: Remove once balance_msat is removed + crate::ln::channel::AvailableBalances { + inbound_capacity_msat: remote_balance_before_fee_msat.saturating_sub(channel_constraints.holder_selected_channel_reserve_satoshis * 1000), + outbound_capacity_msat, + next_outbound_htlc_limit_msat: available_capacity_msat, + next_outbound_htlc_minimum_msat, + } +} + pub(crate) trait TxBuilder { fn get_channel_stats( &self, local: bool, is_outbound_from_holder: bool, channel_value_satoshis: u64, From 121302e1a910e803713cfc09205c9663b122bf82 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 4 Feb 2026 02:33:45 +0000 Subject: [PATCH 106/627] Format `tx_builder::get_available_balances` --- lightning/src/sign/tx_builder.rs | 109 +++++++++++++++++++++---------- 1 file changed, 73 insertions(+), 36 deletions(-) diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index 6c88a75a9d1..8bcfe12f360 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -239,8 +239,7 @@ fn get_next_commitment_stats( } // Calculate inbound htlc count - let inbound_htlcs_count = - next_commitment_htlcs.iter().filter(|htlc| !htlc.outbound).count(); + let inbound_htlcs_count = next_commitment_htlcs.iter().filter(|htlc| !htlc.outbound).count(); // Calculate balances after htlcs let value_to_counterparty_msat = @@ -315,6 +314,17 @@ pub(crate) fn get_available_balances( dust_exposure_limiting_feerate: Option, max_dust_htlc_exposure_msat: u64, channel_constraints: ChannelConstraints, channel_type: &ChannelTypeFeatures, ) -> crate::ln::channel::AvailableBalances { + // When sizing the next HTLC add, we take the remote's view of the set of pending HTLCs in + // `ChannelContext::get_next_commitment_htlcs`, set this view to `pending_htlcs` here, and use this set of + // pending HTLCs to calculate stats on our own commitment below. + // + // This means we do *not* include `LocalRemoved` HTLCs. `LocalRemoved` and `LocalAnnounced` HTLCs are applied + // atomically to our own commitment upon the counterparty's next ack. + // + // `RemoteRemoved` HTLCs *are* included. While we don't expect these HTLCs to be present in our next + // commitment, we have not ack'ed these removals yet, so we expect the counterparty to count them when + // validating our own HTLC add. These HTLCs would also revert to `Committed` upon a disconnection. + let fee_spike_buffer_htlc = if channel_type.supports_anchor_zero_fee_commitments() { 0 } else { 1 }; @@ -380,21 +390,23 @@ pub(crate) fn get_available_balances( pending_htlcs.iter().filter_map(|htlc| htlc.outbound.then_some(htlc.amount_msat)).sum(); let inbound_htlcs_value_msat: u64 = pending_htlcs.iter().filter_map(|htlc| (!htlc.outbound).then_some(htlc.amount_msat)).sum(); - let (local_balance_before_fee_msat, remote_balance_before_fee_msat) = saturating_sub_anchor_outputs( - is_outbound_from_holder, - value_to_holder_msat.saturating_sub(outbound_htlcs_value_msat), - (channel_value_satoshis * 1000).checked_sub(value_to_holder_msat).unwrap().saturating_sub(inbound_htlcs_value_msat), - &channel_type, - ); + let (local_balance_before_fee_msat, remote_balance_before_fee_msat) = + saturating_sub_anchor_outputs( + is_outbound_from_holder, + value_to_holder_msat.saturating_sub(outbound_htlcs_value_msat), + (channel_value_satoshis * 1000) + .checked_sub(value_to_holder_msat) + .unwrap() + .saturating_sub(inbound_htlcs_value_msat), + &channel_type, + ); let outbound_capacity_msat = local_balance_before_fee_msat - .saturating_sub( - channel_constraints.counterparty_selected_channel_reserve_satoshis * 1000); + .saturating_sub(channel_constraints.counterparty_selected_channel_reserve_satoshis * 1000); let mut available_capacity_msat = outbound_capacity_msat; - let (real_htlc_success_tx_fee_sat, real_htlc_timeout_tx_fee_sat) = second_stage_tx_fees_sat( - channel_type, feerate_per_kw - ); + let (real_htlc_success_tx_fee_sat, real_htlc_timeout_tx_fee_sat) = + second_stage_tx_fees_sat(channel_type, feerate_per_kw); if is_outbound_from_holder { // We should mind channel commit tx fee when computing how much of the available capacity @@ -405,31 +417,42 @@ pub(crate) fn get_available_balances( // dependency. // This complicates the computation around dust-values, up to the one-htlc-value. - let real_dust_limit_timeout_sat = real_htlc_timeout_tx_fee_sat + channel_constraints.holder_dust_limit_satoshis; + let real_dust_limit_timeout_sat = + real_htlc_timeout_tx_fee_sat + channel_constraints.holder_dust_limit_satoshis; let max_reserved_commit_tx_fee_msat = local_max_commit_tx_fee_sat * 1000; let min_reserved_commit_tx_fee_msat = local_min_commit_tx_fee_sat * 1000; // We will first subtract the fee as if we were above-dust. Then, if the resulting // value ends up being below dust, we have this fee available again. In that case, // match the value to right-below-dust. - let capacity_minus_max_commitment_fee_msat = available_capacity_msat.saturating_sub(max_reserved_commit_tx_fee_msat); + let capacity_minus_max_commitment_fee_msat = + available_capacity_msat.saturating_sub(max_reserved_commit_tx_fee_msat); if capacity_minus_max_commitment_fee_msat < real_dust_limit_timeout_sat * 1000 { - let capacity_minus_min_commitment_fee_msat = available_capacity_msat.saturating_sub(min_reserved_commit_tx_fee_msat); - available_capacity_msat = cmp::min(real_dust_limit_timeout_sat * 1000 - 1, capacity_minus_min_commitment_fee_msat); + let capacity_minus_min_commitment_fee_msat = + available_capacity_msat.saturating_sub(min_reserved_commit_tx_fee_msat); + available_capacity_msat = cmp::min( + real_dust_limit_timeout_sat * 1000 - 1, + capacity_minus_min_commitment_fee_msat, + ); } else { available_capacity_msat = capacity_minus_max_commitment_fee_msat; } } else { // If the channel is inbound (i.e. counterparty pays the fee), we need to make sure // sending a new HTLC won't reduce their balance below our reserve threshold. - let real_dust_limit_success_sat = real_htlc_success_tx_fee_sat + channel_constraints.counterparty_dust_limit_satoshis; + let real_dust_limit_success_sat = + real_htlc_success_tx_fee_sat + channel_constraints.counterparty_dust_limit_satoshis; let max_reserved_commit_tx_fee_msat = remote_commit_tx_fee_sat * 1000; - let holder_selected_chan_reserve_msat = channel_constraints.holder_selected_channel_reserve_satoshis * 1000; - if remote_balance_before_fee_msat < max_reserved_commit_tx_fee_msat + holder_selected_chan_reserve_msat { + let holder_selected_chan_reserve_msat = + channel_constraints.holder_selected_channel_reserve_satoshis * 1000; + if remote_balance_before_fee_msat + < max_reserved_commit_tx_fee_msat + holder_selected_chan_reserve_msat + { // If another HTLC's fee would reduce the remote's balance below the reserve limit // we've selected for them, we can only send dust HTLCs. - available_capacity_msat = cmp::min(available_capacity_msat, real_dust_limit_success_sat * 1000 - 1); + available_capacity_msat = + cmp::min(available_capacity_msat, real_dust_limit_success_sat * 1000 - 1); } } @@ -443,54 +466,68 @@ pub(crate) fn get_available_balances( let mut dust_exposure_dust_limit_msat = 0; let dust_buffer_feerate = get_dust_buffer_feerate(feerate_per_kw); - let (buffer_htlc_success_tx_fee_sat, buffer_htlc_timeout_tx_fee_sat) = second_stage_tx_fees_sat( - channel_type, dust_buffer_feerate - ); - let buffer_dust_limit_success_sat = buffer_htlc_success_tx_fee_sat + channel_constraints.counterparty_dust_limit_satoshis; - let buffer_dust_limit_timeout_sat = buffer_htlc_timeout_tx_fee_sat + channel_constraints.holder_dust_limit_satoshis; + let (buffer_htlc_success_tx_fee_sat, buffer_htlc_timeout_tx_fee_sat) = + second_stage_tx_fees_sat(channel_type, dust_buffer_feerate); + let buffer_dust_limit_success_sat = + buffer_htlc_success_tx_fee_sat + channel_constraints.counterparty_dust_limit_satoshis; + let buffer_dust_limit_timeout_sat = + buffer_htlc_timeout_tx_fee_sat + channel_constraints.holder_dust_limit_satoshis; if let Some(extra_htlc_remote_dust_exposure) = extra_htlc_remote_dust_exposure_msat { if extra_htlc_remote_dust_exposure > max_dust_htlc_exposure_msat { // If adding an extra HTLC would put us over the dust limit in total fees, we cannot // send any non-dust HTLCs. - available_capacity_msat = cmp::min(available_capacity_msat, buffer_dust_limit_success_sat * 1000); + available_capacity_msat = + cmp::min(available_capacity_msat, buffer_dust_limit_success_sat * 1000); } } - if remote_dust_exposure_msat.saturating_add(buffer_dust_limit_success_sat * 1000) > max_dust_htlc_exposure_msat.saturating_add(1) { + if remote_dust_exposure_msat.saturating_add(buffer_dust_limit_success_sat * 1000) + > max_dust_htlc_exposure_msat.saturating_add(1) + { // Note that we don't use the `counterparty_tx_dust_exposure` (with // `htlc_dust_exposure_msat`) here as it only applies to non-dust HTLCs. remaining_msat_below_dust_exposure_limit = Some(max_dust_htlc_exposure_msat.saturating_sub(remote_dust_exposure_msat)); - dust_exposure_dust_limit_msat = cmp::max(dust_exposure_dust_limit_msat, buffer_dust_limit_success_sat * 1000); + dust_exposure_dust_limit_msat = + cmp::max(dust_exposure_dust_limit_msat, buffer_dust_limit_success_sat * 1000); } - if local_dust_exposure_msat as i64 + buffer_dust_limit_timeout_sat as i64 * 1000 - 1 > max_dust_htlc_exposure_msat.try_into().unwrap_or(i64::max_value()) { + if local_dust_exposure_msat as i64 + buffer_dust_limit_timeout_sat as i64 * 1000 - 1 + > max_dust_htlc_exposure_msat.try_into().unwrap_or(i64::max_value()) + { remaining_msat_below_dust_exposure_limit = Some(cmp::min( remaining_msat_below_dust_exposure_limit.unwrap_or(u64::max_value()), max_dust_htlc_exposure_msat.saturating_sub(local_dust_exposure_msat), )); - dust_exposure_dust_limit_msat = cmp::max(dust_exposure_dust_limit_msat, buffer_dust_limit_timeout_sat * 1000); + dust_exposure_dust_limit_msat = + cmp::max(dust_exposure_dust_limit_msat, buffer_dust_limit_timeout_sat * 1000); } if let Some(remaining_limit_msat) = remaining_msat_below_dust_exposure_limit { if available_capacity_msat < dust_exposure_dust_limit_msat { available_capacity_msat = cmp::min(available_capacity_msat, remaining_limit_msat); } else { - next_outbound_htlc_minimum_msat = cmp::max(next_outbound_htlc_minimum_msat, dust_exposure_dust_limit_msat); + next_outbound_htlc_minimum_msat = + cmp::max(next_outbound_htlc_minimum_msat, dust_exposure_dust_limit_msat); } } - available_capacity_msat = cmp::min(available_capacity_msat, - channel_constraints.counterparty_max_htlc_value_in_flight_msat - outbound_htlcs_value_msat); + available_capacity_msat = cmp::min( + available_capacity_msat, + channel_constraints.counterparty_max_htlc_value_in_flight_msat - outbound_htlcs_value_msat, + ); - if pending_htlcs.iter().filter(|htlc| htlc.outbound).count() + 1 > channel_constraints.counterparty_max_accepted_htlcs as usize { + if pending_htlcs.iter().filter(|htlc| htlc.outbound).count() + 1 + > channel_constraints.counterparty_max_accepted_htlcs as usize + { available_capacity_msat = 0; } #[allow(deprecated)] // TODO: Remove once balance_msat is removed crate::ln::channel::AvailableBalances { - inbound_capacity_msat: remote_balance_before_fee_msat.saturating_sub(channel_constraints.holder_selected_channel_reserve_satoshis * 1000), + inbound_capacity_msat: remote_balance_before_fee_msat + .saturating_sub(channel_constraints.holder_selected_channel_reserve_satoshis * 1000), outbound_capacity_msat, next_outbound_htlc_limit_msat: available_capacity_msat, next_outbound_htlc_minimum_msat, From 51f8c4c9a9fc131e15e04ac78b067c76a8bcf3d0 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Tue, 3 Feb 2026 04:09:43 +0000 Subject: [PATCH 107/627] Multiply the feerate by the spike multiple in `can_accept_incoming_htlc` We choose to multiply `FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE` by the feerate when checking the fee spike buffer in `can_accept_incoming_htlc` instead of multiplying the multiple by the commitment transaction fee. This allows us to delete `NextCommitmentStats::commit_tx_fee_sat`, and return balances including the commitment transaction fee in `TxBuilder::get_channel_stats`. This unblocks a good amount of cleanup. Note that this means LDK now rejects HTLCs that previous versions of LDK would have accepted. We made the mirroring change in `get_available_balances_for_scope` a few commits earlier. We also now account for non-dust HTLCs turning to dust at the multiplied feerate, decreasing the overall weight of the transaction. We also remove other fields in `NextCommitmentStats` which can be easily calculated in `channel` only. `TxBuilder::get_channel_stats` could also check the reserve requirements, given that it gets the reserves in `ChannelConstraints`. I leave this to follow-up work. --- lightning/src/ln/channel.rs | 218 ++++++++++++--------------- lightning/src/ln/update_fee_tests.rs | 9 +- lightning/src/sign/tx_builder.rs | 58 +++---- 3 files changed, 128 insertions(+), 157 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 1a3c32a9a21..b038d70f58c 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -3941,23 +3941,18 @@ impl ChannelContext { let include_counterparty_unknown_htlcs = false; let addl_nondust_htlc_count = MIN_AFFORDABLE_HTLC_COUNT; let dust_exposure_limiting_feerate = channel_context.get_dust_exposure_limiting_feerate(&fee_estimator, funding.get_channel_type()); - let remote_stats = channel_context.get_next_remote_commitment_stats( + let (remote_stats, _remote_htlcs) = channel_context.get_next_remote_commitment_stats( &funding, htlc_candidate, include_counterparty_unknown_htlcs, addl_nondust_htlc_count, channel_context.feerate_per_kw, dust_exposure_limiting_feerate - ).map_err(|()| ChannelError::close(format!("Funding amount ({} sats) can't even pay fee for two anchors on the initial commitment transaction", funders_amount_msat / 1000)))?; + ).map_err(|()| ChannelError::close(format!("Funding amount ({} sats) can't even pay fee for initial commitment transaction.", funders_amount_msat / 1000)))?; - if remote_stats.commitment_stats.counterparty_balance_before_fee_msat / 1000 < remote_stats.commitment_stats.commit_tx_fee_sat { - return Err(ChannelError::close(format!("Funding amount ({} sats) can't even pay fee for initial commitment transaction fee of {} sats.", funders_amount_msat / 1000, remote_stats.commitment_stats.commit_tx_fee_sat))); - } - - let to_remote_satoshis = remote_stats.commitment_stats.counterparty_balance_before_fee_msat / 1000 - remote_stats.commitment_stats.commit_tx_fee_sat; // While it's reasonable for us to not meet the channel reserve initially (if they don't // want to push much to us), our counterparty should always have more than our reserve. - if to_remote_satoshis < funding.holder_selected_channel_reserve_satoshis { + if remote_stats.commitment_stats.counterparty_balance_msat / 1000 < funding.holder_selected_channel_reserve_satoshis { return Err(ChannelError::close("Insufficient funding amount for initial reserve".to_owned())); } @@ -4188,18 +4183,14 @@ impl ChannelContext { let include_counterparty_unknown_htlcs = false; let addl_nondust_htlc_count = MIN_AFFORDABLE_HTLC_COUNT; let dust_exposure_limiting_feerate = channel_context.get_dust_exposure_limiting_feerate(&fee_estimator, funding.get_channel_type()); - let local_stats = channel_context.get_next_local_commitment_stats( + let _local_stats = channel_context.get_next_local_commitment_stats( &funding, htlc_candidate, include_counterparty_unknown_htlcs, addl_nondust_htlc_count, channel_context.feerate_per_kw, dust_exposure_limiting_feerate, - ).map_err(|()| APIError::APIMisuseError { err: format!("Funding amount ({} sats) can't even pay fee for two anchors on the initial commitment transaction", funding.get_value_to_self_msat() / 1000)})?; - - if local_stats.commitment_stats.holder_balance_before_fee_msat / 1000 < local_stats.commitment_stats.commit_tx_fee_sat { - return Err(APIError::APIMisuseError{ err: format!("Funding amount ({}) can't even pay fee for initial commitment transaction fee of {}.", funding.get_value_to_self_msat() / 1000, local_stats.commitment_stats.commit_tx_fee_sat) }); - } + ).map_err(|()| APIError::APIMisuseError { err: format!("Funding amount ({}) can't even pay fee for initial commitment transaction.", funding.get_value_to_self_msat() / 1000)})?; Ok((funding, channel_context)) } @@ -4881,7 +4872,7 @@ impl ChannelContext { &self, funding: &FundingScope, htlc_candidate: Option, include_counterparty_unknown_htlcs: bool, addl_nondust_htlc_count: usize, feerate_per_kw: u32, dust_exposure_limiting_feerate: Option, - ) -> Result { + ) -> Result<(ChannelStats, Vec), ()> { let next_commitment_htlcs = self.get_next_commitment_htlcs( true, htlc_candidate, @@ -4924,7 +4915,7 @@ impl ChannelContext { self.holder_dust_limit_satoshis, funding.get_channel_type(), ) - .expect("Balance after HTLCs and anchors exhausted on local commitment") + .expect("Balance exhausted on local commitment") .commitment_stats; *funding.next_local_fee.lock().unwrap() = PredictedNextFee { predicted_feerate: feerate_per_kw, @@ -4934,14 +4925,14 @@ impl ChannelContext { } } - Ok(local_stats) + Ok((local_stats, next_commitment_htlcs)) } fn get_next_remote_commitment_stats( &self, funding: &FundingScope, htlc_candidate: Option, include_counterparty_unknown_htlcs: bool, addl_nondust_htlc_count: usize, feerate_per_kw: u32, dust_exposure_limiting_feerate: Option, - ) -> Result { + ) -> Result<(ChannelStats, Vec), ()> { let next_commitment_htlcs = self.get_next_commitment_htlcs( false, htlc_candidate, @@ -4984,7 +4975,7 @@ impl ChannelContext { self.counterparty_dust_limit_satoshis, funding.get_channel_type(), ) - .expect("Balance after HTLCs and anchors exhausted on remote commitment") + .expect("Balance exhausted on remote commitment") .commitment_stats; *funding.next_remote_fee.lock().unwrap() = PredictedNextFee { predicted_feerate: feerate_per_kw, @@ -4994,7 +4985,7 @@ impl ChannelContext { } } - Ok(remote_stats) + Ok((remote_stats, next_commitment_htlcs)) } fn validate_update_add_htlc( @@ -5014,7 +5005,7 @@ impl ChannelContext { let include_counterparty_unknown_htlcs = false; // Don't include the extra fee spike buffer HTLC in calculations let fee_spike_buffer_htlc = 0; - let remote_stats = self + let (remote_stats, remote_htlcs) = self .get_next_remote_commitment_stats( funding, Some(HTLCAmountDirection { outbound: false, amount_msat: msg.amount_msat }), @@ -5027,17 +5018,19 @@ impl ChannelContext { ChannelError::close(String::from("Remote HTLC add would overdraw remaining funds")) })?; - if remote_stats.commitment_stats.inbound_htlcs_count - > self.holder_max_accepted_htlcs as usize - { + let inbound_htlcs_count = remote_htlcs.iter().filter(|htlc| !htlc.outbound).count(); + let inbound_htlcs_value_msat: u64 = remote_htlcs + .iter() + .filter_map(|htlc| (!htlc.outbound).then_some(htlc.amount_msat)) + .sum(); + + if inbound_htlcs_count > self.holder_max_accepted_htlcs as usize { return Err(ChannelError::close(format!( "Remote tried to push more than our max accepted HTLCs ({})", self.holder_max_accepted_htlcs, ))); } - if remote_stats.commitment_stats.inbound_htlcs_value_msat - > self.holder_max_htlc_value_in_flight_msat - { + if inbound_htlcs_value_msat > self.holder_max_htlc_value_in_flight_msat { return Err(ChannelError::close(format!( "Remote HTLC add would put them over our max HTLC value ({})", self.holder_max_htlc_value_in_flight_msat, @@ -5059,33 +5052,16 @@ impl ChannelContext { // violate the reserve value if we do not do this (as we forget inbound HTLCs from the // Channel state once they will not be present in the next received commitment // transaction). + if remote_stats.commitment_stats.counterparty_balance_msat + < funding.holder_selected_channel_reserve_satoshis * 1000 { - let remote_commit_tx_fee_msat = if funding.is_outbound() { - 0 - } else { - remote_stats.commitment_stats.commit_tx_fee_sat * 1000 - }; - if remote_stats.commitment_stats.counterparty_balance_before_fee_msat - < remote_commit_tx_fee_msat - { - return Err(ChannelError::close( - "Remote HTLC add would not leave enough to pay for fees".to_owned(), - )); - }; - if remote_stats - .commitment_stats - .counterparty_balance_before_fee_msat - .saturating_sub(remote_commit_tx_fee_msat) - < funding.holder_selected_channel_reserve_satoshis * 1000 - { - return Err(ChannelError::close( - "Remote HTLC add would put them under remote reserve value".to_owned(), - )); - } + return Err(ChannelError::close( + "Remote HTLC add would put them under remote reserve value".to_owned(), + )); } if funding.is_outbound() { - let local_stats = self + let (local_stats, _local_htlcs) = self .get_next_local_commitment_stats( funding, Some(HTLCAmountDirection { outbound: false, amount_msat: msg.amount_msat }), @@ -5095,14 +5071,11 @@ impl ChannelContext { dust_exposure_limiting_feerate, ) .map_err(|()| { - ChannelError::close(String::from( - "Balance after HTLCs and anchors exhausted on local commitment", - )) + ChannelError::close(String::from("Balance exhausted on local commitment")) })?; // Check that they won't violate our local required channel reserve by adding this HTLC. - if local_stats.commitment_stats.holder_balance_before_fee_msat + if local_stats.commitment_stats.holder_balance_msat < funding.counterparty_selected_channel_reserve_satoshis.unwrap() * 1000 - + local_stats.commitment_stats.commit_tx_fee_sat * 1000 { return Err(ChannelError::close( "Cannot accept HTLC that would put our balance under counterparty-announced channel reserve value".to_owned() @@ -5123,7 +5096,7 @@ impl ChannelContext { // Do not include outbound update_add_htlc's in the holding cell, or those which haven't yet been ACK'ed // by the counterparty (ie. LocalAnnounced HTLCs) let include_counterparty_unknown_htlcs = false; - let local_stats = self + let (local_stats, _local_htlcs) = self .get_next_local_commitment_stats( funding, None, @@ -5133,24 +5106,18 @@ impl ChannelContext { dust_exposure_limiting_feerate, ) .map_err(|()| { - ChannelError::close(String::from( - "Balance after HTLCs and anchors exhausted on local commitment", - )) + ChannelError::close(String::from("Funding remote cannot afford proposed new fee")) })?; local_stats .commitment_stats - .get_holder_counterparty_balances_incl_fee_msat() - .and_then(|(_, counterparty_balance_incl_fee_msat)| { - counterparty_balance_incl_fee_msat - .checked_sub(funding.holder_selected_channel_reserve_satoshis * 1000) - .ok_or(()) - }) - .map_err(|()| { - ChannelError::close("Funding remote cannot afford proposed new fee".to_owned()) - })?; + .counterparty_balance_msat + .checked_sub(funding.holder_selected_channel_reserve_satoshis * 1000) + .ok_or(ChannelError::close( + "Funding remote cannot afford proposed new fee".to_owned(), + ))?; - let remote_stats = self + let (remote_stats, _remote_htlcs) = self .get_next_remote_commitment_stats( funding, None, @@ -5160,9 +5127,7 @@ impl ChannelContext { dust_exposure_limiting_feerate, ) .map_err(|()| { - ChannelError::close(String::from( - "Balance after HTLCs and anchors exhausted on remote commitment", - )) + ChannelError::close(String::from("Balance exhausted on remote commitment")) })?; let max_dust_htlc_exposure_msat = @@ -5327,27 +5292,27 @@ impl ChannelContext { // Include outbound update_add_htlc's in the holding cell, and those which haven't yet been ACK'ed by // the counterparty (ie. LocalAnnounced HTLCs) let include_counterparty_unknown_htlcs = true; - let remote_stats = if let Ok(stats) = self.get_next_remote_commitment_stats( - funding, - None, - include_counterparty_unknown_htlcs, - CONCURRENT_INBOUND_HTLC_FEE_BUFFER as usize, - feerate_per_kw, - dust_exposure_limiting_feerate, - ) { + let (remote_stats, _remote_htlcs) = if let Ok(stats) = self + .get_next_remote_commitment_stats( + funding, + None, + include_counterparty_unknown_htlcs, + CONCURRENT_INBOUND_HTLC_FEE_BUFFER as usize, + feerate_per_kw, + dust_exposure_limiting_feerate, + ) { stats } else { log_debug!( logger, - "Cannot afford to send new feerate due to balance after HTLCs and anchors exhausted on remote commitment", + "Cannot afford to send new feerate due to balance exhausted on remote commitment", ); return false; }; // Note that `stats.commit_tx_fee_sat` accounts for any HTLCs that transition from non-dust to dust // under a higher feerate (in the case where HTLC-transactions pay endogenous fees). - if remote_stats.commitment_stats.holder_balance_before_fee_msat - < remote_stats.commitment_stats.commit_tx_fee_sat * 1000 - + funding.counterparty_selected_channel_reserve_satoshis.unwrap() * 1000 + if remote_stats.commitment_stats.holder_balance_msat + < funding.counterparty_selected_channel_reserve_satoshis.unwrap() * 1000 { //TODO: auto-close after a number of failures? log_debug!(logger, "Cannot afford to send new feerate at {}", feerate_per_kw); @@ -5367,7 +5332,7 @@ impl ChannelContext { return false; } - let local_stats = if let Ok(stats) = self.get_next_local_commitment_stats( + let (local_stats, _local_htlcs) = if let Ok(stats) = self.get_next_local_commitment_stats( funding, None, include_counterparty_unknown_htlcs, @@ -5379,7 +5344,7 @@ impl ChannelContext { } else { log_debug!( logger, - "Cannot afford to send new feerate due to balance after HTLCs and anchors exhausted on local commitment", + "Cannot afford to send new feerate due to balance exhausted on local commitment", ); return false; }; @@ -5413,7 +5378,7 @@ impl ChannelContext { cmp::max(self.feerate_per_kw, self.pending_update_fee.map(|(fee, _)| fee).unwrap_or(0)); // A `None` `HTLCCandidate` is used as in this case because we're already accounting for // the incoming HTLC as it has been fully committed by both sides. - let local_stats = self + let (local_stats, _local_htlcs) = self .get_next_local_commitment_stats( funding, None, @@ -5423,10 +5388,13 @@ impl ChannelContext { dust_exposure_limiting_feerate, ) .map_err(|()| { - log_trace!(logger, "Attempting to fail HTLC due to balance after HTLCs and anchors exhausted on local commitment"); + log_trace!( + logger, + "Attempting to fail HTLC due to balance exhausted on local commitment" + ); LocalHTLCFailureReason::ChannelBalanceOverdrawn })?; - let remote_stats = self + let (remote_stats, _remote_htlcs) = self .get_next_remote_commitment_stats( funding, None, @@ -5436,7 +5404,10 @@ impl ChannelContext { dust_exposure_limiting_feerate, ) .map_err(|()| { - log_trace!(logger, "Attempting to fail HTLC due to balance after HTLCs and anchors exhausted on remote commitment"); + log_trace!( + logger, + "Attempting to fail HTLC due to balance exhausted on remote commitment" + ); LocalHTLCFailureReason::ChannelBalanceOverdrawn })?; @@ -5464,19 +5435,33 @@ impl ChannelContext { } if !funding.is_outbound() { - let mut remote_fee_incl_fee_spike_buffer_htlc_msat = - remote_stats.commitment_stats.commit_tx_fee_sat * 1000; // Note that with anchor outputs we are no longer as sensitive to fee spikes, so we don't need // to account for them. - if !funding.get_channel_type().supports_anchors_zero_fee_htlc_tx() { - remote_fee_incl_fee_spike_buffer_htlc_msat *= - FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE; - } - if remote_stats - .commitment_stats - .counterparty_balance_before_fee_msat - .saturating_sub(funding.holder_selected_channel_reserve_satoshis * 1000) - < remote_fee_incl_fee_spike_buffer_htlc_msat + let fee_spike_multiple = + if !funding.get_channel_type().supports_anchors_zero_fee_htlc_tx() { + FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 + } else { + 1 + }; + let spiked_feerate = feerate * fee_spike_multiple; + let (remote_stats, _remote_htlcs) = self + .get_next_remote_commitment_stats( + funding, + None, + include_counterparty_unknown_htlcs, + fee_spike_buffer_htlc, + spiked_feerate, + dust_exposure_limiting_feerate, + ) + .map_err(|()| { + log_trace!( + logger, + "Attempting to fail HTLC due to balance exhausted on remote commitment" + ); + LocalHTLCFailureReason::FeeSpikeBuffer + })?; + if remote_stats.commitment_stats.counterparty_balance_msat + < funding.holder_selected_channel_reserve_satoshis * 1000 { log_info!( logger, @@ -12627,7 +12612,7 @@ where // We are not interested in dust exposure let dust_exposure_limiting_feerate = None; - let local_stats = self + let (local_stats, _local_htlcs) = self .context .get_next_local_commitment_stats( funding, @@ -12637,14 +12622,9 @@ where self.context.feerate_per_kw, dust_exposure_limiting_feerate, ) - .map_err(|()| "Balance after HTLCs and anchors exhausted on local commitment")?; - let (holder_balance_on_local_msat, counterparty_balance_on_local_msat) = - local_stats - .commitment_stats - .get_holder_counterparty_balances_incl_fee_msat() - .map_err(|()| "Channel funder cannot afford the fee on local commitment")?; - - let remote_stats = self + .map_err(|()| "Balance exhausted on local commitment")?; + + let (remote_stats, _remote_htlcs) = self .context .get_next_remote_commitment_stats( funding, @@ -12654,19 +12634,19 @@ where self.context.feerate_per_kw, dust_exposure_limiting_feerate, ) - .map_err(|()| "Balance after HTLCs and anchors exhausted on remote commitment")?; - let (holder_balance_on_remote_msat, counterparty_balance_on_remote_msat) = - remote_stats - .commitment_stats - .get_holder_counterparty_balances_incl_fee_msat() - .map_err(|()| "Channel funder cannot afford the fee on remote commitment")?; + .map_err(|()| "Balance exhausted on remote commitment")?; let holder_balance_floor = Amount::from_sat( - cmp::min(holder_balance_on_local_msat, holder_balance_on_remote_msat) / 1000, + cmp::min( + local_stats.commitment_stats.holder_balance_msat, + remote_stats.commitment_stats.holder_balance_msat, + ) / 1000, ); let counterparty_balance_floor = Amount::from_sat( - cmp::min(counterparty_balance_on_local_msat, counterparty_balance_on_remote_msat) - / 1000, + cmp::min( + local_stats.commitment_stats.counterparty_balance_msat, + remote_stats.commitment_stats.counterparty_balance_msat, + ) / 1000, ); Ok((holder_balance_floor, counterparty_balance_floor)) diff --git a/lightning/src/ln/update_fee_tests.rs b/lightning/src/ln/update_fee_tests.rs index ff3e2a0b7e3..99dcfd962a7 100644 --- a/lightning/src/ln/update_fee_tests.rs +++ b/lightning/src/ln/update_fee_tests.rs @@ -882,8 +882,13 @@ pub fn test_chan_init_feerate_unaffordability() { MIN_AFFORDABLE_HTLC_COUNT as u64, &channel_type_features, ); - assert_eq!(nodes[0].node.create_channel(node_b_id, 100_000, push_amt + 1, 42, None, None).unwrap_err(), - APIError::APIMisuseError { err: "Funding amount (356) can't even pay fee for initial commitment transaction fee of 357.".to_string() }); + assert_eq!( + nodes[0].node.create_channel(node_b_id, 100_000, push_amt + 1, 42, None, None).unwrap_err(), + APIError::APIMisuseError { + err: "Funding amount (356) can't even pay fee for initial commitment transaction." + .to_string() + } + ); // During open, we don't have a "counterparty channel reserve" to check against, so that // requirement only comes into play on the open_channel handling side. diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index 8bcfe12f360..3840cc7551f 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -1,5 +1,4 @@ //! Defines the `TxBuilder` trait, and the `SpecTxBuilder` type -#![allow(dead_code)] use core::cmp; @@ -34,40 +33,19 @@ impl HTLCAmountDirection { } pub(crate) struct NextCommitmentStats { - pub is_outbound_from_holder: bool, - pub inbound_htlcs_count: usize, - pub inbound_htlcs_value_msat: u64, - pub holder_balance_before_fee_msat: u64, - pub counterparty_balance_before_fee_msat: u64, + pub holder_balance_msat: u64, + pub counterparty_balance_msat: u64, + pub dust_exposure_msat: u64, + #[cfg(any(test, fuzzing))] pub nondust_htlc_count: usize, + #[cfg(any(test, fuzzing))] pub commit_tx_fee_sat: u64, - pub dust_exposure_msat: u64, } pub(crate) struct ChannelStats { pub commitment_stats: NextCommitmentStats, } -impl NextCommitmentStats { - pub(crate) fn get_holder_counterparty_balances_incl_fee_msat(&self) -> Result<(u64, u64), ()> { - if self.is_outbound_from_holder { - Ok(( - self.holder_balance_before_fee_msat - .checked_sub(self.commit_tx_fee_sat * 1000) - .ok_or(())?, - self.counterparty_balance_before_fee_msat, - )) - } else { - Ok(( - self.holder_balance_before_fee_msat, - self.counterparty_balance_before_fee_msat - .checked_sub(self.commit_tx_fee_sat * 1000) - .ok_or(())?, - )) - } - } -} - fn commit_plus_htlc_tx_fees_msat( local: bool, next_commitment_htlcs: &[HTLCAmountDirection], dust_buffer_feerate: u32, feerate: u32, broadcaster_dust_limit_satoshis: u64, channel_type: &ChannelTypeFeatures, @@ -238,9 +216,6 @@ fn get_next_commitment_stats( debug_assert_eq!(feerate_per_kw, 0); } - // Calculate inbound htlc count - let inbound_htlcs_count = next_commitment_htlcs.iter().filter(|htlc| !htlc.outbound).count(); - // Calculate balances after htlcs let value_to_counterparty_msat = (channel_value_satoshis * 1000).checked_sub(value_to_holder_msat).ok_or(())?; @@ -296,15 +271,26 @@ fn get_next_commitment_stats( channel_type, ); + let (holder_balance_msat, counterparty_balance_msat) = if is_outbound_from_holder { + ( + holder_balance_before_fee_msat.checked_sub(commit_tx_fee_sat * 1000).ok_or(())?, + counterparty_balance_before_fee_msat, + ) + } else { + ( + holder_balance_before_fee_msat, + counterparty_balance_before_fee_msat.checked_sub(commit_tx_fee_sat * 1000).ok_or(())?, + ) + }; + Ok(NextCommitmentStats { - is_outbound_from_holder, - inbound_htlcs_count, - inbound_htlcs_value_msat, - holder_balance_before_fee_msat, - counterparty_balance_before_fee_msat, + holder_balance_msat, + counterparty_balance_msat, + dust_exposure_msat, + #[cfg(any(test, fuzzing))] nondust_htlc_count: nondust_htlc_count + addl_nondust_htlc_count, + #[cfg(any(test, fuzzing))] commit_tx_fee_sat, - dust_exposure_msat, }) } From 5a780bbf8120b902655589a93717d42af7e621b7 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Tue, 3 Feb 2026 17:46:34 +0000 Subject: [PATCH 108/627] Erase `get_pending_htlc_stats`, `next_*_commit_tx_fee_msat` in `channel` --- lightning/src/ln/channel.rs | 338 ++---------------------------------- 1 file changed, 19 insertions(+), 319 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index b038d70f58c..f52f3affc1f 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -1133,26 +1133,6 @@ pub enum AnnouncementSigsState { PeerReceived, } -/// An enum indicating whether the local or remote side offered a given HTLC. -enum HTLCInitiator { - LocalOffered, - #[allow(dead_code)] - RemoteOffered, -} - -/// Current counts of various HTLCs, useful for calculating current balances available exactly. -struct HTLCStats { - pending_outbound_htlcs: usize, - pending_inbound_htlcs_value_msat: u64, - pending_outbound_htlcs_value_msat: u64, - on_counterparty_tx_dust_exposure_msat: u64, - // If the counterparty sets a feerate on the channel in excess of our dust_exposure_limiting_feerate, - // this will be set to the dust exposure that would result from us adding an additional nondust outbound - // htlc on the counterparty's commitment transaction. - extra_nondust_htlc_on_counterparty_tx_dust_exposure_msat: Option, - on_holder_tx_dust_exposure_msat: u64, -} - /// A struct gathering data on a commitment, either local or remote. struct CommitmentData<'a> { tx: CommitmentTransaction, @@ -1172,18 +1152,6 @@ pub(crate) struct CommitmentStats { pub remote_balance_before_fee_msat: u64, } -/// Used when calculating whether we or the remote can afford an additional HTLC. -struct HTLCCandidate { - amount_msat: u64, - origin: HTLCInitiator, -} - -impl HTLCCandidate { - fn new(amount_msat: u64, origin: HTLCInitiator) -> Self { - Self { amount_msat, origin } - } -} - /// A return value enum for get_update_fulfill_htlc. See UpdateFulfillCommitFetch variants for /// description enum UpdateFulfillFetch { @@ -5691,111 +5659,6 @@ impl ChannelContext { self.counterparty_forwarding_info.clone() } - /// Returns a HTLCStats about pending htlcs - #[rustfmt::skip] - fn get_pending_htlc_stats( - &self, funding: &FundingScope, outbound_feerate_update: Option, - dust_exposure_limiting_feerate: Option, - ) -> HTLCStats { - let context = self; - - let dust_buffer_feerate = self.get_dust_buffer_feerate(outbound_feerate_update); - let (htlc_success_tx_fee_sat, htlc_timeout_tx_fee_sat) = second_stage_tx_fees_sat( - funding.get_channel_type(), dust_buffer_feerate, - ); - - let mut on_holder_tx_dust_exposure_msat = 0; - let mut on_counterparty_tx_dust_exposure_msat = 0; - - let mut on_counterparty_tx_offered_nondust_htlcs = 0; - let mut on_counterparty_tx_accepted_nondust_htlcs = 0; - - let mut pending_inbound_htlcs_value_msat = 0; - - { - let counterparty_dust_limit_timeout_sat = htlc_timeout_tx_fee_sat + context.counterparty_dust_limit_satoshis; - let holder_dust_limit_success_sat = htlc_success_tx_fee_sat + context.holder_dust_limit_satoshis; - for htlc in context.pending_inbound_htlcs.iter() { - pending_inbound_htlcs_value_msat += htlc.amount_msat; - if htlc.amount_msat / 1000 < counterparty_dust_limit_timeout_sat { - on_counterparty_tx_dust_exposure_msat += htlc.amount_msat; - } else { - on_counterparty_tx_offered_nondust_htlcs += 1; - } - if htlc.amount_msat / 1000 < holder_dust_limit_success_sat { - on_holder_tx_dust_exposure_msat += htlc.amount_msat; - } - } - } - - let mut pending_outbound_htlcs_value_msat = 0; - let mut pending_outbound_htlcs = self.pending_outbound_htlcs.len(); - { - let counterparty_dust_limit_success_sat = htlc_success_tx_fee_sat + context.counterparty_dust_limit_satoshis; - let holder_dust_limit_timeout_sat = htlc_timeout_tx_fee_sat + context.holder_dust_limit_satoshis; - for htlc in context.pending_outbound_htlcs.iter() { - pending_outbound_htlcs_value_msat += htlc.amount_msat; - if htlc.amount_msat / 1000 < counterparty_dust_limit_success_sat { - on_counterparty_tx_dust_exposure_msat += htlc.amount_msat; - } else { - on_counterparty_tx_accepted_nondust_htlcs += 1; - } - if htlc.amount_msat / 1000 < holder_dust_limit_timeout_sat { - on_holder_tx_dust_exposure_msat += htlc.amount_msat; - } - } - - for update in context.holding_cell_htlc_updates.iter() { - if let &HTLCUpdateAwaitingACK::AddHTLC { ref amount_msat, .. } = update { - pending_outbound_htlcs += 1; - pending_outbound_htlcs_value_msat += amount_msat; - if *amount_msat / 1000 < counterparty_dust_limit_success_sat { - on_counterparty_tx_dust_exposure_msat += amount_msat; - } else { - on_counterparty_tx_accepted_nondust_htlcs += 1; - } - if *amount_msat / 1000 < holder_dust_limit_timeout_sat { - on_holder_tx_dust_exposure_msat += amount_msat; - } - } - } - } - - // Include any mining "excess" fees in the dust calculation - let excess_feerate_opt = outbound_feerate_update - .or(self.pending_update_fee.map(|(fee, _)| fee)) - .unwrap_or(self.feerate_per_kw) - .checked_sub(dust_exposure_limiting_feerate.unwrap_or(0)); - - // Dust exposure is only decoupled from feerate for zero fee commitment channels. - let is_zero_fee_comm = funding.get_channel_type().supports_anchor_zero_fee_commitments(); - debug_assert_eq!(is_zero_fee_comm, dust_exposure_limiting_feerate.is_none()); - if is_zero_fee_comm { - debug_assert_eq!(excess_feerate_opt, Some(0)); - } - - let extra_nondust_htlc_on_counterparty_tx_dust_exposure_msat = excess_feerate_opt.map(|excess_feerate| { - let extra_htlc_commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(excess_feerate, on_counterparty_tx_accepted_nondust_htlcs + 1 + on_counterparty_tx_offered_nondust_htlcs, funding.get_channel_type()); - let extra_htlc_htlc_tx_fees_sat = chan_utils::htlc_tx_fees_sat(excess_feerate, on_counterparty_tx_accepted_nondust_htlcs + 1, on_counterparty_tx_offered_nondust_htlcs, funding.get_channel_type()); - - let commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(excess_feerate, on_counterparty_tx_accepted_nondust_htlcs + on_counterparty_tx_offered_nondust_htlcs, funding.get_channel_type()); - let htlc_tx_fees_sat = chan_utils::htlc_tx_fees_sat(excess_feerate, on_counterparty_tx_accepted_nondust_htlcs, on_counterparty_tx_offered_nondust_htlcs, funding.get_channel_type()); - - let extra_htlc_dust_exposure = on_counterparty_tx_dust_exposure_msat + (extra_htlc_commit_tx_fee_sat + extra_htlc_htlc_tx_fees_sat) * 1000; - on_counterparty_tx_dust_exposure_msat += (commit_tx_fee_sat + htlc_tx_fees_sat) * 1000; - extra_htlc_dust_exposure - }); - - HTLCStats { - pending_outbound_htlcs, - pending_inbound_htlcs_value_msat, - pending_outbound_htlcs_value_msat, - on_counterparty_tx_dust_exposure_msat, - extra_nondust_htlc_on_counterparty_tx_dust_exposure_msat, - on_holder_tx_dust_exposure_msat, - } - } - /// Returns information on all pending inbound HTLCs. #[rustfmt::skip] pub fn get_pending_inbound_htlc_details(&self, funding: &FundingScope) -> Vec { @@ -5932,169 +5795,6 @@ impl ChannelContext { ) } - /// Get the commitment tx fee for the local's (i.e. our) next commitment transaction based on the - /// number of pending HTLCs that are on track to be in our next commitment tx. - /// - /// Includes the `HTLCCandidate` given by `htlc` and an additional non-dust HTLC if - /// `fee_spike_buffer_htlc` is `Some`. - /// - /// The first extra HTLC is useful for determining whether we can accept a further HTLC, the - /// second allows for creating a buffer to ensure a further HTLC can always be accepted/added. - /// - /// Dust HTLCs are excluded. - #[rustfmt::skip] - fn next_local_commit_tx_fee_msat( - &self, funding: &FundingScope, htlc: HTLCCandidate, fee_spike_buffer_htlc: Option<()>, - ) -> u64 { - let context = self; - assert!(funding.is_outbound()); - - if funding.get_channel_type().supports_anchor_zero_fee_commitments() { - debug_assert_eq!(context.feerate_per_kw, 0); - debug_assert!(fee_spike_buffer_htlc.is_none()); - return 0; - } - - let (htlc_success_tx_fee_sat, htlc_timeout_tx_fee_sat) = second_stage_tx_fees_sat( - funding.get_channel_type(), context.feerate_per_kw, - ); - let real_dust_limit_success_sat = htlc_success_tx_fee_sat + context.holder_dust_limit_satoshis; - let real_dust_limit_timeout_sat = htlc_timeout_tx_fee_sat + context.holder_dust_limit_satoshis; - - let mut addl_htlcs = 0; - if fee_spike_buffer_htlc.is_some() { addl_htlcs += 1; } - match htlc.origin { - HTLCInitiator::LocalOffered => { - if htlc.amount_msat / 1000 >= real_dust_limit_timeout_sat { - addl_htlcs += 1; - } - }, - HTLCInitiator::RemoteOffered => { - if htlc.amount_msat / 1000 >= real_dust_limit_success_sat { - addl_htlcs += 1; - } - } - } - - let mut included_htlcs = 0; - for ref htlc in context.pending_inbound_htlcs.iter() { - if htlc.amount_msat / 1000 < real_dust_limit_success_sat { - continue - } - // We include LocalRemoved HTLCs here because we may still need to broadcast a commitment - // transaction including this HTLC if it times out before they RAA. - included_htlcs += 1; - } - - for ref htlc in context.pending_outbound_htlcs.iter() { - if htlc.amount_msat / 1000 < real_dust_limit_timeout_sat { - continue - } - match htlc.state { - OutboundHTLCState::LocalAnnounced {..} => included_htlcs += 1, - OutboundHTLCState::Committed => included_htlcs += 1, - OutboundHTLCState::RemoteRemoved {..} => included_htlcs += 1, - // We don't include AwaitingRemoteRevokeToRemove HTLCs because our next commitment - // transaction won't be generated until they send us their next RAA, which will mean - // dropping any HTLCs in this state. - _ => {}, - } - } - - for htlc in context.holding_cell_htlc_updates.iter() { - match htlc { - &HTLCUpdateAwaitingACK::AddHTLC { amount_msat, .. } => { - if amount_msat / 1000 < real_dust_limit_timeout_sat { - continue - } - included_htlcs += 1 - }, - _ => {}, // Don't include claims/fails that are awaiting ack, because once we get the - // ack we're guaranteed to never include them in commitment txs anymore. - } - } - - let num_htlcs = included_htlcs + addl_htlcs; - chan_utils::commit_tx_fee_sat(context.feerate_per_kw, num_htlcs, funding.get_channel_type()) * 1000 - } - - /// Get the commitment tx fee for the remote's next commitment transaction based on the number of - /// pending HTLCs that are on track to be in their next commitment tx - /// - /// Optionally includes the `HTLCCandidate` given by `htlc` and an additional non-dust HTLC if - /// `fee_spike_buffer_htlc` is `Some`. - /// - /// The first extra HTLC is useful for determining whether we can accept a further HTLC, the - /// second allows for creating a buffer to ensure a further HTLC can always be accepted/added. - /// - /// Dust HTLCs are excluded. - #[rustfmt::skip] - fn next_remote_commit_tx_fee_msat( - &self, funding: &FundingScope, htlc: Option, fee_spike_buffer_htlc: Option<()>, - ) -> u64 { - let context = self; - assert!(!funding.is_outbound()); - - if funding.get_channel_type().supports_anchor_zero_fee_commitments() { - debug_assert_eq!(context.feerate_per_kw, 0); - debug_assert!(fee_spike_buffer_htlc.is_none()); - return 0 - } - - debug_assert!(htlc.is_some() || fee_spike_buffer_htlc.is_some(), "At least one of the options must be set"); - - let (htlc_success_tx_fee_sat, htlc_timeout_tx_fee_sat) = second_stage_tx_fees_sat( - funding.get_channel_type(), context.feerate_per_kw, - ); - let real_dust_limit_success_sat = htlc_success_tx_fee_sat + context.counterparty_dust_limit_satoshis; - let real_dust_limit_timeout_sat = htlc_timeout_tx_fee_sat + context.counterparty_dust_limit_satoshis; - - let mut addl_htlcs = 0; - if fee_spike_buffer_htlc.is_some() { addl_htlcs += 1; } - if let Some(htlc) = &htlc { - match htlc.origin { - HTLCInitiator::LocalOffered => { - if htlc.amount_msat / 1000 >= real_dust_limit_success_sat { - addl_htlcs += 1; - } - }, - HTLCInitiator::RemoteOffered => { - if htlc.amount_msat / 1000 >= real_dust_limit_timeout_sat { - addl_htlcs += 1; - } - } - } - } - - // When calculating the set of HTLCs which will be included in their next commitment_signed, all - // non-dust inbound HTLCs are included (as all states imply it will be included) and only - // committed outbound HTLCs, see below. - let mut included_htlcs = 0; - for ref htlc in context.pending_inbound_htlcs.iter() { - if htlc.amount_msat / 1000 < real_dust_limit_timeout_sat { - continue - } - included_htlcs += 1; - } - - for ref htlc in context.pending_outbound_htlcs.iter() { - if htlc.amount_msat / 1000 < real_dust_limit_success_sat { - continue - } - // We only include outbound HTLCs if it will not be included in their next commitment_signed, - // i.e. if they've responded to us with an RAA after announcement. - match htlc.state { - OutboundHTLCState::Committed => included_htlcs += 1, - OutboundHTLCState::RemoteRemoved {..} => included_htlcs += 1, - OutboundHTLCState::LocalAnnounced { .. } => included_htlcs += 1, - _ => {}, - } - } - - let num_htlcs = included_htlcs + addl_htlcs; - chan_utils::commit_tx_fee_sat(context.feerate_per_kw, num_htlcs, funding.get_channel_type()) * 1000 - } - #[rustfmt::skip] fn if_unbroadcasted_funding(&self, f: F) -> Option where F: Fn() -> Option { match self.channel_state { @@ -16061,10 +15761,9 @@ mod tests { use crate::chain::BestBlock; use crate::ln::chan_utils::{self, commit_tx_fee_sat, ChannelTransactionParameters}; use crate::ln::channel::{ - AwaitingChannelReadyFlags, ChannelState, FundedChannel, HTLCCandidate, HTLCInitiator, - HTLCUpdateAwaitingACK, InboundHTLCOutput, InboundHTLCState, InboundUpdateAdd, - InboundV1Channel, OutboundHTLCOutput, OutboundHTLCState, OutboundV1Channel, - MIN_THEIR_CHAN_RESERVE_SATOSHIS, + AwaitingChannelReadyFlags, ChannelState, FundedChannel, HTLCUpdateAwaitingACK, + InboundHTLCOutput, InboundHTLCState, InboundUpdateAdd, InboundV1Channel, + OutboundHTLCOutput, OutboundHTLCState, OutboundV1Channel, MIN_THEIR_CHAN_RESERVE_SATOSHIS, }; use crate::ln::channel_keys::{RevocationBasepoint, RevocationKey}; use crate::ln::channelmanager::{self, HTLCSource, PaymentId}; @@ -16074,6 +15773,7 @@ mod tests { use crate::ln::script::ShutdownScript; use crate::prelude::*; use crate::routing::router::{Path, RouteHop}; + use crate::sign::tx_builder::HTLCAmountDirection; #[cfg(ldk_test_vectors)] use crate::sign::{ChannelSigner, EntropySource, InMemorySigner, SignerProvider}; use crate::sync::Mutex; @@ -16266,7 +15966,7 @@ mod tests { let node_b_node_id = PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap()); let mut config = UserConfig::default(); config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; - let mut node_a_chan = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, node_b_node_id, &channelmanager::provided_init_features(&config), 10000000, 100000, 42, &config, 0, 42, None, &logger).unwrap(); + let mut node_a_chan = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, node_b_node_id, &channelmanager::provided_init_features(&config), 10_000_000, 100_000_000, 42, &config, 0, 42, None, &logger).unwrap(); // Create Node B's channel by receiving Node A's open_channel message // Make sure A's dust limit is as we expect. @@ -16325,8 +16025,8 @@ mod tests { // Make sure when Node A calculates their local commitment transaction, none of the HTLCs pass // the dust limit check. - let htlc_candidate = HTLCCandidate::new(htlc_amount_msat, HTLCInitiator::LocalOffered); - let local_commit_tx_fee = node_a_chan.context.next_local_commit_tx_fee_msat(&node_a_chan.funding, htlc_candidate, None); + let htlc_candidate = HTLCAmountDirection { amount_msat: htlc_amount_msat, outbound: true }; + let local_commit_tx_fee = node_a_chan.context.get_next_local_commitment_stats(&node_a_chan.funding, Some(htlc_candidate), false, 0, node_a_chan.context.feerate_per_kw, None).unwrap().0.commitment_stats.commit_tx_fee_sat * 1000; let local_commit_fee_0_htlcs = commit_tx_fee_sat(node_a_chan.context.feerate_per_kw, 0, node_a_chan.funding.get_channel_type()) * 1000; assert_eq!(local_commit_tx_fee, local_commit_fee_0_htlcs); @@ -16334,15 +16034,15 @@ mod tests { // of the HTLCs are seen to be above the dust limit. node_a_chan.funding.channel_transaction_parameters.is_outbound_from_holder = false; let remote_commit_fee_3_htlcs = commit_tx_fee_sat(node_a_chan.context.feerate_per_kw, 3, node_a_chan.funding.get_channel_type()) * 1000; - let htlc_candidate = HTLCCandidate::new(htlc_amount_msat, HTLCInitiator::LocalOffered); - let remote_commit_tx_fee = node_a_chan.context.next_remote_commit_tx_fee_msat(&node_a_chan.funding, Some(htlc_candidate), None); + let htlc_candidate = HTLCAmountDirection { amount_msat: htlc_amount_msat, outbound: true }; + let remote_commit_tx_fee = node_a_chan.context.get_next_remote_commitment_stats(&node_a_chan.funding, Some(htlc_candidate), false, 0, node_a_chan.context.feerate_per_kw, None).unwrap().0.commitment_stats.commit_tx_fee_sat * 1000; assert_eq!(remote_commit_tx_fee, remote_commit_fee_3_htlcs); } #[test] #[rustfmt::skip] fn test_timeout_vs_success_htlc_dust_limit() { - // Make sure that when `next_remote_commit_tx_fee_msat` and `next_local_commit_tx_fee_msat` + // Make sure that when `get_next_local/remote_commitment_stats` // calculate the real dust limits for HTLCs (i.e. the dust limit given by the counterparty // *plus* the fees paid for the HTLC) they don't swap `HTLC_SUCCESS_TX_WEIGHT` for // `HTLC_TIMEOUT_TX_WEIGHT`, and vice versa. @@ -16357,7 +16057,7 @@ mod tests { let node_id = PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap()); let mut config = UserConfig::default(); config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; - let mut chan = OutboundV1Channel::<&TestKeysInterface>::new(&fee_est, &&keys_provider, &&keys_provider, node_id, &channelmanager::provided_init_features(&config), 10000000, 100000, 42, &config, 0, 42, None, &logger).unwrap(); + let mut chan = OutboundV1Channel::<&TestKeysInterface>::new(&fee_est, &&keys_provider, &&keys_provider, node_id, &channelmanager::provided_init_features(&config), 10_000_000, 100_000_000, 42, &config, 0, 42, None, &logger).unwrap(); let commitment_tx_fee_0_htlcs = commit_tx_fee_sat(chan.context.feerate_per_kw, 0, chan.funding.get_channel_type()) * 1000; let commitment_tx_fee_1_htlc = commit_tx_fee_sat(chan.context.feerate_per_kw, 1, chan.funding.get_channel_type()) * 1000; @@ -16368,28 +16068,28 @@ mod tests { // If HTLC_SUCCESS_TX_WEIGHT and HTLC_TIMEOUT_TX_WEIGHT were swapped: then this HTLC would be // counted as dust when it shouldn't be. let htlc_amt_above_timeout = (htlc_timeout_tx_fee_sat + chan.context.holder_dust_limit_satoshis + 1) * 1000; - let htlc_candidate = HTLCCandidate::new(htlc_amt_above_timeout, HTLCInitiator::LocalOffered); - let commitment_tx_fee = chan.context.next_local_commit_tx_fee_msat(&chan.funding, htlc_candidate, None); + let htlc_candidate = HTLCAmountDirection { amount_msat: htlc_amt_above_timeout, outbound: true }; + let commitment_tx_fee = chan.context.get_next_local_commitment_stats(&chan.funding, Some(htlc_candidate), false, 0, chan.context.feerate_per_kw, None).unwrap().0.commitment_stats.commit_tx_fee_sat * 1000; assert_eq!(commitment_tx_fee, commitment_tx_fee_1_htlc); // If swapped: this HTLC would be counted as non-dust when it shouldn't be. let dust_htlc_amt_below_success = (htlc_success_tx_fee_sat + chan.context.holder_dust_limit_satoshis - 1) * 1000; - let htlc_candidate = HTLCCandidate::new(dust_htlc_amt_below_success, HTLCInitiator::RemoteOffered); - let commitment_tx_fee = chan.context.next_local_commit_tx_fee_msat(&chan.funding, htlc_candidate, None); + let htlc_candidate = HTLCAmountDirection { amount_msat: dust_htlc_amt_below_success, outbound: false }; + let commitment_tx_fee = chan.context.get_next_local_commitment_stats(&chan.funding, Some(htlc_candidate), false, 0, chan.context.feerate_per_kw, None).unwrap().0.commitment_stats.commit_tx_fee_sat * 1000; assert_eq!(commitment_tx_fee, commitment_tx_fee_0_htlcs); chan.funding.channel_transaction_parameters.is_outbound_from_holder = false; // If swapped: this HTLC would be counted as non-dust when it shouldn't be. let dust_htlc_amt_above_timeout = (htlc_timeout_tx_fee_sat + chan.context.counterparty_dust_limit_satoshis + 1) * 1000; - let htlc_candidate = HTLCCandidate::new(dust_htlc_amt_above_timeout, HTLCInitiator::LocalOffered); - let commitment_tx_fee = chan.context.next_remote_commit_tx_fee_msat(&chan.funding, Some(htlc_candidate), None); + let htlc_candidate = HTLCAmountDirection { amount_msat: dust_htlc_amt_above_timeout, outbound: true }; + let commitment_tx_fee = chan.context.get_next_remote_commitment_stats(&chan.funding, Some(htlc_candidate), false, 0, chan.context.feerate_per_kw, None).unwrap().0.commitment_stats.commit_tx_fee_sat * 1000; assert_eq!(commitment_tx_fee, commitment_tx_fee_0_htlcs); // If swapped: this HTLC would be counted as dust when it shouldn't be. let htlc_amt_below_success = (htlc_success_tx_fee_sat + chan.context.counterparty_dust_limit_satoshis - 1) * 1000; - let htlc_candidate = HTLCCandidate::new(htlc_amt_below_success, HTLCInitiator::RemoteOffered); - let commitment_tx_fee = chan.context.next_remote_commit_tx_fee_msat(&chan.funding, Some(htlc_candidate), None); + let htlc_candidate = HTLCAmountDirection { amount_msat: htlc_amt_below_success, outbound: false }; + let commitment_tx_fee = chan.context.get_next_remote_commitment_stats(&chan.funding, Some(htlc_candidate), false, 0, chan.context.feerate_per_kw, None).unwrap().0.commitment_stats.commit_tx_fee_sat * 1000; assert_eq!(commitment_tx_fee, commitment_tx_fee_1_htlc); } From 5f44a098fe58778a330707f01ae30b03423d1a26 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Tue, 3 Feb 2026 18:53:10 +0000 Subject: [PATCH 109/627] Return `AvailableBalances` in `get_channel_stats` Note that `AvailableBalances` will always refer to the holder's balances, even when `local` is set to `false`, when calling `TxBuilder::get_channel_stats`. --- lightning/src/ln/channel.rs | 104 ++++++++++++++++++++----------- lightning/src/sign/tx_builder.rs | 56 ++++++++++++----- 2 files changed, 110 insertions(+), 50 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index f52f3affc1f..22ae13f7b34 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -72,8 +72,7 @@ use crate::offers::static_invoice::StaticInvoice; use crate::routing::gossip::NodeId; use crate::sign::ecdsa::EcdsaChannelSigner; use crate::sign::tx_builder::{ - get_available_balances, ChannelConstraints, ChannelStats, HTLCAmountDirection, - SpecTxBuilder, TxBuilder, + ChannelConstraints, ChannelStats, HTLCAmountDirection, SpecTxBuilder, TxBuilder, }; use crate::sign::{ChannelSigner, EntropySource, NodeSigner, Recipient, SignerProvider}; use crate::types::features::{ChannelTypeFeatures, InitFeatures}; @@ -4836,6 +4835,22 @@ impl ChannelContext { .saturating_add(inbound_claimed_htlc_msat) } + fn get_channel_constraints(&self, funding: &FundingScope) -> ChannelConstraints { + ChannelConstraints { + holder_dust_limit_satoshis: self.holder_dust_limit_satoshis, + counterparty_selected_channel_reserve_satoshis: funding + .counterparty_selected_channel_reserve_satoshis + .unwrap_or(0), + counterparty_dust_limit_satoshis: self.counterparty_dust_limit_satoshis, + holder_selected_channel_reserve_satoshis: funding + .holder_selected_channel_reserve_satoshis, + counterparty_htlc_minimum_msat: self.counterparty_htlc_minimum_msat, + counterparty_max_accepted_htlcs: self.counterparty_max_accepted_htlcs as u64, + counterparty_max_htlc_value_in_flight_msat: self + .counterparty_max_htlc_value_in_flight_msat, + } + } + fn get_next_local_commitment_stats( &self, funding: &FundingScope, htlc_candidate: Option, include_counterparty_unknown_htlcs: bool, addl_nondust_htlc_count: usize, @@ -4848,6 +4863,11 @@ impl ChannelContext { ); let next_value_to_self_msat = self.get_next_commitment_value_to_self_msat(true, funding); + let max_dust_htlc_exposure_msat = + self.get_max_dust_htlc_exposure_msat(dust_exposure_limiting_feerate); + + let channel_constraints = self.get_channel_constraints(funding); + let local_stats = SpecTxBuilder {}.get_channel_stats( true, funding.is_outbound(), @@ -4857,7 +4877,8 @@ impl ChannelContext { addl_nondust_htlc_count, feerate_per_kw, dust_exposure_limiting_feerate, - self.holder_dust_limit_satoshis, + max_dust_htlc_exposure_msat, + channel_constraints, funding.get_channel_type(), )?; @@ -4880,7 +4901,8 @@ impl ChannelContext { 0, feerate_per_kw, dust_exposure_limiting_feerate, - self.holder_dust_limit_satoshis, + max_dust_htlc_exposure_msat, + channel_constraints, funding.get_channel_type(), ) .expect("Balance exhausted on local commitment") @@ -4908,6 +4930,11 @@ impl ChannelContext { ); let next_value_to_self_msat = self.get_next_commitment_value_to_self_msat(false, funding); + let max_dust_htlc_exposure_msat = + self.get_max_dust_htlc_exposure_msat(dust_exposure_limiting_feerate); + + let channel_constraints = self.get_channel_constraints(funding); + let remote_stats = SpecTxBuilder {}.get_channel_stats( false, funding.is_outbound(), @@ -4917,7 +4944,8 @@ impl ChannelContext { addl_nondust_htlc_count, feerate_per_kw, dust_exposure_limiting_feerate, - self.counterparty_dust_limit_satoshis, + max_dust_htlc_exposure_msat, + channel_constraints, funding.get_channel_type(), )?; @@ -4940,7 +4968,8 @@ impl ChannelContext { 0, feerate_per_kw, dust_exposure_limiting_feerate, - self.counterparty_dust_limit_satoshis, + max_dust_htlc_exposure_msat, + channel_constraints, funding.get_channel_type(), ) .expect("Balance exhausted on remote commitment") @@ -5752,47 +5781,49 @@ impl ChannelContext { outbound_details } - fn get_channel_constraints(&self, funding: &FundingScope) -> ChannelConstraints { - ChannelConstraints { - holder_dust_limit_satoshis: self.holder_dust_limit_satoshis, - counterparty_selected_channel_reserve_satoshis: funding - .counterparty_selected_channel_reserve_satoshis - .unwrap_or(0), - counterparty_dust_limit_satoshis: self.counterparty_dust_limit_satoshis, - holder_selected_channel_reserve_satoshis: funding - .holder_selected_channel_reserve_satoshis, - counterparty_htlc_minimum_msat: self.counterparty_htlc_minimum_msat, - counterparty_max_accepted_htlcs: self.counterparty_max_accepted_htlcs as u64, - counterparty_max_htlc_value_in_flight_msat: self - .counterparty_max_htlc_value_in_flight_msat, - } - } - #[rustfmt::skip] fn get_available_balances_for_scope( &self, funding: &FundingScope, fee_estimator: &LowerBoundedFeeEstimator, ) -> AvailableBalances { - let local = false; let htlc_candidate = None; let include_counterparty_unknown_htlcs = true; - let pending_htlcs = self.get_next_commitment_htlcs(local, htlc_candidate, include_counterparty_unknown_htlcs); - + let addl_nondust_htlc_count = 0; let dust_exposure_limiting_feerate = self.get_dust_exposure_limiting_feerate( &fee_estimator, funding.get_channel_type(), ); - let max_dust_htlc_exposure_msat = self.get_max_dust_htlc_exposure_msat(dust_exposure_limiting_feerate); - get_available_balances( - funding.is_outbound(), - funding.get_value_satoshis(), - funding.get_value_to_self_msat(), - &pending_htlcs, + let balances = self.get_next_remote_commitment_stats( + funding, + htlc_candidate, + include_counterparty_unknown_htlcs, + addl_nondust_htlc_count, self.feerate_per_kw, - dust_exposure_limiting_feerate, - max_dust_htlc_exposure_msat, - self.get_channel_constraints(funding), - funding.get_channel_type(), - ) + dust_exposure_limiting_feerate + ).map(|(remote_stats, _)| remote_stats.available_balances).unwrap(); + + #[cfg(debug_assertions)] + if balances.next_outbound_htlc_limit_msat >= balances.next_outbound_htlc_minimum_msat + && balances.next_outbound_htlc_limit_msat != 0 + { + let (remote_stats, _remote_htlcs) = self.get_next_remote_commitment_stats( + funding, + Some(HTLCAmountDirection { + outbound: true, + // Note that this likely creates a non-dust HTLC, we could add a check for the + // biggest dust HTLC to make sure we still have a broadcastable commitment in + // that case. + amount_msat: balances.next_outbound_htlc_limit_msat, + }), + include_counterparty_unknown_htlcs, + addl_nondust_htlc_count, + self.feerate_per_kw, + dust_exposure_limiting_feerate + ).unwrap(); + assert!(remote_stats.commitment_stats.holder_balance_msat + >= funding.counterparty_selected_channel_reserve_satoshis.unwrap_or(0) * 1000); + } + + balances } #[rustfmt::skip] @@ -16058,6 +16089,7 @@ mod tests { let mut config = UserConfig::default(); config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; let mut chan = OutboundV1Channel::<&TestKeysInterface>::new(&fee_est, &&keys_provider, &&keys_provider, node_id, &channelmanager::provided_init_features(&config), 10_000_000, 100_000_000, 42, &config, 0, 42, None, &logger).unwrap(); + chan.context.counterparty_max_htlc_value_in_flight_msat = 1_000_000_000; let commitment_tx_fee_0_htlcs = commit_tx_fee_sat(chan.context.feerate_per_kw, 0, chan.funding.get_channel_type()) * 1000; let commitment_tx_fee_1_htlc = commit_tx_fee_sat(chan.context.feerate_per_kw, 1, chan.funding.get_channel_type()) * 1000; diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index 3840cc7551f..c0d6df0fee9 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -44,6 +44,7 @@ pub(crate) struct NextCommitmentStats { pub(crate) struct ChannelStats { pub commitment_stats: NextCommitmentStats, + pub available_balances: crate::ln::channel::AvailableBalances, } fn commit_plus_htlc_tx_fees_msat( @@ -294,7 +295,7 @@ fn get_next_commitment_stats( }) } -pub(crate) fn get_available_balances( +fn get_available_balances( is_outbound_from_holder: bool, channel_value_satoshis: u64, value_to_holder_msat: u64, pending_htlcs: &[HTLCAmountDirection], feerate_per_kw: u32, dust_exposure_limiting_feerate: Option, max_dust_htlc_exposure_msat: u64, @@ -523,10 +524,10 @@ pub(crate) fn get_available_balances( pub(crate) trait TxBuilder { fn get_channel_stats( &self, local: bool, is_outbound_from_holder: bool, channel_value_satoshis: u64, - value_to_holder_msat: u64, next_commitment_htlcs: &[HTLCAmountDirection], + value_to_holder_msat: u64, pending_htlcs: &[HTLCAmountDirection], addl_nondust_htlc_count: usize, feerate_per_kw: u32, - dust_exposure_limiting_feerate: Option, broadcaster_dust_limit_satoshis: u64, - channel_type: &ChannelTypeFeatures, + dust_exposure_limiting_feerate: Option, max_dust_htlc_exposure_msat: u64, + channel_constraints: ChannelConstraints, channel_type: &ChannelTypeFeatures, ) -> Result; fn build_commitment_transaction( &self, local: bool, commitment_number: u64, per_commitment_point: &PublicKey, @@ -541,25 +542,52 @@ pub(crate) struct SpecTxBuilder {} impl TxBuilder for SpecTxBuilder { fn get_channel_stats( &self, local: bool, is_outbound_from_holder: bool, channel_value_satoshis: u64, - value_to_holder_msat: u64, next_commitment_htlcs: &[HTLCAmountDirection], + value_to_holder_msat: u64, pending_htlcs: &[HTLCAmountDirection], addl_nondust_htlc_count: usize, feerate_per_kw: u32, - dust_exposure_limiting_feerate: Option, broadcaster_dust_limit_satoshis: u64, - channel_type: &ChannelTypeFeatures, + dust_exposure_limiting_feerate: Option, max_dust_htlc_exposure_msat: u64, + channel_constraints: ChannelConstraints, channel_type: &ChannelTypeFeatures, ) -> Result { - let commitment_stats = get_next_commitment_stats( - local, + let commitment_stats = if local { + get_next_commitment_stats( + true, + is_outbound_from_holder, + channel_value_satoshis, + value_to_holder_msat, + pending_htlcs, + addl_nondust_htlc_count, + feerate_per_kw, + dust_exposure_limiting_feerate, + channel_constraints.holder_dust_limit_satoshis, + channel_type, + )? + } else { + get_next_commitment_stats( + false, + is_outbound_from_holder, + channel_value_satoshis, + value_to_holder_msat, + pending_htlcs, + addl_nondust_htlc_count, + feerate_per_kw, + dust_exposure_limiting_feerate, + channel_constraints.counterparty_dust_limit_satoshis, + channel_type, + )? + }; + + let available_balances = get_available_balances( is_outbound_from_holder, channel_value_satoshis, value_to_holder_msat, - next_commitment_htlcs, - addl_nondust_htlc_count, + pending_htlcs, feerate_per_kw, dust_exposure_limiting_feerate, - broadcaster_dust_limit_satoshis, + max_dust_htlc_exposure_msat, + channel_constraints, channel_type, - )?; + ); - Ok(ChannelStats { commitment_stats }) + Ok(ChannelStats { commitment_stats, available_balances }) } fn build_commitment_transaction( &self, local: bool, commitment_number: u64, per_commitment_point: &PublicKey, From 69b6b2650b4ab26579cd994d8fabe57220b17f73 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 16 Feb 2026 14:15:57 +0000 Subject: [PATCH 110/627] Let callers handle errors on `get_available_balances_for_scope` `get_available_balances_for_scope` only errors if some party in the channel cannot afford the HTLCs outbound from said party, and the anchors and transaction fee if they are the funder. We do not account for the channel reserve here, so this error should be exceedingly rare, but could nonetheless happen due to concurrent updates on the channel's state. The upcoming zero-reserve channel type could also make this case more reachable. `send_htlc` maps such an error to its own error type since it proposes an update to the channel's state. The other callers only read the channel's state, so it would not be a good fit to have them return an error too. Hence, we choose to let these callers panic in debug mode, and return saturated values in release mode. Note that we now handle the if-we-removed-it-already-but-haven't -fully-resolved-they-can-still-send-an-inbound-HTLC case, as `LocalRemoved` HTLCs are considered resolved when calculating `AvailableBalances`. We update the documentation accordingly. --- lightning/src/ln/channel.rs | 43 ++++++++++++++++++------------ lightning/src/ln/channel_state.rs | 11 +++++++- lightning/src/ln/channelmanager.rs | 11 +++++++- 3 files changed, 46 insertions(+), 19 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 22ae13f7b34..adb67055dc7 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2442,13 +2442,13 @@ where } } - /// Get the available balances, see [`AvailableBalances`]'s fields for more info. - /// Doesn't bother handling the - /// if-we-removed-it-already-but-haven't-fully-resolved-they-can-still-send-an-inbound-HTLC - /// corner case properly. + /// Gets the available balances, see [`AvailableBalances`]'s fields for more info. + /// + /// Returns `Err` if some party cannot currently pay for the HTLCs outbound from said party, and the anchors and + /// transaction fee if they are the funder. pub fn get_available_balances( &self, fee_estimator: &LowerBoundedFeeEstimator, - ) -> AvailableBalances { + ) -> Result { match &self.phase { ChannelPhase::Undefined => unreachable!(), ChannelPhase::Funded(chan) => chan.get_available_balances(fee_estimator), @@ -5784,7 +5784,7 @@ impl ChannelContext { #[rustfmt::skip] fn get_available_balances_for_scope( &self, funding: &FundingScope, fee_estimator: &LowerBoundedFeeEstimator, - ) -> AvailableBalances { + ) -> Result { let htlc_candidate = None; let include_counterparty_unknown_htlcs = true; let addl_nondust_htlc_count = 0; @@ -5799,7 +5799,7 @@ impl ChannelContext { addl_nondust_htlc_count, self.feerate_per_kw, dust_exposure_limiting_feerate - ).map(|(remote_stats, _)| remote_stats.available_balances).unwrap(); + ).map(|(remote_stats, _)| remote_stats.available_balances)?; #[cfg(debug_assertions)] if balances.next_outbound_htlc_limit_msat >= balances.next_outbound_htlc_minimum_msat @@ -5823,7 +5823,7 @@ impl ChannelContext { >= funding.counterparty_selected_channel_reserve_satoshis.unwrap_or(0) * 1000); } - balances + Ok(balances) } #[rustfmt::skip] @@ -12490,7 +12490,12 @@ where return Err((LocalHTLCFailureReason::ZeroAmount, "Cannot send 0-msat HTLC".to_owned())); } - let available_balances = self.get_available_balances(fee_estimator); + let available_balances = self.get_available_balances(fee_estimator).map_err(|()| { + ( + LocalHTLCFailureReason::ChannelBalanceOverdrawn, + "Channel balance overdrawn".to_owned(), + ) + })?; if amount_msat < available_balances.next_outbound_htlc_minimum_msat { return Err(( LocalHTLCFailureReason::HTLCMinimum, @@ -12584,22 +12589,26 @@ where Ok(true) } + /// Gets the available balances, see [`AvailableBalances`]'s fields for more info. + /// + /// Returns `Err` if some party cannot currently pay for the HTLCs outbound from said party, and the anchors and + /// transaction fee if they are the funder. #[rustfmt::skip] pub(super) fn get_available_balances( &self, fee_estimator: &LowerBoundedFeeEstimator, - ) -> AvailableBalances { - core::iter::once(&self.funding) - .chain(self.pending_funding().iter()) - .map(|funding| self.context.get_available_balances_for_scope(funding, fee_estimator)) - .reduce(|acc, e| { - AvailableBalances { + ) -> Result { + let init = self.context.get_available_balances_for_scope(&self.funding, fee_estimator)?; + self.pending_funding().iter().try_fold( + init, + |acc, funding| { + let e = self.context.get_available_balances_for_scope(funding, fee_estimator)?; + Ok(AvailableBalances { inbound_capacity_msat: acc.inbound_capacity_msat.min(e.inbound_capacity_msat), outbound_capacity_msat: acc.outbound_capacity_msat.min(e.outbound_capacity_msat), next_outbound_htlc_limit_msat: acc.next_outbound_htlc_limit_msat.min(e.next_outbound_htlc_limit_msat), next_outbound_htlc_minimum_msat: acc.next_outbound_htlc_minimum_msat.max(e.next_outbound_htlc_minimum_msat), - } + }) }) - .expect("At least one FundingScope is always provided") } fn build_commitment_no_status_check(&mut self, logger: &L) -> ChannelMonitorUpdate { diff --git a/lightning/src/ln/channel_state.rs b/lightning/src/ln/channel_state.rs index c7277d18e3b..5547bee8f4c 100644 --- a/lightning/src/ln/channel_state.rs +++ b/lightning/src/ln/channel_state.rs @@ -525,7 +525,16 @@ impl ChannelDetails { ) -> Self { let context = channel.context(); let funding = channel.funding(); - let balance = channel.get_available_balances(fee_estimator); + let balance_result = channel.get_available_balances(fee_estimator); + let balance = balance_result.unwrap_or_else(|()| { + debug_assert!(false, "some channel balance has been overdrawn"); + crate::ln::channel::AvailableBalances { + inbound_capacity_msat: 0, + outbound_capacity_msat: 0, + next_outbound_htlc_limit_msat: 0, + next_outbound_htlc_minimum_msat: u64::MAX, + } + }); let (to_remote_reserve_satoshis, to_self_reserve_satoshis) = funding.get_holder_counterparty_selected_channel_reserve_satoshis(); #[allow(deprecated)] // TODO: Remove once balance_msat is removed. diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 08cbb6f6bf7..bf2ff6155fe 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -7708,7 +7708,16 @@ impl< .values_mut() .filter_map(Channel::as_funded_mut) .filter_map(|chan| { - let balances = chan.get_available_balances(&self.fee_estimator); + let balances_result = chan.get_available_balances(&self.fee_estimator); + let balances = balances_result.unwrap_or_else(|()| { + debug_assert!(false, "some channel balance has been overdrawn"); + crate::ln::channel::AvailableBalances { + inbound_capacity_msat: 0, + outbound_capacity_msat: 0, + next_outbound_htlc_limit_msat: 0, + next_outbound_htlc_minimum_msat: u64::MAX, + } + }); let is_in_range = (balances.next_outbound_htlc_minimum_msat ..=balances.next_outbound_htlc_limit_msat) .contains(&outgoing_amt_msat); From 882a940c3d4f12d6d17066488d3ba122f60b9900 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Tue, 24 Feb 2026 17:53:14 +0000 Subject: [PATCH 111/627] Format all `get_available_balances` methods in `channel` --- lightning/src/ln/channel.rs | 83 ++++++++++++++++++++----------------- 1 file changed, 44 insertions(+), 39 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index adb67055dc7..faf9b3f4a42 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -5781,46 +5781,50 @@ impl ChannelContext { outbound_details } - #[rustfmt::skip] fn get_available_balances_for_scope( &self, funding: &FundingScope, fee_estimator: &LowerBoundedFeeEstimator, ) -> Result { let htlc_candidate = None; let include_counterparty_unknown_htlcs = true; let addl_nondust_htlc_count = 0; - let dust_exposure_limiting_feerate = self.get_dust_exposure_limiting_feerate( - &fee_estimator, funding.get_channel_type(), - ); + let dust_exposure_limiting_feerate = + self.get_dust_exposure_limiting_feerate(&fee_estimator, funding.get_channel_type()); - let balances = self.get_next_remote_commitment_stats( - funding, - htlc_candidate, - include_counterparty_unknown_htlcs, - addl_nondust_htlc_count, - self.feerate_per_kw, - dust_exposure_limiting_feerate - ).map(|(remote_stats, _)| remote_stats.available_balances)?; + let balances = self + .get_next_remote_commitment_stats( + funding, + htlc_candidate, + include_counterparty_unknown_htlcs, + addl_nondust_htlc_count, + self.feerate_per_kw, + dust_exposure_limiting_feerate, + ) + .map(|(remote_stats, _)| remote_stats.available_balances)?; #[cfg(debug_assertions)] if balances.next_outbound_htlc_limit_msat >= balances.next_outbound_htlc_minimum_msat && balances.next_outbound_htlc_limit_msat != 0 { - let (remote_stats, _remote_htlcs) = self.get_next_remote_commitment_stats( - funding, - Some(HTLCAmountDirection { - outbound: true, - // Note that this likely creates a non-dust HTLC, we could add a check for the - // biggest dust HTLC to make sure we still have a broadcastable commitment in - // that case. - amount_msat: balances.next_outbound_htlc_limit_msat, - }), - include_counterparty_unknown_htlcs, - addl_nondust_htlc_count, - self.feerate_per_kw, - dust_exposure_limiting_feerate - ).unwrap(); - assert!(remote_stats.commitment_stats.holder_balance_msat - >= funding.counterparty_selected_channel_reserve_satoshis.unwrap_or(0) * 1000); + let (remote_stats, _remote_htlcs) = self + .get_next_remote_commitment_stats( + funding, + Some(HTLCAmountDirection { + outbound: true, + // Note that this likely creates a non-dust HTLC, we could add a check for the + // biggest dust HTLC to make sure we still have a broadcastable commitment in + // that case. + amount_msat: balances.next_outbound_htlc_limit_msat, + }), + include_counterparty_unknown_htlcs, + addl_nondust_htlc_count, + self.feerate_per_kw, + dust_exposure_limiting_feerate, + ) + .unwrap(); + assert!( + remote_stats.commitment_stats.holder_balance_msat + >= funding.counterparty_selected_channel_reserve_satoshis.unwrap_or(0) * 1000 + ); } Ok(balances) @@ -12593,22 +12597,23 @@ where /// /// Returns `Err` if some party cannot currently pay for the HTLCs outbound from said party, and the anchors and /// transaction fee if they are the funder. - #[rustfmt::skip] pub(super) fn get_available_balances( &self, fee_estimator: &LowerBoundedFeeEstimator, ) -> Result { let init = self.context.get_available_balances_for_scope(&self.funding, fee_estimator)?; - self.pending_funding().iter().try_fold( - init, - |acc, funding| { - let e = self.context.get_available_balances_for_scope(funding, fee_estimator)?; - Ok(AvailableBalances { - inbound_capacity_msat: acc.inbound_capacity_msat.min(e.inbound_capacity_msat), - outbound_capacity_msat: acc.outbound_capacity_msat.min(e.outbound_capacity_msat), - next_outbound_htlc_limit_msat: acc.next_outbound_htlc_limit_msat.min(e.next_outbound_htlc_limit_msat), - next_outbound_htlc_minimum_msat: acc.next_outbound_htlc_minimum_msat.max(e.next_outbound_htlc_minimum_msat), - }) + self.pending_funding().iter().try_fold(init, |acc, funding| { + let e = self.context.get_available_balances_for_scope(funding, fee_estimator)?; + Ok(AvailableBalances { + inbound_capacity_msat: acc.inbound_capacity_msat.min(e.inbound_capacity_msat), + outbound_capacity_msat: acc.outbound_capacity_msat.min(e.outbound_capacity_msat), + next_outbound_htlc_limit_msat: acc + .next_outbound_htlc_limit_msat + .min(e.next_outbound_htlc_limit_msat), + next_outbound_htlc_minimum_msat: acc + .next_outbound_htlc_minimum_msat + .max(e.next_outbound_htlc_minimum_msat), }) + }) } fn build_commitment_no_status_check(&mut self, logger: &L) -> ChannelMonitorUpdate { From 0ea26165cc985a63ae521acfbc47debbe1f68140 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 20 Feb 2026 22:42:39 +0000 Subject: [PATCH 112/627] Assert that a balance under a post-splice reserve did not budge Notably, if a party splices funds into the channel, their new balance must be above the new reserve. --- lightning/src/ln/channel.rs | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 15aa1daecfe..2bea5aa19b9 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2788,7 +2788,7 @@ impl FundingScope { // New reserve values are based on the new channel value and are v2-specific let counterparty_selected_channel_reserve_satoshis = - Some(get_v2_channel_reserve_satoshis(post_channel_value, MIN_CHAN_DUST_LIMIT_SATOSHIS)); + get_v2_channel_reserve_satoshis(post_channel_value, MIN_CHAN_DUST_LIMIT_SATOSHIS); let holder_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis( post_channel_value, context.counterparty_dust_limit_satoshis, @@ -2798,23 +2798,39 @@ impl FundingScope { channel_transaction_parameters: post_channel_transaction_parameters, value_to_self_msat: post_value_to_self_msat, funding_transaction: None, - counterparty_selected_channel_reserve_satoshis, + counterparty_selected_channel_reserve_satoshis: Some( + counterparty_selected_channel_reserve_satoshis, + ), holder_selected_channel_reserve_satoshis, #[cfg(debug_assertions)] holder_prev_commitment_tx_balance: { let prev = *prev_funding.holder_prev_commitment_tx_balance.lock().unwrap(); - Mutex::new(( - prev.0.saturating_add_signed(our_funding_contribution.to_sat() * 1000), - prev.1.saturating_add_signed(their_funding_contribution.to_sat() * 1000), - )) + let new_holder_balance_msat = + prev.0.saturating_add_signed(our_funding_contribution.to_sat() * 1000); + let new_counterparty_balance_msat = + prev.1.saturating_add_signed(their_funding_contribution.to_sat() * 1000); + if new_holder_balance_msat < counterparty_selected_channel_reserve_satoshis { + assert_eq!(new_holder_balance_msat, prev.0); + } + if new_counterparty_balance_msat < holder_selected_channel_reserve_satoshis { + assert_eq!(new_counterparty_balance_msat, prev.1); + } + Mutex::new((new_holder_balance_msat, new_counterparty_balance_msat)) }, #[cfg(debug_assertions)] counterparty_prev_commitment_tx_balance: { let prev = *prev_funding.counterparty_prev_commitment_tx_balance.lock().unwrap(); - Mutex::new(( - prev.0.saturating_add_signed(our_funding_contribution.to_sat() * 1000), - prev.1.saturating_add_signed(their_funding_contribution.to_sat() * 1000), - )) + let new_holder_balance_msat = + prev.0.saturating_add_signed(our_funding_contribution.to_sat() * 1000); + let new_counterparty_balance_msat = + prev.1.saturating_add_signed(their_funding_contribution.to_sat() * 1000); + if new_holder_balance_msat < counterparty_selected_channel_reserve_satoshis { + assert_eq!(new_holder_balance_msat, prev.0); + } + if new_counterparty_balance_msat < holder_selected_channel_reserve_satoshis { + assert_eq!(new_counterparty_balance_msat, prev.1); + } + Mutex::new((new_holder_balance_msat, new_counterparty_balance_msat)) }, #[cfg(any(test, fuzzing))] next_local_fee: Mutex::new(PredictedNextFee::default()), From a024a760f366eac594ac4ab8e1d3412a81c7f675 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 16 Jun 2025 11:58:23 -0300 Subject: [PATCH 113/627] LSPS1: Add initial integration test We add the first LSPS1 integration test. This is based on the unfinished work in https://github.com/lightningdevkit/rust-lightning/pull/3864, but rebased to account for the new ways we now do integration test setup. --- .../tests/lsps1_integration_tests.rs | 273 ++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 lightning-liquidity/tests/lsps1_integration_tests.rs diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs new file mode 100644 index 00000000000..5e842c6a111 --- /dev/null +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -0,0 +1,273 @@ +#![cfg(all(test, feature = "time", lsps1_service))] + +mod common; + +use common::create_service_and_client_nodes_with_kv_stores; +use common::{get_lsps_message, LSPSNodes}; + +use lightning::ln::peer_handler::CustomMessageHandler; +use lightning_liquidity::events::LiquidityEvent; +use lightning_liquidity::lsps0::ser::LSPSDateTime; +use lightning_liquidity::lsps1::client::LSPS1ClientConfig; +use lightning_liquidity::lsps1::event::LSPS1ClientEvent; +use lightning_liquidity::lsps1::event::LSPS1ServiceEvent; +use lightning_liquidity::lsps1::msgs::LSPS1OrderState; +use lightning_liquidity::lsps1::msgs::{ + LSPS1OnchainPaymentInfo, LSPS1Options, LSPS1OrderParams, LSPS1PaymentInfo, +}; +use lightning_liquidity::lsps1::service::LSPS1ServiceConfig; +use lightning_liquidity::utils::time::DefaultTimeProvider; +use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig}; + +use lightning::ln::functional_test_utils::{ + create_chanmon_cfgs, create_node_cfgs, create_node_chanmgrs, +}; +use lightning::util::test_utils::TestStore; + +use std::str::FromStr; +use std::sync::Arc; + +use lightning::ln::functional_test_utils::{create_network, Node}; + +fn build_lsps1_configs( + supported_options: LSPS1Options, +) -> (LiquidityServiceConfig, LiquidityClientConfig) { + let lsps1_service_config = + LSPS1ServiceConfig { token: None, supported_options: Some(supported_options) }; + let service_config = LiquidityServiceConfig { + lsps1_service_config: Some(lsps1_service_config), + lsps2_service_config: None, + lsps5_service_config: None, + advertise_service: true, + }; + + let lsps1_client_config = LSPS1ClientConfig { max_channel_fees_msat: None }; + let client_config = LiquidityClientConfig { + lsps1_client_config: Some(lsps1_client_config), + lsps2_client_config: None, + lsps5_client_config: None, + }; + + (service_config, client_config) +} + +fn setup_test_lsps1_nodes_with_kv_stores<'a, 'b, 'c>( + nodes: Vec>, service_kv_store: Arc, + client_kv_store: Arc, supported_options: LSPS1Options, +) -> LSPSNodes<'a, 'b, 'c> { + let (service_config, client_config) = build_lsps1_configs(supported_options); + let lsps_nodes = create_service_and_client_nodes_with_kv_stores( + nodes, + service_config, + client_config, + Arc::new(DefaultTimeProvider), + service_kv_store, + client_kv_store, + ); + lsps_nodes +} + +fn setup_test_lsps1_nodes<'a, 'b, 'c>( + nodes: Vec>, supported_options: LSPS1Options, +) -> LSPSNodes<'a, 'b, 'c> { + let service_kv_store = Arc::new(TestStore::new(false)); + let client_kv_store = Arc::new(TestStore::new(false)); + setup_test_lsps1_nodes_with_kv_stores( + nodes, + service_kv_store, + client_kv_store, + supported_options, + ) +} + +#[test] +fn lsps1_happy_path() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let expected_options_supported = LSPS1Options { + min_required_channel_confirmations: 0, + min_funding_confirms_within_blocks: 6, + supports_zero_channel_reserve: true, + max_channel_expiry_blocks: 144, + min_initial_client_balance_sat: 10_000_000, + max_initial_client_balance_sat: 100_000_000, + min_initial_lsp_balance_sat: 100_000, + max_initial_lsp_balance_sat: 100_000_000, + min_channel_balance_sat: 100_000, + max_channel_balance_sat: 100_000_000, + }; + + let LSPSNodes { service_node, client_node } = + setup_test_lsps1_nodes(nodes, expected_options_supported.clone()); + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + let client_handler = client_node.liquidity_manager.lsps1_client_handler().unwrap(); + let service_handler = service_node.liquidity_manager.lsps1_service_handler().unwrap(); + + let request_supported_options_id = client_handler.request_supported_options(service_node_id); + let request_supported_options = get_lsps_message!(client_node, service_node_id); + + service_node + .liquidity_manager + .handle_custom_message(request_supported_options, client_node_id) + .unwrap(); + + let get_info_message = get_lsps_message!(service_node, client_node_id); + + client_node.liquidity_manager.handle_custom_message(get_info_message, service_node_id).unwrap(); + + let get_info_event = client_node.liquidity_manager.next_event().unwrap(); + if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::SupportedOptionsReady { + request_id, + counterparty_node_id, + supported_options, + }) = get_info_event + { + assert_eq!(request_id, request_supported_options_id); + assert_eq!(counterparty_node_id, service_node_id); + assert_eq!(expected_options_supported, supported_options); + } else { + panic!("Unexpected event"); + } + + let order_params = LSPS1OrderParams { + lsp_balance_sat: 100_000, + client_balance_sat: 10_000_000, + required_channel_confirmations: 0, + funding_confirms_within_blocks: 6, + channel_expiry_blocks: 144, + token: None, + announce_channel: true, + }; + + let _create_order_id = + client_handler.create_order(&service_node_id, order_params.clone(), None); + let create_order = get_lsps_message!(client_node, service_node_id); + + service_node.liquidity_manager.handle_custom_message(create_order, client_node_id).unwrap(); + + let _request_for_payment_event = service_node.liquidity_manager.next_event().unwrap(); + + if let LiquidityEvent::LSPS1Service(LSPS1ServiceEvent::RequestForPaymentDetails { + request_id, + counterparty_node_id, + order, + }) = _request_for_payment_event + { + assert_eq!(request_id, _create_order_id.clone()); + assert_eq!(counterparty_node_id, client_node_id); + assert_eq!(order, order_params); + } else { + panic!("Unexpected event"); + } + + let json_str = r#"{ + "state": "EXPECT_PAYMENT", + "expires_at": "2025-01-01T00:00:00Z", + "fee_total_sat": "9999", + "order_total_sat": "200999", + "address": "bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr", + "min_onchain_payment_confirmations": 1, + "min_fee_for_0conf": 253 + }"#; + + let onchain: LSPS1OnchainPaymentInfo = + serde_json::from_str(json_str).expect("Failed to parse JSON"); + let payment_info = LSPS1PaymentInfo { bolt11: None, bolt12: None, onchain: Some(onchain) }; + let _now = LSPSDateTime::from_str("2024-01-01T00:00:00Z").expect("Failed to parse date"); + + let _ = service_handler + .send_payment_details(_create_order_id.clone(), &client_node_id, payment_info.clone(), _now) + .unwrap(); + + let create_order_response = get_lsps_message!(service_node, client_node_id); + + client_node + .liquidity_manager + .handle_custom_message(create_order_response, service_node_id) + .unwrap(); + + let order_created_event = client_node.liquidity_manager.next_event().unwrap(); + let expected_order_id = if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderCreated { + request_id, + counterparty_node_id, + order_id, + order, + payment, + channel, + }) = order_created_event + { + assert_eq!(request_id, _create_order_id); + assert_eq!(counterparty_node_id, service_node_id); + assert_eq!(order, order_params); + assert_eq!(payment, payment_info); + assert!(channel.is_none()); + order_id + } else { + panic!("Unexpected event"); + }; + + let check_order_status_id = + client_handler.check_order_status(&service_node_id, expected_order_id.clone()); + let check_order_status = get_lsps_message!(client_node, service_node_id); + + service_node + .liquidity_manager + .handle_custom_message(check_order_status, client_node_id) + .unwrap(); + + let _check_payment_confirmation_event = service_node.liquidity_manager.next_event().unwrap(); + + if let LiquidityEvent::LSPS1Service(LSPS1ServiceEvent::CheckPaymentConfirmation { + request_id, + counterparty_node_id, + order_id, + }) = _check_payment_confirmation_event + { + assert_eq!(request_id, check_order_status_id); + assert_eq!(counterparty_node_id, client_node_id); + assert_eq!(order_id, expected_order_id.clone()); + } else { + panic!("Unexpected event"); + } + + let _ = service_handler + .update_order_status( + check_order_status_id.clone(), + client_node_id, + expected_order_id.clone(), + LSPS1OrderState::Created, + None, + ) + .unwrap(); + + let order_status_response = get_lsps_message!(service_node, client_node_id); + + client_node + .liquidity_manager + .handle_custom_message(order_status_response, service_node_id) + .unwrap(); + + let order_status_event = client_node.liquidity_manager.next_event().unwrap(); + if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { + request_id, + counterparty_node_id, + order_id, + order, + payment, + channel, + }) = order_status_event + { + assert_eq!(request_id, check_order_status_id); + assert_eq!(counterparty_node_id, service_node_id); + assert_eq!(order, order_params); + assert_eq!(payment, payment_info); + assert!(channel.is_none()); + assert_eq!(order_id, expected_order_id); + } else { + panic!("Unexpected event"); + } +} From 9a64a6595ca67e748336aa771fdaf83d240216d4 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Sun, 16 Nov 2025 12:28:40 +0100 Subject: [PATCH 114/627] Cleanup unused code .. for which we got warnings --- lightning-liquidity/src/lsps1/service.rs | 26 ++++-------------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index d7010652c37..793e376fa26 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -40,8 +40,6 @@ use lightning::util::persist::KVStore; use bitcoin::secp256k1::PublicKey; -use chrono::Utc; - /// Server-side configuration options for bLIP-51 / LSPS1 channel requests. #[derive(Clone, Debug)] pub struct LSPS1ServiceConfig { @@ -63,7 +61,6 @@ impl From for LightningError { enum OutboundRequestState { OrderCreated { order_id: LSPS1OrderId }, WaitingPayment { order_id: LSPS1OrderId }, - Ready, } impl OutboundRequestState { @@ -102,18 +99,11 @@ impl OutboundCRChannel { self.state = self.state.awaiting_payment()?; Ok(()) } - - fn check_order_validity(&self, supported_options: &LSPS1Options) -> bool { - let order = &self.config.order; - - is_valid(order, supported_options) - } } #[derive(Default)] struct PeerState { outbound_channels_by_order_id: HashMap, - request_to_cid: HashMap, pending_requests: HashMap, } @@ -121,14 +111,6 @@ impl PeerState { fn insert_outbound_channel(&mut self, order_id: LSPS1OrderId, channel: OutboundCRChannel) { self.outbound_channels_by_order_id.insert(order_id, channel); } - - fn insert_request(&mut self, request_id: LSPSRequestId, channel_id: u128) { - self.request_to_cid.insert(request_id, channel_id); - } - - fn remove_outbound_channel(&mut self, order_id: LSPS1OrderId) { - self.outbound_channels_by_order_id.remove(&order_id); - } } /// The main object allowing to send and receive bLIP-51 / LSPS1 messages. @@ -137,8 +119,8 @@ where CM::Target: AChannelManager, { entropy_source: ES, - channel_manager: CM, - chain_source: Option, + _channel_manager: CM, + _chain_source: Option, pending_messages: Arc, pending_events: Arc>, per_peer_state: RwLock>>, @@ -158,8 +140,8 @@ where ) -> Self { Self { entropy_source, - channel_manager, - chain_source, + _channel_manager: channel_manager, + _chain_source: chain_source, pending_messages, pending_events, per_peer_state: RwLock::new(new_hash_map()), From 0d7408bfe6b6d47bb1bf3a412da66e906b90a65d Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Dec 2025 12:08:55 +0100 Subject: [PATCH 115/627] Drop `chain_source` from `LSPS1ServiceHandler` We previously considered tracking payment confirmations as part of the handler. However, we can considerably simplify our logic if we stick with the current approach of having the LSPs track the payment status and update us when prompted through events. --- lightning-liquidity/src/lsps1/service.rs | 15 +++++---------- lightning-liquidity/src/manager.rs | 9 ++++----- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 793e376fa26..7d138e3b2c7 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -30,7 +30,6 @@ use crate::prelude::{new_hash_map, HashMap}; use crate::sync::{Arc, Mutex, RwLock}; use crate::utils; -use lightning::chain::Filter; use lightning::ln::channelmanager::AChannelManager; use lightning::ln::msgs::{ErrorAction, LightningError}; use lightning::sign::EntropySource; @@ -114,34 +113,30 @@ impl PeerState { } /// The main object allowing to send and receive bLIP-51 / LSPS1 messages. -pub struct LSPS1ServiceHandler +pub struct LSPS1ServiceHandler where CM::Target: AChannelManager, { entropy_source: ES, _channel_manager: CM, - _chain_source: Option, pending_messages: Arc, pending_events: Arc>, per_peer_state: RwLock>>, config: LSPS1ServiceConfig, } -impl - LSPS1ServiceHandler +impl LSPS1ServiceHandler where CM::Target: AChannelManager, { /// Constructs a `LSPS1ServiceHandler`. pub(crate) fn new( entropy_source: ES, pending_messages: Arc, - pending_events: Arc>, channel_manager: CM, chain_source: Option, - config: LSPS1ServiceConfig, + pending_events: Arc>, channel_manager: CM, config: LSPS1ServiceConfig, ) -> Self { Self { entropy_source, _channel_manager: channel_manager, - _chain_source: chain_source, pending_messages, pending_events, per_peer_state: RwLock::new(new_hash_map()), @@ -397,8 +392,8 @@ where } } -impl LSPSProtocolMessageHandler - for LSPS1ServiceHandler +impl LSPSProtocolMessageHandler + for LSPS1ServiceHandler where CM::Target: AChannelManager, { diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index 1f11fc8add7..5336e6f2111 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -297,7 +297,7 @@ pub struct LiquidityManager< lsps0_client_handler: LSPS0ClientHandler, lsps0_service_handler: Option, #[cfg(lsps1_service)] - lsps1_service_handler: Option>, + lsps1_service_handler: Option>, lsps1_client_handler: Option>, lsps2_service_handler: Option>, lsps2_client_handler: Option>, @@ -474,7 +474,7 @@ where #[cfg(lsps1_service)] let lsps1_service_handler = service_config.as_ref().and_then(|config| { if let Some(number) = - as LSPSProtocolMessageHandler>::PROTOCOL_NUMBER + as LSPSProtocolMessageHandler>::PROTOCOL_NUMBER { supported_protocols.push(number); } @@ -484,7 +484,6 @@ where Arc::clone(&pending_messages), Arc::clone(&pending_events), channel_manager.clone(), - chain_source.clone(), config.clone(), ) }) @@ -544,7 +543,7 @@ where /// Returns a reference to the LSPS1 server-side handler. #[cfg(lsps1_service)] - pub fn lsps1_service_handler(&self) -> Option<&LSPS1ServiceHandler> { + pub fn lsps1_service_handler(&self) -> Option<&LSPS1ServiceHandler> { self.lsps1_service_handler.as_ref() } @@ -1148,7 +1147,7 @@ where #[cfg(lsps1_service)] pub fn lsps1_service_handler( &self, - ) -> Option<&LSPS1ServiceHandler>> { + ) -> Option<&LSPS1ServiceHandler>> { self.inner.lsps1_service_handler() } From 8ad5101b8993cefb098d977349b879fd5e6028ea Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Dec 2025 12:24:41 +0100 Subject: [PATCH 116/627] Drop `Listen`/`Confirm`/etc from `LiquidityManager` Now that we don't do on-chain tracking in LSPS1, we can drop quite a few `LiquidityManager` parameters and generics, which were only added in anticipation of tracking on-chain state. Signed-off-by: Elias Rohrer --- fuzz/src/lsps_message.rs | 2 - lightning-background-processor/src/lib.rs | 27 +-- lightning-liquidity/src/manager.rs | 219 ++---------------- lightning-liquidity/tests/common/mod.rs | 15 -- .../tests/lsps2_integration_tests.rs | 10 +- .../tests/lsps5_integration_tests.rs | 13 +- 6 files changed, 34 insertions(+), 252 deletions(-) diff --git a/fuzz/src/lsps_message.rs b/fuzz/src/lsps_message.rs index 8371d1c5fc7..42feed48cc1 100644 --- a/fuzz/src/lsps_message.rs +++ b/fuzz/src/lsps_message.rs @@ -82,8 +82,6 @@ pub fn do_test(data: &[u8]) { Arc::clone(&keys_manager), Arc::clone(&keys_manager), Arc::clone(&manager), - None::>, - None, kv_store, Arc::clone(&tx_broadcaster), None, diff --git a/lightning-background-processor/src/lib.rs b/lightning-background-processor/src/lib.rs index da415c70a32..fc58eda8eee 100644 --- a/lightning-background-processor/src/lib.rs +++ b/lightning-background-processor/src/lib.rs @@ -464,7 +464,6 @@ pub const NO_LIQUIDITY_MANAGER: Option< NodeSigner = &(dyn lightning::sign::NodeSigner + Send + Sync), AChannelManager = DynChannelManager, CM = &DynChannelManager, - C = &(dyn chain::Filter + Send + Sync), K = &DummyKVStore, TimeProvider = dyn lightning_liquidity::utils::time::TimeProvider + Send + Sync, TP = &(dyn lightning_liquidity::utils::time::TimeProvider + Send + Sync), @@ -486,7 +485,6 @@ pub const NO_LIQUIDITY_MANAGER_SYNC: Option< NodeSigner = &(dyn lightning::sign::NodeSigner + Send + Sync), AChannelManager = DynChannelManager, CM = &DynChannelManager, - C = &(dyn chain::Filter + Send + Sync), KVStoreSync = dyn lightning::util::persist::KVStoreSync + Send + Sync, KS = &(dyn lightning::util::persist::KVStoreSync + Send + Sync), TimeProvider = dyn lightning_liquidity::utils::time::TimeProvider + Send + Sync, @@ -829,7 +827,7 @@ use futures_util::{dummy_waker, Joiner, OptionalSelector, Selector, SelectorOutp /// # type P2PGossipSync
    = lightning::routing::gossip::P2PGossipSync, Arc
      , Arc>; /// # type ChannelManager = lightning::ln::channelmanager::SimpleArcChannelManager, B, FE, Logger>; /// # type OnionMessenger = lightning::onion_message::messenger::OnionMessenger, Arc, Arc, Arc>, Arc, Arc, Arc>>, Arc>, lightning::ln::peer_handler::IgnoringMessageHandler, lightning::ln::peer_handler::IgnoringMessageHandler, lightning::ln::peer_handler::IgnoringMessageHandler>; -/// # type LiquidityManager = lightning_liquidity::LiquidityManager, Arc, Arc>, Arc, Arc, Arc, Arc>; +/// # type LiquidityManager = lightning_liquidity::LiquidityManager, Arc, Arc>, Arc, Arc, Arc>; /// # type Scorer = RwLock, Arc>>; /// # type PeerManager = lightning::ln::peer_handler::SimpleArcPeerManager, B, FE, Arc
        , Logger, F, StoreSync>; /// # type OutputSweeper = lightning::util::sweep::OutputSweeper, Arc, Arc, Arc, Arc, Arc, Arc>; @@ -1898,7 +1896,7 @@ mod tests { use core::sync::atomic::{AtomicBool, Ordering}; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; use lightning::chain::transaction::OutPoint; - use lightning::chain::{chainmonitor, BestBlock, Confirm, Filter}; + use lightning::chain::{chainmonitor, BestBlock, Confirm}; use lightning::events::{Event, PathFailure, ReplayEvent}; use lightning::ln::channelmanager; use lightning::ln::channelmanager::{ @@ -2054,7 +2052,6 @@ mod tests { Arc, Arc, Arc, - Arc, Arc, DefaultTimeProvider, Arc, @@ -2513,8 +2510,6 @@ mod tests { Arc::clone(&keys_manager), Arc::clone(&keys_manager), Arc::clone(&manager), - None, - None, Arc::clone(&kv_store), Arc::clone(&tx_broadcaster), None, @@ -2910,10 +2905,10 @@ mod tests { let kv_store = KVStoreSyncWrapper(kv_store_sync); // Yes, you can unsafe { turn off the borrow checker } - let lm_async: &'static LiquidityManager<_, _, _, _, _, _, _> = unsafe { + let lm_async: &'static LiquidityManager<_, _, _, _, _, _> = unsafe { &*(nodes[0].liquidity_manager.get_lm_async() - as *const LiquidityManager<_, _, _, _, _, _, _>) - as &'static LiquidityManager<_, _, _, _, _, _, _> + as *const LiquidityManager<_, _, _, _, _, _>) + as &'static LiquidityManager<_, _, _, _, _, _> }; let sweeper_async: &'static OutputSweeper<_, _, _, _, _, _, _> = unsafe { &*(nodes[0].sweeper.sweeper_async() as *const OutputSweeper<_, _, _, _, _, _, _>) @@ -3435,10 +3430,10 @@ mod tests { let kv_store = KVStoreSyncWrapper(kv_store_sync); // Yes, you can unsafe { turn off the borrow checker } - let lm_async: &'static LiquidityManager<_, _, _, _, _, _, _> = unsafe { + let lm_async: &'static LiquidityManager<_, _, _, _, _, _> = unsafe { &*(nodes[0].liquidity_manager.get_lm_async() - as *const LiquidityManager<_, _, _, _, _, _, _>) - as &'static LiquidityManager<_, _, _, _, _, _, _> + as *const LiquidityManager<_, _, _, _, _, _>) + as &'static LiquidityManager<_, _, _, _, _, _> }; let sweeper_async: &'static OutputSweeper<_, _, _, _, _, _, _> = unsafe { &*(nodes[0].sweeper.sweeper_async() as *const OutputSweeper<_, _, _, _, _, _, _>) @@ -3662,10 +3657,10 @@ mod tests { let (exit_sender, exit_receiver) = tokio::sync::watch::channel(()); // Yes, you can unsafe { turn off the borrow checker } - let lm_async: &'static LiquidityManager<_, _, _, _, _, _, _> = unsafe { + let lm_async: &'static LiquidityManager<_, _, _, _, _, _> = unsafe { &*(nodes[0].liquidity_manager.get_lm_async() - as *const LiquidityManager<_, _, _, _, _, _, _>) - as &'static LiquidityManager<_, _, _, _, _, _, _> + as *const LiquidityManager<_, _, _, _, _, _>) + as &'static LiquidityManager<_, _, _, _, _, _> }; let sweeper_async: &'static OutputSweeper<_, _, _, _, _, _, _> = unsafe { &*(nodes[0].sweeper.sweeper_async() as *const OutputSweeper<_, _, _, _, _, _, _>) diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index 5336e6f2111..45a85e72003 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -43,8 +43,7 @@ use crate::utils::time::DefaultTimeProvider; use crate::utils::time::TimeProvider; use lightning::chain::chaininterface::BroadcasterInterface; -use lightning::chain::{self, BestBlock, Confirm, Filter, Listen}; -use lightning::ln::channelmanager::{AChannelManager, ChainParameters}; +use lightning::ln::channelmanager::AChannelManager; use lightning::ln::msgs::{ErrorAction, LightningError}; use lightning::ln::peer_handler::CustomMessageHandler; use lightning::ln::wire::CustomMessageReader; @@ -111,8 +110,6 @@ pub trait ALiquidityManager { type AChannelManager: AChannelManager + ?Sized; /// A type that may be dereferenced to [`Self::AChannelManager`]. type CM: Deref + Clone; - /// A type implementing [`Filter`]. - type C: Filter + Clone; /// A type implementing [`KVStore`]. type K: KVStore + Clone; /// A type implementing [`TimeProvider`]. @@ -128,7 +125,6 @@ pub trait ALiquidityManager { Self::EntropySource, Self::NodeSigner, Self::CM, - Self::C, Self::K, Self::TP, Self::BroadcasterInterface, @@ -139,11 +135,10 @@ impl< ES: EntropySource + Clone, NS: NodeSigner + Clone, CM: Deref + Clone, - C: Filter + Clone, K: KVStore + Clone, TP: Deref + Clone, T: BroadcasterInterface + Clone, - > ALiquidityManager for LiquidityManager + > ALiquidityManager for LiquidityManager where CM::Target: AChannelManager, TP::Target: TimeProvider, @@ -152,12 +147,11 @@ where type NodeSigner = NS; type AChannelManager = CM::Target; type CM = CM; - type C = C; type K = K; type TimeProvider = TP::Target; type TP = TP; type BroadcasterInterface = T; - fn get_lm(&self) -> &LiquidityManager { + fn get_lm(&self) -> &LiquidityManager { self } } @@ -175,8 +169,6 @@ pub trait ALiquidityManagerSync { type AChannelManager: AChannelManager + ?Sized; /// A type that may be dereferenced to [`Self::AChannelManager`]. type CM: Deref + Clone; - /// A type implementing [`Filter`]. - type C: Filter + Clone; /// A type implementing [`KVStoreSync`]. type KVStoreSync: KVStoreSync + ?Sized; /// A type that may be dereferenced to [`Self::KVStoreSync`]. @@ -195,7 +187,6 @@ pub trait ALiquidityManagerSync { Self::EntropySource, Self::NodeSigner, Self::CM, - Self::C, KVStoreSyncWrapper, Self::TP, Self::BroadcasterInterface, @@ -207,7 +198,6 @@ pub trait ALiquidityManagerSync { Self::EntropySource, Self::NodeSigner, Self::CM, - Self::C, Self::KS, Self::TP, Self::BroadcasterInterface, @@ -218,11 +208,10 @@ impl< ES: EntropySource + Clone, NS: NodeSigner + Clone, CM: Deref + Clone, - C: Filter + Clone, KS: Deref + Clone, TP: Deref + Clone, T: BroadcasterInterface + Clone, - > ALiquidityManagerSync for LiquidityManagerSync + > ALiquidityManagerSync for LiquidityManagerSync where CM::Target: AChannelManager, KS::Target: KVStoreSync, @@ -232,7 +221,6 @@ where type NodeSigner = NS; type AChannelManager = CM::Target; type CM = CM; - type C = C; type KVStoreSync = KS::Target; type KS = KS; type TimeProvider = TP::Target; @@ -246,14 +234,13 @@ where Self::EntropySource, Self::NodeSigner, Self::CM, - Self::C, KVStoreSyncWrapper, Self::TP, Self::BroadcasterInterface, > { &self.inner } - fn get_lm(&self) -> &LiquidityManagerSync { + fn get_lm(&self) -> &LiquidityManagerSync { self } } @@ -281,7 +268,6 @@ pub struct LiquidityManager< ES: EntropySource + Clone, NS: NodeSigner + Clone, CM: Deref + Clone, - C: Filter + Clone, K: KVStore + Clone, TP: Deref + Clone, T: BroadcasterInterface + Clone, @@ -305,8 +291,6 @@ pub struct LiquidityManager< lsps5_client_handler: Option>, service_config: Option, _client_config: Option, - best_block: RwLock>, - _chain_source: Option, pending_msgs_or_needs_persist_notifier: Arc, } @@ -315,10 +299,9 @@ impl< ES: EntropySource + Clone, NS: NodeSigner + Clone, CM: Deref + Clone, - C: Filter + Clone, K: KVStore + Clone, T: BroadcasterInterface + Clone, - > LiquidityManager + > LiquidityManager where CM::Target: AChannelManager, { @@ -326,9 +309,8 @@ where /// /// Will read persisted service states from the given [`KVStore`]. pub async fn new( - entropy_source: ES, node_signer: NS, channel_manager: CM, chain_source: Option, - chain_params: Option, kv_store: K, transaction_broadcaster: T, - service_config: Option, + entropy_source: ES, node_signer: NS, channel_manager: CM, kv_store: K, + transaction_broadcaster: T, service_config: Option, client_config: Option, ) -> Result { Self::new_with_custom_time_provider( @@ -336,8 +318,6 @@ where node_signer, channel_manager, transaction_broadcaster, - chain_source, - chain_params, kv_store, service_config, client_config, @@ -351,11 +331,10 @@ impl< ES: EntropySource + Clone, NS: NodeSigner + Clone, CM: Deref + Clone, - C: Filter + Clone, K: KVStore + Clone, TP: Deref + Clone, T: BroadcasterInterface + Clone, - > LiquidityManager + > LiquidityManager where CM::Target: AChannelManager, TP::Target: TimeProvider, @@ -370,8 +349,7 @@ where /// [`LiquidityClientConfig`] and [`LiquidityServiceConfig`]. pub async fn new_with_custom_time_provider( entropy_source: ES, node_signer: NS, channel_manager: CM, transaction_broadcaster: T, - chain_source: Option, chain_params: Option, kv_store: K, - service_config: Option, + kv_store: K, service_config: Option, client_config: Option, time_provider: TP, ) -> Result { let pending_msgs_or_needs_persist_notifier = Arc::new(Notifier::new()); @@ -517,8 +495,6 @@ where lsps5_service_handler, service_config, _client_config: client_config, - best_block: RwLock::new(chain_params.map(|chain_params| chain_params.best_block)), - _chain_source: chain_source, pending_msgs_or_needs_persist_notifier, }) } @@ -772,11 +748,10 @@ impl< ES: EntropySource + Clone, NS: NodeSigner + Clone, CM: Deref + Clone, - C: Filter + Clone, K: KVStore + Clone, TP: Deref + Clone, T: BroadcasterInterface + Clone, - > CustomMessageReader for LiquidityManager + > CustomMessageReader for LiquidityManager where CM::Target: AChannelManager, TP::Target: TimeProvider, @@ -799,11 +774,10 @@ impl< ES: EntropySource + Clone, NS: NodeSigner + Clone, CM: Deref + Clone, - C: Filter + Clone, K: KVStore + Clone, TP: Deref + Clone, T: BroadcasterInterface + Clone, - > CustomMessageHandler for LiquidityManager + > CustomMessageHandler for LiquidityManager where CM::Target: AChannelManager, TP::Target: TimeProvider, @@ -924,93 +898,12 @@ where } } -impl< - ES: EntropySource + Clone, - NS: NodeSigner + Clone, - CM: Deref + Clone, - C: Filter + Clone, - K: KVStore + Clone, - TP: Deref + Clone, - T: BroadcasterInterface + Clone, - > Listen for LiquidityManager -where - CM::Target: AChannelManager, - TP::Target: TimeProvider, -{ - fn filtered_block_connected( - &self, header: &bitcoin::block::Header, txdata: &chain::transaction::TransactionData, - height: u32, - ) { - if let Some(best_block) = self.best_block.read().unwrap().as_ref() { - assert_eq!(best_block.block_hash, header.prev_blockhash, - "Blocks must be connected in chain-order - the connected header must build on the last connected header"); - assert_eq!(best_block.height, height - 1, - "Blocks must be connected in chain-order - the connected block height must be one greater than the previous height"); - } - - self.transactions_confirmed(header, txdata, height); - self.best_block_updated(header, height); - } - - fn blocks_disconnected(&self, fork_point: BestBlock) { - if let Some(best_block) = self.best_block.write().unwrap().as_mut() { - assert!(best_block.height > fork_point.height, - "Blocks disconnected must indicate disconnection from the current best height, i.e. the new chain tip must be lower than the previous best height"); - *best_block = fork_point; - } - - // TODO: Call block_disconnected on all sub-modules that require it, e.g., LSPS1MessageHandler. - // Internally this should call transaction_unconfirmed for all transactions that were - // confirmed at a height <= the one we now disconnected. - } -} - -impl< - ES: EntropySource + Clone, - NS: NodeSigner + Clone, - CM: Deref + Clone, - C: Filter + Clone, - K: KVStore + Clone, - TP: Deref + Clone, - T: BroadcasterInterface + Clone, - > Confirm for LiquidityManager -where - CM::Target: AChannelManager, - TP::Target: TimeProvider, -{ - fn transactions_confirmed( - &self, _header: &bitcoin::block::Header, _txdata: &chain::transaction::TransactionData, - _height: u32, - ) { - // TODO: Call transactions_confirmed on all sub-modules that require it, e.g., LSPS1MessageHandler. - } - - fn transaction_unconfirmed(&self, _txid: &bitcoin::Txid) { - // TODO: Call transaction_unconfirmed on all sub-modules that require it, e.g., LSPS1MessageHandler. - // Internally this should call transaction_unconfirmed for all transactions that were - // confirmed at a height <= the one we now unconfirmed. - } - - fn best_block_updated(&self, header: &bitcoin::block::Header, height: u32) { - let new_best_block = BestBlock::new(header.block_hash(), height); - *self.best_block.write().unwrap() = Some(new_best_block); - - // TODO: Call best_block_updated on all sub-modules that require it, e.g., LSPS1MessageHandler. - } - - fn get_relevant_txids(&self) -> Vec<(bitcoin::Txid, u32, Option)> { - // TODO: Collect relevant txids from all sub-modules that, e.g., LSPS1MessageHandler. - Vec::new() - } -} - /// A synchroneous wrapper around [`LiquidityManager`] to be used in contexts where async is not /// available. pub struct LiquidityManagerSync< ES: EntropySource + Clone, NS: NodeSigner + Clone, CM: Deref + Clone, - C: Filter + Clone, KS: Deref + Clone, TP: Deref + Clone, T: BroadcasterInterface + Clone, @@ -1019,7 +912,7 @@ pub struct LiquidityManagerSync< KS::Target: KVStoreSync, TP::Target: TimeProvider, { - inner: LiquidityManager, TP, T>, + inner: LiquidityManager, TP, T>, } #[cfg(feature = "time")] @@ -1027,10 +920,9 @@ impl< ES: EntropySource + Clone, NS: NodeSigner + Clone, CM: Deref + Clone, - C: Filter + Clone, KS: Deref + Clone, T: BroadcasterInterface + Clone, - > LiquidityManagerSync + > LiquidityManagerSync where CM::Target: AChannelManager, KS::Target: KVStoreSync, @@ -1039,9 +931,8 @@ where /// /// Wraps [`LiquidityManager::new`]. pub fn new( - entropy_source: ES, node_signer: NS, channel_manager: CM, chain_source: Option, - chain_params: Option, kv_store_sync: KS, transaction_broadcaster: T, - service_config: Option, + entropy_source: ES, node_signer: NS, channel_manager: CM, kv_store_sync: KS, + transaction_broadcaster: T, service_config: Option, client_config: Option, ) -> Result { let kv_store = KVStoreSyncWrapper(kv_store_sync); @@ -1050,8 +941,6 @@ where entropy_source, node_signer, channel_manager, - chain_source, - chain_params, kv_store, transaction_broadcaster, service_config, @@ -1075,11 +964,10 @@ impl< ES: EntropySource + Clone, NS: NodeSigner + Clone, CM: Deref + Clone, - C: Filter + Clone, KS: Deref + Clone, TP: Deref + Clone, T: BroadcasterInterface + Clone, - > LiquidityManagerSync + > LiquidityManagerSync where CM::Target: AChannelManager, KS::Target: KVStoreSync, @@ -1089,9 +977,8 @@ where /// /// Wraps [`LiquidityManager::new_with_custom_time_provider`]. pub fn new_with_custom_time_provider( - entropy_source: ES, node_signer: NS, channel_manager: CM, chain_source: Option, - chain_params: Option, kv_store_sync: KS, transaction_broadcaster: T, - service_config: Option, + entropy_source: ES, node_signer: NS, channel_manager: CM, kv_store_sync: KS, + transaction_broadcaster: T, service_config: Option, client_config: Option, time_provider: TP, ) -> Result { let kv_store = KVStoreSyncWrapper(kv_store_sync); @@ -1100,8 +987,6 @@ where node_signer, channel_manager, transaction_broadcaster, - chain_source, - chain_params, kv_store, service_config, client_config, @@ -1241,11 +1126,10 @@ impl< ES: EntropySource + Clone, NS: NodeSigner + Clone, CM: Deref + Clone, - C: Filter + Clone, KS: Deref + Clone, TP: Deref + Clone, T: BroadcasterInterface + Clone, - > CustomMessageReader for LiquidityManagerSync + > CustomMessageReader for LiquidityManagerSync where CM::Target: AChannelManager, KS::Target: KVStoreSync, @@ -1264,11 +1148,10 @@ impl< ES: EntropySource + Clone, NS: NodeSigner + Clone, CM: Deref + Clone, - C: Filter + Clone, KS: Deref + Clone, TP: Deref + Clone, T: BroadcasterInterface + Clone, - > CustomMessageHandler for LiquidityManagerSync + > CustomMessageHandler for LiquidityManagerSync where CM::Target: AChannelManager, KS::Target: KVStoreSync, @@ -1302,63 +1185,3 @@ where self.inner.peer_connected(counterparty_node_id, init_msg, inbound) } } - -impl< - ES: EntropySource + Clone, - NS: NodeSigner + Clone, - CM: Deref + Clone, - C: Filter + Clone, - KS: Deref + Clone, - TP: Deref + Clone, - T: BroadcasterInterface + Clone, - > Listen for LiquidityManagerSync -where - CM::Target: AChannelManager, - KS::Target: KVStoreSync, - TP::Target: TimeProvider, -{ - fn filtered_block_connected( - &self, header: &bitcoin::block::Header, txdata: &chain::transaction::TransactionData, - height: u32, - ) { - self.inner.filtered_block_connected(header, txdata, height) - } - - fn blocks_disconnected(&self, fork_point: BestBlock) { - self.inner.blocks_disconnected(fork_point); - } -} - -impl< - ES: EntropySource + Clone, - NS: NodeSigner + Clone, - CM: Deref + Clone, - C: Filter + Clone, - KS: Deref + Clone, - TP: Deref + Clone, - T: BroadcasterInterface + Clone, - > Confirm for LiquidityManagerSync -where - CM::Target: AChannelManager, - KS::Target: KVStoreSync, - TP::Target: TimeProvider, -{ - fn transactions_confirmed( - &self, header: &bitcoin::block::Header, txdata: &chain::transaction::TransactionData, - height: u32, - ) { - self.inner.transactions_confirmed(header, txdata, height) - } - - fn transaction_unconfirmed(&self, txid: &bitcoin::Txid) { - self.inner.transaction_unconfirmed(txid) - } - - fn best_block_updated(&self, header: &bitcoin::block::Header, height: u32) { - self.inner.best_block_updated(header, height) - } - - fn get_relevant_txids(&self) -> Vec<(bitcoin::Txid, u32, Option)> { - self.inner.get_relevant_txids() - } -} diff --git a/lightning-liquidity/tests/common/mod.rs b/lightning-liquidity/tests/common/mod.rs index dea987527ad..2716df7c0a3 100644 --- a/lightning-liquidity/tests/common/mod.rs +++ b/lightning-liquidity/tests/common/mod.rs @@ -3,13 +3,9 @@ use lightning_liquidity::utils::time::TimeProvider; use lightning_liquidity::{LiquidityClientConfig, LiquidityManagerSync, LiquidityServiceConfig}; -use lightning::chain::{BestBlock, Filter}; -use lightning::ln::channelmanager::ChainParameters; use lightning::ln::functional_test_utils::{Node, TestChannelManager}; use lightning::util::test_utils::{TestBroadcaster, TestKeysInterface, TestStore}; -use bitcoin::Network; - use core::ops::Deref; use std::sync::Arc; @@ -26,11 +22,6 @@ fn build_service_and_client_nodes<'a, 'b, 'c>( ) -> (LiquidityNode<'a, 'b, 'c>, LiquidityNode<'a, 'b, 'c>, Option>) { assert!(nodes.len() >= 2, "Need at least two nodes (service and client)"); - let chain_params = ChainParameters { - network: Network::Testnet, - best_block: BestBlock::from_network(Network::Testnet), - }; - let mut nodes_iter = nodes.into_iter(); let service_inner = nodes_iter.next().expect("missing service node"); let client_inner = nodes_iter.next().expect("missing client node"); @@ -40,8 +31,6 @@ fn build_service_and_client_nodes<'a, 'b, 'c>( service_inner.keys_manager, service_inner.keys_manager, service_inner.node, - None::>, - Some(chain_params.clone()), service_kv_store, service_inner.tx_broadcaster, Some(service_config), @@ -54,8 +43,6 @@ fn build_service_and_client_nodes<'a, 'b, 'c>( client_inner.keys_manager, client_inner.keys_manager, client_inner.node, - None::>, - Some(chain_params), client_kv_store, client_inner.tx_broadcaster, None, @@ -137,7 +124,6 @@ pub(crate) struct LiquidityNode<'a, 'b, 'c> { &'c TestKeysInterface, &'c TestKeysInterface, &'a TestChannelManager<'b, 'c>, - Arc, Arc, Arc, &'c TestBroadcaster, @@ -151,7 +137,6 @@ impl<'a, 'b, 'c> LiquidityNode<'a, 'b, 'c> { &'c TestKeysInterface, &'c TestKeysInterface, &'a TestChannelManager<'b, 'c>, - Arc, Arc, Arc, &'c TestBroadcaster, diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 33a6dd697cf..1c37f164d32 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -27,8 +27,7 @@ use lightning_liquidity::lsps2::utils::is_valid_opening_fee_params; use lightning_liquidity::utils::time::{DefaultTimeProvider, TimeProvider}; use lightning_liquidity::{LiquidityClientConfig, LiquidityManagerSync, LiquidityServiceConfig}; -use lightning::chain::{BestBlock, Filter}; -use lightning::ln::channelmanager::{ChainParameters, InterceptId, MIN_FINAL_CLTV_EXPIRY_DELTA}; +use lightning::ln::channelmanager::{InterceptId, MIN_FINAL_CLTV_EXPIRY_DELTA}; use lightning::ln::functional_test_utils::{ create_chanmon_cfgs, create_node_cfgs, create_node_chanmgrs, }; @@ -1071,19 +1070,12 @@ fn lsps2_service_handler_persistence_across_restarts() { let nodes_restart = create_network(2, &node_cfgs, &node_chanmgrs_restart); // Create a new LiquidityManager with the same configuration and KV store to simulate restart - let chain_params = ChainParameters { - network: Network::Testnet, - best_block: BestBlock::from_network(Network::Testnet), - }; - let transaction_broadcaster = Arc::new(TestBroadcaster::new(Network::Testnet)); let restarted_service_lm = LiquidityManagerSync::new_with_custom_time_provider( nodes_restart[0].keys_manager, nodes_restart[0].keys_manager, nodes_restart[0].node, - None::>, - Some(chain_params), service_kv_store, transaction_broadcaster, Some(service_config), diff --git a/lightning-liquidity/tests/lsps5_integration_tests.rs b/lightning-liquidity/tests/lsps5_integration_tests.rs index 16f20fd095f..6af0c137be5 100644 --- a/lightning-liquidity/tests/lsps5_integration_tests.rs +++ b/lightning-liquidity/tests/lsps5_integration_tests.rs @@ -7,9 +7,8 @@ use common::{ get_lsps_message, LSPSNodes, LiquidityNode, }; -use lightning::chain::{BestBlock, Filter}; use lightning::events::ClosureReason; -use lightning::ln::channelmanager::{ChainParameters, InterceptId}; +use lightning::ln::channelmanager::InterceptId; use lightning::ln::functional_test_utils::{ check_closed_event, close_channel, create_chan_between_nodes, create_chanmon_cfgs, create_network, create_node_cfgs, create_node_chanmgrs, Node, @@ -43,8 +42,6 @@ use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig}; use lightning_types::payment::PaymentHash; -use bitcoin::Network; - use std::str::FromStr; use std::sync::{Arc, RwLock}; use std::time::Duration; @@ -1601,18 +1598,10 @@ fn lsps5_service_handler_persistence_across_restarts() { let node_chanmgrs_restart = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes_restart = create_network(2, &node_cfgs, &node_chanmgrs_restart); - // Create a new LiquidityManager with the same configuration and KV store to simulate restart - let chain_params = ChainParameters { - network: Network::Testnet, - best_block: BestBlock::from_network(Network::Testnet), - }; - let restarted_service_lm = LiquidityManagerSync::new_with_custom_time_provider( nodes_restart[0].keys_manager, nodes_restart[0].keys_manager, nodes_restart[0].node, - None::>, - Some(chain_params), service_kv_store, nodes_restart[0].tx_broadcaster, Some(service_config), From c6465f2ff573fe762fc368354895f02688e85018 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Sun, 16 Nov 2025 12:44:55 +0100 Subject: [PATCH 117/627] Move `PeerState` and related types to `peer_state.rs` module We move the `PeerState` related types to a new module. In the following commits we'll bit-by-bit drop the `pub(super)`s introduced here, asserting better separation of state and logic going forward. --- lightning-liquidity/src/lsps1/mod.rs | 2 + lightning-liquidity/src/lsps1/peer_state.rs | 84 +++++++++++++++++++++ lightning-liquidity/src/lsps1/service.rs | 65 +--------------- 3 files changed, 87 insertions(+), 64 deletions(-) create mode 100644 lightning-liquidity/src/lsps1/peer_state.rs diff --git a/lightning-liquidity/src/lsps1/mod.rs b/lightning-liquidity/src/lsps1/mod.rs index b068b186610..bdfc4045f54 100644 --- a/lightning-liquidity/src/lsps1/mod.rs +++ b/lightning-liquidity/src/lsps1/mod.rs @@ -13,4 +13,6 @@ pub mod client; pub mod event; pub mod msgs; #[cfg(lsps1_service)] +mod peer_state; +#[cfg(lsps1_service)] pub mod service; diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs new file mode 100644 index 00000000000..71eeb662120 --- /dev/null +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -0,0 +1,84 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Contains peer state objects that are used by `LSPS1ServiceHandler`. + +use super::msgs::{LSPS1OrderId, LSPS1OrderParams, LSPS1PaymentInfo, LSPS1Request}; + +use crate::lsps0::ser::{LSPSDateTime, LSPSRequestId}; +use crate::prelude::HashMap; + +use lightning::ln::msgs::{ErrorAction, LightningError}; +use lightning::util::logger::Level; + +#[derive(Default)] +pub(super) struct PeerState { + pub(super) outbound_channels_by_order_id: HashMap, + pub(super) pending_requests: HashMap, +} + +impl PeerState { + pub(super) fn insert_outbound_channel( + &mut self, order_id: LSPS1OrderId, channel: OutboundCRChannel, + ) { + self.outbound_channels_by_order_id.insert(order_id, channel); + } +} + +struct ChannelStateError(String); + +impl From for LightningError { + fn from(value: ChannelStateError) -> Self { + LightningError { err: value.0, action: ErrorAction::IgnoreAndLog(Level::Info) } + } +} + +#[derive(PartialEq, Debug)] +pub(super) enum OutboundRequestState { + OrderCreated { order_id: LSPS1OrderId }, + WaitingPayment { order_id: LSPS1OrderId }, +} + +impl OutboundRequestState { + fn awaiting_payment(&self) -> Result { + match self { + OutboundRequestState::OrderCreated { order_id } => { + Ok(OutboundRequestState::WaitingPayment { order_id: order_id.clone() }) + }, + state => Err(ChannelStateError(format!("TODO. JIT Channel was in state: {:?}", state))), + } + } +} + +pub(super) struct OutboundLSPS1Config { + pub(super) order: LSPS1OrderParams, + pub(super) created_at: LSPSDateTime, + pub(super) payment: LSPS1PaymentInfo, +} + +pub(super) struct OutboundCRChannel { + pub(super) state: OutboundRequestState, + pub(super) config: OutboundLSPS1Config, +} + +impl OutboundCRChannel { + pub(super) fn new( + order: LSPS1OrderParams, created_at: LSPSDateTime, order_id: LSPS1OrderId, + payment: LSPS1PaymentInfo, + ) -> Self { + Self { + state: OutboundRequestState::OrderCreated { order_id }, + config: OutboundLSPS1Config { order, created_at, payment }, + } + } + pub(super) fn awaiting_payment(&mut self) -> Result<(), LightningError> { + self.state = self.state.awaiting_payment()?; + Ok(()) + } +} diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 7d138e3b2c7..ac97b614855 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -20,6 +20,7 @@ use super::msgs::{ LSPS1OrderState, LSPS1PaymentInfo, LSPS1Request, LSPS1Response, LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE, }; +use super::peer_state::{OutboundCRChannel, PeerState}; use crate::message_queue::MessageQueue; use crate::events::EventQueue; @@ -48,70 +49,6 @@ pub struct LSPS1ServiceConfig { pub supported_options: Option, } -struct ChannelStateError(String); - -impl From for LightningError { - fn from(value: ChannelStateError) -> Self { - LightningError { err: value.0, action: ErrorAction::IgnoreAndLog(Level::Info) } - } -} - -#[derive(PartialEq, Debug)] -enum OutboundRequestState { - OrderCreated { order_id: LSPS1OrderId }, - WaitingPayment { order_id: LSPS1OrderId }, -} - -impl OutboundRequestState { - fn awaiting_payment(&self) -> Result { - match self { - OutboundRequestState::OrderCreated { order_id } => { - Ok(OutboundRequestState::WaitingPayment { order_id: order_id.clone() }) - }, - state => Err(ChannelStateError(format!("TODO. JIT Channel was in state: {:?}", state))), - } - } -} - -struct OutboundLSPS1Config { - order: LSPS1OrderParams, - created_at: LSPSDateTime, - payment: LSPS1PaymentInfo, -} - -struct OutboundCRChannel { - state: OutboundRequestState, - config: OutboundLSPS1Config, -} - -impl OutboundCRChannel { - fn new( - order: LSPS1OrderParams, created_at: LSPSDateTime, order_id: LSPS1OrderId, - payment: LSPS1PaymentInfo, - ) -> Self { - Self { - state: OutboundRequestState::OrderCreated { order_id }, - config: OutboundLSPS1Config { order, created_at, payment }, - } - } - fn awaiting_payment(&mut self) -> Result<(), LightningError> { - self.state = self.state.awaiting_payment()?; - Ok(()) - } -} - -#[derive(Default)] -struct PeerState { - outbound_channels_by_order_id: HashMap, - pending_requests: HashMap, -} - -impl PeerState { - fn insert_outbound_channel(&mut self, order_id: LSPS1OrderId, channel: OutboundCRChannel) { - self.outbound_channels_by_order_id.insert(order_id, channel); - } -} - /// The main object allowing to send and receive bLIP-51 / LSPS1 messages. pub struct LSPS1ServiceHandler where From fa867c27b2ca6723f7190604cf62c676aac83fc3 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Sun, 16 Nov 2025 13:07:09 +0100 Subject: [PATCH 118/627] Drop bogus channel state handling .. we will re-add a proper state machine in a later commit, but for now we can just drop all of this half-baked logic that doesn't actually do anything. --- lightning-liquidity/src/lsps1/peer_state.rs | 41 +-------------------- lightning-liquidity/src/lsps1/service.rs | 23 ------------ 2 files changed, 2 insertions(+), 62 deletions(-) diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 71eeb662120..3e9d17f4c73 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -14,9 +14,6 @@ use super::msgs::{LSPS1OrderId, LSPS1OrderParams, LSPS1PaymentInfo, LSPS1Request use crate::lsps0::ser::{LSPSDateTime, LSPSRequestId}; use crate::prelude::HashMap; -use lightning::ln::msgs::{ErrorAction, LightningError}; -use lightning::util::logger::Level; - #[derive(Default)] pub(super) struct PeerState { pub(super) outbound_channels_by_order_id: HashMap, @@ -31,31 +28,6 @@ impl PeerState { } } -struct ChannelStateError(String); - -impl From for LightningError { - fn from(value: ChannelStateError) -> Self { - LightningError { err: value.0, action: ErrorAction::IgnoreAndLog(Level::Info) } - } -} - -#[derive(PartialEq, Debug)] -pub(super) enum OutboundRequestState { - OrderCreated { order_id: LSPS1OrderId }, - WaitingPayment { order_id: LSPS1OrderId }, -} - -impl OutboundRequestState { - fn awaiting_payment(&self) -> Result { - match self { - OutboundRequestState::OrderCreated { order_id } => { - Ok(OutboundRequestState::WaitingPayment { order_id: order_id.clone() }) - }, - state => Err(ChannelStateError(format!("TODO. JIT Channel was in state: {:?}", state))), - } - } -} - pub(super) struct OutboundLSPS1Config { pub(super) order: LSPS1OrderParams, pub(super) created_at: LSPSDateTime, @@ -63,22 +35,13 @@ pub(super) struct OutboundLSPS1Config { } pub(super) struct OutboundCRChannel { - pub(super) state: OutboundRequestState, pub(super) config: OutboundLSPS1Config, } impl OutboundCRChannel { pub(super) fn new( - order: LSPS1OrderParams, created_at: LSPSDateTime, order_id: LSPS1OrderId, - payment: LSPS1PaymentInfo, + order: LSPS1OrderParams, created_at: LSPSDateTime, payment: LSPS1PaymentInfo, ) -> Self { - Self { - state: OutboundRequestState::OrderCreated { order_id }, - config: OutboundLSPS1Config { order, created_at, payment }, - } - } - pub(super) fn awaiting_payment(&mut self) -> Result<(), LightningError> { - self.state = self.state.awaiting_payment()?; - Ok(()) + Self { config: OutboundLSPS1Config { order, created_at, payment } } } } diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index ac97b614855..df9d9f02894 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -193,7 +193,6 @@ where let channel = OutboundCRChannel::new( params.order.clone(), created_at, - order_id.clone(), payment.clone(), ); @@ -232,28 +231,6 @@ where match outer_state_lock.get(counterparty_node_id) { Some(inner_state_lock) => { let mut peer_state_lock = inner_state_lock.lock().unwrap(); - - let outbound_channel = peer_state_lock - .outbound_channels_by_order_id - .get_mut(¶ms.order_id) - .ok_or(LightningError { - err: format!( - "Received get order request for unknown order id {:?}", - params.order_id - ), - action: ErrorAction::IgnoreAndLog(Level::Info), - })?; - - if let Err(e) = outbound_channel.awaiting_payment() { - peer_state_lock.outbound_channels_by_order_id.remove(¶ms.order_id); - event_queue_notifier.enqueue(LSPS1ServiceEvent::Refund { - request_id, - counterparty_node_id: *counterparty_node_id, - order_id: params.order_id, - }); - return Err(e); - } - peer_state_lock .pending_requests .insert(request_id.clone(), LSPS1Request::GetOrder(params.clone())); From 0c8e26a32969f5a7a30b6dee732a9e38f846d243 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Sun, 16 Nov 2025 13:01:51 +0100 Subject: [PATCH 119/627] Replace `insert_outbound_channel` with `PeerState::new_order` .. requiring less access to internals --- lightning-liquidity/src/lsps1/peer_state.rs | 7 +++++-- lightning-liquidity/src/lsps1/service.rs | 8 ++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 3e9d17f4c73..729d6827330 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -21,9 +21,12 @@ pub(super) struct PeerState { } impl PeerState { - pub(super) fn insert_outbound_channel( - &mut self, order_id: LSPS1OrderId, channel: OutboundCRChannel, + pub(super) fn new_order( + &mut self, order_id: LSPS1OrderId, order_params: LSPS1OrderParams, + created_at: LSPSDateTime, payment_details: LSPS1PaymentInfo, ) { + let channel = OutboundCRChannel::new(order_params, created_at, payment_details); + self.outbound_channels_by_order_id.insert(order_id, channel); } } diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index df9d9f02894..bda7d6125dd 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -20,7 +20,7 @@ use super::msgs::{ LSPS1OrderState, LSPS1PaymentInfo, LSPS1Request, LSPS1Response, LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE, }; -use super::peer_state::{OutboundCRChannel, PeerState}; +use super::peer_state::PeerState; use crate::message_queue::MessageQueue; use crate::events::EventQueue; @@ -190,14 +190,14 @@ where match peer_state_lock.pending_requests.remove(&request_id) { Some(LSPS1Request::CreateOrder(params)) => { let order_id = self.generate_order_id(); - let channel = OutboundCRChannel::new( + + peer_state_lock.new_order( + order_id.clone(), params.order.clone(), created_at, payment.clone(), ); - peer_state_lock.insert_outbound_channel(order_id.clone(), channel); - let response = LSPS1Response::CreateOrder(LSPS1CreateOrderResponse { order: params.order, order_id, From 6a43a45126e23ba84acb57981d809060aa9b75c6 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Wed, 25 Feb 2026 13:05:07 -0600 Subject: [PATCH 120/627] Propagate unexpected metadata errors when preserving mtime in fs_store Previously, all fs::metadata errors were silently ignored via .ok(), which could hide permission or I/O errors. Now error are properly handled. --- lightning-persister/src/fs_store/common.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lightning-persister/src/fs_store/common.rs b/lightning-persister/src/fs_store/common.rs index f2f5eeb8e2c..7aef941f704 100644 --- a/lightning-persister/src/fs_store/common.rs +++ b/lightning-persister/src/fs_store/common.rs @@ -9,7 +9,7 @@ use lightning::types::string::PrintableString; use std::collections::HashMap; use std::fs; -use std::io::{Read, Write}; +use std::io::{ErrorKind, Read, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, RwLock}; @@ -253,7 +253,11 @@ impl FilesystemStoreInner { version: u64, preserve_mtime: bool, ) -> lightning::io::Result<()> { let mtime = if preserve_mtime { - fs::metadata(&dest_file_path).ok().and_then(|m| m.modified().ok()) + match fs::metadata(&dest_file_path) { + Err(e) if e.kind() == ErrorKind::NotFound => None, + Err(e) => return Err(e.into()), + Ok(m) => Some(m.modified()?), + } } else { None }; From 0723ffdfa535a3f700fb744b38c98b31116b9e61 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Wed, 25 Feb 2026 13:08:56 -0600 Subject: [PATCH 121/627] Get rid of unnecessary clone when constructing page token --- lightning-persister/src/fs_store/v2.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lightning-persister/src/fs_store/v2.rs b/lightning-persister/src/fs_store/v2.rs index 7cff1d35313..426038722dd 100644 --- a/lightning-persister/src/fs_store/v2.rs +++ b/lightning-persister/src/fs_store/v2.rs @@ -146,8 +146,6 @@ impl FilesystemStoreState { let page_entries: Vec<_> = entries.iter().skip(start_idx).take(PAGE_SIZE).cloned().collect(); - let keys: Vec = page_entries.iter().map(|(_, key)| key.clone()).collect(); - // Determine next page token let next_page_token = if start_idx + PAGE_SIZE < entries.len() { page_entries.last().map(|(mtime, key)| PageToken::new(format_page_token(*mtime, key))) @@ -155,6 +153,8 @@ impl FilesystemStoreState { None }; + let keys: Vec = page_entries.into_iter().map(|(_, key)| key).collect(); + Ok(PaginatedListResponse { keys, next_page_token }) } } From f784731e8936994828ddc1572efb8c0dde71a72e Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Sun, 22 Feb 2026 06:05:47 +0000 Subject: [PATCH 122/627] Check that funder covers the fee spike buffer multiple after a splice We do this for HTLCs, so we should also do this for splices. This only applies to channels with non-zero-fee HTLC transactions. --- .../src/upgrade_downgrade_tests.rs | 3 +- lightning/src/ln/async_signer_tests.rs | 2 +- lightning/src/ln/channel.rs | 14 +- lightning/src/ln/splicing_tests.rs | 348 ++++++++++++++++-- lightning/src/sign/tx_builder.rs | 1 + 5 files changed, 330 insertions(+), 38 deletions(-) diff --git a/lightning-tests/src/upgrade_downgrade_tests.rs b/lightning-tests/src/upgrade_downgrade_tests.rs index 93d671b176d..f68615dbb87 100644 --- a/lightning-tests/src/upgrade_downgrade_tests.rs +++ b/lightning-tests/src/upgrade_downgrade_tests.rs @@ -457,7 +457,8 @@ fn do_test_0_1_htlc_forward_after_splice(fail_htlc: bool) { script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]; let channel_id = ChannelId(chan_id_bytes_a); - let funding_contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs); + let funding_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); for node in nodes.iter() { mine_transaction(node, &splice_tx); diff --git a/lightning/src/ln/async_signer_tests.rs b/lightning/src/ln/async_signer_tests.rs index e6cd197bf1e..451af3918bf 100644 --- a/lightning/src/ln/async_signer_tests.rs +++ b/lightning/src/ln/async_signer_tests.rs @@ -1576,7 +1576,7 @@ fn test_async_splice_initial_commit_sig() { value: Amount::from_sat(1_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]; - let contribution = initiate_splice_out(initiator, acceptor, channel_id, outputs); + let contribution = initiate_splice_out(initiator, acceptor, channel_id, outputs).unwrap(); negotiate_splice_tx(initiator, acceptor, channel_id, contribution); assert!(initiator.node.get_and_clear_pending_msg_events().is_empty()); diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 2bea5aa19b9..7fc1b346dff 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -5469,6 +5469,7 @@ impl ChannelContext { } else { 1 }; + // Note that the feerate is 0 in zero-fee commitment channels, so this statement is a noop let spiked_feerate = feerate * fee_spike_multiple; let (remote_stats, _remote_htlcs) = self .get_next_remote_commitment_stats( @@ -12401,6 +12402,15 @@ where // We are not interested in dust exposure let dust_exposure_limiting_feerate = None; + // Note that the feerate is 0 in zero-fee commitment channels, so this statement is a noop + let feerate_per_kw = if !funding.get_channel_type().supports_anchors_zero_fee_htlc_tx() { + // Similar to HTLC additions, require the funder to have enough funds reserved for + // fees such that the feerate can jump without rendering the channel useless. + self.context.feerate_per_kw * FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 + } else { + self.context.feerate_per_kw + }; + let (local_stats, _local_htlcs) = self .context .get_next_local_commitment_stats( @@ -12408,7 +12418,7 @@ where None, // htlc_candidate include_counterparty_unknown_htlcs, addl_nondust_htlc_count, - self.context.feerate_per_kw, + feerate_per_kw, dust_exposure_limiting_feerate, ) .map_err(|()| "Balance exhausted on local commitment")?; @@ -12420,7 +12430,7 @@ where None, // htlc_candidate include_counterparty_unknown_htlcs, addl_nondust_htlc_count, - self.context.feerate_per_kw, + feerate_per_kw, dust_exposure_limiting_feerate, ) .map_err(|()| "Balance exhausted on remote commitment")?; diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index ab890fdbab7..f7c4700c8d7 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -15,7 +15,9 @@ use crate::chain::transaction::OutPoint; use crate::chain::ChannelMonitorUpdateStatus; use crate::events::{ClosureReason, Event, FundingInfo, HTLCHandlingFailureType}; use crate::ln::chan_utils; -use crate::ln::channel::CHANNEL_ANNOUNCEMENT_PROPAGATION_DELAY; +use crate::ln::channel::{ + CHANNEL_ANNOUNCEMENT_PROPAGATION_DELAY, FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE, +}; use crate::ln::channelmanager::{provided_init_features, PaymentId, BREAKDOWN_TIMEOUT}; use crate::ln::functional_test_utils::*; use crate::ln::funding::FundingContribution; @@ -23,6 +25,8 @@ use crate::ln::msgs::{self, BaseMessageHandler, ChannelMessageHandler, MessageSe use crate::ln::outbound_payment::RecipientOnionFields; use crate::ln::types::ChannelId; use crate::routing::router::{PaymentParameters, RouteParameters}; +use crate::types::features::ChannelTypeFeatures; +use crate::util::config::UserConfig; use crate::util::errors::APIError; use crate::util::ser::Writeable; use crate::util::wallet_utils::{WalletSourceSync, WalletSync}; @@ -154,18 +158,25 @@ pub fn do_initiate_splice_in<'a, 'b, 'c, 'd>( pub fn initiate_splice_out<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, outputs: Vec, -) -> FundingContribution { +) -> Result { let node_id_acceptor = acceptor.node.get_our_node_id(); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); let funding_template = initiator.node.splice_channel(&channel_id, &node_id_acceptor, feerate).unwrap(); let wallet = WalletSync::new(Arc::clone(&initiator.wallet_source), initiator.logger); let funding_contribution = funding_template.splice_out_sync(outputs, &wallet).unwrap(); - initiator - .node - .funding_contributed(&channel_id, &node_id_acceptor, funding_contribution.clone(), None) - .unwrap(); - funding_contribution + match initiator.node.funding_contributed( + &channel_id, + &node_id_acceptor, + funding_contribution.clone(), + None, + ) { + Ok(()) => Ok(funding_contribution), + Err(e) => { + expect_splice_failed_events(initiator, &channel_id, funding_contribution); + Err(e) + }, + } } pub fn initiate_splice_in_and_out<'a, 'b, 'c, 'd>( @@ -225,26 +236,29 @@ pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>( let node_id_initiator = initiator.node.get_our_node_id(); let node_id_acceptor = acceptor.node.get_our_node_id(); - let funding_outpoint = initiator + let (funding_outpoint, channel_value_satoshis) = initiator .node .list_channels() .iter() .find(|channel| { channel.counterparty.node_id == node_id_acceptor && channel.channel_id == channel_id }) - .map(|channel| channel.funding_txo.unwrap()) + .map(|channel| (channel.funding_txo.unwrap(), channel.channel_value_satoshis)) .unwrap(); - let (initiator_inputs, initiator_outputs) = initiator_contribution.into_tx_parts(); - let mut expected_initiator_inputs = initiator_inputs + let new_channel_value = Amount::from_sat( + channel_value_satoshis + .checked_add_signed(initiator_contribution.net_value().to_sat()) + .unwrap(), + ); + let (initiator_funding_tx_inputs, mut expected_initiator_outputs) = + initiator_contribution.into_tx_parts(); + let mut expected_initiator_inputs = initiator_funding_tx_inputs .iter() .map(|input| input.utxo.outpoint) .chain(core::iter::once(funding_outpoint.into_bitcoin_outpoint())) .collect::>(); - let mut expected_initiator_scripts = initiator_outputs - .into_iter() - .map(|output| output.script_pubkey) - .chain(core::iter::once(new_funding_script)) - .collect::>(); + expected_initiator_outputs + .push(TxOut { script_pubkey: new_funding_script, value: new_channel_value }); let mut acceptor_sent_tx_complete = false; loop { @@ -264,13 +278,16 @@ pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>( expected_initiator_inputs.iter().position(|input| *input == input_prevout).unwrap(), ); acceptor.node.handle_tx_add_input(node_id_initiator, &tx_add_input); - } else if !expected_initiator_scripts.is_empty() { + } else if !expected_initiator_outputs.is_empty() { let tx_add_output = get_event_msg!(initiator, MessageSendEvent::SendTxAddOutput, node_id_acceptor); - expected_initiator_scripts.remove( - expected_initiator_scripts + expected_initiator_outputs.remove( + expected_initiator_outputs .iter() - .position(|script| *script == tx_add_output.script) + .position(|output| { + *output.script_pubkey == tx_add_output.script + && output.value.to_sat() == tx_add_output.sats + }) .unwrap(), ); acceptor.node.handle_tx_add_output(node_id_initiator, &tx_add_output); @@ -552,7 +569,7 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]; let funding_contribution = - initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()).unwrap(); // Attempt a splice negotiation that only goes up to receiving `splice_init`. Reconnecting // should implicitly abort the negotiation and reset the splice state such that we're able to @@ -598,7 +615,7 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { reconnect_nodes(reconnect_args); let funding_contribution = - initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()).unwrap(); // Attempt a splice negotiation that ends mid-construction of the funding transaction. // Reconnecting should implicitly abort the negotiation and reset the splice state such that @@ -649,7 +666,7 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { reconnect_nodes(reconnect_args); let funding_contribution = - initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()).unwrap(); // Attempt a splice negotiation that ends before the initial `commitment_signed` messages are // exchanged. The node missing the other's `commitment_signed` upon reconnecting should @@ -727,7 +744,8 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { // Attempt a splice negotiation that completes, (i.e. `tx_signatures` are exchanged). Reconnecting // should not abort the negotiation or reset the splice state. - let funding_contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs); + let funding_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); if reload { @@ -785,7 +803,7 @@ fn test_config_reject_inbound_splices() { script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]; let funding_contribution = - initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()).unwrap(); let stfu = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); nodes[1].node.handle_stfu(node_id_0, &stfu); @@ -813,7 +831,8 @@ fn test_config_reject_inbound_splices() { reconnect_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_args); - let funding_contribution = initiate_splice_out(&nodes[1], &nodes[0], channel_id, outputs); + let funding_contribution = + initiate_splice_out(&nodes[1], &nodes[0], channel_id, outputs).unwrap(); let _ = splice_channel(&nodes[1], &nodes[0], channel_id, funding_contribution); } @@ -892,7 +911,8 @@ fn test_splice_out() { script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), }, ]; - let funding_contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs); + let funding_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); mine_transaction(&nodes[0], &splice_tx); @@ -1441,7 +1461,8 @@ fn do_test_splice_reestablish(reload: bool, async_monitor_update: bool) { script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), }, ]; - let initiator_contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs); + let initiator_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); negotiate_splice_tx(&nodes[0], &nodes[1], channel_id, initiator_contribution); // Node 0 should have a signing event to handle since they had a contribution in the splice. @@ -2154,7 +2175,8 @@ fn fail_splice_on_tx_complete_error() { value: Amount::from_sat(1_000), script_pubkey: acceptor.wallet_source.get_change_script().unwrap(), }]; - let funding_contribution = initiate_splice_out(initiator, acceptor, channel_id, outputs); + let funding_contribution = + initiate_splice_out(initiator, acceptor, channel_id, outputs).unwrap(); let _ = complete_splice_handshake(initiator, acceptor); // Queue an outgoing HTLC to the holding cell. It should be freed once we exit quiescence. @@ -2239,7 +2261,7 @@ fn free_holding_cell_on_tx_signatures_quiescence_exit() { value: Amount::from_sat(1_000), script_pubkey: initiator.wallet_source.get_change_script().unwrap(), }]; - let contribution = initiate_splice_out(initiator, acceptor, channel_id, outputs); + let contribution = initiate_splice_out(initiator, acceptor, channel_id, outputs).unwrap(); negotiate_splice_tx(initiator, acceptor, channel_id, contribution); // Queue an outgoing HTLC to the holding cell. It should be freed once we exit quiescence. @@ -2518,7 +2540,8 @@ fn do_test_splice_with_inflight_htlc_forward_and_resolution(expire_scid_pre_forw value: Amount::from_sat(1_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]; - let contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id_0_1, outputs_0_1); + let contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id_0_1, outputs_0_1).unwrap(); let (splice_tx_0_1, _) = splice_channel(&nodes[0], &nodes[1], channel_id_0_1, contribution); for node in &nodes { mine_transaction(node, &splice_tx_0_1); @@ -2528,7 +2551,8 @@ fn do_test_splice_with_inflight_htlc_forward_and_resolution(expire_scid_pre_forw value: Amount::from_sat(1_000), script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), }]; - let contribution = initiate_splice_out(&nodes[1], &nodes[2], channel_id_1_2, outputs_1_2); + let contribution = + initiate_splice_out(&nodes[1], &nodes[2], channel_id_1_2, outputs_1_2).unwrap(); let (splice_tx_1_2, _) = splice_channel(&nodes[1], &nodes[2], channel_id_1_2, contribution); for node in &nodes { mine_transaction(node, &splice_tx_1_2); @@ -2636,7 +2660,8 @@ fn test_splice_buffer_commitment_signed_until_funding_tx_signed() { value: Amount::from_sat(1_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]; - let initiator_contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs); + let initiator_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); negotiate_splice_tx(&nodes[0], &nodes[1], channel_id, initiator_contribution); // Node 0 (initiator with contribution) should have a signing event to handle. @@ -2757,7 +2782,8 @@ fn test_splice_buffer_invalid_commitment_signed_closes_channel() { value: Amount::from_sat(1_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]; - let initiator_contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs); + let initiator_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); negotiate_splice_tx(&nodes[0], &nodes[1], channel_id, initiator_contribution); // Node 0 (initiator with contribution) should have a signing event to handle. @@ -3350,3 +3376,257 @@ fn test_funding_contributed_unfunded_channel() { expect_discard_funding_event(&nodes[0], &unfunded_channel_id, funding_contribution); } + +#[test] +fn test_splice_pending_htlcs() { + let mut config = test_default_channel_config(); + config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + do_test_splice_pending_htlcs(config); + + let mut config = test_default_channel_config(); + config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + do_test_splice_pending_htlcs(config); + + let mut config = test_default_channel_config(); + config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = true; + do_test_splice_pending_htlcs(config); +} + +#[cfg(test)] +fn do_test_splice_pending_htlcs(config: UserConfig) { + // Test balance checks for inbound and outbound splice-outs while there are pending HTLCs in the channel. + // The channel fundee requests unaffordable splice-outs in the first section, while the channel funder does so + // in the second section. + let anchors_features = ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies(); + let initial_channel_value = Amount::from_sat(100_000); + let push_amount = Amount::from_sat(10_000); + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value( + &nodes, + 0, + 1, + initial_channel_value.to_sat(), + push_amount.to_sat() * 1000, + ); + + let details = &nodes[0].node.list_channels()[0]; + let channel_type = details.channel_type.clone().unwrap(); + let feerate_per_kw = details.feerate_sat_per_1000_weight.unwrap(); + 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; + + // Place some pending HTLCs in the channel, in both directions. + let (preimage_1_to_0_a, _hash_1_to_0, ..) = route_payment(&nodes[1], &[&nodes[0]], 2_000_000); + let (preimage_1_to_0_b, _hash_1_to_0, ..) = route_payment(&nodes[1], &[&nodes[0]], 2_000_000); + let (preimage_1_to_0_c, _hash_1_to_0, ..) = route_payment(&nodes[1], &[&nodes[0]], 2_000_000); + let (preimage_0_to_1_a, _hash_0_to_1, ..) = route_payment(&nodes[0], &[&nodes[1]], 40_000_000); + let (preimage_0_to_1_b, _hash_0_to_1, ..) = route_payment(&nodes[0], &[&nodes[1]], 40_000_000); + + let splice_out_dance = |initiator: usize, + acceptor: usize, + // We will setup the channel such that splicing out an additional satoshi + // overdraws the initiator's balance. + splice_out: Amount, + splice_out_incl_fees: Amount, + post_splice_reserve: Amount| + -> FundingContribution { + let initiator = &nodes[initiator]; + let acceptor = &nodes[acceptor]; + let node_id_initiator = initiator.node.get_our_node_id(); + let node_id_acceptor = acceptor.node.get_our_node_id(); + + // 1) Check that splicing out an additional satoshi fails validation on the sender's side. + + let script_pubkey = initiator.wallet_source.get_change_script().unwrap(); + let outputs = vec![TxOut { value: splice_out + Amount::ONE_SAT, script_pubkey }]; + let error = initiate_splice_out(initiator, acceptor, channel_id, outputs).unwrap_err(); + let cannot_accept_contribution = + format!("Channel {} cannot accept funding contribution", channel_id); + assert_eq!(error, APIError::APIMisuseError { err: cannot_accept_contribution }); + let cannot_be_funded = format!( + "Channel {} cannot be funded: Channel {} cannot be spliced out; our post-splice channel balance {} is smaller than their selected v2 reserve {}", + channel_id, channel_id, post_splice_reserve - Amount::ONE_SAT, post_splice_reserve + ); + initiator.logger.assert_log("lightning::ln::channel", cannot_be_funded, 1); + + // 2) Check that splicing out with the additional satoshi removed passes validation on the sender's side. + + let script_pubkey = initiator.wallet_source.get_change_script().unwrap(); + let outputs = vec![TxOut { value: splice_out, script_pubkey }]; + let contribution = + initiate_splice_out(initiator, acceptor, channel_id, outputs.clone()).unwrap(); + assert_eq!(contribution.net_value(), -splice_out_incl_fees.to_signed().unwrap()); + + let stfu_init = get_event_msg!(initiator, MessageSendEvent::SendStfu, node_id_acceptor); + acceptor.node.handle_stfu(node_id_initiator, &stfu_init); + let stfu_ack = get_event_msg!(acceptor, MessageSendEvent::SendStfu, node_id_initiator); + initiator.node.handle_stfu(node_id_acceptor, &stfu_ack); + + // 3) Overwrite the splice-out message to add an additional satoshi to the splice-out, and check that it fails + // validation on the receiver's side. + + let mut splice_init = + get_event_msg!(initiator, MessageSendEvent::SendSpliceInit, node_id_acceptor); + splice_init.funding_contribution_satoshis -= 1; + acceptor.node.handle_splice_init(node_id_initiator, &splice_init); + + let msg = get_warning_msg(acceptor, &node_id_initiator); + assert_eq!(msg.channel_id, channel_id); + let cannot_be_spliced_out = format!( + "Channel {} cannot be spliced out; their post-splice channel balance {} is smaller than our selected v2 reserve {}", + channel_id, post_splice_reserve - Amount::ONE_SAT, post_splice_reserve + ); + assert_eq!(msg.data, cannot_be_spliced_out); + + acceptor.node.peer_disconnected(node_id_initiator); + initiator.node.peer_disconnected(node_id_acceptor); + + let reconnect_args = ReconnectArgs::new(initiator, acceptor); + reconnect_nodes(reconnect_args); + + expect_splice_failed_events(initiator, &channel_id, contribution); + + // 4) Try again with the additional satoshi removed from the splice-out message, and check that it passes + // validation on the receiver's side. + + let contribution = initiate_splice_out(initiator, acceptor, channel_id, outputs).unwrap(); + assert_eq!(contribution.net_value(), -splice_out_incl_fees.to_signed().unwrap()); + + contribution + }; + + let (preimage_1_to_0_d, node_1_splice_out_incl_fees) = { + // 0) Set the channel up such that if node 1 splices out an additional satoshi over the `splice_out` + // value, it overdraws its reserve. + + let debit_htlcs = Amount::from_sat(2_000 * 3); + let balance = push_amount - debit_htlcs; + let estimated_fees = Amount::from_sat(183); + let splice_out = Amount::from_sat(1000); + let splice_out_incl_fees = splice_out + estimated_fees; + let post_splice_reserve = (initial_channel_value - splice_out_incl_fees) / 100; + let pre_splice_balance = post_splice_reserve + splice_out_incl_fees; + let amount_msat = (balance - pre_splice_balance).to_sat() * 1000; + let (preimage_1_to_0_d, ..) = route_payment(&nodes[1], &[&nodes[0]], amount_msat); + + let contribution = + splice_out_dance(1, 0, splice_out, splice_out_incl_fees, post_splice_reserve); + let _new_funding_script = complete_splice_handshake(&nodes[1], &nodes[0]); + + // Don't complete the splice, leave node 1's balance untouched such that its + // `next_outbound_htlc_limit_msat` is exactly equal to its pre-splice balance - its pre-splice reserve. + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + let reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); + reconnect_nodes(reconnect_args); + expect_splice_failed_events(&nodes[1], &channel_id, contribution); + let details = &nodes[1].node.list_channels()[0]; + let expected_outbound_htlc_max = + (pre_splice_balance.to_sat() - details.unspendable_punishment_reserve.unwrap()) * 1000; + assert_eq!(details.next_outbound_htlc_limit_msat, expected_outbound_htlc_max); + + // At the end of the show, we'll claim the HTLC we used to setup the channel's balances above so we + // return its preimage. + // We'll also send a HTLC with the exact remaining amount available in the channel, which will match + // the balance we were about to splice out here. + (preimage_1_to_0_d, splice_out_incl_fees) + }; + + let preimage_0_to_1_d = { + // 0) Set the channel up such that if node 0 splices out an additional satoshi over the `splice_out` + // value, it overdraws its reserve. + + let debit_htlcs = Amount::from_sat(40_000 * 2); + let debit_anchors = + if channel_type == anchors_features { Amount::from_sat(330 * 2) } else { Amount::ZERO }; + let balance = initial_channel_value - push_amount - debit_htlcs - debit_anchors; + let estimated_fees = Amount::from_sat(183); + let splice_out = Amount::from_sat(1000); + let splice_out_incl_fees = splice_out + estimated_fees; + let post_splice_reserve = (initial_channel_value - splice_out_incl_fees) / 100; + // The 6 HTLCs we sent previously, the HTLC we send just below, and the fee spike buffer HTLC. + let htlc_count = 6 + 1 + 1; + let commit_tx_fee = Amount::from_sat(chan_utils::commit_tx_fee_sat( + spiked_feerate, + htlc_count, + &channel_type, + )); + let pre_splice_balance = post_splice_reserve + commit_tx_fee + splice_out_incl_fees; + let amount_msat = (balance - pre_splice_balance).to_sat() * 1000; + let (preimage_0_to_1_d, ..) = route_payment(&nodes[0], &[&nodes[1]], amount_msat); + + // Now actually follow through on the splice. + let contribution = + splice_out_dance(0, 1, splice_out, splice_out_incl_fees, post_splice_reserve); + let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, contribution); + + // The funder's balance has exactly its reserve plus the fee for an inbound non-dust HTLC, + // so its `next_outbound_htlc_limit_msat` is exactly 0. We'll send that last inbound non-dust HTLC + // across further below to close the circle. + assert_eq!(nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat, 0); + + // Confirm and lock the splice. + mine_transaction(&nodes[0], &splice_tx); + mine_transaction(&nodes[1], &splice_tx); + lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); + + // Node 0 has now spliced the channel, so even though node 1 has not done anything, the max-size HTLC node 1 + // can send is now its pre-splice balance - its post-splice reserve. This matches the balance it was about to + // splice out above, but never did. + let outbound_htlc_max = nodes[1].node.list_channels()[0].next_outbound_htlc_limit_msat; + assert_eq!(outbound_htlc_max, node_1_splice_out_incl_fees.to_sat() * 1000); + + // Send the last max-size non-dust HTLC in the channel. + let _ = send_payment(&nodes[1], &[&nodes[0]], node_1_splice_out_incl_fees.to_sat() * 1000); + + // Node 1 is exactly at the V2 channel reserve, given that we just sent node 1's entire available balance + // across. + assert_eq!(nodes[1].node.list_channels()[0].next_outbound_htlc_limit_msat, 0); + + // Node 0's balance is its previous balance (ie the previous reserved fee) + the HTLC it just claimed + // - the new reserved fee (the channel reserves cancel out). + let previous_balance = chan_utils::commit_tx_fee_sat(spiked_feerate, 8, &channel_type); + let claimed_htlc = node_1_splice_out_incl_fees.to_sat(); + let commit_tx_fee = chan_utils::commit_tx_fee_sat(spiked_feerate, 9, &channel_type); + let new_balance = previous_balance + claimed_htlc - commit_tx_fee; + let outbound_htlc_max = nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat; + assert_eq!(outbound_htlc_max, new_balance * 1000); + + // Return the preimage of the HTLC used to setup the balances so we can claim the HTLC below. + preimage_0_to_1_d + }; + + // Clean up the channel. + claim_payment(&nodes[1], &[&nodes[0]], preimage_1_to_0_a); + claim_payment(&nodes[1], &[&nodes[0]], preimage_1_to_0_b); + claim_payment(&nodes[1], &[&nodes[0]], preimage_1_to_0_c); + + claim_payment(&nodes[1], &[&nodes[0]], preimage_1_to_0_d); + + claim_payment(&nodes[0], &[&nodes[1]], preimage_0_to_1_a); + claim_payment(&nodes[0], &[&nodes[1]], preimage_0_to_1_b); + + claim_payment(&nodes[0], &[&nodes[1]], preimage_0_to_1_d); + + // Check that the channel is still operational. + let _ = send_payment(&nodes[0], &[&nodes[1]], 2_000 * 1000); + let _ = send_payment(&nodes[1], &[&nodes[0]], 2_000 * 1000); +} diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index c0d6df0fee9..4273b62c7b7 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -315,6 +315,7 @@ fn get_available_balances( let fee_spike_buffer_htlc = if channel_type.supports_anchor_zero_fee_commitments() { 0 } else { 1 }; + // Note that the feerate is 0 in zero-fee commitment channels, so this statement is a noop let local_feerate = feerate_per_kw * if is_outbound_from_holder && !channel_type.supports_anchors_zero_fee_htlc_tx() { crate::ln::channel::FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 From 12eb3b6cd18155fe0819228edc8d55267fb3de35 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 26 Feb 2026 13:24:48 +0000 Subject: [PATCH 123/627] Use the new `total_cltv_expiry_delta()` in place of explicit sum --- lightning/src/routing/router.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 90697ad246e..f3b1f4e2770 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -3975,8 +3975,8 @@ fn add_random_cltv_offset(route: &mut Route, payment_params: &PaymentParameters, // Limit the offset so we never exceed the max_total_cltv_expiry_delta. To improve plausibility, // we choose the limit to be the largest possible multiple of MEDIAN_HOP_CLTV_EXPIRY_DELTA. - let path_total_cltv_expiry_delta: u32 = path.hops.iter().map(|h| h.cltv_expiry_delta).sum(); - let mut max_path_offset = payment_params.max_total_cltv_expiry_delta - path_total_cltv_expiry_delta; + let mut max_path_offset = + payment_params.max_total_cltv_expiry_delta - path.total_cltv_expiry_delta(); max_path_offset = cmp::max( max_path_offset - (max_path_offset % MEDIAN_HOP_CLTV_EXPIRY_DELTA), max_path_offset % MEDIAN_HOP_CLTV_EXPIRY_DELTA); From 4221afd29eb0f6e5e154ee982321d4393b646ec2 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 26 Feb 2026 13:34:55 +0000 Subject: [PATCH 124/627] Clarify CLTV expiry delta for trampolines further --- lightning/src/routing/router.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index f3b1f4e2770..2a00d44287a 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -520,8 +520,12 @@ pub struct RouteHop { pub fee_msat: u64, /// The CLTV delta added for this hop. /// If this is the last hop in [`Path::hops`]: - /// * if we're sending to a [`BlindedPaymentPath`], this is the CLTV delta for the entire blinded - /// path (including any Trampoline hops) + /// * if we're sending to a [`BlindedPaymentPath`] *with* trampoline hops, this is the CLTV + /// delta for the entire blinded path including the trampoline hops, and is thus equal to the + /// sum of [`TrampolineHop::cltv_expiry_delta`] for all the [`BlindedTail::trampoline_hops`]. + /// * if we're sending to a [`BlindedPaymentPath`], *without* trampoline hops, this is the CLTV + /// delta for the entire blinded path (including + /// [`BlindedTail::excess_final_cltv_expiry_delta`]). /// * otherwise, this is the CLTV delta expected at the destination pub cltv_expiry_delta: u32, /// Indicates whether this hop is possibly announced in the public network graph. @@ -753,9 +757,11 @@ impl Route { let trampoline_cltv_sum: u32 = tail.trampoline_hops.iter().map(|hop| hop.cltv_expiry_delta).sum(); let last_hop_cltv_delta = path.hops.last().unwrap().cltv_expiry_delta; - if trampoline_cltv_sum > last_hop_cltv_delta { + if !tail.trampoline_hops.is_empty() + && trampoline_cltv_sum != last_hop_cltv_delta + { let err = format!( - "Path had a total trampoline CLTV of {trampoline_cltv_sum}, which is less than the total last-hop CLTV delta of {last_hop_cltv_delta}" + "Path had a total trampoline CLTV of {trampoline_cltv_sum}, which is not equal to the total last-hop CLTV delta of {last_hop_cltv_delta}" ); debug_assert!(false, "{}", err); log_error!(logger, "{}", err); From 1545ad546d2b065b435442dc0c71a804390b713c Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 2 Mar 2026 14:09:59 -0600 Subject: [PATCH 125/627] Skip non-key entries in list_paginated Use dir_entry_is_key in list_paginated_impl to skip .tmp files, directories, and other non-key entries, to be the same as list_impl. Claude added a test for this as well. --- lightning-persister/src/fs_store/common.rs | 2 +- lightning-persister/src/fs_store/v2.rs | 41 +++++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/lightning-persister/src/fs_store/common.rs b/lightning-persister/src/fs_store/common.rs index 7aef941f704..77321f6f06f 100644 --- a/lightning-persister/src/fs_store/common.rs +++ b/lightning-persister/src/fs_store/common.rs @@ -720,7 +720,7 @@ impl FilesystemStoreState { } } -fn dir_entry_is_key(dir_entry: &fs::DirEntry) -> Result { +pub(crate) fn dir_entry_is_key(dir_entry: &fs::DirEntry) -> Result { let p = dir_entry.path(); if let Some(ext) = p.extension() { #[cfg(target_os = "windows")] diff --git a/lightning-persister/src/fs_store/v2.rs b/lightning-persister/src/fs_store/v2.rs index 426038722dd..773b22ac3fb 100644 --- a/lightning-persister/src/fs_store/v2.rs +++ b/lightning-persister/src/fs_store/v2.rs @@ -1,5 +1,7 @@ //! Objects related to [`FilesystemStoreV2`] live here. -use crate::fs_store::common::{get_key_from_dir_entry_path, FilesystemStoreState}; +use crate::fs_store::common::{ + dir_entry_is_key, get_key_from_dir_entry_path, FilesystemStoreState, +}; use lightning::util::persist::{ KVStoreSync, MigratableKVStore, PageToken, PaginatedKVStoreSync, PaginatedListResponse, @@ -108,6 +110,16 @@ impl FilesystemStoreState { for dir_entry in fs::read_dir(&prefixed_dest)? { let dir_entry = dir_entry?; + match dir_entry_is_key(&dir_entry) { + // Entry is not a key (e.g., .tmp file, directory), skip it. + Ok(false) => continue, + // Entry is a valid key file, proceed to collect it. + Ok(true) => {}, + // Entry may have been deleted between read_dir and our check. Include + // it anyway to give a more consistent view, matching list's behavior. + Err(_) => {}, + } + let key = get_key_from_dir_entry_path(&dir_entry.path(), prefixed_dest.as_path(), false)?; // Get modification time as millis since epoch @@ -616,6 +628,33 @@ mod tests { assert_eq!(response.keys[3], "apple"); } + #[test] + fn test_paginated_listing_skips_tmp_files() { + use lightning::util::persist::{KVStoreSync, PaginatedKVStoreSync}; + + let mut temp_path = std::env::temp_dir(); + temp_path.push("test_paginated_listing_skips_tmp_files_v2"); + let fs_store = FilesystemStoreV2::new(temp_path.clone()).unwrap(); + + let data = vec![42u8; 32]; + + // Write some real keys + KVStoreSync::write(&fs_store, "ns", "sub", "key0", data.clone()).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + KVStoreSync::write(&fs_store, "ns", "sub", "key1", data.clone()).unwrap(); + + // Create a .tmp file and a subdirectory directly on disk + let dir = temp_path.join("ns").join("sub"); + fs::write(dir.join("inflight.tmp"), &data).unwrap(); + fs::create_dir_all(dir.join("stray_dir")).unwrap(); + + // Paginated listing should only return the two real keys + let response = PaginatedKVStoreSync::list_paginated(&fs_store, "ns", "sub", None).unwrap(); + assert_eq!(response.keys.len(), 2); + assert!(response.keys.contains(&"key0".to_string())); + assert!(response.keys.contains(&"key1".to_string())); + } + #[test] fn test_rejects_v1_data_directory() { let mut temp_path = std::env::temp_dir(); From 3e9dff9c138d9477c92386924868945a6ef26b74 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 10 Feb 2026 17:57:13 -0600 Subject: [PATCH 126/627] Stop persisting QuiescentAction and remove legacy code Now that the Splice variant (containing non-serializable FundingContribution) is the only variant produced, and the previous commit consumes the acceptor's quiescent_action in splice_init(), there is no longer a need to persist it. This allows removing LegacySplice, SpliceInstructions, ChangeStrategy, and related code paths including calculate_change_output, calculate_change_output_value, and the legacy send_splice_init method. With ChangeStrategy removed, the only remaining path in calculate_change_output was FromCoinSelection which always returned Ok(None), making it dead code. The into_interactive_tx_constructor method is simplified accordingly, and the signer_provider parameter is removed from it and from splice_init/splice_ack since it was only needed for the removed change output calculation. On deserialization, quiescent_action (TLV 65) is still read for backwards compatibility but discarded, and the awaiting_quiescence channel state flag is cleared since it cannot be acted upon without a quiescent_action. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 230 +++-------------------------- lightning/src/ln/channelmanager.rs | 2 - lightning/src/ln/funding.rs | 10 -- lightning/src/ln/interactivetxs.rs | 215 +-------------------------- lightning/src/ln/splicing_tests.rs | 76 +--------- 5 files changed, 36 insertions(+), 497 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 7fc1b346dff..9dff6095696 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -28,7 +28,7 @@ use bitcoin::{secp256k1, sighash, FeeRate, Sequence, TxIn}; use crate::blinded_path::message::BlindedMessagePath; use crate::chain::chaininterface::{ - fee_for_weight, ConfirmationTarget, FeeEstimator, LowerBoundedFeeEstimator, TransactionType, + ConfirmationTarget, FeeEstimator, LowerBoundedFeeEstimator, TransactionType, }; use crate::chain::channelmonitor::{ ChannelMonitor, ChannelMonitorUpdate, ChannelMonitorUpdateStep, CommitmentHTLCData, @@ -57,9 +57,9 @@ use crate::ln::channelmanager::{ }; use crate::ln::funding::{FundingContribution, FundingTemplate, FundingTxInput}; use crate::ln::interactivetxs::{ - calculate_change_output_value, get_output_weight, AbortReason, HandleTxCompleteValue, - InteractiveTxConstructor, InteractiveTxConstructorArgs, InteractiveTxMessageSend, - InteractiveTxSigningSession, NegotiationError, SharedOwnedInput, SharedOwnedOutput, + AbortReason, HandleTxCompleteValue, InteractiveTxConstructor, InteractiveTxConstructorArgs, + InteractiveTxMessageSend, InteractiveTxSigningSession, NegotiationError, SharedOwnedInput, + SharedOwnedOutput, }; use crate::ln::msgs; use crate::ln::msgs::{ClosingSigned, ClosingSignedFeeRange, DecodeError, OnionErrorPacket}; @@ -2910,7 +2910,6 @@ impl_writeable_tlv_based!(PendingFunding, { enum FundingNegotiation { AwaitingAck { context: FundingNegotiationContext, - change_strategy: ChangeStrategy, new_holder_funding_key: PublicKey, }, ConstructingTransaction { @@ -2996,38 +2995,8 @@ impl PendingFunding { } } -#[derive(Debug)] -pub(crate) struct SpliceInstructions { - adjusted_funding_contribution: SignedAmount, - our_funding_inputs: Vec, - our_funding_outputs: Vec, - change_script: Option, - funding_feerate_per_kw: u32, - locktime: u32, -} - -impl SpliceInstructions { - fn into_contributed_inputs_and_outputs(self) -> (Vec, Vec) { - ( - self.our_funding_inputs.into_iter().map(|input| input.utxo.outpoint).collect(), - self.our_funding_outputs, - ) - } -} - -impl_writeable_tlv_based!(SpliceInstructions, { - (1, adjusted_funding_contribution, required), - (3, our_funding_inputs, required_vec), - (5, our_funding_outputs, required_vec), - (7, change_script, option), - (9, funding_feerate_per_kw, required), - (11, locktime, required), -}); - #[derive(Debug)] pub(crate) enum QuiescentAction { - // Deprecated in favor of the Splice variant and no longer produced as of LDK 0.3. - LegacySplice(SpliceInstructions), Splice { contribution: FundingContribution, locktime: LockTime, @@ -3045,10 +3014,6 @@ pub(super) enum QuiescentError { impl From for QuiescentError { fn from(action: QuiescentAction) -> Self { match action { - QuiescentAction::LegacySplice(_) => { - debug_assert!(false); - QuiescentError::DoNothing - }, QuiescentAction::Splice { contribution, .. } => { let (contributed_inputs, contributed_outputs) = contribution.into_contributed_inputs_and_outputs(); @@ -3070,24 +3035,6 @@ pub(crate) enum StfuResponse { SpliceInit(msgs::SpliceInit), } -#[cfg(any(test, fuzzing, feature = "_test_utils"))] -impl_writeable_tlv_based_enum_upgradable!(QuiescentAction, - (0, DoNothing) => {}, - (2, Splice) => { - (0, contribution, required), - (1, locktime, required), - }, - {1, LegacySplice} => (), -); -#[cfg(not(any(test, fuzzing, feature = "_test_utils")))] -impl_writeable_tlv_based_enum_upgradable!(QuiescentAction, - (2, Splice) => { - (0, contribution, required), - (1, locktime, required), - }, - {1, LegacySplice} => (), -); - /// Wrapper around a [`Transaction`] useful for caching the result of [`Transaction::compute_txid`]. struct ConfirmedTransaction<'a> { tx: &'a Transaction, @@ -6393,23 +6340,12 @@ pub(super) struct FundingNegotiationContext { pub our_funding_outputs: Vec, } -/// How the funding transaction's change is determined. -#[derive(Debug)] -pub(super) enum ChangeStrategy { - /// The change output, if any, is included in the FundingContribution's outputs. - FromCoinSelection, - - /// The change output script. This will be used if needed or -- if not set -- generated using - /// `SignerProvider::get_destination_script`. - LegacyUserProvided(Option), -} - impl FundingNegotiationContext { /// Prepare and start interactive transaction negotiation. /// If error occurs, it is caused by our side, not the counterparty. fn into_interactive_tx_constructor( - mut self, context: &ChannelContext, funding: &FundingScope, signer_provider: &SP, - entropy_source: &ES, holder_node_id: PublicKey, change_strategy: ChangeStrategy, + self, context: &ChannelContext, funding: &FundingScope, entropy_source: &ES, + holder_node_id: PublicKey, ) -> Result { debug_assert_eq!( self.shared_funding_input.is_some(), @@ -6422,25 +6358,11 @@ impl FundingNegotiationContext { debug_assert!(matches!(context.channel_state, ChannelState::NegotiatingFunding(_))); } - // Note: For the error case when the inputs are insufficient, it will be handled after - // the `calculate_change_output_value` call below - let shared_funding_output = TxOut { value: Amount::from_sat(funding.get_value_satoshis()), script_pubkey: funding.get_funding_redeemscript().to_p2wsh(), }; - match self.calculate_change_output( - context, - signer_provider, - &shared_funding_output, - change_strategy, - ) { - Ok(Some(change_output)) => self.our_funding_outputs.push(change_output), - Ok(None) => {}, - Err(reason) => return Err(self.into_negotiation_error(reason)), - } - let constructor_args = InteractiveTxConstructorArgs { entropy_source, holder_node_id, @@ -6460,57 +6382,6 @@ impl FundingNegotiationContext { InteractiveTxConstructor::new(constructor_args) } - fn calculate_change_output( - &self, context: &ChannelContext, signer_provider: &SP, shared_funding_output: &TxOut, - change_strategy: ChangeStrategy, - ) -> Result, AbortReason> { - if self.our_funding_inputs.is_empty() { - return Ok(None); - } - - let change_script = match change_strategy { - ChangeStrategy::FromCoinSelection => return Ok(None), - ChangeStrategy::LegacyUserProvided(change_script) => change_script, - }; - - let change_value = calculate_change_output_value( - &self, - self.shared_funding_input.is_some(), - &shared_funding_output.script_pubkey, - context.holder_dust_limit_satoshis, - )?; - - if let Some(change_value) = change_value { - let change_script = match change_script { - Some(script) => script, - None => match signer_provider.get_destination_script(context.channel_keys_id) { - Ok(script) => script, - Err(_) => { - return Err(AbortReason::InternalError("Error getting change script")) - }, - }, - }; - let mut change_output = TxOut { value: change_value, script_pubkey: change_script }; - let change_output_weight = get_output_weight(&change_output.script_pubkey).to_wu(); - let change_output_fee = - fee_for_weight(self.funding_feerate_sat_per_1000_weight, change_output_weight); - let change_value_decreased_with_fee = - change_value.to_sat().saturating_sub(change_output_fee); - // Check dust limit again - if change_value_decreased_with_fee > context.holder_dust_limit_satoshis { - change_output.value = Amount::from_sat(change_value_decreased_with_fee); - return Ok(Some(change_output)); - } - } - - Ok(None) - } - - fn into_negotiation_error(self, reason: AbortReason) -> NegotiationError { - let (contributed_inputs, contributed_outputs) = self.into_contributed_inputs_and_outputs(); - NegotiationError { reason, contributed_inputs, contributed_outputs } - } - fn into_contributed_inputs_and_outputs(self) -> (Vec, Vec) { let contributed_inputs = self.our_funding_inputs.into_iter().map(|input| input.utxo.outpoint).collect(); @@ -6757,15 +6628,6 @@ where fn abandon_quiescent_action(&mut self) -> Option { match self.quiescent_action.take() { - Some(QuiescentAction::LegacySplice(instructions)) => { - let (inputs, outputs) = instructions.into_contributed_inputs_and_outputs(); - Some(SpliceFundingFailed { - funding_txo: None, - channel_type: None, - contributed_inputs: inputs, - contributed_outputs: outputs, - }) - }, Some(QuiescentAction::Splice { contribution, .. }) => { let (inputs, outputs) = contribution.into_contributed_inputs_and_outputs(); Some(SpliceFundingFailed { @@ -11974,33 +11836,7 @@ where self.propose_quiescence(logger, QuiescentAction::Splice { contribution, locktime }) } - fn send_splice_init(&mut self, instructions: SpliceInstructions) -> msgs::SpliceInit { - let SpliceInstructions { - adjusted_funding_contribution, - our_funding_inputs, - our_funding_outputs, - change_script, - funding_feerate_per_kw, - locktime, - } = instructions; - - let prev_funding_input = self.funding.to_splice_funding_input(); - let context = FundingNegotiationContext { - is_initiator: true, - our_funding_contribution: adjusted_funding_contribution, - funding_tx_locktime: LockTime::from_consensus(locktime), - funding_feerate_sat_per_1000_weight: funding_feerate_per_kw, - shared_funding_input: Some(prev_funding_input), - our_funding_inputs, - our_funding_outputs, - }; - - self.send_splice_init_internal(context, ChangeStrategy::LegacyUserProvided(change_script)) - } - - fn send_splice_init_internal( - &mut self, context: FundingNegotiationContext, change_strategy: ChangeStrategy, - ) -> msgs::SpliceInit { + fn send_splice_init(&mut self, context: FundingNegotiationContext) -> msgs::SpliceInit { debug_assert!(self.pending_splice.is_none()); // Rotate the funding pubkey using the prev_funding_txid as a tweak let prev_funding_txid = self.funding.get_funding_txid(); @@ -12020,11 +11856,8 @@ where let funding_contribution_satoshis = context.our_funding_contribution.to_sat(); let locktime = context.funding_tx_locktime.to_consensus_u32(); - let funding_negotiation = FundingNegotiation::AwaitingAck { - context, - change_strategy, - new_holder_funding_key: funding_pubkey, - }; + let funding_negotiation = + FundingNegotiation::AwaitingAck { context, new_holder_funding_key: funding_pubkey }; self.pending_splice = Some(PendingFunding { funding_negotiation: Some(funding_negotiation), negotiated_candidates: vec![], @@ -12228,7 +12061,7 @@ where pub(crate) fn splice_init( &mut self, msg: &msgs::SpliceInit, our_funding_contribution_satoshis: i64, - signer_provider: &SP, entropy_source: &ES, holder_node_id: &PublicKey, logger: &L, + entropy_source: &ES, holder_node_id: &PublicKey, logger: &L, ) -> Result { let our_funding_contribution = SignedAmount::from_sat(our_funding_contribution_satoshis); let splice_funding = self.validate_splice_init(msg, our_funding_contribution)?; @@ -12256,11 +12089,8 @@ where .into_interactive_tx_constructor( &self.context, &splice_funding, - signer_provider, entropy_source, holder_node_id.clone(), - // ChangeStrategy doesn't matter when no inputs are contributed - ChangeStrategy::FromCoinSelection, ) .map_err(|err| { ChannelError::WarnAndDisconnect(format!( @@ -12295,8 +12125,8 @@ where } pub(crate) fn splice_ack( - &mut self, msg: &msgs::SpliceAck, signer_provider: &SP, entropy_source: &ES, - holder_node_id: &PublicKey, logger: &L, + &mut self, msg: &msgs::SpliceAck, entropy_source: &ES, holder_node_id: &PublicKey, + logger: &L, ) -> Result, ChannelError> { let splice_funding = self.validate_splice_ack(msg)?; @@ -12311,11 +12141,11 @@ where let pending_splice = self.pending_splice.as_mut().expect("We should have returned an error earlier!"); // TODO: Good candidate for a let else statement once MSRV >= 1.65 - let (funding_negotiation_context, change_strategy) = - if let Some(FundingNegotiation::AwaitingAck { context, change_strategy, .. }) = + let funding_negotiation_context = + if let Some(FundingNegotiation::AwaitingAck { context, .. }) = pending_splice.funding_negotiation.take() { - (context, change_strategy) + context } else { panic!("We should have returned an error earlier!"); }; @@ -12324,10 +12154,8 @@ where .into_interactive_tx_constructor( &self.context, &splice_funding, - signer_provider, entropy_source, holder_node_id.clone(), - change_strategy, ) .map_err(|err| { ChannelError::WarnAndDisconnect(format!( @@ -13212,22 +13040,6 @@ where "Internal Error: Didn't have anything to do after reaching quiescence".to_owned() )); }, - Some(QuiescentAction::LegacySplice(instructions)) => { - if self.pending_splice.is_some() { - debug_assert!(false); - self.quiescent_action = Some(QuiescentAction::LegacySplice(instructions)); - - return Err(ChannelError::WarnAndDisconnect( - format!( - "Channel {} cannot be spliced as it already has a splice pending", - self.context.channel_id(), - ), - )); - } - - let splice_init = self.send_splice_init(instructions); - return Ok(Some(StfuResponse::SpliceInit(splice_init))); - }, Some(QuiescentAction::Splice { contribution, locktime }) => { // TODO(splicing): If the splice has been negotiated but has not been locked, we // can RBF here to add the contribution. @@ -13259,7 +13071,7 @@ where our_funding_outputs, }; - let splice_init = self.send_splice_init_internal(context, ChangeStrategy::FromCoinSelection); + let splice_init = self.send_splice_init(context); return Ok(Some(StfuResponse::SpliceInit(splice_init))); }, #[cfg(any(test, fuzzing, feature = "_test_utils"))] @@ -13301,8 +13113,7 @@ where // We can't initiate another splice while ours is pending, so don't bother becoming // quiescent yet. // TODO(splicing): Allow the splice as an RBF once supported. - let has_splice_action = matches!(action, QuiescentAction::Splice { .. }) - || matches!(action, QuiescentAction::LegacySplice(_)); + let has_splice_action = matches!(action, QuiescentAction::Splice { .. }); if has_splice_action && self.pending_splice.is_some() { log_given_level!( logger, @@ -14894,7 +14705,7 @@ impl Writeable for FundedChannel { (61, fulfill_attribution_data, optional_vec), // Added in 0.2 (63, holder_commitment_point_current, option), // Added in 0.2 (64, pending_splice, option), // Added in 0.2 - (65, self.quiescent_action, option), // Added in 0.2 + // 65 was previously used for quiescent_action (67, pending_outbound_held_htlc_flags, optional_vec), // Added in 0.2 (69, holding_cell_held_htlc_flags, optional_vec), // Added in 0.2 (71, holder_commitment_point_previous_revoked, option), // Added in 0.3 @@ -15284,7 +15095,6 @@ impl<'a, 'b, 'c, ES: EntropySource, SP: SignerProvider> let mut minimum_depth_override: Option = None; let mut pending_splice: Option = None; - let mut quiescent_action = None; let mut pending_outbound_held_htlc_flags_opt: Option>> = None; let mut holding_cell_held_htlc_flags_opt: Option>> = None; @@ -15338,7 +15148,7 @@ impl<'a, 'b, 'c, ES: EntropySource, SP: SignerProvider> (61, fulfill_attribution_data, optional_vec), // Added in 0.2 (63, holder_commitment_point_current_opt, option), // Added in 0.2 (64, pending_splice, option), // Added in 0.2 - (65, quiescent_action, upgradable_option), // Added in 0.2 + // 65 quiescent_action: Added in 0.2; removed in 0.3 (67, pending_outbound_held_htlc_flags_opt, optional_vec), // Added in 0.2 (69, holding_cell_held_htlc_flags_opt, optional_vec), // Added in 0.2 (71, holder_commitment_point_previous_revoked_opt, option), // Added in 0.3 @@ -15803,7 +15613,7 @@ impl<'a, 'b, 'c, ES: EntropySource, SP: SignerProvider> }, holder_commitment_point, pending_splice, - quiescent_action, + quiescent_action: None, }) } } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 6bf04cd62a4..8e061291daf 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -12845,7 +12845,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let init_res = funded_channel.splice_init( msg, our_funding_contribution, - &self.signer_provider, &self.entropy_source, &self.get_our_node_id(), &self.logger, @@ -12889,7 +12888,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ if let Some(ref mut funded_channel) = chan_entry.get_mut().as_funded_mut() { let splice_ack_res = funded_channel.splice_ack( msg, - &self.signer_provider, &self.entropy_source, &self.get_our_node_id(), &self.logger, diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index dc29b23b1e3..1d1762ad2ad 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -286,16 +286,6 @@ pub struct FundingContribution { is_splice: bool, } -impl_writeable_tlv_based!(FundingContribution, { - (1, value_added, required), - (3, estimated_fee, required), - (5, inputs, optional_vec), - (7, outputs, optional_vec), - (9, change_output, option), - (11, feerate, required), - (13, is_splice, required), -}); - impl FundingContribution { pub(super) fn feerate(&self) -> FeeRate { self.feerate diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index 7e7a9fb609c..17dab1918c0 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -12,7 +12,7 @@ use crate::io_extras::sink; use crate::prelude::*; use bitcoin::absolute::LockTime as AbsoluteLockTime; -use bitcoin::amount::{Amount, SignedAmount}; +use bitcoin::amount::Amount; use bitcoin::consensus::Encodable; use bitcoin::constants::WITNESS_SCALE_FACTOR; use bitcoin::ecdsa::Signature as BitcoinSignature; @@ -31,7 +31,7 @@ use crate::ln::chan_utils::{ BASE_INPUT_WEIGHT, EMPTY_SCRIPT_SIG_WEIGHT, FUNDING_TRANSACTION_WITNESS_WEIGHT, SEGWIT_MARKER_FLAG_WEIGHT, }; -use crate::ln::channel::{FundingNegotiationContext, TOTAL_BITCOIN_SUPPLY_SATOSHIS}; +use crate::ln::channel::TOTAL_BITCOIN_SUPPLY_SATOSHIS; use crate::ln::funding::FundingTxInput; use crate::ln::msgs; use crate::ln::msgs::{MessageSendEvent, SerialId, TxSignatures}; @@ -2323,102 +2323,16 @@ impl InteractiveTxConstructor { } } -/// Determine whether a change output should be added, and if yes, of what size, considering our -/// given inputs and outputs, and intended contribution. Takes into account the fees and the dust -/// limit. -/// -/// Three outcomes are possible: -/// - Inputs are sufficient for intended contribution, fees, and a larger-than-dust change: -/// `Ok(Some(change_amount))` -/// - Inputs are sufficient for intended contribution and fees, and a change output isn't needed: -/// `Ok(None)` -/// - Inputs are not sufficient to cover contribution and fees: -/// `Err(AbortReason::InsufficientFees)` -/// -/// Parameters: -/// - `context` - Context of the funding negotiation, including non-shared inputs and feerate. -/// - `is_splice` - Whether we splicing an existing channel or dual-funding a new one. -/// - `shared_output_funding_script` - The script of the shared output. -/// - `funding_outputs` - Our funding outputs. -/// - `change_output_dust_limit` - The dust limit (in sats) to consider. -pub(super) fn calculate_change_output_value( - context: &FundingNegotiationContext, is_splice: bool, shared_output_funding_script: &ScriptBuf, - change_output_dust_limit: u64, -) -> Result, AbortReason> { - let mut total_input_value = Amount::ZERO; - let mut our_funding_inputs_weight = 0u64; - for FundingTxInput { utxo, .. } in context.our_funding_inputs.iter() { - total_input_value = total_input_value.checked_add(utxo.output.value).unwrap_or(Amount::MAX); - - let weight = BASE_INPUT_WEIGHT + utxo.satisfaction_weight; - our_funding_inputs_weight = our_funding_inputs_weight.saturating_add(weight); - } - - let funding_outputs = &context.our_funding_outputs; - let total_output_value = funding_outputs - .iter() - .fold(Amount::ZERO, |total, out| total.checked_add(out.value).unwrap_or(Amount::MAX)); - - let our_funding_outputs_weight = funding_outputs.iter().fold(0u64, |weight, out| { - weight.saturating_add(get_output_weight(&out.script_pubkey).to_wu()) - }); - let mut weight = our_funding_outputs_weight.saturating_add(our_funding_inputs_weight); - - // If we are the initiator, we must pay for the weight of the funding output and - // all common fields in the funding transaction. - if context.is_initiator { - weight = weight.saturating_add(get_output_weight(shared_output_funding_script).to_wu()); - weight = weight.saturating_add(TX_COMMON_FIELDS_WEIGHT); - if is_splice { - // TODO(taproot): Needs to consider different weights based on channel type - weight = weight.saturating_add(BASE_INPUT_WEIGHT); - weight = weight.saturating_add(EMPTY_SCRIPT_SIG_WEIGHT); - weight = weight.saturating_add(FUNDING_TRANSACTION_WITNESS_WEIGHT); - #[cfg(feature = "grind_signatures")] - { - // Guarantees a low R signature - weight -= 1; - } - } - } - - let contributed_fees = - Amount::from_sat(fee_for_weight(context.funding_feerate_sat_per_1000_weight, weight)); - - let contributed_input_value = - context.our_funding_contribution + total_output_value.to_signed().unwrap(); - assert!(contributed_input_value > SignedAmount::ZERO); - let contributed_input_value = contributed_input_value.unsigned_abs(); - - let total_input_value_less_fees = - total_input_value.checked_sub(contributed_fees).unwrap_or(Amount::ZERO); - if total_input_value_less_fees < contributed_input_value { - // Not enough to cover contribution plus fees - return Err(AbortReason::InsufficientFees); - } - - let remaining_value = total_input_value_less_fees - .checked_sub(contributed_input_value) - .expect("remaining_value should not be negative"); - if remaining_value.to_sat() < change_output_dust_limit { - // Enough to cover contribution plus fees, but leftover is below dust limit; no change - Ok(None) - } else { - // Enough to have over-dust change - Ok(Some(remaining_value)) - } -} - #[cfg(test)] mod tests { use crate::chain::chaininterface::{fee_for_weight, FEERATE_FLOOR_SATS_PER_KW}; - use crate::ln::channel::{FundingNegotiationContext, TOTAL_BITCOIN_SUPPLY_SATOSHIS}; + use crate::ln::channel::TOTAL_BITCOIN_SUPPLY_SATOSHIS; use crate::ln::funding::FundingTxInput; use crate::ln::interactivetxs::{ - calculate_change_output_value, generate_holder_serial_id, AbortReason, - HandleTxCompleteValue, InteractiveTxConstructor, InteractiveTxConstructorArgs, - InteractiveTxMessageSend, SharedOwnedInput, SharedOwnedOutput, MAX_INPUTS_OUTPUTS_COUNT, - MAX_RECEIVED_TX_ADD_INPUT_COUNT, MAX_RECEIVED_TX_ADD_OUTPUT_COUNT, + generate_holder_serial_id, AbortReason, HandleTxCompleteValue, InteractiveTxConstructor, + InteractiveTxConstructorArgs, InteractiveTxMessageSend, SharedOwnedInput, + SharedOwnedOutput, MAX_INPUTS_OUTPUTS_COUNT, MAX_RECEIVED_TX_ADD_INPUT_COUNT, + MAX_RECEIVED_TX_ADD_OUTPUT_COUNT, }; use crate::ln::types::ChannelId; use crate::sign::EntropySource; @@ -2433,8 +2347,7 @@ mod tests { use bitcoin::transaction::Version; use bitcoin::{opcodes, WScriptHash, Weight, XOnlyPublicKey}; use bitcoin::{ - OutPoint, PubkeyHash, ScriptBuf, Sequence, SignedAmount, Transaction, TxIn, TxOut, - WPubkeyHash, + OutPoint, PubkeyHash, ScriptBuf, Sequence, Transaction, TxIn, TxOut, WPubkeyHash, }; use super::{ @@ -3398,118 +3311,6 @@ mod tests { assert_eq!(generate_holder_serial_id(&&entropy_source, false) % 2, 1) } - #[test] - fn test_calculate_change_output_value_open() { - let input_prevouts = [ - TxOut { - value: Amount::from_sat(70_000), - script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()), - }, - TxOut { - value: Amount::from_sat(60_000), - script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()), - }, - ]; - let inputs = input_prevouts - .iter() - .map(|txout| { - let prevtx = Transaction { - input: Vec::new(), - output: vec![(*txout).clone()], - lock_time: AbsoluteLockTime::ZERO, - version: Version::TWO, - }; - - FundingTxInput::new_p2wpkh(prevtx, 0).unwrap() - }) - .collect(); - let txout = TxOut { value: Amount::from_sat(10_000), script_pubkey: ScriptBuf::new() }; - let outputs = vec![txout]; - let funding_feerate_sat_per_1000_weight = 3000; - - let total_inputs: Amount = input_prevouts.iter().map(|o| o.value).sum(); - let total_outputs: Amount = outputs.iter().map(|o| o.value).sum(); - let fees = if cfg!(feature = "grind_signatures") { - Amount::from_sat(1734) - } else { - Amount::from_sat(1740) - }; - let common_fees = Amount::from_sat(234); - - // There is leftover for change - let context = FundingNegotiationContext { - is_initiator: true, - our_funding_contribution: SignedAmount::from_sat(110_000), - funding_tx_locktime: AbsoluteLockTime::ZERO, - funding_feerate_sat_per_1000_weight, - shared_funding_input: None, - our_funding_inputs: inputs, - our_funding_outputs: outputs, - }; - let gross_change = - total_inputs - total_outputs - context.our_funding_contribution.to_unsigned().unwrap(); - assert_eq!( - calculate_change_output_value(&context, false, &ScriptBuf::new(), 300), - Ok(Some(gross_change - fees - common_fees)), - ); - - // There is leftover for change, without common fees - let context = FundingNegotiationContext { is_initiator: false, ..context }; - assert_eq!( - calculate_change_output_value(&context, false, &ScriptBuf::new(), 300), - Ok(Some(gross_change - fees)), - ); - - // Insufficient inputs, no leftover - let context = FundingNegotiationContext { - is_initiator: false, - our_funding_contribution: SignedAmount::from_sat(130_000), - ..context - }; - assert_eq!( - calculate_change_output_value(&context, false, &ScriptBuf::new(), 300), - Err(AbortReason::InsufficientFees), - ); - - // Very small leftover - let context = FundingNegotiationContext { - is_initiator: false, - our_funding_contribution: SignedAmount::from_sat(118_000), - ..context - }; - assert_eq!( - calculate_change_output_value(&context, false, &ScriptBuf::new(), 300), - Ok(None), - ); - - // Small leftover, but not dust - let context = FundingNegotiationContext { - is_initiator: false, - our_funding_contribution: SignedAmount::from_sat(117_992), - ..context - }; - let gross_change = - total_inputs - total_outputs - context.our_funding_contribution.to_unsigned().unwrap(); - assert_eq!( - calculate_change_output_value(&context, false, &ScriptBuf::new(), 100), - Ok(Some(gross_change - fees)), - ); - - // Larger fee, smaller change - let context = FundingNegotiationContext { - is_initiator: true, - our_funding_contribution: SignedAmount::from_sat(110_000), - funding_feerate_sat_per_1000_weight: funding_feerate_sat_per_1000_weight * 3, - ..context - }; - let gross_change = - total_inputs - total_outputs - context.our_funding_contribution.to_unsigned().unwrap(); - assert_eq!( - calculate_change_output_value(&context, false, &ScriptBuf::new(), 300), - Ok(Some(gross_change - fees * 3 - common_fees * 3)), - ); - } - fn do_verify_tx_signatures( transaction: Transaction, prev_outputs: Vec, ) -> Result<(), String> { diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index f7c4700c8d7..66ff2e8392c 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -1684,28 +1684,23 @@ fn do_test_splice_reestablish(reload: bool, async_monitor_update: bool) { #[test] fn test_propose_splice_while_disconnected() { - do_test_propose_splice_while_disconnected(false, false); - do_test_propose_splice_while_disconnected(false, true); - do_test_propose_splice_while_disconnected(true, false); - do_test_propose_splice_while_disconnected(true, true); + do_test_propose_splice_while_disconnected(false); + do_test_propose_splice_while_disconnected(true); } #[cfg(test)] -fn do_test_propose_splice_while_disconnected(reload: bool, use_0conf: bool) { +fn do_test_propose_splice_while_disconnected(use_0conf: bool) { // Test that both nodes are able to propose a splice while the counterparty is disconnected, and // whoever doesn't go first due to the quiescence tie-breaker, will retry their splice after the // first one becomes locked. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - let (persister_0a, persister_0b, persister_1a, persister_1b); - let (chain_monitor_0a, chain_monitor_0b, chain_monitor_1a, chain_monitor_1b); let mut config = test_default_channel_config(); if use_0conf { config.channel_handshake_limits.trust_own_funding_0conf = true; } let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); - let (node_0a, node_0b, node_1a, node_1b); - let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let node_id_0 = nodes[0].node.get_our_node_id(); let node_id_1 = nodes[1].node.get_our_node_id(); @@ -1743,15 +1738,8 @@ fn do_test_propose_splice_while_disconnected(reload: bool, use_0conf: bool) { value: Amount::from_sat(splice_out_sat), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]; - let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); - let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); let node_0_funding_contribution = - funding_template.splice_out_sync(node_0_outputs, &wallet).unwrap(); - nodes[0] - .node - .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) - .unwrap(); + initiate_splice_out(&nodes[0], &nodes[1], channel_id, node_0_outputs).unwrap(); assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); @@ -1759,38 +1747,11 @@ fn do_test_propose_splice_while_disconnected(reload: bool, use_0conf: bool) { value: Amount::from_sat(splice_out_sat), script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), }]; - let funding_template = nodes[1].node.splice_channel(&channel_id, &node_id_0, feerate).unwrap(); - let wallet = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); let node_1_funding_contribution = - funding_template.splice_out_sync(node_1_outputs, &wallet).unwrap(); - nodes[1] - .node - .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) - .unwrap(); + initiate_splice_out(&nodes[1], &nodes[0], channel_id, node_1_outputs).unwrap(); assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); - if reload { - let encoded_monitor_0 = get_monitor!(nodes[0], channel_id).encode(); - reload_node!( - nodes[0], - nodes[0].node.encode(), - &[&encoded_monitor_0], - persister_0a, - chain_monitor_0a, - node_0a - ); - let encoded_monitor_1 = get_monitor!(nodes[1], channel_id).encode(); - reload_node!( - nodes[1], - nodes[1].node.encode(), - &[&encoded_monitor_1], - persister_1a, - chain_monitor_1a, - node_1a - ); - } - // Reconnect the nodes. Both nodes should attempt quiescence as the initiator, but only one will // be it via the tie-breaker. let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); @@ -1911,29 +1872,8 @@ fn do_test_propose_splice_while_disconnected(reload: bool, use_0conf: bool) { // Reconnect the nodes. This should trigger the node which lost the tie-breaker to resend `stfu` // for their splice attempt. - if reload { - let encoded_monitor_0 = get_monitor!(nodes[0], channel_id).encode(); - reload_node!( - nodes[0], - nodes[0].node.encode(), - &[&encoded_monitor_0], - persister_0b, - chain_monitor_0b, - node_0b - ); - let encoded_monitor_1 = get_monitor!(nodes[1], channel_id).encode(); - reload_node!( - nodes[1], - nodes[1].node.encode(), - &[&encoded_monitor_1], - persister_1b, - chain_monitor_1b, - node_1b - ); - } else { - nodes[0].node.peer_disconnected(node_id_1); - nodes[1].node.peer_disconnected(node_id_0); - } + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); if !use_0conf { reconnect_args.send_announcement_sigs = (true, true); From 77ce89bb51caa5e3c4dbd95cd9df42f2a977b843 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 11 Feb 2026 22:44:01 -0600 Subject: [PATCH 127/627] Split InteractiveTxConstructor::new into outbound/inbound variants Replace the single public InteractiveTxConstructor::new() with separate new_for_outbound() and new_for_inbound() constructors. This moves the initiator's first message preparation out of the core constructor, making it infallible and removing is_initiator from the args struct. Callers no longer need to handle constructor errors, which avoids having to generate SpliceFailed/DiscardFunding events after the QuiescentAction has already been consumed during splice_init/splice_ack handling. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 44 +++---- lightning/src/ln/interactivetxs.rs | 196 +++++++++++++---------------- 2 files changed, 105 insertions(+), 135 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 9dff6095696..a87fff220b1 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -58,8 +58,7 @@ use crate::ln::channelmanager::{ use crate::ln::funding::{FundingContribution, FundingTemplate, FundingTxInput}; use crate::ln::interactivetxs::{ AbortReason, HandleTxCompleteValue, InteractiveTxConstructor, InteractiveTxConstructorArgs, - InteractiveTxMessageSend, InteractiveTxSigningSession, NegotiationError, SharedOwnedInput, - SharedOwnedOutput, + InteractiveTxMessageSend, InteractiveTxSigningSession, SharedOwnedInput, SharedOwnedOutput, }; use crate::ln::msgs; use crate::ln::msgs::{ClosingSigned, ClosingSignedFeeRange, DecodeError, OnionErrorPacket}; @@ -6346,7 +6345,7 @@ impl FundingNegotiationContext { fn into_interactive_tx_constructor( self, context: &ChannelContext, funding: &FundingScope, entropy_source: &ES, holder_node_id: PublicKey, - ) -> Result { + ) -> (InteractiveTxConstructor, Option) { debug_assert_eq!( self.shared_funding_input.is_some(), funding.channel_transaction_parameters.splice_parent_funding_txid.is_some(), @@ -6369,7 +6368,6 @@ impl FundingNegotiationContext { counterparty_node_id: context.counterparty_node_id, channel_id: context.channel_id(), feerate_sat_per_kw: self.funding_feerate_sat_per_1000_weight, - is_initiator: self.is_initiator, funding_tx_locktime: self.funding_tx_locktime, inputs_to_contribute: self.our_funding_inputs, shared_funding_input: self.shared_funding_input, @@ -6379,7 +6377,11 @@ impl FundingNegotiationContext { ), outputs_to_contribute: self.our_funding_outputs, }; - InteractiveTxConstructor::new(constructor_args) + if self.is_initiator { + InteractiveTxConstructor::new_for_outbound(constructor_args) + } else { + (InteractiveTxConstructor::new_for_inbound(constructor_args), None) + } } fn into_contributed_inputs_and_outputs(self) -> (Vec, Vec) { @@ -12085,20 +12087,14 @@ where our_funding_outputs: Vec::new(), }; - let mut interactive_tx_constructor = funding_negotiation_context + let (interactive_tx_constructor, first_message) = funding_negotiation_context .into_interactive_tx_constructor( &self.context, &splice_funding, entropy_source, holder_node_id.clone(), - ) - .map_err(|err| { - ChannelError::WarnAndDisconnect(format!( - "Failed to start interactive transaction construction, {:?}", - err - )) - })?; - debug_assert!(interactive_tx_constructor.take_initiator_first_message().is_none()); + ); + debug_assert!(first_message.is_none()); // TODO(splicing): if quiescent_action is set, integrate what the user wants to do into the // counterparty-initiated splice. For always-on nodes this probably isn't a useful @@ -12150,20 +12146,14 @@ where panic!("We should have returned an error earlier!"); }; - let mut interactive_tx_constructor = funding_negotiation_context + let (interactive_tx_constructor, tx_msg_opt) = funding_negotiation_context .into_interactive_tx_constructor( &self.context, &splice_funding, entropy_source, holder_node_id.clone(), - ) - .map_err(|err| { - ChannelError::WarnAndDisconnect(format!( - "Failed to start interactive transaction construction, {:?}", - err - )) - })?; - let tx_msg_opt = interactive_tx_constructor.take_initiator_first_message(); + ); + debug_assert!(tx_msg_opt.is_some()); debug_assert!(self.context.interactive_tx_signing_session.is_none()); @@ -14039,7 +14029,7 @@ impl PendingV2Channel { script_pubkey: funding.get_funding_redeemscript().to_p2wsh(), }; - let interactive_tx_constructor = Some(InteractiveTxConstructor::new( + let interactive_tx_constructor = Some(InteractiveTxConstructor::new_for_inbound( InteractiveTxConstructorArgs { entropy_source, holder_node_id, @@ -14047,16 +14037,12 @@ impl PendingV2Channel { channel_id: context.channel_id, feerate_sat_per_kw: funding_negotiation_context.funding_feerate_sat_per_1000_weight, funding_tx_locktime: funding_negotiation_context.funding_tx_locktime, - is_initiator: false, inputs_to_contribute: our_funding_inputs, shared_funding_input: None, shared_funding_output: SharedOwnedOutput::new(shared_funding_output, our_funding_contribution_sats), outputs_to_contribute: funding_negotiation_context.our_funding_outputs.clone(), } - ).map_err(|err| { - let reason = ClosureReason::ProcessingError { err: err.reason.to_string() }; - ChannelError::Close((err.reason.to_string(), reason)) - })?); + )); let unfunded_context = UnfundedChannelContext { unfunded_channel_age_ticks: 0, diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index 17dab1918c0..f7e0ce34346 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -1951,7 +1951,6 @@ impl InteractiveTxInput { pub(super) struct InteractiveTxConstructor { state_machine: StateMachine, is_initiator: bool, - initiator_first_message: Option, channel_id: ChannelId, inputs_to_contribute: Vec<(SerialId, InputOwned)>, outputs_to_contribute: Vec<(SerialId, OutputOwned)>, @@ -2020,7 +2019,6 @@ pub(super) struct InteractiveTxConstructorArgs<'a, ES: EntropySource> { pub counterparty_node_id: PublicKey, pub channel_id: ChannelId, pub feerate_sat_per_kw: u32, - pub is_initiator: bool, pub funding_tx_locktime: AbsoluteLockTime, pub inputs_to_contribute: Vec, pub shared_funding_input: Option, @@ -2031,18 +2029,15 @@ pub(super) struct InteractiveTxConstructorArgs<'a, ES: EntropySource> { impl InteractiveTxConstructor { /// Instantiates a new `InteractiveTxConstructor`. /// - /// If the holder is the initiator, they need to send the first message which is a `TxAddInput` - /// message. - pub fn new( - args: InteractiveTxConstructorArgs, - ) -> Result { + /// Use [`Self::new_for_outbound`] or [`Self::new_for_inbound`] instead to also prepare the + /// first message for the initiator. + fn new(args: InteractiveTxConstructorArgs, is_initiator: bool) -> Self { let InteractiveTxConstructorArgs { entropy_source, holder_node_id, counterparty_node_id, channel_id, feerate_sat_per_kw, - is_initiator, funding_tx_locktime, inputs_to_contribute, shared_funding_input, @@ -2112,28 +2107,43 @@ impl InteractiveTxConstructor { let next_input_index = (!inputs_to_contribute.is_empty()).then_some(0); let next_output_index = (!outputs_to_contribute.is_empty()).then_some(0); - let mut constructor = Self { + Self { state_machine, is_initiator, - initiator_first_message: None, channel_id, inputs_to_contribute, outputs_to_contribute, next_input_index, next_output_index, - }; - // We'll store the first message for the initiator. - if is_initiator { - match constructor.maybe_send_message() { - Ok(message) => { - constructor.initiator_first_message = Some(message); - }, - Err(reason) => { - return Err(constructor.into_negotiation_error(reason)); - }, - } } - Ok(constructor) + } + + /// Instantiates a new `InteractiveTxConstructor` for the initiator (outbound splice). + /// + /// The initiator always has the shared funding output added internally, so preparing the + /// first message should never fail. Debug asserts verify this invariant. + pub fn new_for_outbound( + args: InteractiveTxConstructorArgs, + ) -> (Self, Option) { + let mut constructor = Self::new(args, true); + let message = match constructor.maybe_send_message() { + Ok(message) => Some(message), + Err(reason) => { + debug_assert!( + false, + "Outbound constructor should always have inputs: {:?}", + reason + ); + None + }, + }; + (constructor, message) + } + + /// Instantiates a new `InteractiveTxConstructor` for the non-initiator (inbound splice or + /// dual-funded channel acceptor). + pub fn new_for_inbound(args: InteractiveTxConstructorArgs) -> Self { + Self::new(args, false) } fn into_negotiation_error(self, reason: AbortReason) -> NegotiationError { @@ -2179,10 +2189,6 @@ impl InteractiveTxConstructor { self.is_initiator } - pub fn take_initiator_first_message(&mut self) -> Option { - self.initiator_first_message.take() - } - fn maybe_send_message(&mut self) -> Result { let channel_id = self.channel_id; @@ -2438,84 +2444,64 @@ mod tests { &SecretKey::from_slice(&[43; 32]).unwrap(), ); - let mut constructor_a = match InteractiveTxConstructor::new(InteractiveTxConstructorArgs { - entropy_source, - channel_id, - feerate_sat_per_kw: TEST_FEERATE_SATS_PER_KW, - holder_node_id, - counterparty_node_id, - is_initiator: true, - funding_tx_locktime, - inputs_to_contribute: session.inputs_a, - shared_funding_input: session.a_shared_input.map(|(op, prev_output, lo)| { - SharedOwnedInput::new( - TxIn { - previous_output: op, - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - ..Default::default() - }, - prev_output, - lo, - true, // holder_sig_first - generate_funding_script_pubkey(), // witness_script for test - ) - }), - shared_funding_output: SharedOwnedOutput::new( - session.shared_output_a.0, - session.shared_output_a.1, - ), - outputs_to_contribute: session.outputs_a, - }) { - Ok(r) => Some(r), - Err(e) => { - assert_eq!( - Some((e.reason, ErrorCulprit::NodeA)), - session.expect_error, - "Test: {}", - session.description - ); - return; - }, - }; - let mut constructor_b = match InteractiveTxConstructor::new(InteractiveTxConstructorArgs { - entropy_source, - holder_node_id, - counterparty_node_id, - channel_id, - feerate_sat_per_kw: TEST_FEERATE_SATS_PER_KW, - is_initiator: false, - funding_tx_locktime, - inputs_to_contribute: session.inputs_b, - shared_funding_input: session.b_shared_input.map(|(op, prev_output, lo)| { - SharedOwnedInput::new( - TxIn { - previous_output: op, - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - ..Default::default() - }, - prev_output, - lo, - false, // holder_sig_first - generate_funding_script_pubkey(), // witness_script for test - ) - }), - shared_funding_output: SharedOwnedOutput::new( - session.shared_output_b.0, - session.shared_output_b.1, - ), - outputs_to_contribute: session.outputs_b, - }) { - Ok(r) => Some(r), - Err(e) => { - assert_eq!( - Some((e.reason, ErrorCulprit::NodeB)), - session.expect_error, - "Test: {}", - session.description - ); - return; - }, - }; + let (constructor_a, mut message_send_a) = + InteractiveTxConstructor::new_for_outbound(InteractiveTxConstructorArgs { + entropy_source, + channel_id, + feerate_sat_per_kw: TEST_FEERATE_SATS_PER_KW, + holder_node_id, + counterparty_node_id, + funding_tx_locktime, + inputs_to_contribute: session.inputs_a, + shared_funding_input: session.a_shared_input.map(|(op, prev_output, lo)| { + SharedOwnedInput::new( + TxIn { + previous_output: op, + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + ..Default::default() + }, + prev_output, + lo, + true, // holder_sig_first + generate_funding_script_pubkey(), // witness_script for test + ) + }), + shared_funding_output: SharedOwnedOutput::new( + session.shared_output_a.0, + session.shared_output_a.1, + ), + outputs_to_contribute: session.outputs_a, + }); + let mut constructor_a = Some(constructor_a); + let mut constructor_b = + Some(InteractiveTxConstructor::new_for_inbound(InteractiveTxConstructorArgs { + entropy_source, + holder_node_id, + counterparty_node_id, + channel_id, + feerate_sat_per_kw: TEST_FEERATE_SATS_PER_KW, + funding_tx_locktime, + inputs_to_contribute: session.inputs_b, + shared_funding_input: session.b_shared_input.map(|(op, prev_output, lo)| { + SharedOwnedInput::new( + TxIn { + previous_output: op, + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + ..Default::default() + }, + prev_output, + lo, + false, // holder_sig_first + generate_funding_script_pubkey(), // witness_script for test + ) + }), + shared_funding_output: SharedOwnedOutput::new( + session.shared_output_b.0, + session.shared_output_b.1, + ), + outputs_to_contribute: session.outputs_b, + })); + let mut message_send_b = None; let handle_message_send = |msg: InteractiveTxMessageSend, for_constructor: &mut InteractiveTxConstructor| { @@ -2539,8 +2525,6 @@ mod tests { } }; - let mut message_send_a = constructor_a.as_mut().unwrap().take_initiator_first_message(); - let mut message_send_b = None; let mut final_tx_a = None; let mut final_tx_b = None; while constructor_a.is_some() || constructor_b.is_some() { From d9327ba93171f1080e6285b03d6513136b001ca5 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 23 Feb 2026 10:46:11 -0600 Subject: [PATCH 128/627] Include change output weight in estimate_transaction_fee Add a `change_output: Option<&TxOut>` parameter to `estimate_transaction_fee` so the initial fee estimate accounts for the change output's weight. Previously, the change output weight was omitted from `estimated_fee` in `FundingContribution`, causing the estimate to be slightly too low when a change output was present. This also eliminates an unnecessary `Vec` allocation in `compute_feerate_adjustment`, which previously cloned outputs into a temporary Vec just to include the change output for the fee estimate. A mock `TightBudgetWallet` is added to `splicing_tests` to demonstrate that `validate()` correctly rejects contributions where the input value is sufficient without the change output weight but insufficient with it. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/funding.rs | 65 ++++++++++++++++++------ lightning/src/ln/splicing_tests.rs | 81 +++++++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 17 deletions(-) diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 1d1762ad2ad..18c05a50bbf 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -116,7 +116,7 @@ macro_rules! build_funding_contribution { // The caller creating a FundingContribution is always the initiator for fee estimation // purposes — this is conservative, overestimating rather than underestimating fees if // the node ends up as the acceptor. - let estimated_fee = estimate_transaction_fee(&inputs, &outputs, true, is_splice, feerate); + let estimated_fee = estimate_transaction_fee(&inputs, &outputs, change_output.as_ref(), true, is_splice, feerate); debug_assert!(estimated_fee <= Amount::MAX_MONEY); let contribution = FundingContribution { @@ -208,8 +208,8 @@ impl FundingTemplate { } fn estimate_transaction_fee( - inputs: &[FundingTxInput], outputs: &[TxOut], is_initiator: bool, is_splice: bool, - feerate: FeeRate, + inputs: &[FundingTxInput], outputs: &[TxOut], change_output: Option<&TxOut>, + is_initiator: bool, is_splice: bool, feerate: FeeRate, ) -> Amount { let input_weight: u64 = inputs .iter() @@ -218,6 +218,7 @@ fn estimate_transaction_fee( let output_weight: u64 = outputs .iter() + .chain(change_output.into_iter()) .map(|txout| txout.weight().to_wu()) .fold(0, |total_weight, output_weight| total_weight.saturating_add(output_weight)); @@ -303,6 +304,14 @@ impl FundingContribution { self.outputs.iter().chain(self.change_output.iter()) } + /// Returns the change output included in this contribution, if any. + /// + /// When coin selection provides more value than needed for the funding contribution and fees, + /// the surplus is returned to the wallet via this change output. + pub fn change_output(&self) -> Option<&TxOut> { + self.change_output.as_ref() + } + pub(super) fn into_tx_parts(self) -> (Vec, Vec) { let FundingContribution { inputs, mut outputs, change_output, .. } = self; @@ -372,11 +381,11 @@ impl FundingContribution { .ok_or("Sum of input values is greater than the total bitcoin supply")?; } - // If the inputs are enough to cover intended contribution amount, with fees even when - // there is a change output, we are fine. - // If the inputs are less, but enough to cover intended contribution amount, with - // (lower) fees with no change, we are also fine (change will not be generated). - // So it's enough to check considering the lower, no-change fees. + // If the inputs are enough to cover intended contribution amount plus fees (which + // include the change output weight when present), we are fine. + // If the inputs are less, but enough to cover intended contribution amount with + // (lower) fees without change, we are also fine (change will not be generated). + // Since estimated_fee includes change weight, this check is conservative. // // Note: dust limit is not relevant in this check. @@ -442,45 +451,71 @@ mod tests { // 2 inputs, initiator, 2000 sat/kw feerate assert_eq!( - estimate_transaction_fee(&two_inputs, &[], true, false, FeeRate::from_sat_per_kwu(2000)), + estimate_transaction_fee(&two_inputs, &[], None, true, false, FeeRate::from_sat_per_kwu(2000)), Amount::from_sat(if cfg!(feature = "grind_signatures") { 1512 } else { 1516 }), ); // higher feerate assert_eq!( - estimate_transaction_fee(&two_inputs, &[], true, false, FeeRate::from_sat_per_kwu(3000)), + estimate_transaction_fee(&two_inputs, &[], None, true, false, FeeRate::from_sat_per_kwu(3000)), Amount::from_sat(if cfg!(feature = "grind_signatures") { 2268 } else { 2274 }), ); // only 1 input assert_eq!( - estimate_transaction_fee(&one_input, &[], true, false, FeeRate::from_sat_per_kwu(2000)), + estimate_transaction_fee(&one_input, &[], None, true, false, FeeRate::from_sat_per_kwu(2000)), Amount::from_sat(if cfg!(feature = "grind_signatures") { 970 } else { 972 }), ); // 0 inputs assert_eq!( - estimate_transaction_fee(&[], &[], true, false, FeeRate::from_sat_per_kwu(2000)), + estimate_transaction_fee(&[], &[], None, true, false, FeeRate::from_sat_per_kwu(2000)), Amount::from_sat(428), ); // not initiator assert_eq!( - estimate_transaction_fee(&[], &[], false, false, FeeRate::from_sat_per_kwu(2000)), + estimate_transaction_fee(&[], &[], None, false, false, FeeRate::from_sat_per_kwu(2000)), Amount::from_sat(0), ); // splice initiator assert_eq!( - estimate_transaction_fee(&one_input, &[], true, true, FeeRate::from_sat_per_kwu(2000)), + estimate_transaction_fee(&one_input, &[], None, true, true, FeeRate::from_sat_per_kwu(2000)), Amount::from_sat(if cfg!(feature = "grind_signatures") { 1736 } else { 1740 }), ); // splice acceptor assert_eq!( - estimate_transaction_fee(&one_input, &[], false, true, FeeRate::from_sat_per_kwu(2000)), + estimate_transaction_fee(&one_input, &[], None, false, true, FeeRate::from_sat_per_kwu(2000)), Amount::from_sat(if cfg!(feature = "grind_signatures") { 542 } else { 544 }), ); + + // splice initiator, 1 input, 1 output + let outputs = [funding_output_sats(500)]; + assert_eq!( + estimate_transaction_fee(&one_input, &outputs, None, true, true, FeeRate::from_sat_per_kwu(2000)), + Amount::from_sat(if cfg!(feature = "grind_signatures") { 1984 } else { 1988 }), + ); + + // splice acceptor, 1 input, 1 output + assert_eq!( + estimate_transaction_fee(&one_input, &outputs, None, false, true, FeeRate::from_sat_per_kwu(2000)), + Amount::from_sat(if cfg!(feature = "grind_signatures") { 790 } else { 792 }), + ); + + // splice initiator, 1 input, 1 output, 1 change via change_output parameter + let change = funding_output_sats(1_000); + assert_eq!( + estimate_transaction_fee(&one_input, &outputs, Some(&change), true, true, FeeRate::from_sat_per_kwu(2000)), + Amount::from_sat(if cfg!(feature = "grind_signatures") { 2232 } else { 2236 }), + ); + + // splice acceptor, 1 input, 1 output, 1 change via change_output parameter + assert_eq!( + estimate_transaction_fee(&one_input, &outputs, Some(&change), false, true, FeeRate::from_sat_per_kwu(2000)), + Amount::from_sat(if cfg!(feature = "grind_signatures") { 1038 } else { 1040 }), + ); } #[rustfmt::skip] diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 66ff2e8392c..9bcc47364e1 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -29,15 +29,18 @@ use crate::types::features::ChannelTypeFeatures; use crate::util::config::UserConfig; use crate::util::errors::APIError; use crate::util::ser::Writeable; -use crate::util::wallet_utils::{WalletSourceSync, WalletSync}; +use crate::util::wallet_utils::{ + CoinSelection, CoinSelectionSourceSync, ConfirmedUtxo, Input, WalletSourceSync, WalletSync, +}; use crate::sync::Arc; use bitcoin::hashes::Hash; use bitcoin::secp256k1::ecdsa::Signature; use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use bitcoin::transaction::Version; use bitcoin::{ - Amount, FeeRate, OutPoint as BitcoinOutPoint, ScriptBuf, Transaction, TxOut, WPubkeyHash, + Amount, FeeRate, OutPoint as BitcoinOutPoint, Psbt, ScriptBuf, Transaction, TxOut, WPubkeyHash, }; #[test] @@ -116,6 +119,80 @@ fn test_v1_splice_in_negative_insufficient_inputs() { assert!(funding_template.splice_in_sync(splice_in_value, &wallet).is_err()); } +/// A mock wallet that returns a pre-configured [`CoinSelection`] with a single input and change +/// output. Used to test edge cases where the input value is tight relative to the fee estimate. +#[cfg(test)] +struct TightBudgetWallet { + utxo_value: Amount, + change_value: Amount, +} + +#[cfg(test)] +impl CoinSelectionSourceSync for TightBudgetWallet { + fn select_confirmed_utxos( + &self, _claim_id: Option, _must_spend: Vec, + _must_pay_to: &[TxOut], _target_feerate_sat_per_1000_weight: u32, _max_tx_weight: u64, + ) -> Result { + let prevout = TxOut { + value: self.utxo_value, + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()), + }; + let prevtx = Transaction { + input: vec![], + output: vec![prevout], + version: Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + }; + let utxo = ConfirmedUtxo::new_p2wpkh(prevtx, 0).unwrap(); + + let change_output = TxOut { + value: self.change_value, + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()), + }; + + Ok(CoinSelection { confirmed_utxos: vec![utxo], change_output: Some(change_output) }) + } + + fn sign_psbt(&self, _psbt: Psbt) -> Result { + unreachable!("should not reach signing") + } +} + +#[test] +fn test_validate_accounts_for_change_output_weight() { + // Demonstrates that estimated_fee includes the change output's weight when building a + // FundingContribution. A mock wallet returns a single input whose value is between + // estimated_fee_without_change (1736/1740 sats) and estimated_fee_with_change (1984/1988 + // sats) above value_added. The validate() check correctly catches that the inputs are + // insufficient when the change output weight is included. Without accounting for the change + // output weight, the check would incorrectly pass. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); + + let feerate = FeeRate::from_sat_per_kwu(2000); + let funding_template = nodes[0] + .node + .splice_channel(&channel_id, &nodes[1].node.get_our_node_id(), feerate) + .unwrap(); + + // Input value = value_added + 1800: above 1736/1740 (fee without change), below 1984/1988 + // (fee with change). + let value_added = Amount::from_sat(20_000); + let wallet = TightBudgetWallet { + utxo_value: value_added + Amount::from_sat(1800), + change_value: Amount::from_sat(1000), + }; + let contribution = funding_template.splice_in_sync(value_added, &wallet).unwrap(); + + assert!(contribution.change_output().is_some()); + assert!(contribution.validate().is_err()); +} + pub fn negotiate_splice_tx<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, funding_contribution: FundingContribution, From 7942c7458e79647fa1ae92b1535094b986118390 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 19 Feb 2026 15:19:12 -0600 Subject: [PATCH 129/627] Adjust FundingContribution for acceptor When constructing a FundingContribution, it's always assumed the estimated_fee is for when used as the initiator, who pays for the common fields and shared inputs / outputs. However, when the contribution is used as the acceptor, we'd be overpaying fees. Additionally, the initiator's chosen fee rate may not be compatible with the acceptors contributions. The selected UTXOs may not be enough to pay for a higher feerate (i.e., the change output is not enough to pay or there is no change output). This change provides a method on FundingContribution for adjusting the fee rate with the above concerns in mind. It also updates it to include a max_feerate specified by the user when initiating a splice. This ensures the acceptor isn't forced to pay an overly high fee rate. Co-Authored-By: Claude Opus 4.6 --- fuzz/src/chanmon_consistency.rs | 7 +- fuzz/src/full_stack.rs | 2 + lightning/src/ln/channel.rs | 17 +- lightning/src/ln/channelmanager.rs | 14 +- lightning/src/ln/funding.rs | 1067 +++++++++++++++++++++++++++- lightning/src/ln/splicing_tests.rs | 73 +- 6 files changed, 1120 insertions(+), 60 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 476362324ad..22006897a0f 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1393,7 +1393,12 @@ pub fn do_test( channel_id: &ChannelId, f: &dyn Fn(FundingTemplate) -> Result, funding_feerate_sat_per_kw: FeeRate| { - match node.splice_channel(channel_id, counterparty_node_id, funding_feerate_sat_per_kw) { + match node.splice_channel( + channel_id, + counterparty_node_id, + funding_feerate_sat_per_kw, + FeeRate::MAX, + ) { Ok(funding_template) => { if let Ok(contribution) = f(funding_template) { let _ = node.funding_contributed( diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index 03d5e48a014..5dfa51079d8 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -1036,6 +1036,7 @@ pub fn do_test(mut data: &[u8], logger: &Arc &chan_id, &counterparty, FeeRate::from_sat_per_kwu(253), + FeeRate::MAX, ) { let wallet_sync = WalletSync::new(&wallet, Arc::clone(&logger)); if let Ok(contribution) = funding_template @@ -1076,6 +1077,7 @@ pub fn do_test(mut data: &[u8], logger: &Arc &chan_id, &counterparty, FeeRate::from_sat_per_kwu(253), + FeeRate::MAX, ) { let outputs = vec![TxOut { value: Amount::from_sat(splice_out_sats), diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index a87fff220b1..2c1117a46c6 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -11708,7 +11708,9 @@ where } /// Initiate splicing. - pub fn splice_channel(&self, feerate: FeeRate) -> Result { + pub fn splice_channel( + &self, min_feerate: FeeRate, max_feerate: FeeRate, + ) -> Result { if self.holder_commitment_point.current_point().is_none() { return Err(APIError::APIMisuseError { err: format!( @@ -11750,6 +11752,17 @@ where }); } + if min_feerate > max_feerate { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} min_feerate {} exceeds max_feerate {}", + self.context.channel_id(), + min_feerate, + max_feerate, + ), + }); + } + let funding_txo = self.funding.get_funding_txo().expect("funding_txo should be set"); let previous_utxo = self.funding.get_funding_output().expect("funding_output should be set"); @@ -11759,7 +11772,7 @@ where satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + FUNDING_TRANSACTION_WITNESS_WEIGHT, }; - Ok(FundingTemplate::new(Some(shared_input), feerate)) + Ok(FundingTemplate::new(Some(shared_input), min_feerate, max_feerate)) } pub fn funding_contributed( diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 8e061291daf..19767de3347 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -4642,9 +4642,12 @@ impl< /// # Arguments /// /// The splice initiator is responsible for paying fees for common fields, shared inputs, and - /// shared outputs along with any contributed inputs and outputs. Fees are determined using - /// `feerate` and must be covered by the supplied inputs for splice-in or the channel balance - /// for splice-out. + /// shared outputs along with any contributed inputs and outputs. When building a + /// [`FundingContribution`], fees are estimated using `min_feerate` and must be covered by the + /// supplied inputs for splice-in or the channel balance for splice-out. If the counterparty + /// also initiates a splice and wins the tie-break, they become the initiator and choose the + /// feerate. In that case, `max_feerate` is used to reject a feerate that is too high for our + /// contribution. /// /// Returns a [`FundingTemplate`] which should be used to build a [`FundingContribution`] via /// one of its splice methods (e.g., [`FundingTemplate::splice_in_sync`]). The resulting @@ -4670,7 +4673,8 @@ impl< /// [`FundingContribution`]: crate::ln::funding::FundingContribution #[rustfmt::skip] pub fn splice_channel( - &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, feerate: FeeRate, + &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, + min_feerate: FeeRate, max_feerate: FeeRate, ) -> Result { let per_peer_state = self.per_peer_state.read().unwrap(); @@ -4698,7 +4702,7 @@ impl< match peer_state.channel_by_id.entry(*channel_id) { hash_map::Entry::Occupied(chan_phase_entry) => { if let Some(chan) = chan_phase_entry.get().as_funded() { - chan.splice_channel(feerate) + chan.splice_channel(min_feerate, max_feerate) } else { Err(APIError::ChannelUnavailable { err: format!( diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 18c05a50bbf..84c9d4dd343 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -27,6 +27,75 @@ use crate::util::wallet_utils::{ CoinSelection, CoinSelectionSource, CoinSelectionSourceSync, Input, }; +/// Error returned when the acceptor's contribution cannot accommodate the initiator's proposed +/// feerate. +/// +/// When building a [`FundingContribution`], fees are estimated at `min_feerate` assuming initiator +/// responsibility. If the counterparty also initiates a splice and wins the tie-break, they become +/// the initiator and choose the feerate. The fee is then re-estimated at the counterparty's +/// feerate for only our contributed inputs and outputs. When this re-estimation fails, the +/// contribution is dropped and the counterparty's splice proceeds without it. +/// +/// See [`ChannelManager::splice_channel`] for further details. +/// +/// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel +#[derive(Debug)] +pub(super) enum FeeRateAdjustmentError { + /// The counterparty's proposed feerate is below `min_feerate`, which was used as the feerate + /// during coin selection. + FeeRateTooLow { target_feerate: FeeRate, min_feerate: FeeRate }, + /// The counterparty's proposed feerate is above `max_feerate` and the re-estimated fee for + /// our contributed inputs and outputs exceeds the original fee estimate (computed at + /// `min_feerate` assuming initiator responsibility). If the re-estimated fee were within the + /// original estimate, a feerate above `max_feerate` would be tolerable since the acceptor + /// doesn't pay for common fields or the shared input/output. + FeeRateTooHigh { + target_feerate: FeeRate, + max_feerate: FeeRate, + target_fee: Amount, + original_fee: Amount, + }, + /// Arithmetic overflow when computing the fee buffer. + FeeBufferOverflow, + /// The re-estimated fee exceeds the available fee buffer regardless of `max_feerate`. The fee + /// buffer is the maximum fee that can be accommodated: + /// - **splice-in**: the selected inputs' value minus the contributed amount + /// - **splice-out**: the channel balance minus the withdrawal outputs + FeeBufferInsufficient { source: &'static str, available: Amount, required: Amount }, +} + +impl core::fmt::Display for FeeRateAdjustmentError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + FeeRateAdjustmentError::FeeRateTooLow { target_feerate, min_feerate } => { + write!(f, "Target feerate {} is below our minimum {}", target_feerate, min_feerate) + }, + FeeRateAdjustmentError::FeeRateTooHigh { + target_feerate, + max_feerate, + target_fee, + original_fee, + } => { + write!( + f, + "Target feerate {} exceeds our maximum {} and target fee {} exceeds original fee estimate {}", + target_feerate, max_feerate, target_fee, original_fee, + ) + }, + FeeRateAdjustmentError::FeeBufferOverflow => { + write!(f, "Arithmetic overflow when computing available fee buffer") + }, + FeeRateAdjustmentError::FeeBufferInsufficient { source, available, required } => { + write!( + f, + "Fee buffer {} ({}) is insufficient for required fee {}", + available, source, required, + ) + }, + } + } +} + /// A template for contributing to a channel's splice funding transaction. /// /// This is returned from [`ChannelManager::splice_channel`] when a channel is ready to be @@ -42,23 +111,30 @@ pub struct FundingTemplate { /// transaction. shared_input: Option, - /// The fee rate to use for coin selection. - feerate: FeeRate, + /// The minimum fee rate for the splice transaction, used to propose as initiator. + min_feerate: FeeRate, + + /// The maximum fee rate to accept as acceptor before declining to add our contribution to the + /// splice. + max_feerate: FeeRate, } impl FundingTemplate { /// Constructs a [`FundingTemplate`] for a splice using the provided shared input. - pub(super) fn new(shared_input: Option, feerate: FeeRate) -> Self { - Self { shared_input, feerate } + pub(super) fn new( + shared_input: Option, min_feerate: FeeRate, max_feerate: FeeRate, + ) -> Self { + Self { shared_input, min_feerate, max_feerate } } } macro_rules! build_funding_contribution { - ($value_added:expr, $outputs:expr, $shared_input:expr, $feerate:expr, $wallet:ident, $($await:tt)*) => {{ + ($value_added:expr, $outputs:expr, $shared_input:expr, $feerate:expr, $max_feerate:expr, $wallet:ident, $($await:tt)*) => {{ let value_added: Amount = $value_added; let outputs: Vec = $outputs; let shared_input: Option = $shared_input; let feerate: FeeRate = $feerate; + let max_feerate: FeeRate = $max_feerate; // Validate user-provided amounts are within MAX_MONEY before coin selection to // ensure FundingContribution::net_value() arithmetic cannot overflow. With all @@ -126,6 +202,7 @@ macro_rules! build_funding_contribution { outputs, change_output, feerate, + max_feerate, is_splice, }; @@ -142,8 +219,8 @@ impl FundingTemplate { if value_added == Amount::ZERO { return Err(()); } - let FundingTemplate { shared_input, feerate } = self; - build_funding_contribution!(value_added, vec![], shared_input, feerate, wallet, await) + let FundingTemplate { shared_input, min_feerate, max_feerate } = self; + build_funding_contribution!(value_added, vec![], shared_input, min_feerate, max_feerate, wallet, await) } /// Creates a [`FundingContribution`] for adding funds to a channel using `wallet` to perform @@ -154,8 +231,15 @@ impl FundingTemplate { if value_added == Amount::ZERO { return Err(()); } - let FundingTemplate { shared_input, feerate } = self; - build_funding_contribution!(value_added, vec![], shared_input, feerate, wallet,) + let FundingTemplate { shared_input, min_feerate, max_feerate } = self; + build_funding_contribution!( + value_added, + vec![], + shared_input, + min_feerate, + max_feerate, + wallet, + ) } /// Creates a [`FundingContribution`] for removing funds from a channel using `wallet` to @@ -166,8 +250,8 @@ impl FundingTemplate { if outputs.is_empty() { return Err(()); } - let FundingTemplate { shared_input, feerate } = self; - build_funding_contribution!(Amount::ZERO, outputs, shared_input, feerate, wallet, await) + let FundingTemplate { shared_input, min_feerate, max_feerate } = self; + build_funding_contribution!(Amount::ZERO, outputs, shared_input, min_feerate, max_feerate, wallet, await) } /// Creates a [`FundingContribution`] for removing funds from a channel using `wallet` to @@ -178,8 +262,15 @@ impl FundingTemplate { if outputs.is_empty() { return Err(()); } - let FundingTemplate { shared_input, feerate } = self; - build_funding_contribution!(Amount::ZERO, outputs, shared_input, feerate, wallet,) + let FundingTemplate { shared_input, min_feerate, max_feerate } = self; + build_funding_contribution!( + Amount::ZERO, + outputs, + shared_input, + min_feerate, + max_feerate, + wallet, + ) } /// Creates a [`FundingContribution`] for both adding and removing funds from a channel using @@ -190,8 +281,8 @@ impl FundingTemplate { if value_added == Amount::ZERO && outputs.is_empty() { return Err(()); } - let FundingTemplate { shared_input, feerate } = self; - build_funding_contribution!(value_added, outputs, shared_input, feerate, wallet, await) + let FundingTemplate { shared_input, min_feerate, max_feerate } = self; + build_funding_contribution!(value_added, outputs, shared_input, min_feerate, max_feerate, wallet, await) } /// Creates a [`FundingContribution`] for both adding and removing funds from a channel using @@ -202,8 +293,15 @@ impl FundingTemplate { if value_added == Amount::ZERO && outputs.is_empty() { return Err(()); } - let FundingTemplate { shared_input, feerate } = self; - build_funding_contribution!(value_added, outputs, shared_input, feerate, wallet,) + let FundingTemplate { shared_input, min_feerate, max_feerate } = self; + build_funding_contribution!( + value_added, + outputs, + shared_input, + min_feerate, + max_feerate, + wallet, + ) } } @@ -280,9 +378,12 @@ pub struct FundingContribution { /// The output where any change will be sent. change_output: Option, - /// The fee rate used to select `inputs`. + /// The fee rate used to select `inputs` (the minimum feerate). feerate: FeeRate, + /// The maximum fee rate to accept as acceptor before rejecting the splice. + max_feerate: FeeRate, + /// Whether the contribution is for funding a splice. is_splice: bool, } @@ -404,11 +505,224 @@ impl FundingContribution { Ok(()) } + /// Computes the adjusted fee and change output value for the acceptor at the initiator's + /// proposed feerate, which may differ from the feerate used during coin selection. + /// + /// On success, returns the new estimated fee and, if applicable, the new change output value: + /// - `Some(change)` — the adjusted change output value + /// - `None` — no change output (no inputs or change fell below dust) + /// + /// Returns `Err` if the contribution cannot accommodate the target feerate. + fn compute_feerate_adjustment( + &self, target_feerate: FeeRate, holder_balance: Amount, + ) -> Result<(Amount, Option), FeeRateAdjustmentError> { + if target_feerate < self.feerate { + return Err(FeeRateAdjustmentError::FeeRateTooLow { + target_feerate, + min_feerate: self.feerate, + }); + } + + // If the target fee rate exceeds our max fee rate, we may still add our contribution + // if we pay less in fees. This may happen because the acceptor doesn't pay for common + // fields and the shared input / output. + if target_feerate > self.max_feerate { + let target_fee = estimate_transaction_fee( + &self.inputs, + &self.outputs, + self.change_output.as_ref(), + false, + self.is_splice, + target_feerate, + ); + if target_fee > self.estimated_fee { + return Err(FeeRateAdjustmentError::FeeRateTooHigh { + target_feerate, + max_feerate: self.max_feerate, + target_fee, + original_fee: self.estimated_fee, + }); + } + } + + if !self.inputs.is_empty() { + if let Some(ref change_output) = self.change_output { + let old_change_value = change_output.value; + let dust_limit = change_output.script_pubkey.minimal_non_dust(); + + // Target fee including the change output's weight. + let target_fee = estimate_transaction_fee( + &self.inputs, + &self.outputs, + self.change_output.as_ref(), + false, + self.is_splice, + target_feerate, + ); + + let fee_buffer = self + .estimated_fee + .checked_add(old_change_value) + .ok_or(FeeRateAdjustmentError::FeeBufferOverflow)?; + + match fee_buffer.checked_sub(target_fee) { + Some(new_change_value) if new_change_value >= dust_limit => { + Ok((target_fee, Some(new_change_value))) + }, + _ => { + // Change would be below dust or negative. Try without change. + let target_fee_no_change = estimate_transaction_fee( + &self.inputs, + &self.outputs, + None, + false, + self.is_splice, + target_feerate, + ); + if target_fee_no_change > fee_buffer { + Err(FeeRateAdjustmentError::FeeBufferInsufficient { + source: "estimated fee + change value", + available: fee_buffer, + required: target_fee_no_change, + }) + } else { + Ok((target_fee_no_change, None)) + } + }, + } + } else { + // No change output. + let target_fee = estimate_transaction_fee( + &self.inputs, + &self.outputs, + None, + false, + self.is_splice, + target_feerate, + ); + // The fee buffer is total input value minus value_added and output values. + // This is estimated_fee plus the coin selection surplus (dust burned to + // fees), ensuring we never silently reduce value_added beyond the small + // surplus from coin selection. + let total_input_value: Amount = + self.inputs.iter().map(|i| i.utxo.output.value).sum(); + let output_values: Amount = self.outputs.iter().map(|o| o.value).sum(); + let fee_buffer = total_input_value + .checked_sub(self.value_added) + .and_then(|v| v.checked_sub(output_values)) + .ok_or(FeeRateAdjustmentError::FeeBufferOverflow)?; + if target_fee > fee_buffer { + return Err(FeeRateAdjustmentError::FeeBufferInsufficient { + source: "estimated fee + coin selection surplus", + available: fee_buffer, + required: target_fee, + }); + } + Ok((target_fee, None)) + } + } else { + // No inputs (splice-out): fees paid from channel balance. + let target_fee = estimate_transaction_fee( + &[], + &self.outputs, + None, + false, + self.is_splice, + target_feerate, + ); + + // Check that the channel balance can cover the withdrawal outputs plus fees. + let value_removed: Amount = self.outputs.iter().map(|o| o.value).sum(); + let total_cost = target_fee + .checked_add(value_removed) + .ok_or(FeeRateAdjustmentError::FeeBufferOverflow)?; + if total_cost > holder_balance { + return Err(FeeRateAdjustmentError::FeeBufferInsufficient { + source: "channel balance - withdrawal outputs", + available: holder_balance.checked_sub(value_removed).unwrap_or(Amount::ZERO), + required: target_fee, + }); + } + // Surplus goes back to the channel balance. + Ok((target_fee, None)) + } + } + + /// Adjusts the contribution's change output for the initiator's feerate. + /// + /// When the acceptor has a pending contribution (from the quiescence tie-breaker scenario), + /// the initiator's proposed feerate may differ from the feerate used during coin selection. + /// This adjusts the change output so the acceptor pays their target fee at the target + /// feerate. + pub(super) fn for_acceptor_at_feerate( + mut self, feerate: FeeRate, holder_balance: Amount, + ) -> Result { + let (new_estimated_fee, new_change) = + self.compute_feerate_adjustment(feerate, holder_balance)?; + let surplus = self.fee_buffer_surplus(new_estimated_fee, &new_change); + match new_change { + Some(value) => self.change_output.as_mut().unwrap().value = value, + None => self.change_output = None, + } + self.value_added += surplus; + self.estimated_fee = new_estimated_fee; + self.feerate = feerate; + Ok(self) + } + + /// Returns the net value at the given target feerate without mutating `self`. + /// + /// This serves double duty: it checks feerate compatibility (returning `Err` if the feerate + /// can't be accommodated) and computes the adjusted net value (returning `Ok` with the value + /// accounting for the target feerate). + pub(super) fn net_value_for_acceptor_at_feerate( + &self, target_feerate: FeeRate, holder_balance: Amount, + ) -> Result { + let (new_estimated_fee, new_change) = + self.compute_feerate_adjustment(target_feerate, holder_balance)?; + let surplus = self + .fee_buffer_surplus(new_estimated_fee, &new_change) + .to_signed() + .expect("surplus does not exceed Amount::MAX_MONEY"); + let net_value = self + .net_value_with_fee(new_estimated_fee) + .checked_add(surplus) + .expect("net_value + surplus does not overflow"); + Ok(net_value) + } + + /// Returns the fee buffer surplus when a change output is removed. + /// + /// The fee buffer is the actual amount available for fees from inputs: total input value + /// minus value_added and output values. This includes both the weight-based estimated_fee + /// and any coin selection surplus (dust burned to fees). When the change output is removed, + /// the fee buffer may exceed the new fee; the surplus is returned so it can be redirected + /// to value_added rather than being burned as excess fees. + /// + /// Returns [`Amount::ZERO`] when there are no inputs or the change output is kept. + fn fee_buffer_surplus(&self, new_estimated_fee: Amount, new_change: &Option) -> Amount { + if !self.inputs.is_empty() && new_change.is_none() { + let total_input_value: Amount = self.inputs.iter().map(|i| i.utxo.output.value).sum(); + let output_values: Amount = self.outputs.iter().map(|o| o.value).sum(); + let fee_buffer = total_input_value - self.value_added - output_values; + debug_assert!(fee_buffer >= new_estimated_fee); + fee_buffer - new_estimated_fee + } else { + Amount::ZERO + } + } + /// The net value contributed to a channel by the splice. If negative, more value will be /// spliced out than spliced in. Fees will be deducted from the expected splice-out amount /// if no inputs were included. pub fn net_value(&self) -> SignedAmount { - let unpaid_fees = if self.inputs.is_empty() { self.estimated_fee } else { Amount::ZERO } + self.net_value_with_fee(self.estimated_fee) + } + + /// Computes the net value using the given `estimated_fee` for the splice-out (no inputs) + /// case. For splice-in, fees are paid by inputs so `estimated_fee` is not deducted. + fn net_value_with_fee(&self, estimated_fee: Amount) -> SignedAmount { + let unpaid_fees = if self.inputs.is_empty() { estimated_fee } else { Amount::ZERO } .to_signed() .expect("estimated_fee is validated to not exceed Amount::MAX_MONEY"); let value_added = self @@ -436,7 +750,10 @@ pub type FundingTxInput = crate::util::wallet_utils::ConfirmedUtxo; #[cfg(test)] mod tests { - use super::{estimate_transaction_fee, FundingContribution, FundingTemplate, FundingTxInput}; + use super::{ + estimate_transaction_fee, FeeRateAdjustmentError, FundingContribution, FundingTemplate, + FundingTxInput, + }; use crate::chain::ClaimId; use crate::util::wallet_utils::{CoinSelection, CoinSelectionSourceSync, Input}; use bitcoin::hashes::Hash; @@ -556,6 +873,7 @@ mod tests { change_output: None, is_splice: true, feerate: FeeRate::from_sat_per_kwu(2000), + max_feerate: FeeRate::MAX, }; assert!(contribution.validate().is_ok()); assert_eq!(contribution.net_value(), contribution.value_added.to_signed().unwrap()); @@ -577,6 +895,7 @@ mod tests { change_output: None, is_splice: true, feerate: FeeRate::from_sat_per_kwu(2000), + max_feerate: FeeRate::MAX, }; assert!(contribution.validate().is_ok()); assert_eq!(contribution.net_value(), SignedAmount::from_sat(220_000 - 200_000)); @@ -598,6 +917,7 @@ mod tests { change_output: None, is_splice: true, feerate: FeeRate::from_sat_per_kwu(2000), + max_feerate: FeeRate::MAX, }; assert!(contribution.validate().is_ok()); assert_eq!(contribution.net_value(), SignedAmount::from_sat(220_000 - 400_000)); @@ -619,6 +939,7 @@ mod tests { change_output: None, is_splice: true, feerate: FeeRate::from_sat_per_kwu(90000), + max_feerate: FeeRate::MAX, }; assert_eq!( contribution.validate(), @@ -642,6 +963,7 @@ mod tests { change_output: None, is_splice: true, feerate: FeeRate::from_sat_per_kwu(2000), + max_feerate: FeeRate::MAX, }; assert_eq!( contribution.validate(), @@ -666,6 +988,7 @@ mod tests { change_output: None, is_splice: true, feerate: FeeRate::from_sat_per_kwu(2000), + max_feerate: FeeRate::MAX, }; assert!(contribution.validate().is_ok()); assert_eq!(contribution.net_value(), contribution.value_added.to_signed().unwrap()); @@ -685,6 +1008,7 @@ mod tests { change_output: None, is_splice: true, feerate: FeeRate::from_sat_per_kwu(2200), + max_feerate: FeeRate::MAX, }; assert_eq!( contribution.validate(), @@ -709,6 +1033,7 @@ mod tests { change_output: None, is_splice: false, feerate: FeeRate::from_sat_per_kwu(2000), + max_feerate: FeeRate::MAX, }; assert!(contribution.validate().is_ok()); assert_eq!(contribution.net_value(), contribution.value_added.to_signed().unwrap()); @@ -736,20 +1061,20 @@ mod tests { // splice_in_sync with value_added > MAX_MONEY { - let template = FundingTemplate::new(None, feerate); + let template = FundingTemplate::new(None, feerate, feerate); assert!(template.splice_in_sync(over_max, UnreachableWallet).is_err()); } // splice_out_sync with single output value > MAX_MONEY { - let template = FundingTemplate::new(None, feerate); + let template = FundingTemplate::new(None, feerate, feerate); let outputs = vec![funding_output_sats(over_max.to_sat())]; assert!(template.splice_out_sync(outputs, UnreachableWallet).is_err()); } // splice_out_sync with multiple outputs summing > MAX_MONEY { - let template = FundingTemplate::new(None, feerate); + let template = FundingTemplate::new(None, feerate, feerate); let half_over = Amount::MAX_MONEY / 2 + Amount::from_sat(1); let outputs = vec![ funding_output_sats(half_over.to_sat()), @@ -760,18 +1085,710 @@ mod tests { // splice_in_and_out_sync with value_added > MAX_MONEY { - let template = FundingTemplate::new(None, feerate); + let template = FundingTemplate::new(None, feerate, feerate); let outputs = vec![funding_output_sats(1_000)]; assert!(template.splice_in_and_out_sync(over_max, outputs, UnreachableWallet).is_err()); } // splice_in_and_out_sync with output sum > MAX_MONEY { - let template = FundingTemplate::new(None, feerate); + let template = FundingTemplate::new(None, feerate, feerate); let outputs = vec![funding_output_sats(over_max.to_sat())]; assert!(template .splice_in_and_out_sync(Amount::from_sat(1_000), outputs, UnreachableWallet) .is_err()); } } + + #[test] + fn test_for_acceptor_at_feerate_higher_change_adjusted() { + // Splice-in: higher target feerate reduces the change output. + // The fee overestimates (with is_initiator=true) by including common TX fields, shared + // output, and shared input weight. So we need a sufficiently high target feerate for the + // acceptor's target fee to exceed the original fee estimate, causing the change to decrease. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(6000); + let inputs = vec![funding_input_sats(100_000)]; + let change = funding_output_sats(10_000); + + // Fee estimate computed as initiator (overestimate), including change output weight. + let estimated_fee = + estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::from_sat(50_000), + estimated_fee, + inputs: inputs.clone(), + outputs: vec![], + change_output: Some(change.clone()), + feerate: original_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + let net_value_before = contribution.net_value(); + let contribution = + contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX).unwrap(); + + // Target fee at target feerate for acceptor (is_initiator=false), including change weight. + let expected_target_fee = + estimate_transaction_fee(&inputs, &[], Some(&change), false, true, target_feerate); + let expected_change = estimated_fee + Amount::from_sat(10_000) - expected_target_fee; + + assert_eq!(contribution.estimated_fee, expected_target_fee); + assert!(contribution.change_output.is_some()); + assert_eq!(contribution.change_output.as_ref().unwrap().value, expected_change); + assert!(expected_change < Amount::from_sat(10_000)); // Change reduced + assert_eq!(contribution.net_value(), net_value_before); + } + + #[test] + fn test_for_acceptor_at_feerate_lower_rejected_too_low() { + // Splice-in: target feerate below our minimum is rejected as FeeRateTooLow. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(1000); + let inputs = vec![funding_input_sats(100_000)]; + let change = funding_output_sats(10_000); + + let estimated_fee = + estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::from_sat(50_000), + estimated_fee, + inputs, + outputs: vec![], + change_output: Some(change), + feerate: original_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); + assert!(matches!(result, Err(FeeRateAdjustmentError::FeeRateTooLow { .. }))); + } + + #[test] + fn test_for_acceptor_at_feerate_change_removed() { + // Splice-in: feerate high enough that change drops below dust and is removed, + // but the fee buffer (estimated_fee + change) still covers the fee without the change output. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(7000); + let value_added = Amount::from_sat(50_000); + let change_value = Amount::from_sat(500); + + // Compute estimated_fee first (weight-based, independent of input value). + let dummy_inputs = vec![funding_input_sats(1)]; + let change = funding_output_sats(change_value.to_sat()); + let estimated_fee = estimate_transaction_fee( + &dummy_inputs, + &[], + Some(&change), + true, + true, + original_feerate, + ); + + // Realistic input: value_added + estimated_fee + change (what coin selection produces). + let input_value = value_added + estimated_fee + change_value; + let inputs = vec![funding_input_sats(input_value.to_sat())]; + let change = funding_output_sats(change_value.to_sat()); + + let contribution = FundingContribution { + value_added, + estimated_fee, + inputs: inputs.clone(), + outputs: vec![], + change_output: Some(change), + feerate: original_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + let net_value_before = contribution.net_value(); + let contribution = + contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX).unwrap(); + + // Change should be removed; estimated_fee updated to no-change target fee. + assert!(contribution.change_output.is_none()); + let expected_fee_no_change = + estimate_transaction_fee(&inputs, &[], None, false, true, target_feerate); + assert_eq!(contribution.estimated_fee, expected_fee_no_change); + // The surplus (old fee buffer - new fee) goes to value_added, increasing net_value. + let surplus = estimated_fee + change_value - expected_fee_no_change; + assert_eq!(contribution.net_value(), net_value_before + surplus.to_signed().unwrap()); + } + + #[test] + fn test_for_acceptor_at_feerate_too_high_rejected() { + // Splice-in: feerate so high that even without change, the fee can't be covered. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(100_000); + let inputs = vec![funding_input_sats(100_000)]; + let change = funding_output_sats(500); + + let estimated_fee = + estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::from_sat(50_000), + estimated_fee, + inputs, + outputs: vec![], + change_output: Some(change), + feerate: original_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); + assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. }))); + } + + #[test] + fn test_for_acceptor_at_feerate_splice_out_sufficient() { + // Splice-out (no inputs): the fee estimate from the is_initiator=true overestimate covers + // the acceptor's target fee at a moderately higher target feerate. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(3000); + let outputs = vec![funding_output_sats(50_000)]; + + let estimated_fee = + estimate_transaction_fee(&[], &outputs, None, true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::ZERO, + estimated_fee, + inputs: vec![], + outputs: outputs.clone(), + change_output: None, + feerate: original_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + let contribution = + contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX).unwrap(); + // estimated_fee is updated to the target fee; surplus goes back to channel balance. + let expected_target_fee = + estimate_transaction_fee(&[], &outputs, None, false, true, target_feerate); + assert_eq!(contribution.estimated_fee, expected_target_fee); + assert!(expected_target_fee <= estimated_fee); + } + + #[test] + fn test_for_acceptor_at_feerate_splice_out_insufficient() { + // Splice-out: channel balance too small for outputs + target fee at high target feerate. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(50_000); + let outputs = vec![funding_output_sats(50_000)]; + + let estimated_fee = + estimate_transaction_fee(&[], &outputs, None, true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::ZERO, + estimated_fee, + inputs: vec![], + outputs, + change_output: None, + feerate: original_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + // Balance of 55,000 sats can't cover outputs (50,000) + target_fee at 50k sat/kwu. + let holder_balance = Amount::from_sat(55_000); + let result = contribution.for_acceptor_at_feerate(target_feerate, holder_balance); + assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. }))); + } + + #[test] + fn test_net_value_for_acceptor_at_feerate_splice_in() { + // Splice-in: net_value_for_acceptor_at_feerate returns the same value as net_value() since + // splice-in fees are paid by inputs, not from channel balance. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(3000); + let inputs = vec![funding_input_sats(100_000)]; + let change = funding_output_sats(10_000); + + let estimated_fee = + estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::from_sat(50_000), + estimated_fee, + inputs, + outputs: vec![], + change_output: Some(change), + feerate: original_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + // For splice-in with change that stays above dust, the surplus is absorbed by the change + // output so net_value_for_acceptor_at_feerate equals net_value. + let net_at_feerate = + contribution.net_value_for_acceptor_at_feerate(target_feerate, Amount::MAX).unwrap(); + assert_eq!(net_at_feerate, contribution.net_value()); + assert_eq!(net_at_feerate, Amount::from_sat(50_000).to_signed().unwrap()); + } + + #[test] + fn test_net_value_for_acceptor_at_feerate_splice_out() { + // Splice-out: net_value_for_acceptor_at_feerate returns the adjusted value using the target fee + // at the target feerate. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(3000); + let outputs = vec![funding_output_sats(50_000)]; + + let estimated_fee = + estimate_transaction_fee(&[], &outputs, None, true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::ZERO, + estimated_fee, + inputs: vec![], + outputs: outputs.clone(), + change_output: None, + feerate: original_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + let net_at_feerate = + contribution.net_value_for_acceptor_at_feerate(target_feerate, Amount::MAX).unwrap(); + + // The target fee at target feerate should be less than the initiator's fee estimate. + let target_fee = estimate_transaction_fee(&[], &outputs, None, false, true, target_feerate); + let expected_net = SignedAmount::ZERO + - Amount::from_sat(50_000).to_signed().unwrap() + - target_fee.to_signed().unwrap(); + assert_eq!(net_at_feerate, expected_net); + + // Should be less negative than net_value() which uses the higher fee estimate. + assert!(net_at_feerate > contribution.net_value()); + } + + #[test] + fn test_net_value_for_acceptor_at_feerate_does_not_mutate() { + // Verify net_value_for_acceptor_at_feerate does not modify the contribution. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(5000); + let inputs = vec![funding_input_sats(100_000)]; + let change = funding_output_sats(10_000); + + let estimated_fee = + estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::from_sat(50_000), + estimated_fee, + inputs, + outputs: vec![], + change_output: Some(change), + feerate: original_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + let net_before = contribution.net_value(); + let fee_before = contribution.estimated_fee; + let change_before = contribution.change_output.as_ref().unwrap().value; + + let _ = contribution.net_value_for_acceptor_at_feerate(target_feerate, Amount::MAX); + + // Nothing should have changed. + assert_eq!(contribution.net_value(), net_before); + assert_eq!(contribution.estimated_fee, fee_before); + assert_eq!(contribution.change_output.as_ref().unwrap().value, change_before); + } + + #[test] + fn test_net_value_for_acceptor_at_feerate_too_high() { + // net_value_for_acceptor_at_feerate returns Err when feerate can't be accommodated. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(100_000); + let inputs = vec![funding_input_sats(100_000)]; + let change = funding_output_sats(500); + + let estimated_fee = + estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::from_sat(50_000), + estimated_fee, + inputs, + outputs: vec![], + change_output: Some(change), + feerate: original_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + let result = contribution.net_value_for_acceptor_at_feerate(target_feerate, Amount::MAX); + assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. }))); + } + + #[test] + fn test_for_acceptor_at_feerate_exceeds_max_rejected() { + // Splice-in: target feerate exceeds max_feerate and target fee exceeds the fee buffer, + // so the adjustment is rejected as FeeRateTooHigh. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let max_feerate = FeeRate::from_sat_per_kwu(3000); + let target_feerate = FeeRate::from_sat_per_kwu(100_000); + let inputs = vec![funding_input_sats(100_000)]; + let change = funding_output_sats(10_000); + + let estimated_fee = + estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::from_sat(50_000), + estimated_fee, + inputs, + outputs: vec![], + change_output: Some(change), + feerate: original_feerate, + max_feerate, + is_splice: true, + }; + + let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); + assert!(matches!(result, Err(FeeRateAdjustmentError::FeeRateTooHigh { .. }))); + } + + #[test] + fn test_for_acceptor_at_feerate_exceeds_max_allowed() { + // Splice-in: target feerate exceeds max_feerate but the acceptor's target fee + // (is_initiator=false at target) is less than the fee buffer (is_initiator=true at + // original feerate). This works because the initiator fee estimate includes ~598 WU of + // extra weight (common TX fields, funding output, shared input) that the acceptor + // doesn't pay for, so the fee buffer is ~2.5x larger than the acceptor's target fee at + // the same feerate. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let max_feerate = FeeRate::from_sat_per_kwu(3000); + let target_feerate = FeeRate::from_sat_per_kwu(4000); + let inputs = vec![funding_input_sats(100_000)]; + let change = funding_output_sats(10_000); + + let estimated_fee = + estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::from_sat(50_000), + estimated_fee, + inputs, + outputs: vec![], + change_output: Some(change.clone()), + feerate: original_feerate, + max_feerate, + is_splice: true, + }; + + let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); + assert!(result.is_ok()); + let adjusted = result.unwrap(); + + // The acceptor's target fee at target (4000, is_initiator=false) is less than the + // fee estimate at original (2000, is_initiator=true) due to the ~2.5x weight ratio, + // so change increases despite the higher feerate. + assert!(adjusted.change_output.is_some()); + assert!(adjusted.change_output.as_ref().unwrap().value > Amount::from_sat(10_000)); + } + + #[test] + fn test_for_acceptor_at_feerate_within_range() { + // Splice-in: target feerate is between min and max, so the min/max checks + // don't interfere and the normal adjustment logic applies. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let max_feerate = FeeRate::from_sat_per_kwu(5000); + let target_feerate = FeeRate::from_sat_per_kwu(3000); + let inputs = vec![funding_input_sats(100_000)]; + let change = funding_output_sats(10_000); + + let estimated_fee = + estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::from_sat(50_000), + estimated_fee, + inputs, + outputs: vec![], + change_output: Some(change), + feerate: original_feerate, + max_feerate, + is_splice: true, + }; + + let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); + assert!(result.is_ok()); + let adjusted = result.unwrap(); + + // At a higher target feerate, the target fee increases so change should decrease + // (or stay the same if the fee estimate absorbs the difference). + // The key assertion is that the adjustment succeeds with a valid change output. + assert!(adjusted.change_output.is_some()); + } + + #[test] + fn test_for_acceptor_at_feerate_no_change_shortfall_from_value_added() { + // Inputs present, no change output. Higher target feerate makes target_fee > estimated_fee. + // With realistic inputs (no coin selection surplus), the fee buffer is just estimated_fee, + // so the shortfall cannot be absorbed and the contribution is dropped. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(20_000); + let value_added = Amount::from_sat(50_000); + + // Compute estimated_fee first (weight-based, independent of input value). + let dummy_inputs = vec![funding_input_sats(1)]; + let estimated_fee = + estimate_transaction_fee(&dummy_inputs, &[], None, true, true, original_feerate); + + // Realistic input: value_added + estimated_fee (what coin selection produces, no surplus). + let inputs = vec![funding_input_sats((value_added + estimated_fee).to_sat())]; + let target_fee = estimate_transaction_fee(&inputs, &[], None, false, true, target_feerate); + + // Verify our setup: target_fee > estimated_fee (shortfall exists) and the fee buffer + // (estimated_fee, with no coin selection surplus) cannot cover it. + assert!(target_fee > estimated_fee); + + let contribution = FundingContribution { + value_added, + estimated_fee, + inputs, + outputs: vec![], + change_output: None, + feerate: original_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); + assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. }))); + } + + #[test] + fn test_for_acceptor_at_feerate_no_change_insufficient() { + // Inputs present, no change output. The target feerate is so high that the fee buffer + // (total input value minus value_added) cannot cover the target fee. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(20_000); + let value_added = Amount::from_sat(1); + + // Compute estimated_fee first (weight-based, independent of input value). + let dummy_inputs = vec![funding_input_sats(1)]; + let estimated_fee = + estimate_transaction_fee(&dummy_inputs, &[], None, true, true, original_feerate); + + // Realistic input: value_added + estimated_fee (no surplus). + let inputs = vec![funding_input_sats((value_added + estimated_fee).to_sat())]; + let target_fee = estimate_transaction_fee(&inputs, &[], None, false, true, target_feerate); + assert!(target_fee > estimated_fee); + + let contribution = FundingContribution { + value_added, + estimated_fee, + inputs, + outputs: vec![], + change_output: None, + feerate: original_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); + assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. }))); + } + + #[test] + fn test_for_acceptor_at_feerate_no_change_surplus_below_dust() { + // Inputs present, no change output. The acceptor built their contribution at a low + // feerate as if they were the initiator (including common TX fields in estimated_fee). + // The initiator proposes a ~3x higher feerate. At that rate, the acceptor's target fee + // (only their personal input weight) nearly matches the original fee estimate, leaving a + // small surplus below the dust limit. + let original_feerate = FeeRate::from_sat_per_kwu(1000); + let target_feerate = FeeRate::from_sat_per_kwu(3000); + let inputs = vec![funding_input_sats(100_000)]; + + // estimated_fee includes common TX fields (is_initiator=true) at the original feerate. + let estimated_fee = + estimate_transaction_fee(&inputs, &[], None, true, true, original_feerate); + + // target_fee only includes the acceptor's contributed weight (is_initiator=false) at the + // higher target feerate. + let target_fee = estimate_transaction_fee(&inputs, &[], None, false, true, target_feerate); + + // Verify our setup: surplus is positive and below the P2WPKH dust limit (294 sats). + assert!(estimated_fee > target_fee); + let dust_limit = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()).minimal_non_dust(); + assert!(estimated_fee - target_fee < dust_limit); + + let contribution = FundingContribution { + value_added: Amount::from_sat(50_000), + estimated_fee, + inputs, + outputs: vec![], + change_output: None, + feerate: original_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); + assert!(result.is_ok()); + let adjusted = result.unwrap(); + assert!(adjusted.change_output.is_none()); + assert_eq!(adjusted.estimated_fee, target_fee); + } + + #[test] + fn test_for_acceptor_at_feerate_no_change_surplus_absorbed() { + // Inputs, no change. The estimated_fee (is_initiator=true) far exceeds the acceptor's + // target fee (is_initiator=false). The surplus stays in the channel balance rather than + // being burned as excess fees. + let feerate = FeeRate::from_sat_per_kwu(2000); + let value_added = Amount::from_sat(50_000); + + // Compute estimated_fee first (weight-based, independent of input value). + let dummy_inputs = vec![funding_input_sats(1)]; + let estimated_fee = estimate_transaction_fee(&dummy_inputs, &[], None, true, true, feerate); + + // Realistic input: value_added + estimated_fee (no surplus). + let inputs = vec![funding_input_sats((value_added + estimated_fee).to_sat())]; + + // Initiator fee estimate includes common TX fields + shared output + shared input weight, + // making it ~3x the acceptor's target fee at the same feerate. + let target_fee = estimate_transaction_fee(&inputs, &[], None, false, true, feerate); + + let contribution = FundingContribution { + value_added, + estimated_fee, + inputs, + outputs: vec![], + change_output: None, + feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + // target == min feerate, so FeeRateTooLow check passes. + // The surplus (estimated_fee - target_fee) goes to value_added (shared output). + let net_value_before = contribution.net_value(); + let result = contribution.for_acceptor_at_feerate(feerate, Amount::MAX); + assert!(result.is_ok()); + let adjusted = result.unwrap(); + assert!(adjusted.change_output.is_none()); + assert_eq!(adjusted.estimated_fee, target_fee); + let surplus = estimated_fee - target_fee; + assert_eq!(adjusted.value_added, value_added + surplus); + assert_eq!(adjusted.net_value(), net_value_before + surplus.to_signed().unwrap()); + } + + #[test] + fn test_for_acceptor_at_feerate_fee_buffer_overflow() { + // Construct a contribution with estimated_fee and change values that overflow Amount. + let feerate = FeeRate::from_sat_per_kwu(2000); + let inputs = vec![funding_input_sats(100_000)]; + + let contribution = FundingContribution { + value_added: Amount::from_sat(50_000), + estimated_fee: Amount::MAX, + inputs, + outputs: vec![], + change_output: Some(funding_output_sats(1)), + feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + let result = contribution.for_acceptor_at_feerate(feerate, Amount::MAX); + assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferOverflow))); + } + + #[test] + fn test_for_acceptor_at_feerate_splice_out_balance_insufficient() { + // Splice-out: channel balance too small to cover outputs + target fee. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(3000); + let outputs = vec![funding_output_sats(50_000)]; + + let estimated_fee = + estimate_transaction_fee(&[], &outputs, None, true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::ZERO, + estimated_fee, + inputs: vec![], + outputs: outputs.clone(), + change_output: None, + feerate: original_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + // Balance of 40,000 sats is less than outputs (50,000) + target_fee. + let holder_balance = Amount::from_sat(40_000); + let result = contribution.for_acceptor_at_feerate(target_feerate, holder_balance); + assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. }))); + } + + #[test] + fn test_for_acceptor_at_feerate_splice_out_balance_sufficient() { + // Splice-out: channel balance large enough to cover outputs + target fee. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(3000); + let outputs = vec![funding_output_sats(50_000)]; + + let estimated_fee = + estimate_transaction_fee(&[], &outputs, None, true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::ZERO, + estimated_fee, + inputs: vec![], + outputs: outputs.clone(), + change_output: None, + feerate: original_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + // Balance of 100,000 sats is more than outputs (50,000) + target_fee. + let holder_balance = Amount::from_sat(100_000); + let contribution = + contribution.for_acceptor_at_feerate(target_feerate, holder_balance).unwrap(); + let expected_target_fee = + estimate_transaction_fee(&[], &outputs, None, false, true, target_feerate); + assert_eq!(contribution.estimated_fee, expected_target_fee); + } + + #[test] + fn test_net_value_for_acceptor_at_feerate_splice_out_balance_insufficient() { + // Splice-out: net_value_for_acceptor_at_feerate returns Err when channel balance + // is too small to cover outputs + target fee. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(3000); + let outputs = vec![funding_output_sats(50_000)]; + + let estimated_fee = + estimate_transaction_fee(&[], &outputs, None, true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::ZERO, + estimated_fee, + inputs: vec![], + outputs, + change_output: None, + feerate: original_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + // Balance of 40,000 sats is less than outputs (50,000) + target_fee. + let holder_balance = Amount::from_sat(40_000); + let result = contribution.net_value_for_acceptor_at_feerate(target_feerate, holder_balance); + assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. }))); + } } diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 9bcc47364e1..70b347e0bd2 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -59,7 +59,7 @@ fn test_splicing_not_supported_api_error() { let (_, _, channel_id, _) = create_announced_chan_between_nodes(&nodes, 0, 1); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let res = nodes[1].node.splice_channel(&channel_id, &node_id_0, feerate); + let res = nodes[1].node.splice_channel(&channel_id, &node_id_0, feerate, FeeRate::MAX); match res { Err(APIError::ChannelUnavailable { err }) => { assert!(err.contains("Peer does not support splicing")) @@ -80,7 +80,7 @@ fn test_splicing_not_supported_api_error() { reconnect_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_args); - let res = nodes[1].node.splice_channel(&channel_id, &node_id_0, feerate); + let res = nodes[1].node.splice_channel(&channel_id, &node_id_0, feerate, FeeRate::MAX); match res { Err(APIError::ChannelUnavailable { err }) => { assert!(err.contains("Peer does not support quiescence, a splicing prerequisite")) @@ -112,7 +112,7 @@ fn test_v1_splice_in_negative_insufficient_inputs() { // Initiate splice-in, with insufficient input contribution let funding_template = nodes[0] .node - .splice_channel(&channel_id, &nodes[1].node.get_our_node_id(), feerate) + .splice_channel(&channel_id, &nodes[1].node.get_our_node_id(), feerate, FeeRate::MAX) .unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); @@ -177,7 +177,7 @@ fn test_validate_accounts_for_change_output_weight() { let feerate = FeeRate::from_sat_per_kwu(2000); let funding_template = nodes[0] .node - .splice_channel(&channel_id, &nodes[1].node.get_our_node_id(), feerate) + .splice_channel(&channel_id, &nodes[1].node.get_our_node_id(), feerate, FeeRate::MAX) .unwrap(); // Input value = value_added + 1800: above 1736/1740 (fee without change), below 1984/1988 @@ -221,8 +221,10 @@ pub fn do_initiate_splice_in<'a, 'b, 'c, 'd>( ) -> FundingContribution { let node_id_acceptor = acceptor.node.get_our_node_id(); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = - initiator.node.splice_channel(&channel_id, &node_id_acceptor, feerate).unwrap(); + let funding_template = initiator + .node + .splice_channel(&channel_id, &node_id_acceptor, feerate, FeeRate::MAX) + .unwrap(); let wallet = WalletSync::new(Arc::clone(&initiator.wallet_source), initiator.logger); let funding_contribution = funding_template.splice_in_sync(value_added, &wallet).unwrap(); initiator @@ -238,8 +240,10 @@ pub fn initiate_splice_out<'a, 'b, 'c, 'd>( ) -> Result { let node_id_acceptor = acceptor.node.get_our_node_id(); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = - initiator.node.splice_channel(&channel_id, &node_id_acceptor, feerate).unwrap(); + let funding_template = initiator + .node + .splice_channel(&channel_id, &node_id_acceptor, feerate, FeeRate::MAX) + .unwrap(); let wallet = WalletSync::new(Arc::clone(&initiator.wallet_source), initiator.logger); let funding_contribution = funding_template.splice_out_sync(outputs, &wallet).unwrap(); match initiator.node.funding_contributed( @@ -269,8 +273,10 @@ pub fn do_initiate_splice_in_and_out<'a, 'b, 'c, 'd>( ) -> FundingContribution { let node_id_acceptor = acceptor.node.get_our_node_id(); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = - initiator.node.splice_channel(&channel_id, &node_id_acceptor, feerate).unwrap(); + let funding_template = initiator + .node + .splice_channel(&channel_id, &node_id_acceptor, feerate, FeeRate::MAX) + .unwrap(); let wallet = WalletSync::new(Arc::clone(&initiator.wallet_source), initiator.logger); let funding_contribution = funding_template.splice_in_and_out_sync(value_added, outputs, &wallet).unwrap(); @@ -1162,7 +1168,8 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { }]; let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate).unwrap(); + let funding_template = + nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate, FeeRate::MAX).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); let funding_contribution = funding_template.splice_out_sync(outputs.clone(), &wallet).unwrap(); nodes[0] @@ -1171,7 +1178,7 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { .unwrap(); assert_eq!( - nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate), + nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate, FeeRate::MAX), Err(APIError::APIMisuseError { err: format!( "Channel {} cannot be spliced as one is waiting to be negotiated", @@ -1183,7 +1190,7 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { let new_funding_script = complete_splice_handshake(&nodes[0], &nodes[1]); assert_eq!( - nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate), + nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate, FeeRate::MAX), Err(APIError::APIMisuseError { err: format!( "Channel {} cannot be spliced as one is currently being negotiated", @@ -1194,7 +1201,8 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { // The acceptor can enqueue a quiescent action while the current splice is pending. let added_value = Amount::from_sat(initial_channel_value_sat); - let acceptor_template = nodes[1].node.splice_channel(&channel_id, &node_0_id, feerate).unwrap(); + let acceptor_template = + nodes[1].node.splice_channel(&channel_id, &node_0_id, feerate, FeeRate::MAX).unwrap(); let acceptor_wallet = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); let acceptor_contribution = acceptor_template.splice_in_sync(added_value, &acceptor_wallet).unwrap(); @@ -1212,7 +1220,7 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { ); assert_eq!( - nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate), + nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate, FeeRate::MAX), Err(APIError::APIMisuseError { err: format!( "Channel {} cannot be spliced as one is currently being negotiated", @@ -1229,7 +1237,7 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { // Now that the splice is pending, another splice may be initiated, but we must wait until // the `splice_locked` exchange to send the initiator `stfu`. - assert!(nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate).is_ok()); + assert!(nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate, FeeRate::MAX).is_ok()); if reconnect { nodes[0].node.peer_disconnected(node_1_id); @@ -1270,7 +1278,8 @@ fn test_initiating_splice_holds_stfu_with_pending_splice() { let funding_contribution_0 = initiate_splice_in(&nodes[0], &nodes[1], channel_id, value_added); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = nodes[1].node.splice_channel(&channel_id, &node_0_id, feerate).unwrap(); + let funding_template = + nodes[1].node.splice_channel(&channel_id, &node_0_id, feerate, FeeRate::MAX).unwrap(); let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution_0); @@ -2956,7 +2965,8 @@ fn test_funding_contributed_counterparty_not_found() { provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let funding_template = + nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); let funding_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); @@ -2995,7 +3005,8 @@ fn test_funding_contributed_channel_not_found() { provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let funding_template = + nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); let funding_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); @@ -3039,7 +3050,8 @@ fn test_funding_contributed_splice_already_pending() { script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_raw_hash(Hash::all_zeros())), }; let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let funding_template = + nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); let first_contribution = funding_template .splice_in_and_out_sync(splice_in_amount, vec![first_splice_out.clone()], &wallet) @@ -3061,7 +3073,8 @@ fn test_funding_contributed_splice_already_pending() { nodes[0].wallet_source.clear_utxos(); provide_utxo_reserves(&nodes, 1, splice_in_amount * 3); - let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let funding_template = + nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); let second_contribution = funding_template .splice_in_and_out_sync(splice_in_amount, vec![second_splice_out.clone()], &wallet) @@ -3130,7 +3143,8 @@ fn test_funding_contributed_duplicate_contribution_no_event() { provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let funding_template = + nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); let contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); @@ -3188,7 +3202,8 @@ fn do_test_funding_contributed_active_funding_negotiation(state: u8) { // Build first contribution let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let funding_template = + nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); let first_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); @@ -3196,7 +3211,8 @@ fn do_test_funding_contributed_active_funding_negotiation(state: u8) { nodes[0].wallet_source.clear_utxos(); provide_utxo_reserves(&nodes, 1, splice_in_amount * 3); - let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let funding_template = + nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); let second_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); @@ -3316,7 +3332,8 @@ fn test_funding_contributed_channel_shutdown() { provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let funding_template = + nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); let funding_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); @@ -3369,8 +3386,10 @@ fn test_funding_contributed_unfunded_channel() { provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = - nodes[0].node.splice_channel(&funded_channel_id, &node_id_1, feerate).unwrap(); + let funding_template = nodes[0] + .node + .splice_channel(&funded_channel_id, &node_id_1, feerate, FeeRate::MAX) + .unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); let funding_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); From d964be910af9a7b17873b8e3a6b0e73035df6c75 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 10 Feb 2026 16:02:31 -0600 Subject: [PATCH 130/627] Contribute to splice as acceptor When both nodes want to splice simultaneously, the quiescence tie-breaker designates one as the initiator. Previously, the losing node responded with zero contribution, requiring a second full splice session after the first splice locked. This is wasteful, especially for often-offline nodes that may connect and immediately want to splice. Instead, the losing node contributes to the winner's splice as the acceptor, merging both contributions into a single splice transaction. Since the FundingContribution was originally built with initiator fees (which include common fields and shared input/output weight), the fee is adjusted to the acceptor rate before contributing, with the surplus returned to the change output. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 82 ++++- lightning/src/ln/channelmanager.rs | 24 +- lightning/src/ln/splicing_tests.rs | 479 ++++++++++++++++++++++++----- 3 files changed, 491 insertions(+), 94 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 2c1117a46c6..9361cd3c749 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -11851,6 +11851,26 @@ where self.propose_quiescence(logger, QuiescentAction::Splice { contribution, locktime }) } + /// Returns a reference to the funding contribution queued by a pending [`QuiescentAction`], + /// if any. + fn queued_funding_contribution(&self) -> Option<&FundingContribution> { + match &self.quiescent_action { + Some(QuiescentAction::Splice { contribution, .. }) => Some(contribution), + _ => None, + } + } + + /// Consumes and returns the funding contribution from the pending [`QuiescentAction`], if any. + fn take_queued_funding_contribution(&mut self) -> Option { + match &self.quiescent_action { + Some(QuiescentAction::Splice { .. }) => match self.quiescent_action.take() { + Some(QuiescentAction::Splice { contribution, .. }) => Some(contribution), + _ => unreachable!(), + }, + _ => None, + } + } + fn send_splice_init(&mut self, context: FundingNegotiationContext) -> msgs::SpliceInit { debug_assert!(self.pending_splice.is_none()); // Rotate the funding pubkey using the prev_funding_txid as a tweak @@ -11948,10 +11968,6 @@ where )); } - // TODO(splicing): Once splice acceptor can contribute, check that inputs are sufficient, - // similarly to the check in `funding_contributed`. - debug_assert_eq!(our_funding_contribution, SignedAmount::ZERO); - let their_funding_contribution = SignedAmount::from_sat(msg.funding_contribution_satoshis); if their_funding_contribution == SignedAmount::ZERO { return Err(ChannelError::WarnAndDisconnect(format!( @@ -12075,11 +12091,52 @@ where } pub(crate) fn splice_init( - &mut self, msg: &msgs::SpliceInit, our_funding_contribution_satoshis: i64, - entropy_source: &ES, holder_node_id: &PublicKey, logger: &L, + &mut self, msg: &msgs::SpliceInit, entropy_source: &ES, holder_node_id: &PublicKey, + logger: &L, ) -> Result { - let our_funding_contribution = SignedAmount::from_sat(our_funding_contribution_satoshis); - let splice_funding = self.validate_splice_init(msg, our_funding_contribution)?; + let feerate = FeeRate::from_sat_per_kwu(msg.funding_feerate_per_kw as u64); + let holder_balance = self + .get_holder_counterparty_balances_floor_incl_fee(&self.funding) + .map(|(holder, _)| holder) + .map_err(|e| { + log_info!( + logger, + "Cannot compute holder balance for channel {}: {}; \ + proceeding without contribution", + self.context.channel_id(), + e, + ); + }) + .ok(); + let our_funding_contribution = + holder_balance.and_then(|_| self.queued_funding_contribution()).and_then(|c| { + c.net_value_for_acceptor_at_feerate(feerate, holder_balance.unwrap()) + .map_err(|e| { + log_info!( + logger, + "Cannot accommodate initiator's feerate ({}) for channel {}: {}; \ + proceeding without contribution", + feerate, + self.context.channel_id(), + e, + ); + }) + .ok() + }); + + let splice_funding = + self.validate_splice_init(msg, our_funding_contribution.unwrap_or(SignedAmount::ZERO))?; + + let (our_funding_inputs, our_funding_outputs) = if our_funding_contribution.is_some() { + self.take_queued_funding_contribution() + .expect("queued_funding_contribution was Some") + .for_acceptor_at_feerate(feerate, holder_balance.unwrap()) + .expect("feerate compatibility already checked") + .into_tx_parts() + } else { + Default::default() + }; + let our_funding_contribution = our_funding_contribution.unwrap_or(SignedAmount::ZERO); log_info!( logger, @@ -12096,8 +12153,8 @@ where funding_tx_locktime: LockTime::from_consensus(msg.locktime), funding_feerate_sat_per_1000_weight: msg.funding_feerate_per_kw, shared_funding_input: Some(prev_funding_input), - our_funding_inputs: Vec::new(), - our_funding_outputs: Vec::new(), + our_funding_inputs, + our_funding_outputs, }; let (interactive_tx_constructor, first_message) = funding_negotiation_context @@ -12109,11 +12166,6 @@ where ); debug_assert!(first_message.is_none()); - // TODO(splicing): if quiescent_action is set, integrate what the user wants to do into the - // counterparty-initiated splice. For always-on nodes this probably isn't a useful - // optimization, but for often-offline nodes it may be, as we may connect and immediately - // go into splicing from both sides. - let new_funding_pubkey = splice_funding.get_holder_pubkeys().funding_pubkey; self.pending_splice = Some(PendingFunding { funding_negotiation: Some(FundingNegotiation::ConstructingTransaction { diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 19767de3347..ada27af749f 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -4643,11 +4643,21 @@ impl< /// /// The splice initiator is responsible for paying fees for common fields, shared inputs, and /// shared outputs along with any contributed inputs and outputs. When building a - /// [`FundingContribution`], fees are estimated using `min_feerate` and must be covered by the - /// supplied inputs for splice-in or the channel balance for splice-out. If the counterparty - /// also initiates a splice and wins the tie-break, they become the initiator and choose the - /// feerate. In that case, `max_feerate` is used to reject a feerate that is too high for our - /// contribution. + /// [`FundingContribution`], fees are estimated at `min_feerate` assuming initiator + /// responsibility and must be covered by the supplied inputs for splice-in or the channel + /// balance for splice-out. If the counterparty also initiates a splice and wins the + /// tie-break, they become the initiator and choose the feerate. The fee is then + /// re-estimated at the counterparty's feerate for only our contributed inputs and outputs, + /// which may be higher or lower than the original estimate. The contribution is dropped and + /// the splice proceeds without it when: + /// - the counterparty's feerate is below `min_feerate` + /// - the counterparty's feerate is above `max_feerate` and the re-estimated fee exceeds the + /// original fee estimate + /// - the re-estimated fee exceeds the *fee buffer* regardless of `max_feerate` + /// + /// The fee buffer is the maximum fee that can be accommodated: + /// - **splice-in**: the selected inputs' value minus the contributed amount + /// - **splice-out**: the channel balance minus the withdrawal outputs /// /// Returns a [`FundingTemplate`] which should be used to build a [`FundingContribution`] via /// one of its splice methods (e.g., [`FundingTemplate::splice_in_sync`]). The resulting @@ -12826,9 +12836,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; - // TODO(splicing): Currently not possible to contribute on the splicing-acceptor side - let our_funding_contribution = 0i64; - // Look for the channel match peer_state.channel_by_id.entry(msg.channel_id) { hash_map::Entry::Vacant(_) => { @@ -12848,7 +12855,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ if let Some(ref mut funded_channel) = chan_entry.get_mut().as_funded_mut() { let init_res = funded_channel.splice_init( msg, - our_funding_contribution, &self.entropy_source, &self.get_our_node_id(), &self.logger, diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 70b347e0bd2..b45c3ce9ba8 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -315,6 +315,23 @@ pub fn complete_splice_handshake<'a, 'b, 'c, 'd>( pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, initiator_contribution: FundingContribution, new_funding_script: ScriptBuf, +) { + complete_interactive_funding_negotiation_for_both( + initiator, + acceptor, + channel_id, + initiator_contribution, + None, + 0, + new_funding_script, + ); +} + +pub fn complete_interactive_funding_negotiation_for_both<'a, 'b, 'c, 'd>( + initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, + initiator_contribution: FundingContribution, + acceptor_contribution: Option, acceptor_funding_satoshis: i64, + new_funding_script: ScriptBuf, ) { let node_id_initiator = initiator.node.get_our_node_id(); let node_id_acceptor = acceptor.node.get_our_node_id(); @@ -331,6 +348,8 @@ pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>( let new_channel_value = Amount::from_sat( channel_value_satoshis .checked_add_signed(initiator_contribution.net_value().to_sat()) + .unwrap() + .checked_add_signed(acceptor_funding_satoshis) .unwrap(), ); let (initiator_funding_tx_inputs, mut expected_initiator_outputs) = @@ -343,8 +362,22 @@ pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>( expected_initiator_outputs .push(TxOut { script_pubkey: new_funding_script, value: new_channel_value }); + let (mut expected_acceptor_inputs, mut expected_acceptor_scripts) = + if let Some(acceptor_contribution) = acceptor_contribution { + let (acceptor_inputs, acceptor_outputs) = acceptor_contribution.into_tx_parts(); + let expected_acceptor_inputs = + acceptor_inputs.iter().map(|input| input.utxo.outpoint).collect::>(); + let expected_acceptor_scripts = + acceptor_outputs.into_iter().map(|output| output.script_pubkey).collect::>(); + (expected_acceptor_inputs, expected_acceptor_scripts) + } else { + (Vec::new(), Vec::new()) + }; + let mut acceptor_sent_tx_complete = false; + let mut initiator_sent_tx_complete; loop { + // Initiator's turn: send TxAddInput, TxAddOutput, or TxComplete if !expected_initiator_inputs.is_empty() { let tx_add_input = get_event_msg!(initiator, MessageSendEvent::SendTxAddInput, node_id_acceptor); @@ -361,6 +394,7 @@ pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>( expected_initiator_inputs.iter().position(|input| *input == input_prevout).unwrap(), ); acceptor.node.handle_tx_add_input(node_id_initiator, &tx_add_input); + initiator_sent_tx_complete = false; } else if !expected_initiator_outputs.is_empty() { let tx_add_output = get_event_msg!(initiator, MessageSendEvent::SendTxAddOutput, node_id_acceptor); @@ -374,6 +408,7 @@ pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>( .unwrap(), ); acceptor.node.handle_tx_add_output(node_id_initiator, &tx_add_output); + initiator_sent_tx_complete = false; } else { let msg_events = initiator.node.get_and_clear_pending_msg_events(); assert_eq!(msg_events.len(), 1, "{msg_events:?}"); @@ -382,24 +417,69 @@ pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>( } else { panic!(); } + initiator_sent_tx_complete = true; if acceptor_sent_tx_complete { break; } } - let mut msg_events = acceptor.node.get_and_clear_pending_msg_events(); + // Acceptor's turn: send TxAddInput, TxAddOutput, or TxComplete + let msg_events = acceptor.node.get_and_clear_pending_msg_events(); assert_eq!(msg_events.len(), 1, "{msg_events:?}"); - if let MessageSendEvent::SendTxComplete { ref msg, .. } = msg_events.remove(0) { - initiator.node.handle_tx_complete(node_id_acceptor, msg); - } else { - panic!(); + match &msg_events[0] { + MessageSendEvent::SendTxAddInput { msg, .. } => { + let input_prevout = BitcoinOutPoint { + txid: msg + .prevtx + .as_ref() + .map(|prevtx| prevtx.compute_txid()) + .or(msg.shared_input_txid) + .unwrap(), + vout: msg.prevtx_out, + }; + expected_acceptor_inputs.remove( + expected_acceptor_inputs + .iter() + .position(|input| *input == input_prevout) + .unwrap(), + ); + initiator.node.handle_tx_add_input(node_id_acceptor, msg); + acceptor_sent_tx_complete = false; + }, + MessageSendEvent::SendTxAddOutput { msg, .. } => { + expected_acceptor_scripts.remove( + expected_acceptor_scripts + .iter() + .position(|script| *script == msg.script) + .unwrap(), + ); + initiator.node.handle_tx_add_output(node_id_acceptor, msg); + acceptor_sent_tx_complete = false; + }, + MessageSendEvent::SendTxComplete { msg, .. } => { + initiator.node.handle_tx_complete(node_id_acceptor, msg); + acceptor_sent_tx_complete = true; + if initiator_sent_tx_complete { + break; + } + }, + _ => panic!("Unexpected message event: {:?}", msg_events[0]), } - acceptor_sent_tx_complete = true; } + + assert!(expected_acceptor_inputs.is_empty(), "Not all acceptor inputs were sent"); + assert!(expected_acceptor_scripts.is_empty(), "Not all acceptor outputs were sent"); } pub fn sign_interactive_funding_tx<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, is_0conf: bool, +) -> (Transaction, Option<(msgs::SpliceLocked, PublicKey)>) { + sign_interactive_funding_tx_with_acceptor_contribution(initiator, acceptor, is_0conf, false) +} + +pub fn sign_interactive_funding_tx_with_acceptor_contribution<'a, 'b, 'c, 'd>( + initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, is_0conf: bool, + acceptor_has_contribution: bool, ) -> (Transaction, Option<(msgs::SpliceLocked, PublicKey)>) { let node_id_initiator = initiator.node.get_our_node_id(); let node_id_acceptor = acceptor.node.get_our_node_id(); @@ -433,6 +513,29 @@ pub fn sign_interactive_funding_tx<'a, 'b, 'c, 'd>( }; acceptor.node.handle_commitment_signed(node_id_initiator, &initial_commit_sig_for_acceptor); + if acceptor_has_contribution { + // When the acceptor contributed inputs, it needs to sign as well. The counterparty's + // commitment_signed is buffered until the acceptor signs. + assert!(acceptor.node.get_and_clear_pending_msg_events().is_empty()); + + let event = get_event!(acceptor, Event::FundingTransactionReadyForSigning); + if let Event::FundingTransactionReadyForSigning { + channel_id, + counterparty_node_id, + unsigned_transaction, + .. + } = event + { + let partially_signed_tx = acceptor.wallet_source.sign_tx(unsigned_transaction).unwrap(); + acceptor + .node + .funding_transaction_signed(&channel_id, &counterparty_node_id, partially_signed_tx) + .unwrap(); + } else { + panic!(); + } + } + let msg_events = acceptor.node.get_and_clear_pending_msg_events(); assert_eq!(msg_events.len(), 2, "{msg_events:?}"); if let MessageSendEvent::UpdateHTLCs { ref updates, .. } = &msg_events[0] { @@ -1301,6 +1404,292 @@ fn test_initiating_splice_holds_stfu_with_pending_splice() { ); } +#[test] +fn test_splice_both_contribute_tiebreak() { + // Same feerate: the acceptor's change increases because is_initiator=false has lower weight. + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + do_test_splice_tiebreak(feerate, feerate, Amount::from_sat(50_000), true); +} + +#[test] +fn test_splice_tiebreak_higher_feerate() { + // Node 0 (winner) uses a higher feerate than node 1 (loser). Node 1's change output is + // adjusted (reduced) to accommodate the higher feerate. Negotiation succeeds. + let feerate = FEERATE_FLOOR_SATS_PER_KW as u64; + do_test_splice_tiebreak( + FeeRate::from_sat_per_kwu(feerate * 3), + FeeRate::from_sat_per_kwu(feerate), + Amount::from_sat(50_000), + true, + ); +} + +#[test] +fn test_splice_tiebreak_lower_feerate() { + // Node 0 (winner) uses a lower feerate than node 1 (loser). Since the initiator's feerate + // is below node 1's minimum, node 1 proceeds without contribution and retries as initiator. + let feerate = FEERATE_FLOOR_SATS_PER_KW as u64; + do_test_splice_tiebreak( + FeeRate::from_sat_per_kwu(feerate), + FeeRate::from_sat_per_kwu(feerate * 3), + Amount::from_sat(50_000), + false, + ); +} + +#[test] +fn test_splice_tiebreak_feerate_too_high() { + // Node 0 (winner) uses a high feerate (20,000 sat/kwu). Node 1 splices in 95,000 sats from + // a 100,000 sat UTXO, leaving too little budget for fees. Node 1 proceeds without its + // contribution and retries as initiator. + let feerate = FEERATE_FLOOR_SATS_PER_KW as u64; + do_test_splice_tiebreak( + FeeRate::from_sat_per_kwu(20_000), + FeeRate::from_sat_per_kwu(feerate), + Amount::from_sat(95_000), + false, + ); +} + +/// Runs the splice tie-breaker test with the given per-node feerates and node 1's splice value. +/// +/// Both nodes call splice_channel + splice_in_sync + funding_contributed, both send STFU, +/// node 0 wins the tie-break. If `expect_acceptor_contributes` is true, node 1 contributes +/// to the splice; otherwise, node 1 proceeds without contribution and retries as initiator. +#[cfg(test)] +fn do_test_splice_tiebreak( + node_0_feerate: FeeRate, node_1_feerate: FeeRate, node_1_splice_value: Amount, + expect_acceptor_contributes: bool, +) { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000)); + + // Node 0 calls splice_channel + splice_in_sync + funding_contributed. + let funding_template_0 = nodes[0] + .node + .splice_channel(&channel_id, &node_id_1, node_0_feerate, FeeRate::MAX) + .unwrap(); + let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let node_0_funding_contribution = + funding_template_0.splice_in_sync(added_value, &wallet_0).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) + .unwrap(); + + // Node 1 calls splice_channel + splice_in_sync + funding_contributed. + let funding_template_1 = nodes[1] + .node + .splice_channel(&channel_id, &node_id_0, node_1_feerate, FeeRate::MAX) + .unwrap(); + let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let node_1_funding_contribution = + funding_template_1.splice_in_sync(node_1_splice_value, &wallet_1).unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) + .unwrap(); + + // Both nodes emit STFU. + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + assert!(stfu_0.initiator); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + assert!(stfu_1.initiator); + + // Tie-break: node 1 handles node 0's STFU first — node 1 loses (not the outbound funder). + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + + // Node 0 handles node 1's STFU — node 0 wins (outbound funder), sends SpliceInit. + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + + // Node 1 handles SpliceInit — whether it contributes depends on feerate/budget constraints. + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + let acceptor_contributes = splice_ack.funding_contribution_satoshis != 0; + assert_eq!( + acceptor_contributes, expect_acceptor_contributes, + "Expected acceptor contribution: {}, got: {}", + expect_acceptor_contributes, acceptor_contributes, + ); + + // Node 0 handles SpliceAck — starts interactive tx construction. + nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); + + // Compute the new funding script from the splice pubkeys. + let new_funding_script = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + + if acceptor_contributes { + // Capture change output values for assertions. + let node_0_change = node_0_funding_contribution + .change_output() + .expect("splice-in should have a change output") + .clone(); + let node_1_change = node_1_funding_contribution + .change_output() + .expect("splice-in should have a change output") + .clone(); + + // Complete interactive funding negotiation with both parties' inputs/outputs. + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + node_0_funding_contribution, + Some(node_1_funding_contribution), + splice_ack.funding_contribution_satoshis, + new_funding_script, + ); + + // Sign (acceptor has contribution) and broadcast. + let (tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( + &nodes[0], &nodes[1], false, true, + ); + assert!(splice_locked.is_none()); + + // The initiator's change output should remain unchanged (no feerate adjustment). + let initiator_change_in_tx = tx + .output + .iter() + .find(|o| o.script_pubkey == node_0_change.script_pubkey) + .expect("Initiator's change output should be in the splice transaction"); + assert_eq!( + initiator_change_in_tx.value, node_0_change.value, + "Initiator's change output should remain unchanged", + ); + + // The acceptor's change output should be adjusted based on the feerate difference. + let acceptor_change_in_tx = tx + .output + .iter() + .find(|o| o.script_pubkey == node_1_change.script_pubkey) + .expect("Acceptor's change output should be in the splice transaction"); + if node_0_feerate <= node_1_feerate { + // Initiator's feerate <= acceptor's original: the acceptor's change increases because + // is_initiator=false has lower weight, and the feerate is the same or lower. + assert!( + acceptor_change_in_tx.value > node_1_change.value, + "Acceptor's change should increase when initiator feerate ({}) <= acceptor \ + feerate ({}): adjusted {} vs original {}", + node_0_feerate.to_sat_per_kwu(), + node_1_feerate.to_sat_per_kwu(), + acceptor_change_in_tx.value, + node_1_change.value, + ); + } else { + // Initiator's feerate > acceptor's original: the higher feerate more than compensates + // for the lower weight, so the acceptor's change decreases. + assert!( + acceptor_change_in_tx.value < node_1_change.value, + "Acceptor's change should decrease when initiator feerate ({}) > acceptor \ + feerate ({}): adjusted {} vs original {}", + node_0_feerate.to_sat_per_kwu(), + node_1_feerate.to_sat_per_kwu(), + acceptor_change_in_tx.value, + node_1_change.value, + ); + } + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + mine_transaction(&nodes[0], &tx); + mine_transaction(&nodes[1], &tx); + + lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); + } else { + // Acceptor does not contribute — complete with only node 0's inputs/outputs. + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + node_0_funding_contribution, + None, + 0, + new_funding_script, + ); + + // Sign (no acceptor contribution) and broadcast. + let (tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( + &nodes[0], &nodes[1], false, false, + ); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + mine_transaction(&nodes[0], &tx); + mine_transaction(&nodes[1], &tx); + + // After splice_locked, node 1's preserved QuiescentAction triggers STFU for retry. + let node_1_stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); + let stfu_1 = if let Some(MessageSendEvent::SendStfu { msg, .. }) = node_1_stfu { + assert!(msg.initiator); + msg + } else { + panic!("Expected SendStfu from node 1 after splice_locked"); + }; + + // === Part 2: Node 1 retries as initiator at its preferred feerate === + // TODO(splicing): Node 1 should retry contribution via RBF above instead + + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + let splice_init = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceInit, node_id_0); + + nodes[0].node.handle_splice_init(node_id_1, &splice_init); + let splice_ack = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceAck, node_id_1); + + nodes[1].node.handle_splice_ack(node_id_0, &splice_ack); + + let new_funding_script_2 = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + + complete_interactive_funding_negotiation( + &nodes[1], + &nodes[0], + channel_id, + node_1_funding_contribution, + new_funding_script_2, + ); + + let (new_splice_tx, splice_locked) = + sign_interactive_funding_tx(&nodes[1], &nodes[0], false); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[1], &node_id_0); + expect_splice_pending_event(&nodes[0], &node_id_1); + + mine_transaction(&nodes[1], &new_splice_tx); + mine_transaction(&nodes[0], &new_splice_tx); + + lock_splice_after_blocks(&nodes[1], &nodes[0], ANTI_REORG_DELAY - 1); + } +} + #[cfg(test)] #[derive(PartialEq)] enum SpliceStatus { @@ -1777,8 +2166,8 @@ fn test_propose_splice_while_disconnected() { #[cfg(test)] fn do_test_propose_splice_while_disconnected(use_0conf: bool) { // Test that both nodes are able to propose a splice while the counterparty is disconnected, and - // whoever doesn't go first due to the quiescence tie-breaker, will retry their splice after the - // first one becomes locked. + // whoever doesn't go first due to the quiescence tie-breaker, will have their contribution + // merged into the counterparty-initiated splice. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let mut config = test_default_channel_config(); @@ -1858,23 +2247,29 @@ fn do_test_propose_splice_while_disconnected(use_0conf: bool) { .map(|monitor| (monitor.get_funding_txo(), monitor.get_funding_script())) .unwrap(); - // Negotiate the first splice to completion. + // Negotiate the splice to completion. Node 1's quiescent action should be consumed by + // splice_init, so both contributions are merged into a single splice. nodes[1].node.handle_splice_init(node_id_0, &splice_init); let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + assert_ne!(splice_ack.funding_contribution_satoshis, 0); nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); let new_funding_script = chan_utils::make_funding_redeemscript( &splice_init.funding_pubkey, &splice_ack.funding_pubkey, ) .to_p2wsh(); - complete_interactive_funding_negotiation( + complete_interactive_funding_negotiation_for_both( &nodes[0], &nodes[1], channel_id, node_0_funding_contribution, + Some(node_1_funding_contribution), + splice_ack.funding_contribution_satoshis, new_funding_script, ); - let (splice_tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], use_0conf); + let (splice_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( + &nodes[0], &nodes[1], use_0conf, true, + ); expect_splice_pending_event(&nodes[0], &node_id_1); expect_splice_pending_event(&nodes[1], &node_id_0); @@ -1888,7 +2283,7 @@ fn do_test_propose_splice_while_disconnected(use_0conf: bool) { mine_transaction(&nodes[0], &splice_tx); mine_transaction(&nodes[1], &splice_tx); - // Mine enough blocks for the first splice to become locked. + // Mine enough blocks for the splice to become locked. connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1); connect_blocks(&nodes[1], ANTI_REORG_DELAY - 1); @@ -1896,10 +2291,9 @@ fn do_test_propose_splice_while_disconnected(use_0conf: bool) { }; nodes[1].node.handle_splice_locked(node_id_0, &splice_locked); - // We should see the node which lost the tie-breaker attempt their splice now by first - // negotiating quiescence, but their `stfu` won't be sent until after another reconnection. + // Node 1's quiescent action was consumed, so it should NOT send stfu. let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), if use_0conf { 2 } else { 3 }, "{msg_events:?}"); + assert_eq!(msg_events.len(), if use_0conf { 1 } else { 2 }, "{msg_events:?}"); if let MessageSendEvent::SendSpliceLocked { ref msg, .. } = &msg_events[0] { nodes[0].node.handle_splice_locked(node_id_1, msg); if use_0conf { @@ -1920,10 +2314,6 @@ fn do_test_propose_splice_while_disconnected(use_0conf: bool) { panic!("Unexpected event {:?}", &msg_events[1]); } } - assert!(matches!( - &msg_events[if use_0conf { 1 } else { 2 }], - MessageSendEvent::SendStfu { .. } - )); let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); assert_eq!(msg_events.len(), if use_0conf { 0 } else { 2 }, "{msg_events:?}"); @@ -1956,57 +2346,6 @@ fn do_test_propose_splice_while_disconnected(use_0conf: bool) { .chain_source .remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script); - // Reconnect the nodes. This should trigger the node which lost the tie-breaker to resend `stfu` - // for their splice attempt. - nodes[0].node.peer_disconnected(node_id_1); - nodes[1].node.peer_disconnected(node_id_0); - let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); - if !use_0conf { - reconnect_args.send_announcement_sigs = (true, true); - } - reconnect_args.send_stfu = (true, false); - reconnect_nodes(reconnect_args); - - // Drive the second splice to completion. - let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), 1, "{msg_events:?}"); - if let MessageSendEvent::SendStfu { ref msg, .. } = msg_events[0] { - nodes[1].node.handle_stfu(node_id_0, msg); - } else { - panic!("Unexpected event {:?}", &msg_events[0]); - } - - let splice_init = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceInit, node_id_0); - nodes[0].node.handle_splice_init(node_id_1, &splice_init); - let splice_ack = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceAck, node_id_1); - nodes[1].node.handle_splice_ack(node_id_0, &splice_ack); - let new_funding_script = chan_utils::make_funding_redeemscript( - &splice_init.funding_pubkey, - &splice_ack.funding_pubkey, - ) - .to_p2wsh(); - complete_interactive_funding_negotiation( - &nodes[1], - &nodes[0], - channel_id, - node_1_funding_contribution, - new_funding_script, - ); - let (splice_tx, splice_locked) = sign_interactive_funding_tx(&nodes[1], &nodes[0], use_0conf); - expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); - - if use_0conf { - let (splice_locked, for_node_id) = splice_locked.unwrap(); - assert_eq!(for_node_id, node_id_0); - lock_splice(&nodes[1], &nodes[0], &splice_locked, true); - } else { - assert!(splice_locked.is_none()); - mine_transaction(&nodes[0], &splice_tx); - mine_transaction(&nodes[1], &splice_tx); - lock_splice_after_blocks(&nodes[1], &nodes[0], ANTI_REORG_DELAY - 1); - } - // Sanity check that we can still make a test payment. send_payment(&nodes[0], &[&nodes[1]], 1_000_000); } From 6c100549565f1035ab48d5fe4dab0eb3e3eef66a Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 25 Feb 2026 10:51:04 -0600 Subject: [PATCH 131/627] Refactor complete_interactive_funding_negotiation_for_both Use a single get_and_clear_pending_msg_events() + match pattern for the initiator's turn, matching the existing acceptor code path. Also add assertions that all expected initiator inputs and outputs were sent. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/splicing_tests.rs | 87 +++++++++++++++--------------- 1 file changed, 44 insertions(+), 43 deletions(-) diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index b45c3ce9ba8..486e386be87 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -374,53 +374,52 @@ pub fn complete_interactive_funding_negotiation_for_both<'a, 'b, 'c, 'd>( (Vec::new(), Vec::new()) }; - let mut acceptor_sent_tx_complete = false; let mut initiator_sent_tx_complete; + let mut acceptor_sent_tx_complete = false; loop { // Initiator's turn: send TxAddInput, TxAddOutput, or TxComplete - if !expected_initiator_inputs.is_empty() { - let tx_add_input = - get_event_msg!(initiator, MessageSendEvent::SendTxAddInput, node_id_acceptor); - let input_prevout = BitcoinOutPoint { - txid: tx_add_input - .prevtx - .as_ref() - .map(|prevtx| prevtx.compute_txid()) - .or(tx_add_input.shared_input_txid) - .unwrap(), - vout: tx_add_input.prevtx_out, - }; - expected_initiator_inputs.remove( - expected_initiator_inputs.iter().position(|input| *input == input_prevout).unwrap(), - ); - acceptor.node.handle_tx_add_input(node_id_initiator, &tx_add_input); - initiator_sent_tx_complete = false; - } else if !expected_initiator_outputs.is_empty() { - let tx_add_output = - get_event_msg!(initiator, MessageSendEvent::SendTxAddOutput, node_id_acceptor); - expected_initiator_outputs.remove( - expected_initiator_outputs - .iter() - .position(|output| { - *output.script_pubkey == tx_add_output.script - && output.value.to_sat() == tx_add_output.sats - }) - .unwrap(), - ); - acceptor.node.handle_tx_add_output(node_id_initiator, &tx_add_output); - initiator_sent_tx_complete = false; - } else { - let msg_events = initiator.node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), 1, "{msg_events:?}"); - if let MessageSendEvent::SendTxComplete { ref msg, .. } = &msg_events[0] { + let msg_events = initiator.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + match &msg_events[0] { + MessageSendEvent::SendTxAddInput { msg, .. } => { + let input_prevout = BitcoinOutPoint { + txid: msg + .prevtx + .as_ref() + .map(|prevtx| prevtx.compute_txid()) + .or(msg.shared_input_txid) + .unwrap(), + vout: msg.prevtx_out, + }; + expected_initiator_inputs.remove( + expected_initiator_inputs + .iter() + .position(|input| *input == input_prevout) + .unwrap(), + ); + acceptor.node.handle_tx_add_input(node_id_initiator, msg); + initiator_sent_tx_complete = false; + }, + MessageSendEvent::SendTxAddOutput { msg, .. } => { + expected_initiator_outputs.remove( + expected_initiator_outputs + .iter() + .position(|output| { + *output.script_pubkey == msg.script && output.value.to_sat() == msg.sats + }) + .unwrap(), + ); + acceptor.node.handle_tx_add_output(node_id_initiator, msg); + initiator_sent_tx_complete = false; + }, + MessageSendEvent::SendTxComplete { msg, .. } => { acceptor.node.handle_tx_complete(node_id_initiator, msg); - } else { - panic!(); - } - initiator_sent_tx_complete = true; - if acceptor_sent_tx_complete { - break; - } + initiator_sent_tx_complete = true; + if acceptor_sent_tx_complete { + break; + } + }, + _ => panic!("Unexpected message event: {:?}", msg_events[0]), } // Acceptor's turn: send TxAddInput, TxAddOutput, or TxComplete @@ -467,6 +466,8 @@ pub fn complete_interactive_funding_negotiation_for_both<'a, 'b, 'c, 'd>( } } + assert!(expected_initiator_inputs.is_empty(), "Not all initiator inputs were sent"); + assert!(expected_initiator_outputs.is_empty(), "Not all initiator outputs were sent"); assert!(expected_acceptor_inputs.is_empty(), "Not all acceptor inputs were sent"); assert!(expected_acceptor_scripts.is_empty(), "Not all acceptor outputs were sent"); } From 40bc82cf308ccc88bb44d34d72da1df08f1ea4ef Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 6 Mar 2026 14:24:20 +0100 Subject: [PATCH 132/627] Fix spurious `debug_assert` in UTXO gossip dedup check `check_replace_previous_entry` hit `debug_assert!(false)` when `channel_announce` was `None` on a still-live `UtxoMessages`. The comment claimed this was unreachable because `channel_announce` is set under the same lock as the channel map entry. However, there is a legitimate race: 1. A channel announcement arrives, an async UTXO lookup starts, and `pending_channels[scid]` is set with a `Weak` to the `UtxoMessages`. 2. The lookup resolves. `resolve_single_future` takes both `channel_announce` and `complete` via `.take()`, but the `Arc>` is still alive on the stack of `check_resolved_futures`. 3. A duplicate announcement for the same SCID arrives during this window. `check_replace_previous_entry` upgrades the `Weak`, finds `channel_announce` is `None`, and hits the assert. Replace the unconditional `debug_assert!(false)` with a targeted check that `complete` has also been taken (confirming the future resolved), which would catch a genuinely unexpected state where `channel_announce` is `None` but `complete` is still pending. Co-Authored-By: HAL 9000 --- lightning/src/routing/utxo.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/lightning/src/routing/utxo.rs b/lightning/src/routing/utxo.rs index 466b9416f41..6b2f2963b76 100644 --- a/lightning/src/routing/utxo.rs +++ b/lightning/src/routing/utxo.rs @@ -308,23 +308,27 @@ impl PendingChecks { // This may be called with the mutex held on a different UtxoMessages // struct, however in that case we have a global lockorder of new messages // -> old messages, which makes this safe. - let pending_matches = match &pending_msgs - .unsafe_well_ordered_double_lock_self() - .channel_announce - { + let pending_state = pending_msgs.unsafe_well_ordered_double_lock_self(); + let pending_matches = match &pending_state.channel_announce { Some(ChannelAnnouncement::Full(pending_msg)) => { Some(pending_msg) == full_msg }, Some(ChannelAnnouncement::Unsigned(pending_msg)) => pending_msg == msg, None => { - // This shouldn't actually be reachable. We set the - // `channel_announce` field under the same lock as setting the - // channel map entry. Still, we can just treat it as + // This can be reached if `resolve_single_future` has already + // consumed `channel_announce` via `.take()` while the + // `Arc>` is still alive (e.g. held on + // the stack of `check_resolved_futures`). In that case, + // `complete` should also have been taken. Treat it as // non-matching and let the new request fly. - debug_assert!(false); + debug_assert!( + pending_state.complete.is_none(), + "channel_announce is None but complete is still pending" + ); false }, }; + drop(pending_state); if pending_matches { return Err(LightningError { err: "Channel announcement is already being checked".to_owned(), From 5574dbefbd42c85cad7956cfe39b42e241bb8b66 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 9 Mar 2026 15:05:08 +0000 Subject: [PATCH 133/627] Small tweaks to CLAUDE.md --- CLAUDE.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index f87bc665bd4..15dd4581e34 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,11 @@ See [README.md](README.md) for the workspace layout and [ARCH.md](ARCH.md) for s of the full task you might prompt the user whether they want you to run the full CI tests via `./ci/ci-tests.sh`. Note however that this script will run for a very long time, so please don't timeout when you do. -- Run `cargo +1.75.0 fmt --all` after every code change +- Run `cargo +1.75.0 fmt --all` before committing code changes. If rust 1.75.0 is + not installed, skip this step. - Never add new dependencies unless explicitly requested - Please always disclose the use of any AI tools in commit messages and PR descriptions using a `Co-Authored-By:` line. - When adding new `.rs` files, please ensure to always add the licensing header as found, e.g., in `lightning/src/lib.rs` and other files. +- When adding comments, do not refer to internal logic in other modules, instead + make sure comments make sense in the context they're in without needing other + context. From 607abaffd4a2b156d82d606a48ce09e9246b6725 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 16 Dec 2025 13:37:23 +0200 Subject: [PATCH 134/627] ln/events: multiple htlcs in/out for trampoline PaymentForwarded --- .../tests/lsps2_integration_tests.rs | 8 +- lightning/src/events/mod.rs | 156 +++++++++++------- lightning/src/ln/chanmon_update_fail_tests.rs | 4 +- lightning/src/ln/channelmanager.rs | 16 +- lightning/src/ln/functional_test_utils.rs | 42 ++--- lightning/src/ln/functional_tests.rs | 26 +-- lightning/src/util/ser.rs | 1 + 7 files changed, 150 insertions(+), 103 deletions(-) diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 33a6dd697cf..77be3cb5aa1 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -1331,14 +1331,14 @@ fn client_trusts_lsp_end_to_end_test() { let total_fee_msat = match service_events[0].clone() { Event::PaymentForwarded { - prev_node_id, - next_node_id, + ref prev_htlcs, + ref next_htlcs, skimmed_fee_msat, total_fee_earned_msat, .. } => { - assert_eq!(prev_node_id, Some(payer_node_id)); - assert_eq!(next_node_id, Some(client_node_id)); + assert_eq!(prev_htlcs[0].node_id, Some(payer_node_id)); + assert_eq!(next_htlcs[0].node_id, Some(client_node_id)); service_handler.payment_forwarded(channel_id, skimmed_fee_msat.unwrap_or(0)).unwrap(); Some(total_fee_earned_msat.unwrap() - skimmed_fee_msat.unwrap()) }, diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 3f6bb0efb01..01bbd5d1a11 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -738,6 +738,31 @@ pub enum InboundChannelFunds { DualFunded, } +/// Identifies the channel and peer committed to a HTLC, used for both incoming and outgoing HTLCs. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HTLCLocator { + /// The channel that the HTLC was sent or received on. + pub channel_id: ChannelId, + + /// The `user_channel_id` for `channel_id`. + /// + /// This will be `None` if the payment was settled via an on-chain transaction. It will also + /// be `None` for events serialized by versions prior to 0.0.122. + pub user_channel_id: Option, + + /// The public key identity of the node that the HTLC was sent to or received from. + /// + /// This is only `None` for HTLCs received prior to 0.1 or for events serialized by versions + /// prior to 0.1. + pub node_id: Option, +} + +impl_writeable_tlv_based!(HTLCLocator, { + (1, channel_id, required), + (3, user_channel_id, option), + (5, node_id, option), +}); + /// An Event which you should probably take some action in response to. /// /// Note that while Writeable and Readable are implemented for Event, you probably shouldn't use @@ -1331,38 +1356,22 @@ pub enum Event { /// This event is generated when a payment has been successfully forwarded through us and a /// forwarding fee earned. /// + /// Note that downgrading from 0.3 and above with pending trampoline forwards that use multipart + /// payments will produce an event that only provides information about the first htlc that was + /// received/dispatched. + /// /// # Failure Behavior and Persistence /// This event will eventually be replayed after failures-to-handle (i.e., the event handler /// returning `Err(ReplayEvent ())`) and will be persisted across restarts. PaymentForwarded { - /// The channel id of the incoming channel between the previous node and us. - /// - /// This is only `None` for events generated or serialized by versions prior to 0.0.107. - prev_channel_id: Option, - /// The channel id of the outgoing channel between the next node and us. - /// - /// This is only `None` for events generated or serialized by versions prior to 0.0.107. - next_channel_id: Option, - /// The `user_channel_id` of the incoming channel between the previous node and us. - /// - /// This is only `None` for events generated or serialized by versions prior to 0.0.122. - prev_user_channel_id: Option, - /// The `user_channel_id` of the outgoing channel between the next node and us. - /// - /// This will be `None` if the payment was settled via an on-chain transaction. See the - /// caveat described for the `total_fee_earned_msat` field. Moreover it will be `None` for - /// events generated or serialized by versions prior to 0.0.122. - next_user_channel_id: Option, - /// The node id of the previous node. - /// - /// This is only `None` for HTLCs received prior to 0.1 or for events serialized by - /// versions prior to 0.1 - prev_node_id: Option, - /// The node id of the next node. - /// - /// This is only `None` for HTLCs received prior to 0.1 or for events serialized by - /// versions prior to 0.1 - next_node_id: Option, + /// The set of HTLCs forwarded to our node that will be claimed by this forward. Contains a + /// single HTLC for source-routed payments, and may contain multiple HTLCs when we acted as + /// a trampoline router, responsible for pathfinding within the route. + prev_htlcs: Vec, + /// The set of HTLCs forwarded by our node that have been claimed by this forward. Contains + /// a single HTLC for regular source-routed payments, and may contain multiple HTLCs when + /// we acted as a trampoline router, responsible for pathfinding within the route. + next_htlcs: Vec, /// The total fee, in milli-satoshis, which was earned as a result of the payment. /// /// Note that if we force-closed the channel over which we forwarded an HTLC while the HTLC @@ -2026,29 +2035,47 @@ impl Writeable for Event { }); }, &Event::PaymentForwarded { - prev_channel_id, - next_channel_id, - prev_user_channel_id, - next_user_channel_id, - prev_node_id, - next_node_id, + ref prev_htlcs, + ref next_htlcs, total_fee_earned_msat, skimmed_fee_msat, claim_from_onchain_tx, outbound_amount_forwarded_msat, } => { 7u8.write(writer)?; + // Fields 1, 3, 9, 11, 13 and 15 are written for backwards compatibility. We don't + // want to fail writes, so we write garbage data if we don't have at least on htlc. + debug_assert!( + !prev_htlcs.is_empty(), + "at least one prev_htlc required for PaymentForwarded", + ); + debug_assert!( + !next_htlcs.is_empty(), + "at least one next_htlc required for PaymentForwarded", + ); + let empty_locator = HTLCLocator { + channel_id: ChannelId::new_zero(), + user_channel_id: None, + node_id: None, + }; + let legacy_prev = prev_htlcs.first().unwrap_or(&empty_locator); + let legacy_next = next_htlcs.first().unwrap_or(&empty_locator); write_tlv_fields!(writer, { (0, total_fee_earned_msat, option), - (1, prev_channel_id, option), + (1, Some(legacy_prev.channel_id), option), (2, claim_from_onchain_tx, required), - (3, next_channel_id, option), + (3, Some(legacy_next.channel_id), option), (5, outbound_amount_forwarded_msat, option), (7, skimmed_fee_msat, option), - (9, prev_user_channel_id, option), - (11, next_user_channel_id, option), - (13, prev_node_id, option), - (15, next_node_id, option), + (9, legacy_prev.user_channel_id, option), + (11, legacy_next.user_channel_id, option), + (13, legacy_prev.node_id, option), + (15, legacy_next.node_id, option), + // HTLCs are written as required, rather than required_vec, so that they can be + // deserialized using default_value to fill in legacy fields which expects + // LengthReadable (required_vec is WithoutLength). + (17, *prev_htlcs, required), + (19, *next_htlcs, required), }); }, &Event::ChannelClosed { @@ -2548,35 +2575,48 @@ impl MaybeReadable for Event { }, 7u8 => { let mut f = || { - let mut prev_channel_id = None; - let mut next_channel_id = None; - let mut prev_user_channel_id = None; - let mut next_user_channel_id = None; - let mut prev_node_id = None; - let mut next_node_id = None; + // Legacy values that have been replaced by prev_htlcs and next_htlcs. + let mut prev_channel_id_legacy = None; + let mut next_channel_id_legacy = None; + let mut prev_user_channel_id_legacy = None; + let mut next_user_channel_id_legacy = None; + let mut prev_node_id_legacy = None; + let mut next_node_id_legacy = None; + let mut total_fee_earned_msat = None; let mut skimmed_fee_msat = None; let mut claim_from_onchain_tx = false; let mut outbound_amount_forwarded_msat = None; + let mut prev_htlcs = vec![]; + let mut next_htlcs = vec![]; read_tlv_fields!(reader, { (0, total_fee_earned_msat, option), - (1, prev_channel_id, option), + (1, prev_channel_id_legacy, option), (2, claim_from_onchain_tx, required), - (3, next_channel_id, option), + (3, next_channel_id_legacy, option), (5, outbound_amount_forwarded_msat, option), (7, skimmed_fee_msat, option), - (9, prev_user_channel_id, option), - (11, next_user_channel_id, option), - (13, prev_node_id, option), - (15, next_node_id, option), + (9, prev_user_channel_id_legacy, option), + (11, next_user_channel_id_legacy, option), + (13, prev_node_id_legacy, option), + (15, next_node_id_legacy, option), + // We never expect prev/next_channel_id_legacy to be None because this field + // was only None for versions before 0.0.107 and we do not allow upgrades + // with pending forwards to 0.1 for any version 0.0.123 or earlier. + (17, prev_htlcs, (default_value, vec![HTLCLocator{ + channel_id: prev_channel_id_legacy.ok_or(DecodeError::InvalidValue)?, + user_channel_id: prev_user_channel_id_legacy, + node_id: prev_node_id_legacy, + }])), + (19, next_htlcs, (default_value, vec![HTLCLocator{ + channel_id: next_channel_id_legacy.ok_or(DecodeError::InvalidValue)?, + user_channel_id: next_user_channel_id_legacy, + node_id: next_node_id_legacy, + }])), }); Ok(Some(Event::PaymentForwarded { - prev_channel_id, - next_channel_id, - prev_user_channel_id, - next_user_channel_id, - prev_node_id, - next_node_id, + prev_htlcs, + next_htlcs, total_fee_earned_msat, skimmed_fee_msat, claim_from_onchain_tx, diff --git a/lightning/src/ln/chanmon_update_fail_tests.rs b/lightning/src/ln/chanmon_update_fail_tests.rs index cd32d219b93..36428256d67 100644 --- a/lightning/src/ln/chanmon_update_fail_tests.rs +++ b/lightning/src/ln/chanmon_update_fail_tests.rs @@ -3940,11 +3940,11 @@ fn do_test_durable_preimages_on_closed_channel( let evs = nodes[1].node.get_and_clear_pending_events(); assert_eq!(evs.len(), if close_chans_before_reload { 2 } else { 1 }); for ev in evs { - if let Event::PaymentForwarded { claim_from_onchain_tx, next_user_channel_id, .. } = ev { + if let Event::PaymentForwarded { claim_from_onchain_tx, next_htlcs, .. } = ev { if !claim_from_onchain_tx { // If the outbound channel is still open, the `next_user_channel_id` should be available. // This was previously broken. - assert!(next_user_channel_id.is_some()) + assert!(next_htlcs[0].user_channel_id.is_some()) } } else { panic!(); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 6bf04cd62a4..a5725a70fbd 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -9756,12 +9756,16 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ ( Some(MonitorUpdateCompletionAction::EmitEventAndFreeOtherChannel { event: events::Event::PaymentForwarded { - prev_channel_id: Some(prev_channel_id), - next_channel_id: Some(next_channel_id), - prev_user_channel_id, - next_user_channel_id, - prev_node_id, - next_node_id: Some(next_channel_counterparty_node_id), + prev_htlcs: vec![events::HTLCLocator { + channel_id: prev_channel_id, + user_channel_id: prev_user_channel_id, + node_id: prev_node_id, + }], + next_htlcs: vec![events::HTLCLocator { + channel_id: next_channel_id, + user_channel_id: next_user_channel_id, + node_id: Some(next_channel_counterparty_node_id), + }], total_fee_earned_msat, skimmed_fee_msat, claim_from_onchain_tx: from_onchain, diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 2d971c3a100..641842ddaff 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -3095,17 +3095,16 @@ pub fn expect_payment_forwarded>( ) -> Option { match event { Event::PaymentForwarded { - prev_channel_id, - next_channel_id, - prev_user_channel_id, - next_user_channel_id, - prev_node_id, - next_node_id, + prev_htlcs, + next_htlcs, total_fee_earned_msat, skimmed_fee_msat, claim_from_onchain_tx, .. } => { + assert_eq!(prev_htlcs.len(), 1); + assert_eq!(next_htlcs.len(), 1); + if allow_1_msat_fee_overpay { // Aggregating fees for blinded paths may result in a rounding error, causing slight // overpayment in fees. @@ -3120,33 +3119,36 @@ pub fn expect_payment_forwarded>( // overpaid amount. assert!(skimmed_fee_msat == expected_extra_fees_msat); if !upstream_force_closed { - assert_eq!(prev_node.node().get_our_node_id(), prev_node_id.unwrap()); + let prev_node_id = prev_htlcs[0].node_id.unwrap(); + let prev_channel_id = prev_htlcs[0].channel_id; + let prev_user_channel_id = prev_htlcs[0].user_channel_id.unwrap(); + + assert_eq!(prev_node.node().get_our_node_id(), prev_node_id); // Is the event prev_channel_id in one of the channels between the two nodes? let node_chans = node.node().list_channels(); - assert!(node_chans.iter().any(|x| x.counterparty.node_id == prev_node_id.unwrap() - && x.channel_id == prev_channel_id.unwrap() - && x.user_channel_id == prev_user_channel_id.unwrap())); + assert!(node_chans.iter().any(|x| x.counterparty.node_id == prev_node_id + && x.channel_id == prev_channel_id + && x.user_channel_id == prev_user_channel_id)); } // We check for force closures since a force closed channel is removed from the // node's channel list if !downstream_force_closed { + let next_node_id = next_htlcs[0].node_id.unwrap(); + let next_channel_id = next_htlcs[0].channel_id; + let next_user_channel_id = next_htlcs[0].user_channel_id.unwrap(); // As documented, `next_user_channel_id` will only be `Some` if we didn't settle via an // onchain transaction, just as the `total_fee_earned_msat` field. Rather than // introducing yet another variable, we use the latter's state as a flag to detect // this and only check if it's `Some`. - assert_eq!(next_node.node().get_our_node_id(), next_node_id.unwrap()); + assert_eq!(next_node.node().get_our_node_id(), next_node_id); let node_chans = node.node().list_channels(); if total_fee_earned_msat.is_none() { - assert!(node_chans - .iter() - .any(|x| x.counterparty.node_id == next_node_id.unwrap() - && x.channel_id == next_channel_id.unwrap())); + assert!(node_chans.iter().any(|x| x.counterparty.node_id == next_node_id + && x.channel_id == next_channel_id)); } else { - assert!(node_chans - .iter() - .any(|x| x.counterparty.node_id == next_node_id.unwrap() - && x.channel_id == next_channel_id.unwrap() - && x.user_channel_id == next_user_channel_id.unwrap())); + assert!(node_chans.iter().any(|x| x.counterparty.node_id == next_node_id + && x.channel_id == next_channel_id + && x.user_channel_id == next_user_channel_id)); } } assert_eq!(claim_from_onchain_tx, downstream_force_closed); diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index 09a87d93156..17fbc1fce28 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -1490,37 +1490,37 @@ pub fn test_htlc_on_chain_success() { connect_blocks(&nodes[1], TEST_FINAL_CLTV); // Confirm blocks until the HTLC expires let forwarded_events = nodes[1].node.get_and_clear_pending_events(); assert_eq!(forwarded_events.len(), 3); - let chan_id = Some(chan_1.2); + let chan_id = chan_1.2; match forwarded_events[0] { Event::PaymentForwarded { + ref prev_htlcs, + ref next_htlcs, total_fee_earned_msat, - prev_channel_id, claim_from_onchain_tx, - next_channel_id, outbound_amount_forwarded_msat, .. } => { assert_eq!(total_fee_earned_msat, Some(1000)); - assert_eq!(prev_channel_id, chan_id); + assert_eq!(prev_htlcs[0].channel_id, chan_id); assert_eq!(claim_from_onchain_tx, true); - assert_eq!(next_channel_id, Some(chan_2.2)); + assert_eq!(next_htlcs[0].channel_id, chan_2.2); assert_eq!(outbound_amount_forwarded_msat, Some(3000000)); }, _ => panic!(), } match forwarded_events[1] { Event::PaymentForwarded { + ref prev_htlcs, + ref next_htlcs, total_fee_earned_msat, - prev_channel_id, claim_from_onchain_tx, - next_channel_id, outbound_amount_forwarded_msat, .. } => { assert_eq!(total_fee_earned_msat, Some(1000)); - assert_eq!(prev_channel_id, chan_id); + assert_eq!(prev_htlcs[0].channel_id, chan_id); assert_eq!(claim_from_onchain_tx, true); - assert_eq!(next_channel_id, Some(chan_2.2)); + assert_eq!(next_htlcs[0].channel_id, chan_2.2); assert_eq!(outbound_amount_forwarded_msat, Some(3000000)); }, _ => panic!(), @@ -4031,17 +4031,17 @@ pub fn test_onchain_to_onchain_claim() { assert_eq!(events.len(), 2); match events[0] { Event::PaymentForwarded { + ref prev_htlcs, + ref next_htlcs, total_fee_earned_msat, - prev_channel_id, claim_from_onchain_tx, - next_channel_id, outbound_amount_forwarded_msat, .. } => { assert_eq!(total_fee_earned_msat, Some(1000)); - assert_eq!(prev_channel_id, Some(chan_1.2)); + assert_eq!(prev_htlcs[0].channel_id, chan_1.2); assert_eq!(claim_from_onchain_tx, true); - assert_eq!(next_channel_id, Some(chan_2.2)); + assert_eq!(next_htlcs[0].channel_id, chan_2.2); assert_eq!(outbound_amount_forwarded_msat, Some(3000000)); }, _ => panic!("Unexpected event"), diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs index 2eace55a4bf..45ca98b6fd0 100644 --- a/lightning/src/util/ser.rs +++ b/lightning/src/util/ser.rs @@ -1109,6 +1109,7 @@ impl_for_vec!(crate::routing::router::TrampolineHop); impl_for_vec_with_element_length_prefix!(crate::ln::msgs::UpdateAddHTLC); impl_writeable_for_vec_with_element_length_prefix!(&crate::ln::msgs::UpdateAddHTLC); impl_for_vec!(u32); +impl_for_vec!(crate::events::HTLCLocator); impl Writeable for Vec { #[inline] From dd15359d3cc8b7e7ccf31b47e6cc4f416ab513fb Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 16 Dec 2025 15:14:55 +0200 Subject: [PATCH 135/627] ln: make event optional in EmitEventAndFreeOtherChannel In the commits that follow, we want to be able to free the other channel without emitting an event so that we can emit a single event for trampoline payments with multiple incoming HTLCs. We still want to go through the full claim flow for each incoming HTLC (and persist the EmitEventAndFreeOtherChannel event to be picked up on restart), but do not want multiple events for the same trampoline forward. Changing from upgradable_required to upgradable_option is forwards compatible - old versions of the software will always have written this field, newer versions don't require it to be there but will be able to read it as-is. This change is not backwards compatible, because older versions of the software will expect the field to be present but newer versions may not write it. An alternative would be to add a new event type, but that would need to have an even TLV (because the event must be understood and processed on restart to claim the incoming HTLC), so that option isn't backwards compatible either. --- lightning/src/ln/channelmanager.rs | 15 ++++++++++----- pending_changelog/4304.txt | 3 +++ 2 files changed, 13 insertions(+), 5 deletions(-) create mode 100644 pending_changelog/4304.txt diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index a5725a70fbd..d9aa933494e 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -1393,7 +1393,7 @@ pub(crate) enum MonitorUpdateCompletionAction { /// edge completes, we will surface an [`Event::PaymentForwarded`] as well as unblock the /// outbound edge. EmitEventAndFreeOtherChannel { - event: events::Event, + event: Option, downstream_counterparty_and_funding_outpoint: Option, }, /// Indicates we should immediately resume the operation of another channel, unless there is @@ -1428,7 +1428,10 @@ impl_writeable_tlv_based_enum_upgradable!(MonitorUpdateCompletionAction, (5, downstream_channel_id, required), }, (2, EmitEventAndFreeOtherChannel) => { - (0, event, upgradable_required), + // LDK prior to 0.3 required this field. It will not be present for trampoline payments + // with multiple incoming HTLCS, so nodes cannot downgrade while trampoline payments + // are in the process of being resolved. + (0, event, upgradable_option), // LDK prior to 0.0.116 did not have this field as the monitor update application order was // required by clients. If we downgrade to something prior to 0.0.116 this may result in // monitor updates which aren't properly blocked or resumed, however that's fine - we don't @@ -9755,7 +9758,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ ); ( Some(MonitorUpdateCompletionAction::EmitEventAndFreeOtherChannel { - event: events::Event::PaymentForwarded { + event: Some(events::Event::PaymentForwarded { prev_htlcs: vec![events::HTLCLocator { channel_id: prev_channel_id, user_channel_id: prev_user_channel_id, @@ -9770,7 +9773,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ skimmed_fee_msat, claim_from_onchain_tx: from_onchain, outbound_amount_forwarded_msat: forwarded_htlc_value_msat, - }, + }), downstream_counterparty_and_funding_outpoint: chan_to_release, }), None, @@ -10000,7 +10003,9 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ event, downstream_counterparty_and_funding_outpoint, } => { - self.pending_events.lock().unwrap().push_back((event, None)); + if let Some(event) = event { + self.pending_events.lock().unwrap().push_back((event, None)); + } if let Some(unblocked) = downstream_counterparty_and_funding_outpoint { self.handle_monitor_update_release( unblocked.counterparty_node_id, diff --git a/pending_changelog/4304.txt b/pending_changelog/4304.txt new file mode 100644 index 00000000000..8c1580a2f4c --- /dev/null +++ b/pending_changelog/4304.txt @@ -0,0 +1,3 @@ +## Backwards Compatibility + +* Downgrade is not possible while the node has in-flight trampoline forwards. From 631cf882d935bab6099983afa155e32b32474c52 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 7 Jan 2026 15:36:30 -0500 Subject: [PATCH 136/627] ln/refactor: rename EmitEventAndFreeOtherChannel to note optional event --- lightning/src/ln/channelmanager.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index d9aa933494e..ef3d59e9048 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -1392,7 +1392,7 @@ pub(crate) enum MonitorUpdateCompletionAction { /// completes a monitor update containing the payment preimage. In that case, after the inbound /// edge completes, we will surface an [`Event::PaymentForwarded`] as well as unblock the /// outbound edge. - EmitEventAndFreeOtherChannel { + EmitEventOptionAndFreeOtherChannel { event: Option, downstream_counterparty_and_funding_outpoint: Option, }, @@ -1403,8 +1403,8 @@ pub(crate) enum MonitorUpdateCompletionAction { /// This is usually generated when we've forwarded an HTLC and want to block the outbound edge /// from completing a monitor update which removes the payment preimage until the inbound edge /// completes a monitor update containing the payment preimage. However, we use this variant - /// instead of [`Self::EmitEventAndFreeOtherChannel`] when we discover that the claim was in - /// fact duplicative and we simply want to resume the outbound edge channel immediately. + /// instead of [`Self::EmitEventOptionAndFreeOtherChannel`] when we discover that the claim was + /// in fact duplicative and we simply want to resume the outbound edge channel immediately. /// /// This variant should thus never be written to disk, as it is processed inline rather than /// stored for later processing. @@ -1427,7 +1427,7 @@ impl_writeable_tlv_based_enum_upgradable!(MonitorUpdateCompletionAction, (4, blocking_action, upgradable_required), (5, downstream_channel_id, required), }, - (2, EmitEventAndFreeOtherChannel) => { + (2, EmitEventOptionAndFreeOtherChannel) => { // LDK prior to 0.3 required this field. It will not be present for trampoline payments // with multiple incoming HTLCS, so nodes cannot downgrade while trampoline payments // are in the process of being resolved. @@ -9757,7 +9757,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ "skimmed_fee_msat must always be included in total_fee_earned_msat" ); ( - Some(MonitorUpdateCompletionAction::EmitEventAndFreeOtherChannel { + Some(MonitorUpdateCompletionAction::EmitEventOptionAndFreeOtherChannel { event: Some(events::Event::PaymentForwarded { prev_htlcs: vec![events::HTLCLocator { channel_id: prev_channel_id, @@ -9999,7 +9999,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } } }, - MonitorUpdateCompletionAction::EmitEventAndFreeOtherChannel { + MonitorUpdateCompletionAction::EmitEventOptionAndFreeOtherChannel { event, downstream_counterparty_and_funding_outpoint, } => { @@ -19511,7 +19511,7 @@ impl< let logger = WithContext::from(&args.logger, Some(node_id), Some(*channel_id), None); for action in actions.iter() { - if let MonitorUpdateCompletionAction::EmitEventAndFreeOtherChannel { + if let MonitorUpdateCompletionAction::EmitEventOptionAndFreeOtherChannel { downstream_counterparty_and_funding_outpoint: Some(EventUnblockedChannel { counterparty_node_id: blocked_node_id, From 103686d0d59a12cd16c3bbf55f80de6309683197 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 25 Feb 2026 15:35:42 +0200 Subject: [PATCH 137/627] ln: make channel required in `MonitorUpdateCompletionAction` `downstream_counterparty_and_funding_outpoint` was added to LDK in 0.0.116. We do not allow direct upgrades with pending forwards to 0.1 from 0.0.123 and below, so we can now assume that this field will always be present. This change also makes it impossible to create a `EmitEventOptionAndFreeOtherChannel` action with nothing in it (no event or channel), which could have been possible now that we've made the event optional). --- lightning/src/ln/channelmanager.rs | 47 +++++++++++++----------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index ef3d59e9048..d60c21c8587 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -1394,7 +1394,7 @@ pub(crate) enum MonitorUpdateCompletionAction { /// outbound edge. EmitEventOptionAndFreeOtherChannel { event: Option, - downstream_counterparty_and_funding_outpoint: Option, + downstream_counterparty_and_funding_outpoint: EventUnblockedChannel, }, /// Indicates we should immediately resume the operation of another channel, unless there is /// some other reason why the channel is blocked. In practice this simply means immediately @@ -1432,12 +1432,7 @@ impl_writeable_tlv_based_enum_upgradable!(MonitorUpdateCompletionAction, // with multiple incoming HTLCS, so nodes cannot downgrade while trampoline payments // are in the process of being resolved. (0, event, upgradable_option), - // LDK prior to 0.0.116 did not have this field as the monitor update application order was - // required by clients. If we downgrade to something prior to 0.0.116 this may result in - // monitor updates which aren't properly blocked or resumed, however that's fine - we don't - // support async monitor updates even in LDK 0.0.116 and once we do we'll require no - // downgrades to prior versions. - (1, downstream_counterparty_and_funding_outpoint, upgradable_option), + (1, downstream_counterparty_and_funding_outpoint, upgradable_required), }, ); @@ -9674,12 +9669,12 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ None, Some(attribution_data), |htlc_claim_value_msat, definitely_duplicate| { - let chan_to_release = Some(EventUnblockedChannel { + let chan_to_release = EventUnblockedChannel { counterparty_node_id: next_channel_counterparty_node_id, funding_txo: next_channel_outpoint, channel_id: next_channel_id, blocking_action: completed_blocker, - }); + }; if definitely_duplicate && startup_replay { // On startup we may get redundant claims which are related to @@ -9732,15 +9727,15 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } (None, None) } else if definitely_duplicate { - if let Some(other_chan) = chan_to_release { - (Some(MonitorUpdateCompletionAction::FreeOtherChannelImmediately { - downstream_counterparty_node_id: other_chan.counterparty_node_id, - downstream_channel_id: other_chan.channel_id, - blocking_action: other_chan.blocking_action, - }), None) - } else { - (None, None) - } + ( + Some(MonitorUpdateCompletionAction::FreeOtherChannelImmediately { + downstream_counterparty_node_id: chan_to_release + .counterparty_node_id, + downstream_channel_id: chan_to_release.channel_id, + blocking_action: chan_to_release.blocking_action, + }), + None, + ) } else { let total_fee_earned_msat = if let Some(forwarded_htlc_value) = forwarded_htlc_value_msat { @@ -10006,13 +10001,11 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ if let Some(event) = event { self.pending_events.lock().unwrap().push_back((event, None)); } - if let Some(unblocked) = downstream_counterparty_and_funding_outpoint { - self.handle_monitor_update_release( - unblocked.counterparty_node_id, - unblocked.channel_id, - Some(unblocked.blocking_action), - ); - } + self.handle_monitor_update_release( + downstream_counterparty_and_funding_outpoint.counterparty_node_id, + downstream_counterparty_and_funding_outpoint.channel_id, + Some(downstream_counterparty_and_funding_outpoint.blocking_action), + ); }, MonitorUpdateCompletionAction::FreeOtherChannelImmediately { downstream_counterparty_node_id, @@ -19513,12 +19506,12 @@ impl< for action in actions.iter() { if let MonitorUpdateCompletionAction::EmitEventOptionAndFreeOtherChannel { downstream_counterparty_and_funding_outpoint: - Some(EventUnblockedChannel { + EventUnblockedChannel { counterparty_node_id: blocked_node_id, funding_txo: _, channel_id: blocked_channel_id, blocking_action, - }), + }, .. } = action { From c26e451b41e67565353b77b34fb2838768ffdb1b Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Thu, 26 Feb 2026 09:51:50 +0200 Subject: [PATCH 138/627] ln/refactor: rename FreeOtherChannelImmediately to FreeDuplicateClaimImmediately --- lightning/src/ln/channelmanager.rs | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index d60c21c8587..acf18720de3 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -1400,15 +1400,15 @@ pub(crate) enum MonitorUpdateCompletionAction { /// some other reason why the channel is blocked. In practice this simply means immediately /// removing the [`RAAMonitorUpdateBlockingAction`] provided from the blocking set. /// - /// This is usually generated when we've forwarded an HTLC and want to block the outbound edge - /// from completing a monitor update which removes the payment preimage until the inbound edge + /// This is generated when we've forwarded an HTLC and want to block the outbound edge from + /// completing a monitor update which removes the payment preimage until the inbound edge /// completes a monitor update containing the payment preimage. However, we use this variant /// instead of [`Self::EmitEventOptionAndFreeOtherChannel`] when we discover that the claim was /// in fact duplicative and we simply want to resume the outbound edge channel immediately. /// /// This variant should thus never be written to disk, as it is processed inline rather than /// stored for later processing. - FreeOtherChannelImmediately { + FreeDuplicateClaimImmediately { downstream_counterparty_node_id: PublicKey, blocking_action: RAAMonitorUpdateBlockingAction, downstream_channel_id: ChannelId, @@ -1420,9 +1420,9 @@ impl_writeable_tlv_based_enum_upgradable!(MonitorUpdateCompletionAction, (0, payment_hash, required), (9999999999, pending_mpp_claim, (static_value, None)), }, - // Note that FreeOtherChannelImmediately should never be written - we were supposed to free + // Note that FreeDuplicateClaimImmediately should never be written - we were supposed to free // *immediately*. However, for simplicity we implement read/write here. - (1, FreeOtherChannelImmediately) => { + (1, FreeDuplicateClaimImmediately) => { (0, downstream_counterparty_node_id, required), (4, blocking_action, upgradable_required), (5, downstream_channel_id, required), @@ -9420,7 +9420,7 @@ impl< log_trace!(logger, "Completing monitor update completion action as claim was redundant: {:?}", action); - if let MonitorUpdateCompletionAction::FreeOtherChannelImmediately { + if let MonitorUpdateCompletionAction::FreeDuplicateClaimImmediately { downstream_counterparty_node_id: node_id, blocking_action: blocker, downstream_channel_id: channel_id, @@ -9728,12 +9728,14 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ (None, None) } else if definitely_duplicate { ( - Some(MonitorUpdateCompletionAction::FreeOtherChannelImmediately { - downstream_counterparty_node_id: chan_to_release - .counterparty_node_id, - downstream_channel_id: chan_to_release.channel_id, - blocking_action: chan_to_release.blocking_action, - }), + Some( + MonitorUpdateCompletionAction::FreeDuplicateClaimImmediately { + downstream_counterparty_node_id: chan_to_release + .counterparty_node_id, + downstream_channel_id: chan_to_release.channel_id, + blocking_action: chan_to_release.blocking_action, + }, + ), None, ) } else { @@ -10007,7 +10009,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ Some(downstream_counterparty_and_funding_outpoint.blocking_action), ); }, - MonitorUpdateCompletionAction::FreeOtherChannelImmediately { + MonitorUpdateCompletionAction::FreeDuplicateClaimImmediately { downstream_counterparty_node_id, downstream_channel_id, blocking_action, @@ -19534,7 +19536,7 @@ impl< // anymore. } } - if let MonitorUpdateCompletionAction::FreeOtherChannelImmediately { + if let MonitorUpdateCompletionAction::FreeDuplicateClaimImmediately { .. } = action { From c7e0a536cfed625bbf2b2828b2c692514e9739ae Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 7 Jan 2026 15:05:44 -0500 Subject: [PATCH 139/627] ln+events: allow multiple prev_channel_id in HTLCHandlingFailed In preparation for trampoline failures, allow multiple previous channel ids. We'll only emit a single HTLCHandlingFailed for all of our failed back HTLCs, so we want to be able to express all of them in one event. --- lightning/src/events/mod.rs | 32 +++++++++++++++++++++++------- lightning/src/ln/channelmanager.rs | 4 ++-- lightning/src/ln/monitor_tests.rs | 4 ++-- lightning/src/util/ser.rs | 1 + 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 01bbd5d1a11..adf4ca0ace7 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -1665,12 +1665,17 @@ pub enum Event { /// Indicates that the HTLC was accepted, but could not be processed when or after attempting to /// forward it. /// + /// Note that downgrading from 0.3 with pending trampoline forwards that have incoming multipart + /// payments will produce an event that only provides information about the first htlc that was + /// received/dispatched. + /// /// # Failure Behavior and Persistence /// This event will eventually be replayed after failures-to-handle (i.e., the event handler /// returning `Err(ReplayEvent ())`) and will be persisted across restarts. HTLCHandlingFailed { - /// The channel over which the HTLC was received. - prev_channel_id: ChannelId, + /// The channel(s) over which the HTLC(s) was received. May contain multiple entries for + /// trampoline forwards. + prev_channel_ids: Vec, /// The type of HTLC handling that failed. failure_type: HTLCHandlingFailureType, /// The reason that the HTLC failed. @@ -2223,15 +2228,24 @@ impl Writeable for Event { }) }, &Event::HTLCHandlingFailed { - ref prev_channel_id, + ref prev_channel_ids, ref failure_type, ref failure_reason, } => { 25u8.write(writer)?; + // Legacy field is written for backwards compatibility. We don't want to fail writes + // so we write garbage data if we don't have the data we expect. + debug_assert!( + !prev_channel_ids.is_empty(), + "at least one prev_channel_id required for HTLCHandlingFailed" + ); + let zero_id = ChannelId::new_zero(); + let legacy_chan_id = prev_channel_ids.first().unwrap_or(&zero_id); write_tlv_fields!(writer, { - (0, prev_channel_id, required), + (0, legacy_chan_id, required), (1, failure_reason, option), (2, failure_type, required), + (3, *prev_channel_ids, required), }) }, &Event::BumpTransaction(ref event) => { @@ -2806,13 +2820,17 @@ impl MaybeReadable for Event { }, 25u8 => { let mut f = || { - let mut prev_channel_id = ChannelId::new_zero(); + let mut prev_channel_id_legacy = ChannelId::new_zero(); let mut failure_reason = None; let mut failure_type_opt = UpgradableRequired(None); + let mut prev_channel_ids = vec![]; read_tlv_fields!(reader, { - (0, prev_channel_id, required), + (0, prev_channel_id_legacy, required), (1, failure_reason, option), (2, failure_type_opt, upgradable_required), + (3, prev_channel_ids, (default_value, vec![ + prev_channel_id_legacy, + ])), }); // If a legacy HTLCHandlingFailureType::UnknownNextHop was written, upgrade @@ -2827,7 +2845,7 @@ impl MaybeReadable for Event { failure_reason = Some(LocalHTLCFailureReason::UnknownNextPeer.into()); } Ok(Some(Event::HTLCHandlingFailed { - prev_channel_id, + prev_channel_ids, failure_type: _init_tlv_based_struct_field!( failure_type_opt, upgradable_required diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index acf18720de3..2520d684d08 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -7423,7 +7423,7 @@ impl< .push(failure); self.pending_events.lock().unwrap().push_back(( events::Event::HTLCHandlingFailed { - prev_channel_id: incoming_channel_id, + prev_channel_ids: vec![incoming_channel_id], failure_type, failure_reason: Some(failure_reason), }, @@ -9018,7 +9018,7 @@ impl< let mut pending_events = self.pending_events.lock().unwrap(); pending_events.push_back(( events::Event::HTLCHandlingFailed { - prev_channel_id: *channel_id, + prev_channel_ids: vec![*channel_id], failure_type, failure_reason: Some(onion_error.into()), }, diff --git a/lightning/src/ln/monitor_tests.rs b/lightning/src/ln/monitor_tests.rs index 18a976871a6..2368776dd3f 100644 --- a/lightning/src/ln/monitor_tests.rs +++ b/lightning/src/ln/monitor_tests.rs @@ -3780,8 +3780,8 @@ fn do_test_lost_timeout_monitor_events(confirm_tx: CommitmentType, dust_htlcs: b Event::PaymentFailed { payment_hash, .. } => { assert_eq!(payment_hash, Some(hash_b)); }, - Event::HTLCHandlingFailed { prev_channel_id, .. } => { - assert_eq!(prev_channel_id, chan_a); + Event::HTLCHandlingFailed { prev_channel_ids, .. } => { + assert_eq!(prev_channel_ids[0], chan_a); }, _ => panic!("Wrong event {ev:?}"), } diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs index 45ca98b6fd0..b226332ae93 100644 --- a/lightning/src/util/ser.rs +++ b/lightning/src/util/ser.rs @@ -1110,6 +1110,7 @@ impl_for_vec_with_element_length_prefix!(crate::ln::msgs::UpdateAddHTLC); impl_writeable_for_vec_with_element_length_prefix!(&crate::ln::msgs::UpdateAddHTLC); impl_for_vec!(u32); impl_for_vec!(crate::events::HTLCLocator); +impl_for_vec!(crate::ln::types::ChannelId); impl Writeable for Vec { #[inline] From ec5168b11828b0d7d10ba138b376212dd9054f61 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 6 Jan 2026 15:28:42 -0500 Subject: [PATCH 140/627] events: add TrampolineForward variant to HTLCHandlingFailureType --- lightning/src/events/mod.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index adf4ca0ace7..011b7f595bc 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -584,6 +584,10 @@ pub enum HTLCHandlingFailureType { /// The payment hash of the payment we attempted to process. payment_hash: PaymentHash, }, + /// We were responsible for pathfinding and forwarding of a trampoline payment, but failed to + /// do so. An example of such an instance is when we can't find a route to the specified + /// trampoline destination. + TrampolineForward {}, } impl_writeable_tlv_based_enum_upgradable!(HTLCHandlingFailureType, @@ -601,6 +605,7 @@ impl_writeable_tlv_based_enum_upgradable!(HTLCHandlingFailureType, (4, Receive) => { (0, payment_hash, required), }, + (5, TrampolineForward) => {}, ); /// The reason for HTLC failures in [`Event::HTLCHandlingFailed`]. From 7bda2afb79643e7f4ee7701636082a74e1a022b2 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 2 Dec 2025 10:06:41 -0500 Subject: [PATCH 141/627] ln: add TrampolineForward SendHTLCId variant This commit adds a SendHTLCId for trampoline forwards, identified by their session_priv. As with an OutboundRoute, we can expect our HTLC to be uniquely identified by a randomly generated session_priv. TrampolineForward could also be identified by the set of all previous outbound scid/htlc id pairs that represent its incoming HTLC(s). We choose the 32 byte session_priv to fix the size of this identifier rather than 16 byte scid/id pairs that will grow with the number of incoming htlcs. --- lightning/src/ln/channelmanager.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 2520d684d08..19322baa320 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -756,6 +756,7 @@ impl Default for OptionalOfferPaymentParams { pub(crate) enum SentHTLCId { PreviousHopData { prev_outbound_scid_alias: u64, htlc_id: u64 }, OutboundRoute { session_priv: [u8; SECRET_KEY_SIZE] }, + TrampolineForward { session_priv: [u8; SECRET_KEY_SIZE] }, } impl SentHTLCId { pub(crate) fn from_source(source: &HTLCSource) -> Self { @@ -778,6 +779,9 @@ impl_writeable_tlv_based_enum!(SentHTLCId, (2, OutboundRoute) => { (0, session_priv, required), }, + (4, TrampolineForward) => { + (0, session_priv, required), + }, ); type FailedHTLCForward = (HTLCSource, PaymentHash, HTLCFailReason, HTLCHandlingFailureType); From 028b63701fbc9d8bb80a220facb1c144b5a71d58 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 22 Aug 2025 10:37:21 -0400 Subject: [PATCH 142/627] ln: add TrampolineForward variant to HTLCSource enum We only have payment details for HTLCSource::TrampolineForward available once we've dispatched the payment. If we get to the stage where we need a HTLCId for the outbound payment, we expect dispatch details to be present. Co-authored-by: Arik Sosman Co-authored-by: Maurice Poirrier --- lightning/src/chain/channelmonitor.rs | 2 + lightning/src/ln/channelmanager.rs | 70 ++++++++++++++++++++++++++- lightning/src/routing/router.rs | 5 ++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs index a8d055a9c5b..f4d57142531 100644 --- a/lightning/src/chain/channelmonitor.rs +++ b/lightning/src/chain/channelmonitor.rs @@ -2795,6 +2795,7 @@ impl ChannelMonitorImpl { let outbound_payment = match source { None => panic!("Outbound HTLCs should have a source"), Some(&HTLCSource::PreviousHopData(_)) => false, + Some(&HTLCSource::TrampolineForward { .. }) => false, Some(&HTLCSource::OutboundRoute { .. }) => true, }; return Some(Balance::MaybeTimeoutClaimableHTLC { @@ -3007,6 +3008,7 @@ impl ChannelMonitor { let outbound_payment = match source { None => panic!("Outbound HTLCs should have a source"), Some(HTLCSource::PreviousHopData(_)) => false, + Some(HTLCSource::TrampolineForward { .. }) => false, Some(HTLCSource::OutboundRoute { .. }) => true, }; if outbound_payment { diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 19322baa320..053f8feddfe 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -759,12 +759,23 @@ pub(crate) enum SentHTLCId { TrampolineForward { session_priv: [u8; SECRET_KEY_SIZE] }, } impl SentHTLCId { + /// Creates an identifier for the [`HTLCSource`] provided. Note that for MPP trampoline payments + /// each outgoing HTLC will have a distinct identifier. pub(crate) fn from_source(source: &HTLCSource) -> Self { match source { HTLCSource::PreviousHopData(hop_data) => Self::PreviousHopData { prev_outbound_scid_alias: hop_data.prev_outbound_scid_alias, htlc_id: hop_data.htlc_id, }, + HTLCSource::TrampolineForward { + ref outbound_payment, + .. + } => Self::TrampolineForward { + session_priv: outbound_payment + .as_ref() + .map(|o| o.session_priv.secret_bytes()) + .expect("trying to identify a trampoline payment that we have no outbound_payment tracked for"), + }, HTLCSource::OutboundRoute { session_priv, .. } => { Self::OutboundRoute { session_priv: session_priv.secret_bytes() } }, @@ -789,11 +800,31 @@ type FailedHTLCForward = (HTLCSource, PaymentHash, HTLCFailReason, HTLCHandlingF mod fuzzy_channelmanager { use super::*; + /// Information about a HTLC sent as part of a (possibly MPP) payment to the next trampoline. + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct TrampolineDispatch { + /// The payment ID used for the outbound payment. + pub payment_id: PaymentId, + /// The path used for the outbound payment. + pub path: Path, + /// The session private key used for inter-trampoline outer onions. + pub session_priv: SecretKey, + } + /// Tracks the inbound corresponding to an outbound HTLC - #[allow(clippy::derive_hash_xor_eq)] // Our Hash is faithful to the data, we just don't have SecretKey::hash + #[allow(clippy::derive_hash_xor_eq, dead_code)] // Our Hash is faithful to the data, we just don't have SecretKey::hash #[derive(Clone, Debug, PartialEq, Eq)] pub enum HTLCSource { PreviousHopData(HTLCPreviousHopData), + TrampolineForward { + /// We might be forwarding an incoming payment that was received over MPP, and therefore + /// need to store the vector of corresponding `HTLCPreviousHopData` values. + previous_hop_data: Vec, + incoming_trampoline_shared_secret: [u8; 32], + /// Track outbound payment details once the payment has been dispatched, will be `None` + /// when waiting for incoming MPP to accumulate. + outbound_payment: Option, + }, OutboundRoute { path: Path, session_priv: SecretKey, @@ -856,6 +887,20 @@ impl core::hash::Hash for HTLCSource { first_hop_htlc_msat.hash(hasher); bolt12_invoice.hash(hasher); }, + HTLCSource::TrampolineForward { + previous_hop_data, + incoming_trampoline_shared_secret, + outbound_payment, + } => { + 2u8.hash(hasher); + previous_hop_data.hash(hasher); + incoming_trampoline_shared_secret.hash(hasher); + if let Some(payment) = outbound_payment { + payment.payment_id.hash(hasher); + payment.path.hash(hasher); + payment.session_priv[..].hash(hasher); + } + }, } } } @@ -9029,6 +9074,7 @@ impl< None, )); }, + HTLCSource::TrampolineForward { .. } => todo!(), } } @@ -9783,6 +9829,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }, ); }, + HTLCSource::TrampolineForward { .. } => todo!(), } } @@ -17271,6 +17318,8 @@ impl Readable for HTLCSource { }) } 1 => Ok(HTLCSource::PreviousHopData(Readable::read(reader)?)), + // Note: we intentionally do not read HTLCSource::TrampolineForward because we do not + // want to allow downgrades with in-flight trampoline forwards. _ => Err(DecodeError::UnknownRequiredFeature), } } @@ -17303,6 +17352,18 @@ impl Writeable for HTLCSource { 1u8.write(writer)?; field.write(writer)?; }, + HTLCSource::TrampolineForward { + ref previous_hop_data, + incoming_trampoline_shared_secret, + ref outbound_payment, + } => { + 2u8.write(writer)?; + write_tlv_fields!(writer, { + (1, *previous_hop_data, required_vec), + (3, incoming_trampoline_shared_secret, required), + (5, outbound_payment, option), + }); + }, } Ok(()) } @@ -17320,6 +17381,12 @@ impl_writeable_tlv_based!(PendingAddHTLCInfo, { (9, prev_counterparty_node_id, required), }); +impl_writeable_tlv_based!(TrampolineDispatch, { + (1, payment_id, required), + (3, path, required), + (5, session_priv, required), +}); + impl Writeable for HTLCForwardInfo { fn write(&self, w: &mut W) -> Result<(), io::Error> { const FAIL_HTLC_VARIANT_ID: u8 = 1; @@ -19176,6 +19243,7 @@ impl< } else { true } }); }, + HTLCSource::TrampolineForward { .. } => todo!(), HTLCSource::OutboundRoute { payment_id, session_priv, diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 90697ad246e..874ea12ed9c 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -656,6 +656,11 @@ impl Path { } } +impl_writeable_tlv_based!(Path,{ + (1, hops, required_vec), + (3, blinded_tail, option), +}); + /// A route directs a payment from the sender (us) to the recipient. If the recipient supports MPP, /// it can take multiple paths. Each path is composed of one or more hops through the network. #[derive(Clone, Debug, Hash, PartialEq, Eq)] From bde040b1e75e7a65bd5b093d06bef2400c99800d Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 11 Feb 2026 09:48:24 +0200 Subject: [PATCH 143/627] ln: add failure_type helper to HTLCSource for HTLCHandlingFailureType To create the right handling type based on source, add a helper. This is mainly useful for PreviousHopData/TrampolineForward. This helper maps an OutboundRoute to a HTLCHandlingFailureType::Forward. This value isn't actually used once we reach `forward_htlc_backwards_internal`, because we don't emit `HTLCHandlingFailed` events for our own payments. This issue is pre-existing, and could be addressed with an API change to the failure function, which is left out of scope of this work. --- lightning/src/ln/channelmanager.rs | 99 +++++++++++++++++++----------- 1 file changed, 62 insertions(+), 37 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 053f8feddfe..69cf85a1443 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -839,6 +839,26 @@ mod fuzzy_channelmanager { }, } + impl HTLCSource { + pub fn failure_type( + &self, counterparty_node: PublicKey, channel_id: ChannelId, + ) -> HTLCHandlingFailureType { + match self { + // We won't actually emit an event with HTLCHandlingFailure if our source is an + // OutboundRoute, but `fail_htlc_backwards_internal` requires that we provide it. + HTLCSource::PreviousHopData(_) | HTLCSource::OutboundRoute { .. } => { + HTLCHandlingFailureType::Forward { + node_id: Some(counterparty_node), + channel_id, + } + }, + HTLCSource::TrampolineForward { .. } => { + HTLCHandlingFailureType::TrampolineForward {} + }, + } + } + } + /// Tracks the inbound corresponding to an outbound HTLC #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub struct HTLCPreviousHopData { @@ -4043,12 +4063,9 @@ impl< for htlc_source in failed_htlcs.drain(..) { let failure_reason = LocalHTLCFailureReason::ChannelClosed; let reason = HTLCFailReason::from_failure_code(failure_reason); - let receiver = HTLCHandlingFailureType::Forward { - node_id: Some(*counterparty_node_id), - channel_id: *chan_id, - }; let (source, hash) = htlc_source; - self.fail_htlc_backwards_internal(&source, &hash, &reason, receiver, None); + let failure_type = source.failure_type(*counterparty_node_id, *chan_id); + self.fail_htlc_backwards_internal(&source, &hash, &reason, failure_type, None); } let _ = self.handle_error(shutdown_result, *counterparty_node_id); @@ -4210,11 +4227,8 @@ impl< let (source, payment_hash, counterparty_node_id, channel_id) = htlc_source; let failure_reason = LocalHTLCFailureReason::ChannelClosed; let reason = HTLCFailReason::from_failure_code(failure_reason); - let receiver = HTLCHandlingFailureType::Forward { - node_id: Some(counterparty_node_id), - channel_id, - }; - self.fail_htlc_backwards_internal(&source, &payment_hash, &reason, receiver, None); + let failure_type = source.failure_type(counterparty_node_id, channel_id); + self.fail_htlc_backwards_internal(&source, &payment_hash, &reason, failure_type, None); } if let Some((_, funding_txo, _channel_id, monitor_update)) = shutdown_res.monitor_update { debug_assert!(false, "This should have been handled in `convert_channel_err`"); @@ -7657,6 +7671,8 @@ impl< }; failed_forwards.push(( + // This can't be a trampoline payment because we don't process them + // as forwards (we're the last/"receiving" onion node). HTLCSource::PreviousHopData(prev_hop), payment_hash, HTLCFailReason::reason(reason, err_data), @@ -7767,6 +7783,10 @@ impl< continue; } } else { + debug_assert!( + false, + "We only expect to handle regular forwards in forwarding_channel_not_found" + ); let msg = format!("Unknown short channel id {} for forward HTLC", short_chan_id); failure_handler( @@ -8935,11 +8955,14 @@ impl< for (htlc_src, payment_hash) in htlcs_to_fail.drain(..) { let reason = HTLCFailReason::reason(failure_reason, onion_failure_data.clone()); - let receiver = HTLCHandlingFailureType::Forward { - node_id: Some(counterparty_node_id.clone()), - channel_id, - }; - self.fail_htlc_backwards_internal(&htlc_src, &payment_hash, &reason, receiver, None); + let failure_type = htlc_src.failure_type(*counterparty_node_id, channel_id); + self.fail_htlc_backwards_internal( + &htlc_src, + &payment_hash, + &reason, + failure_type, + None, + ); } } @@ -9910,11 +9933,14 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } self.finalize_claims(finalized_claimed_htlcs); for failure in failed_htlcs { - let receiver = HTLCHandlingFailureType::Forward { - node_id: Some(counterparty_node_id), - channel_id, - }; - self.fail_htlc_backwards_internal(&failure.0, &failure.1, &failure.2, receiver, None); + let failure_type = failure.0.failure_type(counterparty_node_id, channel_id); + self.fail_htlc_backwards_internal( + &failure.0, + &failure.1, + &failure.2, + failure_type, + None, + ); } self.prune_persisted_inbound_htlc_onions( channel_id, @@ -12062,13 +12088,10 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } } for htlc_source in dropped_htlcs.drain(..) { - let receiver = HTLCHandlingFailureType::Forward { - node_id: Some(counterparty_node_id.clone()), - channel_id: msg.channel_id, - }; - let reason = HTLCFailReason::from_failure_code(LocalHTLCFailureReason::ChannelClosed); let (source, hash) = htlc_source; - self.fail_htlc_backwards_internal(&source, &hash, &reason, receiver, None); + let failure_type = source.failure_type(*counterparty_node_id, msg.channel_id); + let reason = HTLCFailReason::from_failure_code(LocalHTLCFailureReason::ChannelClosed); + self.fail_htlc_backwards_internal(&source, &hash, &reason, failure_type, None); } Ok(()) @@ -13111,10 +13134,8 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } else { log_trace!(logger, "Failing HTLC from our monitor"); let failure_reason = LocalHTLCFailureReason::OnChainTimeout; - let receiver = HTLCHandlingFailureType::Forward { - node_id: Some(counterparty_node_id), - channel_id, - }; + let failure_type = + htlc_update.source.failure_type(counterparty_node_id, channel_id); let reason = HTLCFailReason::from_failure_code(failure_reason); let completion_update = Some(PaymentCompleteUpdate { counterparty_node_id, @@ -13126,7 +13147,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ &htlc_update.source, &htlc_update.payment_hash, &reason, - receiver, + failure_type, completion_update, ); } @@ -15579,8 +15600,8 @@ impl< for (source, payment_hash) in timed_out_pending_htlcs.drain(..) { let reason = LocalHTLCFailureReason::CLTVExpiryTooSoon; let data = self.get_htlc_inbound_temp_fail_data(reason); - timed_out_htlcs.push((source, payment_hash, HTLCFailReason::reason(reason, data), - HTLCHandlingFailureType::Forward { node_id: Some(funded_channel.context.get_counterparty_node_id()), channel_id: *channel_id })); + let failure_type = source.failure_type(funded_channel.context.get_counterparty_node_id(), *channel_id); + timed_out_htlcs.push((source, payment_hash, HTLCFailReason::reason(reason, data), failure_type)); } let logger = WithChannelContext::from(&self.logger, &funded_channel.context, None); match funding_confirmed_opt { @@ -20080,11 +20101,15 @@ impl< for htlc_source in failed_htlcs { let (source, hash, counterparty_id, channel_id, failure_reason, ev_action) = htlc_source; - let receiver = - HTLCHandlingFailureType::Forward { node_id: Some(counterparty_id), channel_id }; + let failure_type = source.failure_type(counterparty_id, channel_id); let reason = HTLCFailReason::from_failure_code(failure_reason); - channel_manager - .fail_htlc_backwards_internal(&source, &hash, &reason, receiver, ev_action); + channel_manager.fail_htlc_backwards_internal( + &source, + &hash, + &reason, + failure_type, + ev_action, + ); } for ((_, hash), htlcs) in already_forwarded_htlcs.into_iter() { for (htlc, _) in htlcs { From f70e6525bdb181741cbcf446ae2fbd9fa03169be Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 16 Dec 2025 15:21:57 +0200 Subject: [PATCH 144/627] ln/refactor: add claim funds for htlc forward helper Will need to share this code when we add trampoline forwarding. This commit exactly moves the logic as-is, in preparation for the next commit that will update to suit trampoline. Co-authored-by: Arik Sosman Co-authored-by: Maurice Poirrier --- lightning/src/ln/channelmanager.rs | 292 ++++++++++++++++------------- 1 file changed, 159 insertions(+), 133 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 69cf85a1443..90393736daf 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -9321,6 +9321,153 @@ impl< } } + /// Claims funds for a forwarded HTLC where we are an intermediate hop. + /// + /// Processes attribution data, calculates fees earned, and emits a [`Event::PaymentForwarded`] + /// event upon successful claim. + fn claim_funds_from_htlc_forward_hop( + &self, payment_preimage: PaymentPreimage, forwarded_htlc_value_msat: Option, + skimmed_fee_msat: Option, from_onchain: bool, startup_replay: bool, + next_channel_counterparty_node_id: PublicKey, next_channel_outpoint: OutPoint, + next_channel_id: ChannelId, next_user_channel_id: Option, + hop_data: HTLCPreviousHopData, attribution_data: Option, + send_timestamp: Option, + ) { + let prev_channel_id = hop_data.channel_id; + let prev_user_channel_id = hop_data.user_channel_id; + let prev_node_id = hop_data.counterparty_node_id; + let completed_blocker = RAAMonitorUpdateBlockingAction::from_prev_hop_data(&hop_data); + + // Obtain hold time, if available. + let hold_time = hold_time_since(send_timestamp).unwrap_or(0); + + // If attribution data was received from downstream, we shift it and get it ready for adding our hold + // time. Note that fulfilled HTLCs take a fast path to the incoming side. We don't need to wait for RAA + // to record the hold time like we do for failed HTLCs. + let attribution_data = process_fulfill_attribution_data( + attribution_data, + &hop_data.incoming_packet_shared_secret, + hold_time, + ); + + #[cfg(test)] + let claiming_chan_funding_outpoint = hop_data.outpoint; + self.claim_funds_from_hop( + hop_data, + payment_preimage, + None, + Some(attribution_data), + |htlc_claim_value_msat, definitely_duplicate| { + let chan_to_release = EventUnblockedChannel { + counterparty_node_id: next_channel_counterparty_node_id, + funding_txo: next_channel_outpoint, + channel_id: next_channel_id, + blocking_action: completed_blocker, + }; + + if definitely_duplicate && startup_replay { + // On startup we may get redundant claims which are related to + // monitor updates still in flight. In that case, we shouldn't + // immediately free, but instead let that monitor update complete + // in the background. + #[cfg(test)] + { + let per_peer_state = self.per_peer_state.deadlocking_read(); + // The channel we'd unblock should already be closed, or... + let channel_closed = per_peer_state + .get(&next_channel_counterparty_node_id) + .map(|lck| lck.deadlocking_lock()) + .map(|peer| !peer.channel_by_id.contains_key(&next_channel_id)) + .unwrap_or(true); + let background_events = self.pending_background_events.lock().unwrap(); + // there should be a `BackgroundEvent` pending... + let matching_bg_event = + background_events.iter().any(|ev| { + match ev { + // to apply a monitor update that blocked the claiming channel, + BackgroundEvent::MonitorUpdateRegeneratedOnStartup { + funding_txo, + update, + .. + } => { + if *funding_txo == claiming_chan_funding_outpoint { + assert!( + update.updates.iter().any(|upd| { + if let ChannelMonitorUpdateStep::PaymentPreimage { + payment_preimage: update_preimage, .. + } = upd { + payment_preimage == *update_preimage + } else { false } + }), + "{:?}", + update + ); + true + } else { + false + } + }, + // or the monitor update has completed and will unblock + // immediately once we get going. + BackgroundEvent::MonitorUpdatesComplete { + channel_id, .. + } => *channel_id == prev_channel_id, + } + }); + assert!(channel_closed || matching_bg_event, "{:?}", *background_events); + } + (None, None) + } else if definitely_duplicate { + ( + Some(MonitorUpdateCompletionAction::FreeDuplicateClaimImmediately { + downstream_counterparty_node_id: chan_to_release.counterparty_node_id, + downstream_channel_id: chan_to_release.channel_id, + blocking_action: chan_to_release.blocking_action, + }), + None, + ) + } else { + let total_fee_earned_msat = + if let Some(forwarded_htlc_value) = forwarded_htlc_value_msat { + if let Some(claimed_htlc_value) = htlc_claim_value_msat { + Some(claimed_htlc_value - forwarded_htlc_value) + } else { + None + } + } else { + None + }; + debug_assert!( + skimmed_fee_msat <= total_fee_earned_msat, + "skimmed_fee_msat must always be included in total_fee_earned_msat" + ); + ( + Some(MonitorUpdateCompletionAction::EmitEventOptionAndFreeOtherChannel { + event: Some(events::Event::PaymentForwarded { + prev_htlcs: vec![events::HTLCLocator { + channel_id: prev_channel_id, + user_channel_id: prev_user_channel_id, + node_id: prev_node_id, + }], + next_htlcs: vec![events::HTLCLocator { + channel_id: next_channel_id, + user_channel_id: next_user_channel_id, + node_id: Some(next_channel_counterparty_node_id), + }], + total_fee_earned_msat, + skimmed_fee_msat, + claim_from_onchain_tx: from_onchain, + outbound_amount_forwarded_msat: forwarded_htlc_value_msat, + }), + downstream_counterparty_and_funding_outpoint: chan_to_release, + }), + None, + ) + } + }, + ); + } + fn claim_funds_from_hop< ComplFunc: FnOnce( Option, @@ -9716,140 +9863,19 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } }, HTLCSource::PreviousHopData(hop_data) => { - let prev_channel_id = hop_data.channel_id; - let prev_user_channel_id = hop_data.user_channel_id; - let prev_node_id = hop_data.counterparty_node_id; - let completed_blocker = - RAAMonitorUpdateBlockingAction::from_prev_hop_data(&hop_data); - - // Obtain hold time, if available. - let hold_time = hold_time_since(send_timestamp).unwrap_or(0); - - // If attribution data was received from downstream, we shift it and get it ready for adding our hold - // time. Note that fulfilled HTLCs take a fast path to the incoming side. We don't need to wait for RAA - // to record the hold time like we do for failed HTLCs. - let attribution_data = process_fulfill_attribution_data( - attribution_data, - &hop_data.incoming_packet_shared_secret, - hold_time, - ); - - #[cfg(test)] - let claiming_chan_funding_outpoint = hop_data.outpoint; - self.claim_funds_from_hop( - hop_data, + self.claim_funds_from_htlc_forward_hop( payment_preimage, - None, - Some(attribution_data), - |htlc_claim_value_msat, definitely_duplicate| { - let chan_to_release = EventUnblockedChannel { - counterparty_node_id: next_channel_counterparty_node_id, - funding_txo: next_channel_outpoint, - channel_id: next_channel_id, - blocking_action: completed_blocker, - }; - - if definitely_duplicate && startup_replay { - // On startup we may get redundant claims which are related to - // monitor updates still in flight. In that case, we shouldn't - // immediately free, but instead let that monitor update complete - // in the background. - #[cfg(test)] - { - let per_peer_state = self.per_peer_state.deadlocking_read(); - // The channel we'd unblock should already be closed, or... - let channel_closed = per_peer_state - .get(&next_channel_counterparty_node_id) - .map(|lck| lck.deadlocking_lock()) - .map(|peer| !peer.channel_by_id.contains_key(&next_channel_id)) - .unwrap_or(true); - let background_events = - self.pending_background_events.lock().unwrap(); - // there should be a `BackgroundEvent` pending... - let matching_bg_event = - background_events.iter().any(|ev| { - match ev { - // to apply a monitor update that blocked the claiming channel, - BackgroundEvent::MonitorUpdateRegeneratedOnStartup { - funding_txo, update, .. - } => { - if *funding_txo == claiming_chan_funding_outpoint { - assert!(update.updates.iter().any(|upd| - if let ChannelMonitorUpdateStep::PaymentPreimage { - payment_preimage: update_preimage, .. - } = upd { - payment_preimage == *update_preimage - } else { false } - ), "{:?}", update); - true - } else { false } - }, - // or the monitor update has completed and will unblock - // immediately once we get going. - BackgroundEvent::MonitorUpdatesComplete { - channel_id, .. - } => - *channel_id == prev_channel_id, - } - }); - assert!( - channel_closed || matching_bg_event, - "{:?}", - *background_events - ); - } - (None, None) - } else if definitely_duplicate { - ( - Some( - MonitorUpdateCompletionAction::FreeDuplicateClaimImmediately { - downstream_counterparty_node_id: chan_to_release - .counterparty_node_id, - downstream_channel_id: chan_to_release.channel_id, - blocking_action: chan_to_release.blocking_action, - }, - ), - None, - ) - } else { - let total_fee_earned_msat = - if let Some(forwarded_htlc_value) = forwarded_htlc_value_msat { - if let Some(claimed_htlc_value) = htlc_claim_value_msat { - Some(claimed_htlc_value - forwarded_htlc_value) - } else { - None - } - } else { - None - }; - debug_assert!( - skimmed_fee_msat <= total_fee_earned_msat, - "skimmed_fee_msat must always be included in total_fee_earned_msat" - ); - ( - Some(MonitorUpdateCompletionAction::EmitEventOptionAndFreeOtherChannel { - event: Some(events::Event::PaymentForwarded { - prev_htlcs: vec![events::HTLCLocator { - channel_id: prev_channel_id, - user_channel_id: prev_user_channel_id, - node_id: prev_node_id, - }], - next_htlcs: vec![events::HTLCLocator { - channel_id: next_channel_id, - user_channel_id: next_user_channel_id, - node_id: Some(next_channel_counterparty_node_id), - }], - total_fee_earned_msat, - skimmed_fee_msat, - claim_from_onchain_tx: from_onchain, - outbound_amount_forwarded_msat: forwarded_htlc_value_msat, - }), - downstream_counterparty_and_funding_outpoint: chan_to_release, - }), - None, - ) - } - }, + forwarded_htlc_value_msat, + skimmed_fee_msat, + from_onchain, + startup_replay, + next_channel_counterparty_node_id, + next_channel_outpoint, + next_channel_id, + next_user_channel_id, + hop_data, + attribution_data, + send_timestamp, ); }, HTLCSource::TrampolineForward { .. } => todo!(), From 077aa08864abb12d300b8e51b047620137ca7c02 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 6 Jan 2026 09:09:06 -0500 Subject: [PATCH 145/627] ln/refactor: pass closure to create PaymentForwarded event When we introduce trampoline forwards, we're going to want to provide two external pieces of information to create events: - When to emit an event: we only want to emit one trampoline event, even when we have multiple incoming htlcs. We need to make multiple calls to claim_funds_from_htlc_forward_hop to claim each individual htlc, which are not aware of each other, so we rely on the caller's closure to decide when to emit Some or None. - Forwarding fees: we will not be able to calculate the total fee for a trampoline forward when an individual outgoing htlcs is fulfilled, because there may be other outgoing htlcs that are not accounted for (we only get the htlc_claim_value_msat for the single htlc that was just fulfilled). In future, we'll be able to provide the total fee from the channelmanager's top level view. --- lightning/src/ln/channelmanager.rs | 102 ++++++++++++++++------------- 1 file changed, 57 insertions(+), 45 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 90393736daf..d8661f4298d 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -879,6 +879,16 @@ mod fuzzy_channelmanager { /// channel remains unconfirmed for too long. pub cltv_expiry: Option, } + + impl From<&HTLCPreviousHopData> for events::HTLCLocator { + fn from(value: &HTLCPreviousHopData) -> Self { + events::HTLCLocator { + channel_id: value.channel_id, + user_channel_id: value.user_channel_id, + node_id: value.counterparty_node_id, + } + } + } } #[cfg(fuzzing)] pub use self::fuzzy_channelmanager::*; @@ -9324,18 +9334,16 @@ impl< /// Claims funds for a forwarded HTLC where we are an intermediate hop. /// /// Processes attribution data, calculates fees earned, and emits a [`Event::PaymentForwarded`] - /// event upon successful claim. + /// event upon successful claim. `make_payment_forwarded_event` is responsible for creating a + /// single [`Event::PaymentForwarded`] event that represents the forward. fn claim_funds_from_htlc_forward_hop( - &self, payment_preimage: PaymentPreimage, forwarded_htlc_value_msat: Option, - skimmed_fee_msat: Option, from_onchain: bool, startup_replay: bool, - next_channel_counterparty_node_id: PublicKey, next_channel_outpoint: OutPoint, - next_channel_id: ChannelId, next_user_channel_id: Option, - hop_data: HTLCPreviousHopData, attribution_data: Option, - send_timestamp: Option, + &self, payment_preimage: PaymentPreimage, + make_payment_forwarded_event: impl FnOnce(Option) -> Option, + startup_replay: bool, next_channel_counterparty_node_id: PublicKey, + next_channel_outpoint: OutPoint, next_channel_id: ChannelId, hop_data: HTLCPreviousHopData, + attribution_data: Option, send_timestamp: Option, ) { - let prev_channel_id = hop_data.channel_id; - let prev_user_channel_id = hop_data.user_channel_id; - let prev_node_id = hop_data.counterparty_node_id; + let _prev_channel_id = hop_data.channel_id; let completed_blocker = RAAMonitorUpdateBlockingAction::from_prev_hop_data(&hop_data); // Obtain hold time, if available. @@ -9411,7 +9419,7 @@ impl< // immediately once we get going. BackgroundEvent::MonitorUpdatesComplete { channel_id, .. - } => *channel_id == prev_channel_id, + } => *channel_id == _prev_channel_id, } }); assert!(channel_closed || matching_bg_event, "{:?}", *background_events); @@ -9427,38 +9435,16 @@ impl< None, ) } else { - let total_fee_earned_msat = - if let Some(forwarded_htlc_value) = forwarded_htlc_value_msat { - if let Some(claimed_htlc_value) = htlc_claim_value_msat { - Some(claimed_htlc_value - forwarded_htlc_value) - } else { - None - } - } else { - None - }; - debug_assert!( - skimmed_fee_msat <= total_fee_earned_msat, - "skimmed_fee_msat must always be included in total_fee_earned_msat" - ); + let event = make_payment_forwarded_event(htlc_claim_value_msat); + if let Some(ref payment_forwarded) = event { + debug_assert!(matches!( + payment_forwarded, + &events::Event::PaymentForwarded { .. } + )); + } ( Some(MonitorUpdateCompletionAction::EmitEventOptionAndFreeOtherChannel { - event: Some(events::Event::PaymentForwarded { - prev_htlcs: vec![events::HTLCLocator { - channel_id: prev_channel_id, - user_channel_id: prev_user_channel_id, - node_id: prev_node_id, - }], - next_htlcs: vec![events::HTLCLocator { - channel_id: next_channel_id, - user_channel_id: next_user_channel_id, - node_id: Some(next_channel_counterparty_node_id), - }], - total_fee_earned_msat, - skimmed_fee_msat, - claim_from_onchain_tx: from_onchain, - outbound_amount_forwarded_msat: forwarded_htlc_value_msat, - }), + event, downstream_counterparty_and_funding_outpoint: chan_to_release, }), None, @@ -9863,16 +9849,42 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } }, HTLCSource::PreviousHopData(hop_data) => { + let prev_htlcs = vec![events::HTLCLocator::from(&hop_data)]; self.claim_funds_from_htlc_forward_hop( payment_preimage, - forwarded_htlc_value_msat, - skimmed_fee_msat, - from_onchain, + |htlc_claim_value_msat: Option| -> Option { + let total_fee_earned_msat = + if let Some(forwarded_htlc_value) = forwarded_htlc_value_msat { + if let Some(claimed_htlc_value) = htlc_claim_value_msat { + Some(claimed_htlc_value - forwarded_htlc_value) + } else { + None + } + } else { + None + }; + debug_assert!( + skimmed_fee_msat <= total_fee_earned_msat, + "skimmed_fee_msat must always be included in total_fee_earned_msat" + ); + + Some(events::Event::PaymentForwarded { + prev_htlcs, + next_htlcs: vec![events::HTLCLocator { + channel_id: next_channel_id, + user_channel_id: next_user_channel_id, + node_id: Some(next_channel_counterparty_node_id), + }], + total_fee_earned_msat, + skimmed_fee_msat, + claim_from_onchain_tx: from_onchain, + outbound_amount_forwarded_msat: forwarded_htlc_value_msat, + }) + }, startup_replay, next_channel_counterparty_node_id, next_channel_outpoint, next_channel_id, - next_user_channel_id, hop_data, attribution_data, send_timestamp, From e25d97ce2a9b16f059c1d3e4da62a55520105a97 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 6 Jan 2026 09:42:37 -0500 Subject: [PATCH 146/627] ln: add trampoline routing payment claiming Implement payment claiming for `HTLCSource::TrampolineForward` by iterating through previous hop data and claiming funds for each HTLC. Similar to regular forwards, we need to block the outbound channel's RAA on the inbound monitor persisting preimages received. If we have multiple inbound HTLCs for trampoline, we'll add multiple blockers so that we don't proceed until each inbound HTLC is claimable. Co-authored-by: Arik Sosman Co-authored-by: Maurice Poirrier --- lightning/src/ln/channelmanager.rs | 64 ++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index d8661f4298d..1bc4dd0cbab 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -9890,7 +9890,50 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ send_timestamp, ); }, - HTLCSource::TrampolineForward { .. } => todo!(), + HTLCSource::TrampolineForward { previous_hop_data, .. } => { + // Only emit a single event for trampoline claims. + let prev_htlcs: Vec = + previous_hop_data.iter().map(Into::into).collect(); + for (i, current_previous_hop_data) in previous_hop_data.into_iter().enumerate() { + self.claim_funds_from_htlc_forward_hop( + payment_preimage, + |_: Option| -> Option { + if i == 0 { + Some(events::Event::PaymentForwarded { + prev_htlcs: prev_htlcs.clone(), + // TODO: When trampoline payments are tracked in our + // pending_outbound_payments, we'll be able to provide all the + // outgoing htlcs for this forward. + next_htlcs: vec![events::HTLCLocator { + channel_id: next_channel_id, + user_channel_id: next_user_channel_id, + node_id: Some(next_channel_counterparty_node_id), + }], + // TODO: When trampoline payments are tracked in our + // pending_outbound_payments, we'll be able to lookup our total + // fee earnings. + total_fee_earned_msat: None, + skimmed_fee_msat, + claim_from_onchain_tx: from_onchain, + // TODO: When trampoline payments are tracked in our + // pending_outbound_payments, set to the total amount sent (not + // just the amount of the outgoing htlc that was first settled). + outbound_amount_forwarded_msat: forwarded_htlc_value_msat, + }) + } else { + None + } + }, + startup_replay, + next_channel_counterparty_node_id, + next_channel_outpoint, + next_channel_id, + current_previous_hop_data, + attribution_data.clone(), + send_timestamp, + ); + } + }, } } @@ -12282,20 +12325,25 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ chan.update_fulfill_htlc(&msg), chan_entry ); - if let HTLCSource::PreviousHopData(prev_hop) = &res.0 { - let logger = - WithChannelContext::from(&self.logger, &chan.context, None); + let prev_hops = match &res.0 { + HTLCSource::PreviousHopData(prev_hop) => vec![prev_hop], + HTLCSource::TrampolineForward { previous_hop_data, .. } => { + previous_hop_data.iter().collect() + }, + _ => vec![], + }; + let logger = WithChannelContext::from(&self.logger, &chan.context, None); + for prev_hop in prev_hops { log_trace!(logger, "Holding the next revoke_and_ack until the preimage is durably persisted in the inbound edge's ChannelMonitor", - ); + ); peer_state .actions_blocking_raa_monitor_updates .entry(msg.channel_id) .or_insert_with(Vec::new) - .push(RAAMonitorUpdateBlockingAction::from_prev_hop_data( - &prev_hop, - )); + .push(RAAMonitorUpdateBlockingAction::from_prev_hop_data(prev_hop)); } + // Note that we do not need to push an `actions_blocking_raa_monitor_updates` // entry here, even though we *do* need to block the next RAA monitor update. // We do this instead in the `claim_funds_internal` by attaching a From 4954de516072c24b33210912ad21d52c7e5c75b8 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Thu, 20 Nov 2025 11:04:59 -0500 Subject: [PATCH 147/627] ln/refactor: add blinded forwarding failure helper function We'll want this extracted when we need to handle trampoline and regular forwards. Co-authored-by: Arik Sosman Co-authored-by: Maurice Poirrier --- lightning/src/ln/channelmanager.rs | 100 ++++++++++++++++++----------- 1 file changed, 62 insertions(+), 38 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 1bc4dd0cbab..81b5bd12bc1 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8992,6 +8992,19 @@ impl< debug_assert_ne!(peer.held_by_thread(), LockHeldState::HeldByThread); } + let push_forward_htlcs_failure = + |prev_outbound_scid_alias: u64, failure: HTLCForwardInfo| { + let mut forward_htlcs = self.forward_htlcs.lock().unwrap(); + match forward_htlcs.entry(prev_outbound_scid_alias) { + hash_map::Entry::Occupied(mut entry) => { + entry.get_mut().push(failure); + }, + hash_map::Entry::Vacant(entry) => { + entry.insert(vec![failure]); + }, + } + }; + //TODO: There is a timing attack here where if a node fails an HTLC back to us they can //identify whether we sent it or not based on the (I presume) very different runtime //between the branches here. We should make this async and move it into the forward HTLCs @@ -9058,45 +9071,19 @@ impl< if blinded_failure.is_some() { "blinded " } else { "" }, onion_error ); - // In case of trampoline + phantom we prioritize the trampoline failure over the phantom failure. - // TODO: Correctly wrap the error packet twice if failing back a trampoline + phantom HTLC. - let secondary_shared_secret = trampoline_shared_secret.or(*phantom_shared_secret); - let failure = match blinded_failure { - Some(BlindedFailure::FromIntroductionNode) => { - let blinded_onion_error = HTLCFailReason::reason( - LocalHTLCFailureReason::InvalidOnionBlinding, - vec![0; 32], - ); - let err_packet = blinded_onion_error.get_encrypted_failure_packet( - incoming_packet_shared_secret, - &secondary_shared_secret, - ); - HTLCForwardInfo::FailHTLC { htlc_id: *htlc_id, err_packet } - }, - Some(BlindedFailure::FromBlindedNode) => HTLCForwardInfo::FailMalformedHTLC { - htlc_id: *htlc_id, - failure_code: LocalHTLCFailureReason::InvalidOnionBlinding.failure_code(), - sha256_of_onion: [0; 32], - }, - None => { - let err_packet = onion_error.get_encrypted_failure_packet( - incoming_packet_shared_secret, - &secondary_shared_secret, - ); - HTLCForwardInfo::FailHTLC { htlc_id: *htlc_id, err_packet } - }, - }; - let mut forward_htlcs = self.forward_htlcs.lock().unwrap(); - match forward_htlcs.entry(*prev_outbound_scid_alias) { - hash_map::Entry::Occupied(mut entry) => { - entry.get_mut().push(failure); - }, - hash_map::Entry::Vacant(entry) => { - entry.insert(vec![failure]); - }, - } - mem::drop(forward_htlcs); + push_forward_htlcs_failure( + *prev_outbound_scid_alias, + get_htlc_forward_failure( + blinded_failure, + onion_error, + incoming_packet_shared_secret, + trampoline_shared_secret, + phantom_shared_secret, + *htlc_id, + ), + ); + let mut pending_events = self.pending_events.lock().unwrap(); pending_events.push_back(( events::Event::HTLCHandlingFailed { @@ -13873,6 +13860,43 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } } +/// Constructs an HTLC forward failure for sending back to the previous hop, converting to a blinded +/// failure where appropriate. +/// +/// When both trampoline and phantom secrets are present, the trampoline secret takes priority +/// for error encryption. +fn get_htlc_forward_failure( + blinded_failure: &Option, onion_error: &HTLCFailReason, + incoming_packet_shared_secret: &[u8; 32], trampoline_shared_secret: &Option<[u8; 32]>, + phantom_shared_secret: &Option<[u8; 32]>, htlc_id: u64, +) -> HTLCForwardInfo { + // TODO: Correctly wrap the error packet twice if failing back a trampoline + phantom HTLC. + let secondary_shared_secret = trampoline_shared_secret.or(*phantom_shared_secret); + match blinded_failure { + Some(BlindedFailure::FromIntroductionNode) => { + let blinded_onion_error = + HTLCFailReason::reason(LocalHTLCFailureReason::InvalidOnionBlinding, vec![0; 32]); + let err_packet = blinded_onion_error.get_encrypted_failure_packet( + incoming_packet_shared_secret, + &secondary_shared_secret, + ); + HTLCForwardInfo::FailHTLC { htlc_id, err_packet } + }, + Some(BlindedFailure::FromBlindedNode) => HTLCForwardInfo::FailMalformedHTLC { + htlc_id, + failure_code: LocalHTLCFailureReason::InvalidOnionBlinding.failure_code(), + sha256_of_onion: [0; 32], + }, + None => { + let err_packet = onion_error.get_encrypted_failure_packet( + incoming_packet_shared_secret, + &secondary_shared_secret, + ); + HTLCForwardInfo::FailHTLC { htlc_id, err_packet } + }, + } +} + /// Parameters used with [`create_bolt11_invoice`]. /// /// [`create_bolt11_invoice`]: ChannelManager::create_bolt11_invoice From 4bcd0f601b89832e6075249ea9303793e62a0ef1 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Mon, 1 Dec 2025 15:50:46 -0500 Subject: [PATCH 148/627] ln: add trampoline routing failure handling Implement failure propagation for `HTLCSource::TrampolineForward` by iterating through previous hop data and failing each HTLC with `TemporaryTrampolineFailure`. Note that testing should be implemented when trampoline forward is completed. Co-authored-by: Arik Sosman Co-authored-by: Maurice Poirrier --- lightning/src/ln/channelmanager.rs | 69 +++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 81b5bd12bc1..5ac5c0d61ee 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -9094,7 +9094,74 @@ impl< None, )); }, - HTLCSource::TrampolineForward { .. } => todo!(), + HTLCSource::TrampolineForward { + previous_hop_data, + incoming_trampoline_shared_secret, + .. + } => { + let decoded_onion_failure = + onion_error.decode_onion_failure(&self.secp_ctx, &self.logger, &source); + log_trace!( + WithContext::from(&self.logger, None, None, Some(*payment_hash)), + "Trampoline forward failed downstream on {}", + if let Some(scid) = decoded_onion_failure.short_channel_id { + scid.to_string() + } else { + "unknown channel".to_string() + }, + ); + let incoming_trampoline_shared_secret = Some(*incoming_trampoline_shared_secret); + + // TODO: when we receive a failure from a single outgoing trampoline HTLC, we don't + // necessarily want to fail all of our incoming HTLCs back yet. We may have other + // outgoing HTLCs that need to resolve first. This will be tracked in our + // pending_outbound_payments in a followup. + for current_hop_data in previous_hop_data { + let HTLCPreviousHopData { + prev_outbound_scid_alias, + htlc_id, + incoming_packet_shared_secret, + blinded_failure, + channel_id, + .. + } = current_hop_data; + log_trace!( + WithContext::from(&self.logger, None, Some(*channel_id), Some(*payment_hash)), + "Failing {}HTLC with payment_hash {} backwards from us following Trampoline forwarding failure: {:?}", + if blinded_failure.is_some() { "blinded " } else { "" }, &payment_hash, onion_error + ); + let onion_error = HTLCFailReason::reason( + LocalHTLCFailureReason::TemporaryTrampolineFailure, + Vec::new(), + ); + push_forward_htlcs_failure( + *prev_outbound_scid_alias, + get_htlc_forward_failure( + blinded_failure, + &onion_error, + incoming_packet_shared_secret, + &incoming_trampoline_shared_secret, + &None, + *htlc_id, + ), + ); + } + + // We only want to emit a single event for trampoline failures, so we do it once + // we've failed back all of our incoming HTLCs. + let mut pending_events = self.pending_events.lock().unwrap(); + pending_events.push_back(( + events::Event::HTLCHandlingFailed { + prev_channel_ids: previous_hop_data + .iter() + .map(|prev| prev.channel_id) + .collect(), + failure_type, + failure_reason: Some(onion_error.into()), + }, + None, + )); + }, } } From bdac5ef60cc6751c7d3f966bec14bb4e540764c4 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 25 Aug 2025 15:33:44 -0400 Subject: [PATCH 149/627] ln/refactor: extract channelmonitor recovery to external helper Move recovery logic for `HTLCSource::PreviousHopData` into `channel_monitor_recovery_internal` to prepare for trampoline forward reuse. Co-authored-by: Arik Sosman Co-authored-by: Maurice Poirrier --- lightning/src/ln/channelmanager.rs | 171 +++++++++++++++++------------ 1 file changed, 99 insertions(+), 72 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 5ac5c0d61ee..f0aaac0bade 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -19244,21 +19244,6 @@ impl< (ChannelId, PaymentHash), Vec<(HTLCPreviousHopData, OutboundHop)>, > = new_hash_map(); - let prune_forwarded_htlc = |already_forwarded_htlcs: &mut HashMap< - (ChannelId, PaymentHash), - Vec<(HTLCPreviousHopData, OutboundHop)>, - >, - prev_hop: &HTLCPreviousHopData, - payment_hash: &PaymentHash| { - if let hash_map::Entry::Occupied(mut entry) = - already_forwarded_htlcs.entry((prev_hop.channel_id, *payment_hash)) - { - entry.get_mut().retain(|(htlc, _)| prev_hop.htlc_id != htlc.htlc_id); - if entry.get().is_empty() { - entry.remove(); - } - } - }; { // If we're tracking pending payments, ensure we haven't lost any by looking at the // ChannelMonitor data for any channels for which we do not have authorative state @@ -19381,65 +19366,19 @@ impl< let htlc_id = SentHTLCId::from_source(&htlc_source); match htlc_source { HTLCSource::PreviousHopData(prev_hop_data) => { - let pending_forward_matches_htlc = |info: &PendingAddHTLCInfo| { - info.prev_funding_outpoint == prev_hop_data.outpoint - && info.prev_htlc_id == prev_hop_data.htlc_id - }; - - // If `reconstruct_manager_from_monitors` is set, we always add all inbound committed - // HTLCs to `decode_update_add_htlcs` in the above loop, but we need to prune from - // those added HTLCs if they were already forwarded to the outbound edge. Otherwise, - // we'll double-forward. - if reconstruct_manager_from_monitors { - dedup_decode_update_add_htlcs( - &mut decode_update_add_htlcs, - &prev_hop_data, - "HTLC already forwarded to the outbound edge", - &&logger, - ); - prune_forwarded_htlc( - &mut already_forwarded_htlcs, - &prev_hop_data, - &htlc.payment_hash, - ); - } - - // The ChannelMonitor is now responsible for this HTLC's - // failure/success and will let us know what its outcome is. If we - // still have an entry for this HTLC in `forward_htlcs_legacy`, - // `pending_intercepted_htlcs_legacy`, or - // `decode_update_add_htlcs_legacy`, we were apparently not persisted - // after the monitor was when forwarding the payment. - dedup_decode_update_add_htlcs( + reconcile_pending_htlcs_with_monitor( + reconstruct_manager_from_monitors, + &mut already_forwarded_htlcs, + &mut forward_htlcs_legacy, + &mut pending_events_read, + &mut pending_intercepted_htlcs_legacy, + &mut decode_update_add_htlcs, &mut decode_update_add_htlcs_legacy, - &prev_hop_data, - "HTLC was forwarded to the closed channel", - &&logger, + prev_hop_data, + &logger, + htlc.payment_hash, + monitor.channel_id(), ); - forward_htlcs_legacy.retain(|_, forwards| { - forwards.retain(|forward| { - if let HTLCForwardInfo::AddHTLC(htlc_info) = forward { - if pending_forward_matches_htlc(&htlc_info) { - log_info!(logger, "Removing pending to-forward HTLC with hash {} as it was forwarded to the closed channel {}", - &htlc.payment_hash, &monitor.channel_id()); - false - } else { true } - } else { true } - }); - !forwards.is_empty() - }); - pending_intercepted_htlcs_legacy.retain(|intercepted_id, htlc_info| { - if pending_forward_matches_htlc(&htlc_info) { - log_info!(logger, "Removing pending intercepted HTLC with hash {} as it was forwarded to the closed channel {}", - &htlc.payment_hash, &monitor.channel_id()); - pending_events_read.retain(|(event, _)| { - if let Event::HTLCIntercepted { intercept_id: ev_id, .. } = event { - intercepted_id != ev_id - } else { true } - }); - false - } else { true } - }); }, HTLCSource::TrampolineForward { .. } => todo!(), HTLCSource::OutboundRoute { @@ -20341,6 +20280,94 @@ impl< } } +fn prune_forwarded_htlc( + already_forwarded_htlcs: &mut HashMap< + (ChannelId, PaymentHash), + Vec<(HTLCPreviousHopData, OutboundHop)>, + >, + prev_hop: &HTLCPreviousHopData, payment_hash: &PaymentHash, +) { + if let hash_map::Entry::Occupied(mut entry) = + already_forwarded_htlcs.entry((prev_hop.channel_id, *payment_hash)) + { + entry.get_mut().retain(|(htlc, _)| prev_hop.htlc_id != htlc.htlc_id); + if entry.get().is_empty() { + entry.remove(); + } + } +} + +/// Removes pending HTLC entries that the ChannelMonitor has already taken responsibility for, +/// cleaning up state mismatches that can occur during restart. +fn reconcile_pending_htlcs_with_monitor( + reconstruct_manager_from_monitors: bool, + already_forwarded_htlcs: &mut HashMap< + (ChannelId, PaymentHash), + Vec<(HTLCPreviousHopData, OutboundHop)>, + >, + forward_htlcs_legacy: &mut HashMap>, + pending_events_read: &mut VecDeque<(Event, Option)>, + pending_intercepted_htlcs_legacy: &mut HashMap, + decode_update_add_htlcs: &mut HashMap>, + decode_update_add_htlcs_legacy: &mut HashMap>, + prev_hop_data: HTLCPreviousHopData, logger: &impl Logger, payment_hash: PaymentHash, + channel_id: ChannelId, +) { + let pending_forward_matches_htlc = |info: &PendingAddHTLCInfo| { + info.prev_funding_outpoint == prev_hop_data.outpoint + && info.prev_htlc_id == prev_hop_data.htlc_id + }; + + // If `reconstruct_manager_from_monitors` is set, we always add all inbound committed + // HTLCs to `decode_update_add_htlcs` in the above loop, but we need to prune from + // those added HTLCs if they were already forwarded to the outbound edge. Otherwise, + // we'll double-forward. + if reconstruct_manager_from_monitors { + dedup_decode_update_add_htlcs( + decode_update_add_htlcs, + &prev_hop_data, + "HTLC already forwarded to the outbound edge", + &&logger, + ); + prune_forwarded_htlc(already_forwarded_htlcs, &prev_hop_data, &payment_hash); + } + + // The ChannelMonitor is now responsible for this HTLC's failure/success and will let us know + // what its outcome is. If we still have an entry for this HTLC in `forward_htlcs_legacy`, + // `pending_intercepted_htlcs_legacy`, or `decode_update_add_htlcs_legacy`, we were apparently + // not persisted after the monitor was when forwarding the payment. + dedup_decode_update_add_htlcs( + decode_update_add_htlcs_legacy, + &prev_hop_data, + "HTLC was forwarded to the closed channel", + &&logger, + ); + forward_htlcs_legacy.retain(|_, forwards| { + forwards.retain(|forward| { + if let HTLCForwardInfo::AddHTLC(htlc_info) = forward { + if pending_forward_matches_htlc(&htlc_info) { + log_info!(logger, "Removing pending to-forward HTLC with hash {} as it was forwarded to the closed channel {}", + &payment_hash, channel_id); + false + } else { true } + } else { true } + }); + !forwards.is_empty() + }); + pending_intercepted_htlcs_legacy.retain(|intercepted_id, htlc_info| { + if pending_forward_matches_htlc(&htlc_info) { + log_info!(logger, "Removing pending intercepted HTLC with hash {} as it was forwarded to the closed channel {}", + payment_hash, channel_id); + pending_events_read.retain(|(event, _)| { + if let Event::HTLCIntercepted { intercept_id: ev_id, .. } = event { + intercepted_id != ev_id + } else { true } + }); + false + } else { true } + }); +} + #[cfg(test)] mod tests { use crate::events::{ClosureReason, Event, HTLCHandlingFailureType}; From f65e5d40223ac263eb3b467a58c58f4272f6e2c8 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 25 Aug 2025 15:38:34 -0400 Subject: [PATCH 150/627] ln: add channel monitor recovery for trampoline forwards Implement channel monitor recovery for trampoline forwards iterating over all hop data and updating pending forwards. --- lightning/src/ln/channelmanager.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index f0aaac0bade..260a9d0797d 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -19380,7 +19380,23 @@ impl< monitor.channel_id(), ); }, - HTLCSource::TrampolineForward { .. } => todo!(), + HTLCSource::TrampolineForward { previous_hop_data, .. } => { + for prev_hop_data in previous_hop_data { + reconcile_pending_htlcs_with_monitor( + reconstruct_manager_from_monitors, + &mut already_forwarded_htlcs, + &mut forward_htlcs_legacy, + &mut pending_events_read, + &mut pending_intercepted_htlcs_legacy, + &mut decode_update_add_htlcs, + &mut decode_update_add_htlcs_legacy, + prev_hop_data, + &logger, + htlc.payment_hash, + monitor.channel_id(), + ); + } + }, HTLCSource::OutboundRoute { payment_id, session_priv, From bf106824009c3a7aad45d965cf0f9d54e45a78aa Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Fri, 27 Feb 2026 10:50:00 +0200 Subject: [PATCH 151/627] ln: no longer support claims with missing counterparty_node_id Move handling of payment replay into its own function and deprecate old code that handled missing counterparty_node_id. By the time we reach 0.3 we should have this data present. --- lightning/src/ln/channelmanager.rs | 158 +++++++++++------------------ 1 file changed, 57 insertions(+), 101 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 260a9d0797d..3600b97aaed 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -43,7 +43,7 @@ use crate::chain::chaininterface::{ TransactionType, }; use crate::chain::channelmonitor::{ - Balance, ChannelMonitor, ChannelMonitorUpdate, ChannelMonitorUpdateStep, MonitorEvent, + ChannelMonitor, ChannelMonitorUpdate, ChannelMonitorUpdateStep, MonitorEvent, WithChannelMonitor, ANTI_REORG_DELAY, CLTV_CLAIM_BUFFER, HTLC_FAIL_BACK_BUFFER, LATENCY_GRACE_PERIOD_BLOCKS, MAX_BLOCKS_FOR_CONF, }; @@ -19507,112 +19507,68 @@ impl< // preimages from it which may be needed in upstream channels for forwarded // payments. let mut fail_read = false; - let outbound_claimed_htlcs_iter = monitor.get_all_current_outbound_htlcs() + let outbound_claimed_htlcs_iter = monitor + .get_all_current_outbound_htlcs() .into_iter() .filter_map(|(htlc_source, (htlc, preimage_opt))| { - if let HTLCSource::PreviousHopData(prev_hop) = &htlc_source { - if let Some(payment_preimage) = preimage_opt { - let inbound_edge_monitor = args.channel_monitors.get(&prev_hop.channel_id); - // Note that for channels which have gone to chain, - // `get_all_current_outbound_htlcs` is never pruned and always returns - // a constant set until the monitor is removed/archived. Thus, we - // want to skip replaying claims that have definitely been resolved - // on-chain. - - // If the inbound monitor is not present, we assume it was fully - // resolved and properly archived, implying this payment had plenty - // of time to get claimed and we can safely skip any further - // attempts to claim it (they wouldn't succeed anyway as we don't - // have a monitor against which to do so). - let inbound_edge_monitor = if let Some(monitor) = inbound_edge_monitor { - monitor - } else { - return None; - }; - // Second, if the inbound edge of the payment's monitor has been - // fully claimed we've had at least `ANTI_REORG_DELAY` blocks to - // get any PaymentForwarded event(s) to the user and assume that - // there's no need to try to replay the claim just for that. - let inbound_edge_balances = inbound_edge_monitor.get_claimable_balances(); - if inbound_edge_balances.is_empty() { - return None; - } - - if prev_hop.counterparty_node_id.is_none() { - // We no longer support claiming an HTLC where we don't have - // the counterparty_node_id available if the claim has to go to - // a closed channel. Its possible we can get away with it if - // the channel is not yet closed, but its by no means a - // guarantee. - - // Thus, in this case we are a bit more aggressive with our - // pruning - if we have no use for the claim (because the - // inbound edge of the payment's monitor has already claimed - // the HTLC) we skip trying to replay the claim. - let htlc_payment_hash: PaymentHash = payment_preimage.into(); - let logger = WithChannelMonitor::from( - &args.logger, - monitor, - Some(htlc_payment_hash), - ); - let balance_could_incl_htlc = |bal| match bal { - &Balance::ClaimableOnChannelClose { .. } => { - // The channel is still open, assume we can still - // claim against it - true - }, - &Balance::MaybePreimageClaimableHTLC { payment_hash, .. } => { - payment_hash == htlc_payment_hash - }, - _ => false, - }; - let htlc_may_be_in_balances = - inbound_edge_balances.iter().any(balance_could_incl_htlc); - if !htlc_may_be_in_balances { - return None; - } + let payment_preimage = preimage_opt?; + let prev_htlcs = match &htlc_source { + HTLCSource::PreviousHopData(prev_hop) => vec![prev_hop], + // If it was an outbound payment, we've handled it above - if a preimage + // came in and we persisted the `ChannelManager` we either handled it + // and are good to go or the channel force-closed - we don't have to + // handle the channel still live case here. + _ => vec![], + }; + let prev_htlcs_count = prev_htlcs.len(); + if prev_htlcs_count == 0 { + return None; + } - // First check if we're absolutely going to fail - if we need - // to replay this claim to get the preimage into the inbound - // edge monitor but the channel is closed (and thus we'll - // immediately panic if we call claim_funds_from_hop). - if short_to_chan_info.get(&prev_hop.prev_outbound_scid_alias).is_none() { - log_error!(logger, - "We need to replay the HTLC claim for payment_hash {} (preimage {}) but cannot do so as the HTLC was forwarded prior to LDK 0.0.124.\ - All HTLCs that were forwarded by LDK 0.0.123 and prior must be resolved prior to upgrading to LDK 0.1", - htlc_payment_hash, - payment_preimage, - ); - fail_read = true; - } + for prev_hop in prev_htlcs { + // Note that for channels which have gone to chain, + // `get_all_current_outbound_htlcs` is never pruned and always returns + // a constant set until the monitor is removed/archived. Thus, we want + // to skip replaying claims that have definitely been resolved on-chain. + + // If the inbound monitor is not present, we assume it was fully + // resolved and properly archived, implying this payment had plenty of + // time to get claimed and we can safely skip any further attempts to + // claim it (they wouldn't succeed anyway as we don't have a monitor + // against which to do so). + let inbound_edge_monitor = + match args.channel_monitors.get(&prev_hop.channel_id) { + Some(monitor) => monitor, + None => continue, + }; - // At this point we're confident we need the claim, but the - // inbound edge channel is still live. As long as this remains - // the case, we can conceivably proceed, but we run some risk - // of panicking at runtime. The user ideally should have read - // the release notes and we wouldn't be here, but we go ahead - // and let things run in the hope that it'll all just work out. - log_error!(logger, - "We need to replay the HTLC claim for payment_hash {} (preimage {}) but don't have all the required information to do so reliably.\ - As long as the channel for the inbound edge of the forward remains open, this may work okay, but we may panic at runtime!\ - All HTLCs that were forwarded by LDK 0.0.123 and prior must be resolved prior to upgrading to LDK 0.1\ - Continuing anyway, though panics may occur!", - htlc_payment_hash, - payment_preimage, - ); - } + if inbound_edge_monitor.get_claimable_balances().is_empty() { + continue; + } - Some((htlc_source, payment_preimage, htlc.amount_msat, - is_channel_closed, monitor.get_counterparty_node_id(), - monitor.get_funding_txo(), monitor.channel_id(), user_channel_id_opt)) - } else { None } - } else { - // If it was an outbound payment, we've handled it above - if a preimage - // came in and we persisted the `ChannelManager` we either handled it and - // are good to go or the channel force-closed - we don't have to handle the - // channel still live case here. - None + // We no longer support claiming an HTLC where we don't have the + // counterparty_node_id. This field has been populated since 0.0.124, + // so we expect it to be present for in flight claims in 0.3+. + if prev_hop.counterparty_node_id.is_none() { + fail_read = true; + return None; + } + return Some(( + // When we have multiple prev_htlcs we know that they are all from + // a single HTLCSource (see match above) which contains all previous + // hops, so we can exit on the first claimable prev_hop because this + // will result in all prev_hops being claimed. + htlc_source, + payment_preimage, + htlc.amount_msat, + is_channel_closed, + monitor.get_counterparty_node_id(), + monitor.get_funding_txo(), + monitor.channel_id(), + user_channel_id_opt, + )); } + None }); for tuple in outbound_claimed_htlcs_iter { pending_claims_to_replay.push(tuple); From 7b3d661a56bd6d759b66d7b603829155e3cc7b36 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Fri, 16 Jan 2026 13:03:55 -0500 Subject: [PATCH 152/627] ln: handle trampoline claims on restart This commit uses the existing outbound payment claims replay logic to restore trampoline claims. If any single previous hop in a htlc source with multiple previous hops requires claim, we represent this with a single outbound claimed htlc because we assume that *all* of the incoming htlcs are represented in the source, and will be appropriately claimed (rather than submitting multiple claims, which will end up being duplicates of each other). This is the case for trampoline payments, where the htlc_source stores all previous hops. --- lightning/src/ln/channelmanager.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 3600b97aaed..ad5d4d4d23a 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -19514,6 +19514,9 @@ impl< let payment_preimage = preimage_opt?; let prev_htlcs = match &htlc_source { HTLCSource::PreviousHopData(prev_hop) => vec![prev_hop], + HTLCSource::TrampolineForward { previous_hop_data, .. } => { + previous_hop_data.iter().collect() + }, // If it was an outbound payment, we've handled it above - if a preimage // came in and we persisted the `ChannelManager` we either handled it // and are good to go or the channel force-closed - we don't have to From a06c44698c6861b9a770711f33f3d441f4c64a3f Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Tue, 20 Jan 2026 14:22:48 +0100 Subject: [PATCH 153/627] Reject offer_amount of 0 as invalid per BOLT 12 Per the spec clarification in https://github.com/lightning/bolts/pull/1316: - Writers MUST set offer_amount greater than zero when present - Readers MUST NOT respond to offers where offer_amount is zero Reject amount_msats(0) in the builder with InvalidAmount, and reject parsed offers with amount=0 (with or without currency) during TLV deserialization. Co-Authored-By: Claude Opus 4.6 --- lightning/src/offers/invoice_request.rs | 6 +++ lightning/src/offers/offer.rs | 64 ++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 4311d194dca..7805882ef73 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -2040,6 +2040,12 @@ mod tests { Err(e) => assert_eq!(e, Bolt12SemanticError::MissingAmount), } + // An offer with amount_msats(0) must be rejected by the builder per BOLT 12. + match OfferBuilder::new(recipient_pubkey()).amount_msats(0).build() { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!(e, Bolt12SemanticError::InvalidAmount), + } + match OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .supported_quantity(Quantity::Unbounded) diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index 5592c50a264..b2703454169 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -402,7 +402,7 @@ macro_rules! offer_builder_methods { ( pub fn build($($self_mut)* $self: $self_type) -> Result { match $self.offer.amount { Some(Amount::Bitcoin { amount_msats }) => { - if amount_msats > MAX_VALUE_MSAT { + if amount_msats == 0 || amount_msats > MAX_VALUE_MSAT { return Err(Bolt12SemanticError::InvalidAmount); } }, @@ -1306,11 +1306,12 @@ impl TryFrom for OfferContents { let amount = match (currency, amount) { (None, None) => None, - (None, Some(amount_msats)) if amount_msats > MAX_VALUE_MSAT => { + (None, Some(amount_msats)) if amount_msats == 0 || amount_msats > MAX_VALUE_MSAT => { return Err(Bolt12SemanticError::InvalidAmount); }, (None, Some(amount_msats)) => Some(Amount::Bitcoin { amount_msats }), (Some(_), None) => return Err(Bolt12SemanticError::MissingAmount), + (Some(_), Some(0)) => return Err(Bolt12SemanticError::InvalidAmount), (Some(currency_bytes), Some(amount)) => { let iso4217_code = CurrencyCode::new(currency_bytes) .map_err(|_| Bolt12SemanticError::InvalidCurrencyCode)?; @@ -1702,6 +1703,12 @@ mod tests { Ok(_) => panic!("expected error"), Err(e) => assert_eq!(e, Bolt12SemanticError::InvalidAmount), } + + // An amount of 0 must be rejected per BOLT 12. + match OfferBuilder::new(pubkey(42)).amount_msats(0).build() { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!(e, Bolt12SemanticError::InvalidAmount), + } } #[test] @@ -1974,6 +1981,59 @@ mod tests { Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::InvalidCurrencyCode) ), } + + // An offer with amount=0 must be rejected per BOLT 12. + let mut tlv_stream = offer.as_tlv_stream(); + tlv_stream.0.amount = Some(0); + tlv_stream.0.currency = None; + + let mut encoded_offer = Vec::new(); + tlv_stream.write(&mut encoded_offer).unwrap(); + + match Offer::try_from(encoded_offer) { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::InvalidAmount) + ), + } + + // An offer with amount=0 and a currency must also be rejected. + let mut tlv_stream = offer.as_tlv_stream(); + tlv_stream.0.amount = Some(0); + tlv_stream.0.currency = Some(b"USD"); + + let mut encoded_offer = Vec::new(); + tlv_stream.write(&mut encoded_offer).unwrap(); + + match Offer::try_from(encoded_offer) { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::InvalidAmount) + ), + } + + // BOLT 12 test vectors: verify rejection of offers with amount=0 from their + // bech32 encoding (see bolt12/offers-test.json). + match "lno1pqqq5qqkyyp4he0fg7pqje62jmnq78cr0ashv4q06qql58tyd9rhp3t2wuyugtq".parse::() + { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::InvalidAmount) + ), + } + + match "lno1qcp4256ypqqq5qqkyyp4he0fg7pqje62jmnq78cr0ashv4q06qql58tyd9rhp3t2wuyugtq" + .parse::() + { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::InvalidAmount) + ), + } } #[test] From b35a84980ef796fd0462aa190cb6e37b3f14bc97 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Tue, 10 Mar 2026 07:13:12 -0400 Subject: [PATCH 154/627] Clean up redundant steps in ci-tests-workspace.sh Replace the per-member cargo check + cargo doc loop with a single `cargo doc --workspace` call. The per-member cargo check is redundant with the workspace-level cargo check already run earlier in the script. Also remove the separate `cargo test -p lightning-custom-message` which is covered by the workspace-level cargo test. Fix stale "except lightning-transaction-sync" echo messages, as it has been a workspace member for a while now. AI tools were used in preparing this commit. --- ci/ci-tests-workspace.sh | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/ci/ci-tests-workspace.sh b/ci/ci-tests-workspace.sh index 3302f075394..f8be49bba7d 100755 --- a/ci/ci-tests-workspace.sh +++ b/ci/ci-tests-workspace.sh @@ -1,16 +1,13 @@ #!/bin/bash -#shellcheck disable=SC2002,SC2207 set -eox pipefail # shellcheck source=ci/ci-tests-common.sh source "$(dirname "$0")/ci-tests-common.sh" -echo -e "\n\nChecking the workspace, except lightning-transaction-sync." +echo -e "\n\nChecking the workspace." cargo check --quiet --color always -WORKSPACE_MEMBERS=( $(cat Cargo.toml | tr '\n' '\r' | sed 's/\r //g' | tr '\r' '\n' | grep '^members =' | sed 's/members.*=.*\[//' | tr -d '"' | tr ',' ' ') ) - -echo -e "\n\nTesting the workspace, except lightning-transaction-sync." +echo -e "\n\nTesting the workspace." cargo test --quiet --color always echo -e "\n\nTesting upgrade from prior versions of LDK" @@ -18,14 +15,9 @@ pushd lightning-tests cargo test --quiet popd -echo -e "\n\nChecking and building docs for all workspace members individually..." -for DIR in "${WORKSPACE_MEMBERS[@]}"; do - cargo check -p "$DIR" --quiet --color always - cargo doc -p "$DIR" --quiet --document-private-items -done +echo -e "\n\nBuilding docs for all workspace members." +cargo doc --workspace --quiet --document-private-items -echo -e "\n\nTest Custom Message Macros" -cargo test -p lightning-custom-message --quiet --color always [ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean # Test that we can build downstream code with only the "release pins". From 954881604d2dda8ca27a0d56273be0c83af0ca4f Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 10 Mar 2026 17:25:32 +0000 Subject: [PATCH 155/627] Tell claude to be DRY --- CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 15dd4581e34..cecd79c4981 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,3 +23,5 @@ See [README.md](README.md) for the workspace layout and [ARCH.md](ARCH.md) for s - When adding comments, do not refer to internal logic in other modules, instead make sure comments make sense in the context they're in without needing other context. +- Try to keep code DRY - if new code you add is duplicate with other code, + deduplicate it. From 7b955db7a3785e4fa43f2b7930bb53746ce6af16 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Wed, 11 Mar 2026 16:53:45 +0000 Subject: [PATCH 156/627] Add claude code reviewing We can tweak the prompt as we get experience, for now its just the one copied https://github.com/anthropics/claude-code-action/blob/main/docs/solutions.md --- .github/workflows/claude-review.yml | 38 +++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/claude-review.yml diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml new file mode 100644 index 00000000000..c6c326dc2a1 --- /dev/null +++ b/.github/workflows/claude-review.yml @@ -0,0 +1,38 @@ +name: Claude Auto Review +on: + pull_request: + types: [opened, synchronize] + +jobs: + review: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + id-token: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ github.event.pull_request.number }} + + Please review this pull request with a focus on: + - Code quality and best practices + - Potential bugs or issues + - Security implications + - Performance considerations + + Note: The PR branch is already checked out in the current working directory. + + Use `gh pr comment` for top-level feedback. + Use `mcp__github_inline_comment__create_inline_comment` to highlight specific code issues. + Only post GitHub comments - don't submit review text as messages. + + claude_args: | + --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)" From e93d43cf767f11a2f3953316bf8ff08cc0096bbc Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 11 Mar 2026 14:20:02 -0400 Subject: [PATCH 157/627] Emit SpliceFailed for acceptor contributions The maybe_create_splice_funding_failed! macro only emitted SpliceFailed and DiscardFunding events for the splice initiator. When an acceptor contributed inputs/outputs and the negotiation failed (e.g., disconnect), their contributions were silently discarded with no event notification, preventing the acceptor from reclaiming its UTXOs. Replace the is_initiator() filter with a post-hoc check on whether there are contributions to discard. The initiator always gets events, the acceptor gets events when it has contributions, and acceptors without contributions get no events (nothing to discard). Co-Authored-By: Claude Opus 4.6 (1M context) --- lightning/src/ln/channel.rs | 14 ++++-- lightning/src/ln/splicing_tests.rs | 72 ++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 9361cd3c749..05bd9b3611e 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -6568,8 +6568,9 @@ macro_rules! maybe_create_splice_funding_failed { ($funded_channel: expr, $pending_splice: expr, $get: ident, $contributed_inputs_and_outputs: ident) => {{ $pending_splice .and_then(|pending_splice| pending_splice.funding_negotiation.$get()) - .filter(|funding_negotiation| funding_negotiation.is_initiator()) - .map(|funding_negotiation| { + .and_then(|funding_negotiation| { + let is_initiator = funding_negotiation.is_initiator(); + let funding_txo = funding_negotiation .as_funding() .and_then(|funding| funding.get_funding_txo()) @@ -6595,12 +6596,17 @@ macro_rules! maybe_create_splice_funding_failed { .$contributed_inputs_and_outputs(), }; - SpliceFundingFailed { + if !is_initiator && contributed_inputs.is_empty() && contributed_outputs.is_empty() + { + return None; + } + + Some(SpliceFundingFailed { funding_txo, channel_type, contributed_inputs, contributed_outputs, - } + }) }) }}; } diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 486e386be87..f8c188c68a0 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -4006,3 +4006,75 @@ fn do_test_splice_pending_htlcs(config: UserConfig) { let _ = send_payment(&nodes[0], &[&nodes[1]], 2_000 * 1000); let _ = send_payment(&nodes[1], &[&nodes[0]], 2_000 * 1000); } + +#[test] +fn test_splice_acceptor_disconnect_emits_events() { + // When both nodes contribute to a splice and the negotiation fails due to disconnect, + // both the initiator and acceptor should receive SpliceFailed + DiscardFunding events + // so each can reclaim their UTXOs. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 1, added_value * 2); + + // Both nodes initiate splice-in (tiebreak: node 0 wins). + let node_0_funding_contribution = + do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let _node_1_funding_contribution = + do_initiate_splice_in(&nodes[1], &nodes[0], channel_id, added_value); + + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + assert_ne!(splice_ack.funding_contribution_satoshis, 0); + nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); + + // Disconnect mid-interactive-TX negotiation. + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + + // The initiator should get SpliceFailed + DiscardFunding. + expect_splice_failed_events(&nodes[0], &channel_id, node_0_funding_contribution); + + // The acceptor should also get SpliceFailed + DiscardFunding with its contributions + // so it can reclaim its UTXOs. The contribution is feerate-adjusted by handle_splice_init, + // so we check for non-empty inputs/outputs rather than exact values. + let events = nodes[1].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2, "{events:?}"); + match &events[0] { + Event::SpliceFailed { channel_id: cid, .. } => assert_eq!(*cid, channel_id), + other => panic!("Expected SpliceFailed, got {:?}", other), + } + match &events[1] { + Event::DiscardFunding { + funding_info: FundingInfo::Contribution { inputs, outputs }, + .. + } => { + assert!(!inputs.is_empty(), "Expected acceptor inputs, got empty"); + assert!(!outputs.is_empty(), "Expected acceptor outputs, got empty"); + }, + other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), + } + + // Reconnect and verify the channel is still operational. + let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); + reconnect_args.send_channel_ready = (true, true); + reconnect_args.send_announcement_sigs = (true, true); + reconnect_nodes(reconnect_args); +} From 1d172dca212808664f83b58fd1ef29c678824d9c Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Thu, 12 Mar 2026 12:20:50 -0400 Subject: [PATCH 158/627] Fix claude-code-action CI failures Use pull_request_target to ensure secrets are available, and pass github_token explicitly to avoid the OIDC token exchange flow. See https://github.com/anthropics/claude-code-action/issues/649 AI tools were used in preparing this commit. --- .github/workflows/claude-review.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index c6c326dc2a1..0d6d6451860 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -18,6 +18,7 @@ jobs: - uses: anthropics/claude-code-action@v1 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} prompt: | REPO: ${{ github.repository }} PR NUMBER: ${{ github.event.pull_request.number }} From 102bcd63a2c3b4a28c0300a06c37fd5b5cb468ab Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Thu, 12 Mar 2026 11:11:26 -0700 Subject: [PATCH 159/627] Rustfmt reorg_tests.rs --- lightning/src/ln/reorg_tests.rs | 359 ++++++++++++++++++++++++-------- 1 file changed, 273 insertions(+), 86 deletions(-) diff --git a/lightning/src/ln/reorg_tests.rs b/lightning/src/ln/reorg_tests.rs index 89d2f2c5ae6..d4ef5fba668 100644 --- a/lightning/src/ln/reorg_tests.rs +++ b/lightning/src/ln/reorg_tests.rs @@ -1,5 +1,3 @@ -#![cfg_attr(rustfmt, rustfmt_skip)] - // This file is Copyright its original authors, visible in version control // history. // @@ -12,10 +10,10 @@ //! Further functional tests which test blockchain reorganizations. use crate::chain::chaininterface::LowerBoundedFeeEstimator; -use crate::chain::channelmonitor::{ANTI_REORG_DELAY, Balance, LATENCY_GRACE_PERIOD_BLOCKS}; +use crate::chain::channelmonitor::{Balance, ANTI_REORG_DELAY, LATENCY_GRACE_PERIOD_BLOCKS}; use crate::chain::transaction::OutPoint; use crate::chain::Confirm; -use crate::events::{Event, ClosureReason, HTLCHandlingFailureType}; +use crate::events::{ClosureReason, Event, HTLCHandlingFailureType}; use crate::ln::msgs::{BaseMessageHandler, ChannelMessageHandler, Init, MessageSendEvent}; use crate::ln::types::ChannelId; use crate::sign::OutputSpender; @@ -23,8 +21,8 @@ use crate::types::payment::PaymentHash; use crate::types::string::UntrustedString; use crate::util::ser::Writeable; -use bitcoin::script::Builder; use bitcoin::opcodes; +use bitcoin::script::Builder; use bitcoin::secp256k1::Secp256k1; use crate::prelude::*; @@ -57,11 +55,12 @@ fn do_test_onchain_htlc_reorg(local_commitment: bool, claim: bool) { let chan_2 = create_announced_chan_between_nodes(&nodes, 1, 2); // Make sure all nodes are at the same starting height - connect_blocks(&nodes[0], 2*CHAN_CONFIRM_DEPTH + 1 - nodes[0].best_block_info().1); - connect_blocks(&nodes[1], 2*CHAN_CONFIRM_DEPTH + 1 - nodes[1].best_block_info().1); - connect_blocks(&nodes[2], 2*CHAN_CONFIRM_DEPTH + 1 - nodes[2].best_block_info().1); + connect_blocks(&nodes[0], 2 * CHAN_CONFIRM_DEPTH + 1 - nodes[0].best_block_info().1); + connect_blocks(&nodes[1], 2 * CHAN_CONFIRM_DEPTH + 1 - nodes[1].best_block_info().1); + connect_blocks(&nodes[2], 2 * CHAN_CONFIRM_DEPTH + 1 - nodes[2].best_block_info().1); - let (our_payment_preimage, our_payment_hash, ..) = route_payment(&nodes[0], &[&nodes[1], &nodes[2]], 1_000_000); + let (our_payment_preimage, our_payment_hash, ..) = + route_payment(&nodes[0], &[&nodes[1], &nodes[2]], 1_000_000); // Provide preimage to node 2 by claiming payment nodes[2].node.claim_funds(our_payment_preimage); @@ -78,18 +77,31 @@ fn do_test_onchain_htlc_reorg(local_commitment: bool, claim: bool) { check_spends!(node_1_commitment_txn[1], node_1_commitment_txn[0]); // Give node 2 node 1's transactions and get its response (claiming the HTLC instead). - connect_block(&nodes[2], &create_dummy_block(nodes[2].best_block_hash(), 42, node_1_commitment_txn.clone())); + connect_block( + &nodes[2], + &create_dummy_block(nodes[2].best_block_hash(), 42, node_1_commitment_txn.clone()), + ); check_closed_broadcast(&nodes[2], 1, true); // We should get a BroadcastChannelUpdate (and *only* a BroadcstChannelUpdate) check_added_monitors(&nodes[2], 1); - check_closed_event(&nodes[2], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[1].node.get_our_node_id()], 100000); - let node_2_commitment_txn = nodes[2].tx_broadcaster.txn_broadcasted.lock().unwrap().split_off(0); + check_closed_event( + &nodes[2], + 1, + ClosureReason::CommitmentTxConfirmed, + &[nodes[1].node.get_our_node_id()], + 100000, + ); + let node_2_commitment_txn = + nodes[2].tx_broadcaster.txn_broadcasted.lock().unwrap().split_off(0); assert_eq!(node_2_commitment_txn.len(), 1); // ChannelMonitor: 1 offered HTLC-Claim check_spends!(node_2_commitment_txn[0], node_1_commitment_txn[0]); // Make sure node 1's height is the same as the !local_commitment case connect_blocks(&nodes[1], 1); // Confirm node 1's commitment txn (and HTLC-Timeout) on node 1 - connect_block(&nodes[1], &create_dummy_block(nodes[1].best_block_hash(), 42, node_1_commitment_txn.clone())); + connect_block( + &nodes[1], + &create_dummy_block(nodes[1].best_block_hash(), 42, node_1_commitment_txn.clone()), + ); // ...but return node 1's commitment tx in case claim is set and we're preparing to reorg vec![node_1_commitment_txn[0].clone(), node_2_commitment_txn[0].clone()] @@ -115,7 +127,13 @@ fn do_test_onchain_htlc_reorg(local_commitment: bool, claim: bool) { }; check_closed_broadcast(&nodes[1], 1, true); // We should get a BroadcastChannelUpdate (and *only* a BroadcstChannelUpdate) check_added_monitors(&nodes[1], 1); - check_closed_event(&nodes[1], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[2].node.get_our_node_id()], 100000); + check_closed_event( + &nodes[1], + 1, + ClosureReason::CommitmentTxConfirmed, + &[nodes[2].node.get_our_node_id()], + 100000, + ); // Connect ANTI_REORG_DELAY - 2 blocks, giving us a confirmation count of ANTI_REORG_DELAY - 1. connect_blocks(&nodes[1], ANTI_REORG_DELAY - 2); check_added_monitors(&nodes[1], 0); @@ -136,7 +154,10 @@ fn do_test_onchain_htlc_reorg(local_commitment: bool, claim: bool) { connect_block(&nodes[1], &create_dummy_block(nodes[1].best_block_hash(), 42, Vec::new())); expect_and_process_pending_htlcs_and_htlc_handling_failed( &nodes[1], - &[HTLCHandlingFailureType::Forward { node_id: Some(nodes[2].node.get_our_node_id()), channel_id: chan_2.2 }] + &[HTLCHandlingFailureType::Forward { + node_id: Some(nodes[2].node.get_our_node_id()), + channel_id: chan_2.2, + }], ); } @@ -145,16 +166,28 @@ fn do_test_onchain_htlc_reorg(local_commitment: bool, claim: bool) { let mut htlc_updates = get_htlc_update_msgs(&nodes[1], &nodes[0].node.get_our_node_id()); if claim { assert_eq!(htlc_updates.update_fulfill_htlcs.len(), 1); - nodes[0].node.handle_update_fulfill_htlc(nodes[1].node.get_our_node_id(), htlc_updates.update_fulfill_htlcs.remove(0)); + nodes[0].node.handle_update_fulfill_htlc( + nodes[1].node.get_our_node_id(), + htlc_updates.update_fulfill_htlcs.remove(0), + ); } else { assert_eq!(htlc_updates.update_fail_htlcs.len(), 1); - nodes[0].node.handle_update_fail_htlc(nodes[1].node.get_our_node_id(), &htlc_updates.update_fail_htlcs[0]); + nodes[0].node.handle_update_fail_htlc( + nodes[1].node.get_our_node_id(), + &htlc_updates.update_fail_htlcs[0], + ); } do_commitment_signed_dance(&nodes[0], &nodes[1], &htlc_updates.commitment_signed, false, true); if claim { expect_payment_sent!(nodes[0], our_payment_preimage); } else { - expect_payment_failed_with_update!(nodes[0], our_payment_hash, false, chan_2.0.contents.short_channel_id, true); + expect_payment_failed_with_update!( + nodes[0], + our_payment_hash, + false, + chan_2.0.contents.short_channel_id, + true + ); } } @@ -196,7 +229,8 @@ fn test_counterparty_revoked_reorg() { // Now add two HTLCs in each direction, one dust and one not. route_payment(&nodes[0], &[&nodes[1]], 5_000_000); route_payment(&nodes[0], &[&nodes[1]], 5_000); - let (payment_preimage_3, payment_hash_3, ..) = route_payment(&nodes[1], &[&nodes[0]], 4_000_000); + let (payment_preimage_3, payment_hash_3, ..) = + route_payment(&nodes[1], &[&nodes[0]], 4_000_000); let payment_hash_4 = route_payment(&nodes[1], &[&nodes[0]], 4_000).1; nodes[0].node.claim_funds(payment_preimage_3); @@ -206,15 +240,23 @@ fn test_counterparty_revoked_reorg() { let mut unrevoked_local_txn = get_local_commitment_txn!(nodes[0], chan.2); assert_eq!(unrevoked_local_txn.len(), 3); // commitment + 2 HTLC txn - // Sort the unrevoked transactions in reverse order, ie commitment tx, then HTLC 1 then HTLC 3 - unrevoked_local_txn.sort_unstable_by_key(|tx| 1_000_000 - tx.output.iter().map(|outp| outp.value.to_sat()).sum::()); + // Sort the unrevoked transactions in reverse order, ie commitment tx, then HTLC 1 then HTLC 3 + unrevoked_local_txn.sort_unstable_by_key(|tx| { + 1_000_000 - tx.output.iter().map(|outp| outp.value.to_sat()).sum::() + }); // Now mine A's old commitment transaction, which should close the channel, but take no action // on any of the HTLCs, at least until we get six confirmations (which we won't get). mine_transaction(&nodes[1], &revoked_local_txn[0]); check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); - check_closed_event(&nodes[1], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[0].node.get_our_node_id()], 1000000); + check_closed_event( + &nodes[1], + 1, + ClosureReason::CommitmentTxConfirmed, + &[nodes[0].node.get_our_node_id()], + 1000000, + ); // Connect up to one block before the revoked transaction would be considered final, then do a // reorg that disconnects the full chain and goes up to the height at which the revoked @@ -248,7 +290,10 @@ fn test_counterparty_revoked_reorg() { expect_payment_failed_conditions(&nodes[1], payment_hash_4, false, conditions) } -fn do_test_unconf_chan(reload_node: bool, reorg_after_reload: bool, use_funding_unconfirmed: bool, connect_style: ConnectStyle) { +fn do_test_unconf_chan( + reload_node: bool, reorg_after_reload: bool, use_funding_unconfirmed: bool, + connect_style: ConnectStyle, +) { // After creating a chan between nodes, we disconnect all blocks previously seen to force a // channel close on nodes[0] side. We also use this to provide very basic testing of logic // around freeing background events which store monitor updates during block_[dis]connected. @@ -264,12 +309,14 @@ fn do_test_unconf_chan(reload_node: bool, reorg_after_reload: bool, use_funding_ let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); *nodes[0].connect_style.borrow_mut() = connect_style; - let chan_conf_height = core::cmp::max(nodes[0].best_block_info().1 + 1, nodes[1].best_block_info().1 + 1); + let chan_conf_height = + core::cmp::max(nodes[0].best_block_info().1 + 1, nodes[1].best_block_info().1 + 1); let chan = create_announced_chan_between_nodes(&nodes, 0, 1); { let per_peer_state = nodes[0].node.per_peer_state.read().unwrap(); - let peer_state = per_peer_state.get(&nodes[1].node.get_our_node_id()).unwrap().lock().unwrap(); + let peer_state = + per_peer_state.get(&nodes[1].node.get_our_node_id()).unwrap().lock().unwrap(); assert_eq!(peer_state.channel_by_id.len(), 1); assert_eq!(nodes[0].node.short_to_chan_info.read().unwrap().len(), 2); } @@ -311,7 +358,8 @@ fn do_test_unconf_chan(reload_node: bool, reorg_after_reload: bool, use_funding_ { let per_peer_state = nodes[0].node.per_peer_state.read().unwrap(); - let peer_state = per_peer_state.get(&nodes[1].node.get_our_node_id()).unwrap().lock().unwrap(); + let peer_state = + per_peer_state.get(&nodes[1].node.get_our_node_id()).unwrap().lock().unwrap(); assert_eq!(peer_state.channel_by_id.len(), 0); assert_eq!(nodes[0].node.short_to_chan_info.read().unwrap().len(), 0); } @@ -323,7 +371,12 @@ fn do_test_unconf_chan(reload_node: bool, reorg_after_reload: bool, use_funding_ if reload_node && !reorg_after_reload { handle_announce_close_broadcast_events(&nodes, 0, 1, true, "Channel closed because of an exception: Funding transaction was un-confirmed, originally locked at 6 confs."); check_added_monitors(&nodes[1], 1); - let reason = ClosureReason::CounterpartyForceClosed { peer_msg: UntrustedString(format!("Channel closed because of an exception: {}", expected_err)) }; + let reason = ClosureReason::CounterpartyForceClosed { + peer_msg: UntrustedString(format!( + "Channel closed because of an exception: {}", + expected_err + )), + }; check_closed_event(&nodes[1], 1, reason, &[nodes[0].node.get_our_node_id()], 100000); } @@ -335,7 +388,15 @@ fn do_test_unconf_chan(reload_node: bool, reorg_after_reload: bool, use_funding_ let nodes_0_serialized = nodes[0].node.encode(); let chan_0_monitor_serialized = get_monitor!(nodes[0], chan.2).encode(); - reload_node!(nodes[0], nodes[0].node.get_current_config(), &nodes_0_serialized, &[&chan_0_monitor_serialized], persister, new_chain_monitor, nodes_0_deserialized); + reload_node!( + nodes[0], + nodes[0].node.get_current_config(), + &nodes_0_serialized, + &[&chan_0_monitor_serialized], + persister, + new_chain_monitor, + nodes_0_deserialized + ); nodes[1].node.peer_disconnected(nodes[0].node.get_our_node_id()); @@ -381,7 +442,8 @@ fn do_test_unconf_chan(reload_node: bool, reorg_after_reload: bool, use_funding_ { let per_peer_state = nodes[0].node.per_peer_state.read().unwrap(); - let peer_state = per_peer_state.get(&nodes[1].node.get_our_node_id()).unwrap().lock().unwrap(); + let peer_state = + per_peer_state.get(&nodes[1].node.get_our_node_id()).unwrap().lock().unwrap(); assert_eq!(peer_state.channel_by_id.len(), 0); assert_eq!(nodes[0].node.short_to_chan_info.read().unwrap().len(), 0); } @@ -400,23 +462,52 @@ fn do_test_unconf_chan(reload_node: bool, reorg_after_reload: bool, use_funding_ if reorg_after_reload || !reload_node { handle_announce_close_broadcast_events(&nodes, 0, 1, true, "Channel closed because of an exception: Funding transaction was un-confirmed, originally locked at 6 confs."); check_added_monitors(&nodes[1], 1); - let reason = ClosureReason::CounterpartyForceClosed { peer_msg: UntrustedString(format!("Channel closed because of an exception: {}", expected_err)) }; + let reason = ClosureReason::CounterpartyForceClosed { + peer_msg: UntrustedString(format!( + "Channel closed because of an exception: {}", + expected_err + )), + }; check_closed_event(&nodes[1], 1, reason, &[nodes[0].node.get_our_node_id()], 100000); } - check_closed_event(&nodes[0], 1, ClosureReason::ProcessingError { err: expected_err.to_owned() }, &[nodes[1].node.get_our_node_id()], 100000); + check_closed_event( + &nodes[0], + 1, + ClosureReason::ProcessingError { err: expected_err.to_owned() }, + &[nodes[1].node.get_our_node_id()], + 100000, + ); // Now check that we can create a new channel if reload_node && !reorg_after_reload { // If we dropped the channel before reloading the node, nodes[1] was also dropped from // nodes[0] storage, and hence not connected again on startup. We therefore need to // reconnect to the node before attempting to create a new channel. - nodes[0].node.peer_connected(nodes[1].node.get_our_node_id(), &Init { - features: nodes[1].node.init_features(), networks: None, remote_network_address: None - }, true).unwrap(); - nodes[1].node.peer_connected(nodes[0].node.get_our_node_id(), &Init { - features: nodes[0].node.init_features(), networks: None, remote_network_address: None - }, true).unwrap(); + nodes[0] + .node + .peer_connected( + nodes[1].node.get_our_node_id(), + &Init { + features: nodes[1].node.init_features(), + networks: None, + remote_network_address: None, + }, + true, + ) + .unwrap(); + nodes[1] + .node + .peer_connected( + nodes[0].node.get_our_node_id(), + &Init { + features: nodes[0].node.init_features(), + networks: None, + remote_network_address: None, + }, + true, + ) + .unwrap(); } create_announced_chan_between_nodes(&nodes, 0, 1); @@ -474,8 +565,10 @@ fn test_set_outpoints_partial_claiming() { let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let chan = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1000000, 59000000); - let (payment_preimage_1, payment_hash_1, ..) = route_payment(&nodes[1], &[&nodes[0]], 3_000_000); - let (payment_preimage_2, payment_hash_2, ..) = route_payment(&nodes[1], &[&nodes[0]], 3_000_000); + let (payment_preimage_1, payment_hash_1, ..) = + route_payment(&nodes[1], &[&nodes[0]], 3_000_000); + let (payment_preimage_2, payment_hash_2, ..) = + route_payment(&nodes[1], &[&nodes[0]], 3_000_000); // Remote commitment txn with 4 outputs: to_local, to_remote, 2 outgoing HTLC let remote_txn = get_local_commitment_txn!(nodes[1], chan.2); @@ -498,7 +591,13 @@ fn test_set_outpoints_partial_claiming() { // Connect blocks on node A commitment transaction mine_transaction(&nodes[0], &remote_txn[0]); check_closed_broadcast(&nodes[0], 1, true); - check_closed_event(&nodes[0], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[1].node.get_our_node_id()], 1000000); + check_closed_event( + &nodes[0], + 1, + ClosureReason::CommitmentTxConfirmed, + &[nodes[1].node.get_our_node_id()], + 1000000, + ); check_added_monitors(&nodes[0], 1); // Verify node A broadcast tx claiming both HTLCs { @@ -513,16 +612,19 @@ fn test_set_outpoints_partial_claiming() { // Connect blocks on node B connect_blocks(&nodes[1], TEST_FINAL_CLTV + LATENCY_GRACE_PERIOD_BLOCKS + 1); check_closed_broadcast(&nodes[1], 1, true); - check_closed_events(&nodes[1], &[ExpectedCloseEvent { - channel_capacity_sats: Some(1_000_000), - channel_id: Some(chan.2), - counterparty_node_id: Some(nodes[0].node.get_our_node_id()), - discard_funding: false, - splice_failed: false, - reason: None, // Could be due to either HTLC timing out, so don't bother checking - channel_funding_txo: None, - user_channel_id: None, - }]); + check_closed_events( + &nodes[1], + &[ExpectedCloseEvent { + channel_capacity_sats: Some(1_000_000), + channel_id: Some(chan.2), + counterparty_node_id: Some(nodes[0].node.get_our_node_id()), + discard_funding: false, + splice_failed: false, + reason: None, // Could be due to either HTLC timing out, so don't bother checking + channel_funding_txo: None, + user_channel_id: None, + }], + ); check_added_monitors(&nodes[1], 1); // Verify node B broadcast 2 HTLC-timeout txn let partial_claim_tx = { @@ -599,11 +701,23 @@ fn do_test_to_remote_after_local_detection(style: ConnectStyle) { check_closed_broadcast(&nodes[0], 1, true); assert!(nodes[0].node.list_channels().is_empty()); check_added_monitors(&nodes[0], 1); - check_closed_event(&nodes[0], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[1].node.get_our_node_id()], 1000000); + check_closed_event( + &nodes[0], + 1, + ClosureReason::CommitmentTxConfirmed, + &[nodes[1].node.get_our_node_id()], + 1000000, + ); check_closed_broadcast(&nodes[1], 1, true); assert!(nodes[1].node.list_channels().is_empty()); check_added_monitors(&nodes[1], 1); - check_closed_event(&nodes[1], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[0].node.get_our_node_id()], 1000000); + check_closed_event( + &nodes[1], + 1, + ClosureReason::CommitmentTxConfirmed, + &[nodes[0].node.get_our_node_id()], + 1000000, + ); assert!(nodes[0].chain_monitor.chain_monitor.get_and_clear_pending_events().is_empty()); assert!(nodes[1].chain_monitor.chain_monitor.get_and_clear_pending_events().is_empty()); @@ -637,11 +751,23 @@ fn do_test_to_remote_after_local_detection(style: ConnectStyle) { let mut node_a_spendable = nodes[0].chain_monitor.chain_monitor.get_and_clear_pending_events(); assert_eq!(node_a_spendable.len(), 1); - if let Event::SpendableOutputs { outputs, channel_id, counterparty_node_id: _ } = node_a_spendable.pop().unwrap() { + if let Event::SpendableOutputs { outputs, channel_id, counterparty_node_id: _ } = + node_a_spendable.pop().unwrap() + { assert_eq!(outputs.len(), 1); assert_eq!(channel_id, Some(chan_id)); - let spend_tx = nodes[0].keys_manager.backing.spend_spendable_outputs(&[&outputs[0]], Vec::new(), - Builder::new().push_opcode(opcodes::all::OP_RETURN).into_script(), 253, None, &Secp256k1::new()).unwrap(); + let spend_tx = nodes[0] + .keys_manager + .backing + .spend_spendable_outputs( + &[&outputs[0]], + Vec::new(), + Builder::new().push_opcode(opcodes::all::OP_RETURN).into_script(), + 253, + None, + &Secp256k1::new(), + ) + .unwrap(); check_spends!(spend_tx, remote_txn_b[0]); } @@ -658,11 +784,23 @@ fn do_test_to_remote_after_local_detection(style: ConnectStyle) { let mut node_b_spendable = nodes[1].chain_monitor.chain_monitor.get_and_clear_pending_events(); assert_eq!(node_b_spendable.len(), 1); - if let Event::SpendableOutputs { outputs, channel_id, counterparty_node_id: _ } = node_b_spendable.pop().unwrap() { + if let Event::SpendableOutputs { outputs, channel_id, counterparty_node_id: _ } = + node_b_spendable.pop().unwrap() + { assert_eq!(outputs.len(), 1); assert_eq!(channel_id, Some(chan_id)); - let spend_tx = nodes[1].keys_manager.backing.spend_spendable_outputs(&[&outputs[0]], Vec::new(), - Builder::new().push_opcode(opcodes::all::OP_RETURN).into_script(), 253, None, &Secp256k1::new()).unwrap(); + let spend_tx = nodes[1] + .keys_manager + .backing + .spend_spendable_outputs( + &[&outputs[0]], + Vec::new(), + Builder::new().push_opcode(opcodes::all::OP_RETURN).into_script(), + 253, + None, + &Secp256k1::new(), + ) + .unwrap(); check_spends!(spend_tx, remote_txn_a[0]); } } @@ -699,7 +837,11 @@ fn test_htlc_preimage_claim_holder_commitment_after_counterparty_commitment_reor // holder commitment. nodes[0] .node - .force_close_broadcasting_latest_txn(&chan_id, &nodes[1].node.get_our_node_id(), message.clone()) + .force_close_broadcasting_latest_txn( + &chan_id, + &nodes[1].node.get_our_node_id(), + message.clone(), + ) .unwrap(); check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); @@ -711,7 +853,11 @@ fn test_htlc_preimage_claim_holder_commitment_after_counterparty_commitment_reor nodes[1] .node - .force_close_broadcasting_latest_txn(&chan_id, &nodes[0].node.get_our_node_id(), message.clone()) + .force_close_broadcasting_latest_txn( + &chan_id, + &nodes[0].node.get_our_node_id(), + message.clone(), + ) .unwrap(); check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); @@ -743,8 +889,11 @@ fn test_htlc_preimage_claim_holder_commitment_after_counterparty_commitment_reor // Provide the preimage now, such that we only claim from the holder commitment (since it's // currently confirmed) and not the counterparty's. get_monitor!(nodes[1], chan_id).provide_payment_preimage_unsafe_legacy( - &payment_hash, &payment_preimage, &nodes[1].tx_broadcaster, - &LowerBoundedFeeEstimator(nodes[1].fee_estimator), &nodes[1].logger + &payment_hash, + &payment_preimage, + &nodes[1].tx_broadcaster, + &LowerBoundedFeeEstimator(nodes[1].fee_estimator), + &nodes[1].logger, ); let mut txn = nodes[1].tx_broadcaster.txn_broadcast(); @@ -754,7 +903,8 @@ fn test_htlc_preimage_claim_holder_commitment_after_counterparty_commitment_reor } #[test] -fn test_htlc_preimage_claim_prev_counterparty_commitment_after_current_counterparty_commitment_reorg() { +fn test_htlc_preimage_claim_prev_counterparty_commitment_after_current_counterparty_commitment_reorg( +) { // We detect a counterparty commitment confirm onchain, followed by a reorg and a // confirmation of the previous (still unrevoked) counterparty commitment. Then, if we learn // of the preimage for an HTLC in both commitments, test that we only claim the currently @@ -778,22 +928,33 @@ fn test_htlc_preimage_claim_prev_counterparty_commitment_after_current_counterpa check_added_monitors(&nodes[0], 1); let mut msg_events = nodes[0].node.get_and_clear_pending_msg_events(); assert_eq!(msg_events.len(), 1); - let (update_fee, commit_sig) = if let MessageSendEvent::UpdateHTLCs { node_id, channel_id: _, mut updates } = msg_events.pop().unwrap() { - assert_eq!(node_id, nodes[1].node.get_our_node_id()); - (updates.update_fee.take().unwrap(), updates.commitment_signed) - } else { - panic!("Unexpected message send event"); - }; + let (update_fee, commit_sig) = + if let MessageSendEvent::UpdateHTLCs { node_id, channel_id: _, mut updates } = + msg_events.pop().unwrap() + { + assert_eq!(node_id, nodes[1].node.get_our_node_id()); + (updates.update_fee.take().unwrap(), updates.commitment_signed) + } else { + panic!("Unexpected message send event"); + }; // Handle the fee update on the other side, but don't send the last RAA such that the previous // commitment is still valid (unrevoked). nodes[1].node().handle_update_fee(nodes[0].node.get_our_node_id(), &update_fee); - let _last_revoke_and_ack = commitment_signed_dance_return_raa(&nodes[1], &nodes[0], &commit_sig, false); + let _last_revoke_and_ack = + commitment_signed_dance_return_raa(&nodes[1], &nodes[0], &commit_sig, false); let message = "Channel force-closed".to_owned(); // Force close with the latest commitment, confirm it, and reorg it with the previous commitment. - nodes[0].node.force_close_broadcasting_latest_txn(&chan_id, &nodes[1].node.get_our_node_id(), message.clone()).unwrap(); + nodes[0] + .node + .force_close_broadcasting_latest_txn( + &chan_id, + &nodes[1].node.get_our_node_id(), + message.clone(), + ) + .unwrap(); check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); let reason = ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }; @@ -810,7 +971,13 @@ fn test_htlc_preimage_claim_prev_counterparty_commitment_after_current_counterpa check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); - check_closed_event(&nodes[1], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[0].node.get_our_node_id()], 100000); + check_closed_event( + &nodes[1], + 1, + ClosureReason::CommitmentTxConfirmed, + &[nodes[0].node.get_our_node_id()], + 100000, + ); disconnect_blocks(&nodes[0], 1); disconnect_blocks(&nodes[1], 1); @@ -821,8 +988,11 @@ fn test_htlc_preimage_claim_prev_counterparty_commitment_after_current_counterpa // Provide the preimage now, such that we only claim from the previous commitment (since it's // currently confirmed) and not the latest. get_monitor!(nodes[1], chan_id).provide_payment_preimage_unsafe_legacy( - &payment_hash, &payment_preimage, &nodes[1].tx_broadcaster, - &LowerBoundedFeeEstimator(nodes[1].fee_estimator), &nodes[1].logger + &payment_hash, + &payment_preimage, + &nodes[1].tx_broadcaster, + &LowerBoundedFeeEstimator(nodes[1].fee_estimator), + &nodes[1].logger, ); let mut txn = nodes[1].tx_broadcaster.txn_broadcast(); @@ -831,10 +1001,15 @@ fn test_htlc_preimage_claim_prev_counterparty_commitment_after_current_counterpa check_spends!(htlc_preimage_tx, prev_commitment_a); // Make sure it was indeed a preimage claim and not a revocation claim since the previous // commitment (still unrevoked) is the currently confirmed closing transaction. - assert_eq!(htlc_preimage_tx.input[0].witness.second_to_last().unwrap(), &payment_preimage.0[..]); + assert_eq!( + htlc_preimage_tx.input[0].witness.second_to_last().unwrap(), + &payment_preimage.0[..] + ); } -fn do_test_retries_own_commitment_broadcast_after_reorg(keyed_anchors: bool, p2a_anchor: bool, revoked_counterparty_commitment: bool) { +fn do_test_retries_own_commitment_broadcast_after_reorg( + keyed_anchors: bool, p2a_anchor: bool, revoked_counterparty_commitment: bool, +) { // Tests that a node will retry broadcasting its own commitment after seeing a confirmed // counterparty commitment be reorged out. let mut chanmon_cfgs = create_chanmon_cfgs(2); @@ -847,7 +1022,8 @@ fn do_test_retries_own_commitment_broadcast_after_reorg(keyed_anchors: bool, p2a config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = p2a_anchor; let persister; let new_chain_monitor; - let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config.clone())]); + let node_chanmgrs = + create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config.clone())]); let nodes_1_deserialized; let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); @@ -866,7 +1042,13 @@ fn do_test_retries_own_commitment_broadcast_after_reorg(keyed_anchors: bool, p2a let _ = route_payment(&nodes[0], &[&nodes[1]], 1000); reload_node!( - nodes[1], config, &serialized_node, &[&serialized_monitor], persister, new_chain_monitor, nodes_1_deserialized + nodes[1], + config, + &serialized_node, + &[&serialized_monitor], + persister, + new_chain_monitor, + nodes_1_deserialized ); } @@ -905,7 +1087,11 @@ fn do_test_retries_own_commitment_broadcast_after_reorg(keyed_anchors: bool, p2a let message = "Channel force-closed".to_owned(); nodes[1] .node - .force_close_broadcasting_latest_txn(&chan_id, &nodes[0].node.get_our_node_id(), message.clone()) + .force_close_broadcasting_latest_txn( + &chan_id, + &nodes[0].node.get_our_node_id(), + message.clone(), + ) .unwrap(); check_closed_broadcast(&nodes[1], 1, !revoked_counterparty_commitment); check_added_monitors(&nodes[1], 1); @@ -925,7 +1111,6 @@ fn do_test_retries_own_commitment_broadcast_after_reorg(keyed_anchors: bool, p2a // Confirm B's commitment, A should now broadcast an HTLC timeout for commitment B. mine_transactions(&nodes[0], &[&tx, &anchor_tx]); tx - } else { let mut txn = nodes[1].tx_broadcaster.txn_broadcast(); assert_eq!(txn.len(), 1); @@ -946,7 +1131,7 @@ fn do_test_retries_own_commitment_broadcast_after_reorg(keyed_anchors: bool, p2a assert_eq!(txn.len(), 3); check_spends!(txn[0], commitment_b); check_spends!(txn[1], funding_tx); - check_spends!(txn[2], txn[1], coinbase_tx); // Anchor output spend transaction. + check_spends!(txn[2], txn[1], coinbase_tx); // Anchor output spend transaction. } else { let mut txn = nodes[0].tx_broadcaster.txn_broadcast(); assert_eq!(txn.len(), 2); @@ -971,7 +1156,7 @@ fn do_test_retries_own_commitment_broadcast_after_reorg(keyed_anchors: bool, p2a if keyed_anchors || p2a_anchor { assert_eq!(txn.len(), 2); check_spends!(txn[0], funding_tx); - check_spends!(txn[1], txn[0], coinbase_tx); // Anchor output spend. + check_spends!(txn[1], txn[0], coinbase_tx); // Anchor output spend. } else { assert_eq!(txn.len(), 2); check_spends!(txn[0], txn[1]); // HTLC timeout A @@ -1087,10 +1272,10 @@ fn do_test_split_htlc_expiry_tracking(use_third_htlc: bool, reorg_out: bool, p2a assert_eq!(txn.len(), 3, "{txn:?}"); if p2a_anchor { check_spends!(txn[0], funding_tx); - check_spends!(txn[1], txn[0], anchor_tx.as_ref().unwrap()); // Anchor output spend. + check_spends!(txn[1], txn[0], anchor_tx.as_ref().unwrap()); // Anchor output spend. } else { check_spends!(txn[0], funding_tx); - check_spends!(txn[1], txn[0], coinbase_tx); // Anchor output spend. + check_spends!(txn[1], txn[0], coinbase_tx); // Anchor output spend. } } else { assert_eq!(txn.len(), 1, "{txn:?}"); @@ -1123,7 +1308,8 @@ fn do_test_split_htlc_expiry_tracking(use_third_htlc: bool, reorg_out: bool, p2a let mut found_expected_events = [false, false, false, false]; for event in sent_events { match event { - Event::PaymentSent { payment_hash, .. }|Event::PaymentPathSuccessful { payment_hash: Some(payment_hash), .. } => { + Event::PaymentSent { payment_hash, .. } + | Event::PaymentPathSuccessful { payment_hash: Some(payment_hash), .. } => { let path_success = matches!(event, Event::PaymentPathSuccessful { .. }); if payment_hash == payment_hash_a { found_expected_events[0 + if path_success { 1 } else { 0 }] = true; @@ -1214,7 +1400,8 @@ fn do_test_split_htlc_expiry_tracking(use_third_htlc: bool, reorg_out: bool, p2a let mut found_expected_events = [false, false]; for event in failed_events { match event { - Event::PaymentFailed { payment_hash: Some(payment_hash), .. }|Event::PaymentPathFailed { payment_hash, .. } => { + Event::PaymentFailed { payment_hash: Some(payment_hash), .. } + | Event::PaymentPathFailed { payment_hash, .. } => { let path_failed = matches!(event, Event::PaymentPathFailed { .. }); if payment_hash == payment_hash_c { found_expected_events[if path_failed { 1 } else { 0 }] = true; From 55196db0e147ead7b0a683e982c2293b36079be6 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 18 Feb 2026 13:37:44 -0600 Subject: [PATCH 160/627] Accept tx_init_rbf for pending splice transactions When a splice funding transaction has been negotiated but not yet confirmed, either party may initiate RBF to bump the feerate. This enables the acceptor to handle such requests, allowing continued progress toward on-chain confirmation of splices in rising fee environments. Only the acceptor side is implemented; the acceptor does not contribute funds beyond the shared funding input. The initiator side (sending tx_init_rbf and handling tx_ack_rbf) is left for a follow-up. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 240 +++++++++++++++++++--- lightning/src/ln/channelmanager.rs | 62 +++++- lightning/src/ln/interactivetxs.rs | 9 + lightning/src/ln/splicing_tests.rs | 313 +++++++++++++++++++++++++++++ 4 files changed, 590 insertions(+), 34 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 05bd9b3611e..be8e0e1c307 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2106,6 +2106,7 @@ where let funding_negotiation = pending_splice.funding_negotiation.take(); if let Some(FundingNegotiation::ConstructingTransaction { mut funding, + funding_feerate_sat_per_1000_weight, interactive_tx_constructor, }) = funding_negotiation { @@ -2116,6 +2117,7 @@ where Some(FundingNegotiation::AwaitingSignatures { is_initiator, funding, + funding_feerate_sat_per_1000_weight, initial_commitment_signed_from_counterparty: None, }); interactive_tx_constructor @@ -2896,6 +2898,10 @@ struct PendingFunding { /// The funding txid used in the `splice_locked` received from the counterparty. received_funding_txid: Option, + + /// The feerate used in the last successfully negotiated funding transaction. + /// Used for validating the 25/24 feerate increase rule on RBF attempts. + last_funding_feerate_sat_per_1000_weight: Option, } impl_writeable_tlv_based!(PendingFunding, { @@ -2903,6 +2909,7 @@ impl_writeable_tlv_based!(PendingFunding, { (3, negotiated_candidates, required_vec), (5, sent_funding_txid, option), (7, received_funding_txid, option), + (8, last_funding_feerate_sat_per_1000_weight, option), }); #[derive(Debug)] @@ -2913,10 +2920,12 @@ enum FundingNegotiation { }, ConstructingTransaction { funding: FundingScope, + funding_feerate_sat_per_1000_weight: u32, interactive_tx_constructor: InteractiveTxConstructor, }, AwaitingSignatures { funding: FundingScope, + funding_feerate_sat_per_1000_weight: u32, is_initiator: bool, /// The initial [`msgs::CommitmentSigned`] message received for the [`FundingScope`] above. /// We delay processing this until the user manually approves the splice via @@ -2936,6 +2945,7 @@ impl_writeable_tlv_based_enum_upgradable!(FundingNegotiation, (0, AwaitingSignatures) => { (1, funding, required), (3, is_initiator, required), + (5, funding_feerate_sat_per_1000_weight, (default_value, 0)), (_unused, initial_commitment_signed_from_counterparty, (static_value, None)), }, unread_variants: AwaitingAck, ConstructingTransaction @@ -2959,6 +2969,37 @@ impl FundingNegotiation { FundingNegotiation::AwaitingSignatures { is_initiator, .. } => *is_initiator, } } + fn for_acceptor( + funding: FundingScope, context: &ChannelContext, entropy_source: &ES, + holder_node_id: &PublicKey, our_funding_contribution: SignedAmount, + prev_funding_input: SharedOwnedInput, locktime: u32, feerate_sat_per_1000_weight: u32, + our_funding_inputs: Vec, our_funding_outputs: Vec, + ) -> FundingNegotiation { + let funding_negotiation_context = FundingNegotiationContext { + is_initiator: false, + our_funding_contribution, + funding_tx_locktime: LockTime::from_consensus(locktime), + funding_feerate_sat_per_1000_weight: feerate_sat_per_1000_weight, + shared_funding_input: Some(prev_funding_input), + our_funding_inputs, + our_funding_outputs, + }; + + let (interactive_tx_constructor, first_message) = funding_negotiation_context + .into_interactive_tx_constructor( + context, + &funding, + entropy_source, + holder_node_id.clone(), + ); + debug_assert!(first_message.is_none()); + + FundingNegotiation::ConstructingTransaction { + funding, + funding_feerate_sat_per_1000_weight: feerate_sat_per_1000_weight, + interactive_tx_constructor, + } + } } impl PendingFunding { @@ -8794,10 +8835,15 @@ where if let Some(pending_splice) = self.pending_splice.as_mut() { self.context.channel_state.clear_quiescent(); - if let Some(FundingNegotiation::AwaitingSignatures { mut funding, .. }) = - pending_splice.funding_negotiation.take() + if let Some(FundingNegotiation::AwaitingSignatures { + mut funding, + funding_feerate_sat_per_1000_weight, + .. + }) = pending_splice.funding_negotiation.take() { funding.funding_transaction = Some(funding_tx); + pending_splice.last_funding_feerate_sat_per_1000_weight = + Some(funding_feerate_sat_per_1000_weight); let funding_txo = funding.get_funding_txo().expect("funding outpoint should be set"); @@ -11904,6 +11950,7 @@ where negotiated_candidates: vec![], sent_funding_txid: None, received_funding_txid: None, + last_funding_feerate_sat_per_1000_weight: None, }); msgs::SpliceInit { @@ -12096,11 +12143,9 @@ where Ok(()) } - pub(crate) fn splice_init( - &mut self, msg: &msgs::SpliceInit, entropy_source: &ES, holder_node_id: &PublicKey, - logger: &L, - ) -> Result { - let feerate = FeeRate::from_sat_per_kwu(msg.funding_feerate_per_kw as u64); + fn resolve_queued_contribution( + &self, feerate: FeeRate, logger: &L, + ) -> (Option, Option) { let holder_balance = self .get_holder_counterparty_balances_floor_incl_fee(&self.funding) .map(|(holder, _)| holder) @@ -12114,7 +12159,8 @@ where ); }) .ok(); - let our_funding_contribution = + + let net_value = holder_balance.and_then(|_| self.queued_funding_contribution()).and_then(|c| { c.net_value_for_acceptor_at_feerate(feerate, holder_balance.unwrap()) .map_err(|e| { @@ -12130,6 +12176,17 @@ where .ok() }); + (net_value, holder_balance) + } + + pub(crate) fn splice_init( + &mut self, msg: &msgs::SpliceInit, entropy_source: &ES, holder_node_id: &PublicKey, + logger: &L, + ) -> Result { + let feerate = FeeRate::from_sat_per_kwu(msg.funding_feerate_per_kw as u64); + let (our_funding_contribution, holder_balance) = + self.resolve_queued_contribution(feerate, logger); + let splice_funding = self.validate_splice_init(msg, our_funding_contribution.unwrap_or(SignedAmount::ZERO))?; @@ -12152,35 +12209,26 @@ where self.funding.get_value_satoshis(), ); + let new_funding_pubkey = splice_funding.get_holder_pubkeys().funding_pubkey; let prev_funding_input = self.funding.to_splice_funding_input(); - let funding_negotiation_context = FundingNegotiationContext { - is_initiator: false, + let funding_negotiation = FundingNegotiation::for_acceptor( + splice_funding, + &self.context, + entropy_source, + holder_node_id, our_funding_contribution, - funding_tx_locktime: LockTime::from_consensus(msg.locktime), - funding_feerate_sat_per_1000_weight: msg.funding_feerate_per_kw, - shared_funding_input: Some(prev_funding_input), + prev_funding_input, + msg.locktime, + msg.funding_feerate_per_kw, our_funding_inputs, our_funding_outputs, - }; - - let (interactive_tx_constructor, first_message) = funding_negotiation_context - .into_interactive_tx_constructor( - &self.context, - &splice_funding, - entropy_source, - holder_node_id.clone(), - ); - debug_assert!(first_message.is_none()); - - let new_funding_pubkey = splice_funding.get_holder_pubkeys().funding_pubkey; + ); self.pending_splice = Some(PendingFunding { - funding_negotiation: Some(FundingNegotiation::ConstructingTransaction { - funding: splice_funding, - interactive_tx_constructor, - }), + funding_negotiation: Some(funding_negotiation), negotiated_candidates: Vec::new(), received_funding_txid: None, sent_funding_txid: None, + last_funding_feerate_sat_per_1000_weight: None, }); Ok(msgs::SpliceAck { @@ -12191,6 +12239,137 @@ where }) } + /// Checks during handling tx_init_rbf for an existing splice + fn validate_tx_init_rbf( + &self, msg: &msgs::TxInitRbf, our_funding_contribution: SignedAmount, + fee_estimator: &LowerBoundedFeeEstimator, + ) -> Result { + if self.holder_commitment_point.current_point().is_none() { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} commitment point needs to be advanced once before RBF", + self.context.channel_id(), + ))); + } + + if !self.context.channel_state.is_quiescent() { + return Err(ChannelError::WarnAndDisconnect("Quiescence needed for RBF".to_owned())); + } + + if self.context.minimum_depth(&self.funding) == Some(0) { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} has option_zeroconf, cannot RBF splice", + self.context.channel_id(), + ))); + } + + let pending_splice = match &self.pending_splice { + Some(pending_splice) => pending_splice, + None => { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} has no pending splice to RBF", + self.context.channel_id(), + ))); + }, + }; + + if pending_splice.funding_negotiation.is_some() { + return Err(ChannelError::Abort(AbortReason::NegotiationInProgress)); + } + + if pending_splice.received_funding_txid.is_some() { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} counterparty already sent splice_locked, cannot RBF", + self.context.channel_id(), + ))); + } + + if pending_splice.sent_funding_txid.is_some() { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} already sent splice_locked, cannot RBF", + self.context.channel_id(), + ))); + } + + let last_candidate = match pending_splice.negotiated_candidates.last() { + Some(candidate) => candidate, + None => { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} has no negotiated splice candidates to RBF", + self.context.channel_id(), + ))); + }, + }; + + // Check the 25/24 feerate increase rule + let prev_feerate = + pending_splice.last_funding_feerate_sat_per_1000_weight.unwrap_or_else(|| { + fee_estimator.bounded_sat_per_1000_weight(ConfirmationTarget::UrgentOnChainSweep) + }); + let new_feerate = msg.feerate_sat_per_1000_weight; + if (new_feerate as u64) * 24 < (prev_feerate as u64) * 25 { + return Err(ChannelError::Abort(AbortReason::InsufficientRbfFeerate)); + } + + let their_funding_contribution = match msg.funding_output_contribution { + Some(value) => SignedAmount::from_sat(value), + None => SignedAmount::ZERO, + }; + + self.validate_splice_contributions(our_funding_contribution, their_funding_contribution) + .map_err(|e| ChannelError::WarnAndDisconnect(e))?; + + // Reuse funding pubkeys from the last negotiated candidate since all RBF candidates + // for the same splice share the same funding output script. + let holder_pubkeys = last_candidate.get_holder_pubkeys().clone(); + let counterparty_funding_pubkey = *last_candidate.counterparty_funding_pubkey(); + + Ok(FundingScope::for_splice( + &self.funding, + &self.context, + our_funding_contribution, + their_funding_contribution, + counterparty_funding_pubkey, + holder_pubkeys, + )) + } + + pub(crate) fn tx_init_rbf( + &mut self, msg: &msgs::TxInitRbf, entropy_source: &ES, holder_node_id: &PublicKey, + fee_estimator: &LowerBoundedFeeEstimator, logger: &L, + ) -> Result { + let our_funding_contribution = SignedAmount::ZERO; + let rbf_funding = + self.validate_tx_init_rbf(msg, our_funding_contribution, fee_estimator)?; + + log_info!( + logger, + "Starting RBF funding negotiation for channel {} after receiving tx_init_rbf; channel value: {} sats", + self.context.channel_id, + rbf_funding.get_value_satoshis(), + ); + + let prev_funding_input = self.funding.to_splice_funding_input(); + let funding_negotiation = FundingNegotiation::for_acceptor( + rbf_funding, + &self.context, + entropy_source, + holder_node_id, + our_funding_contribution, + prev_funding_input, + msg.locktime, + msg.feerate_sat_per_1000_weight, + Vec::new(), + Vec::new(), + ); + let pending_splice = self.pending_splice.as_mut().expect("pending_splice should exist"); + pending_splice.funding_negotiation = Some(funding_negotiation); + + Ok(msgs::TxAckRbf { + channel_id: self.context.channel_id, + funding_output_contribution: None, + }) + } + pub(crate) fn splice_ack( &mut self, msg: &msgs::SpliceAck, entropy_source: &ES, holder_node_id: &PublicKey, logger: &L, @@ -12217,6 +12396,8 @@ where panic!("We should have returned an error earlier!"); }; + let funding_feerate_sat_per_1000_weight = + funding_negotiation_context.funding_feerate_sat_per_1000_weight; let (interactive_tx_constructor, tx_msg_opt) = funding_negotiation_context .into_interactive_tx_constructor( &self.context, @@ -12230,6 +12411,7 @@ where pending_splice.funding_negotiation = Some(FundingNegotiation::ConstructingTransaction { funding: splice_funding, + funding_feerate_sat_per_1000_weight, interactive_tx_constructor, }); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index ada27af749f..640dc821fb4 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -12877,6 +12877,53 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } } + /// Handle incoming tx_init_rbf, start a new round of interactive transaction construction. + fn internal_tx_init_rbf( + &self, counterparty_node_id: &PublicKey, msg: &msgs::TxInitRbf, + ) -> Result<(), MsgHandleErrInternal> { + let per_peer_state = self.per_peer_state.read().unwrap(); + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) + })?; + let mut peer_state_lock = peer_state_mutex.lock().unwrap(); + let peer_state = &mut *peer_state_lock; + + match peer_state.channel_by_id.entry(msg.channel_id) { + hash_map::Entry::Vacant(_) => { + return Err(MsgHandleErrInternal::no_such_channel_for_peer( + counterparty_node_id, + msg.channel_id, + )) + }, + hash_map::Entry::Occupied(mut chan_entry) => { + if let Some(ref mut funded_channel) = chan_entry.get_mut().as_funded_mut() { + let init_res = funded_channel.tx_init_rbf( + msg, + &self.entropy_source, + &self.get_our_node_id(), + &self.fee_estimator, + &self.logger, + ); + let tx_ack_rbf_msg = try_channel_entry!(self, peer_state, init_res, chan_entry); + peer_state.pending_msg_events.push(MessageSendEvent::SendTxAckRbf { + node_id: *counterparty_node_id, + msg: tx_ack_rbf_msg, + }); + Ok(()) + } else { + try_channel_entry!( + self, + peer_state, + Err( + ChannelError::close("Channel is not funded, cannot RBF splice".into(),) + ), + chan_entry + ) + } + }, + } + } + /// Handle incoming splice request ack, transition channel to splice-pending (unless some check fails). fn internal_splice_ack( &self, counterparty_node_id: &PublicKey, msg: &msgs::SpliceAck, @@ -16330,11 +16377,16 @@ impl< } fn handle_tx_init_rbf(&self, counterparty_node_id: PublicKey, msg: &msgs::TxInitRbf) { - let err = Err(MsgHandleErrInternal::send_err_msg_no_close( - "Dual-funded channels not supported".to_owned(), - msg.channel_id.clone(), - )); - let _: Result<(), _> = self.handle_error(err, counterparty_node_id); + let _persistence_guard = PersistenceNotifierGuard::optionally_notify(self, || { + let res = self.internal_tx_init_rbf(&counterparty_node_id, msg); + let persist = match &res { + Err(e) if e.closes_channel() => NotifyOption::DoPersist, + Err(_) => NotifyOption::SkipPersistHandleEvents, + Ok(()) => NotifyOption::SkipPersistHandleEvents, + }; + let _ = self.handle_error(res, counterparty_node_id); + persist + }); } fn handle_tx_ack_rbf(&self, counterparty_node_id: PublicKey, msg: &msgs::TxAckRbf) { diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index f7e0ce34346..5a9964a6063 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -136,6 +136,11 @@ pub(crate) enum AbortReason { DuplicateFundingOutput, /// More than one funding (shared) input found. DuplicateFundingInput, + /// The RBF feerate is insufficient (e.g., doesn't satisfy the 25/24 rule or can't accommodate + /// prior contributions). + InsufficientRbfFeerate, + /// A funding negotiation is already in progress. + NegotiationInProgress, /// Internal error InternalError(&'static str), } @@ -195,6 +200,10 @@ impl Display for AbortReason { f.write_str("More than one funding output found") }, AbortReason::DuplicateFundingInput => f.write_str("More than one funding input found"), + AbortReason::InsufficientRbfFeerate => f.write_str("Insufficient RBF feerate"), + AbortReason::NegotiationInProgress => { + f.write_str("A funding negotiation is already in progress") + }, AbortReason::InternalError(text) => { f.write_fmt(format_args!("Internal error: {}", text)) }, diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index f8c188c68a0..9adc318a4d8 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -4007,6 +4007,20 @@ fn do_test_splice_pending_htlcs(config: UserConfig) { let _ = send_payment(&nodes[1], &[&nodes[0]], 2_000 * 1000); } +// Returns after both sides are quiescent (no splice_init is generated since we use DoNothing). +pub fn reenter_quiescence<'a, 'b, 'c>( + node_a: &Node<'a, 'b, 'c>, node_b: &Node<'a, 'b, 'c>, channel_id: &ChannelId, +) { + let node_id_a = node_a.node.get_our_node_id(); + let node_id_b = node_b.node.get_our_node_id(); + + node_a.node.maybe_propose_quiescence(&node_id_b, channel_id).unwrap(); + let stfu_a = get_event_msg!(node_a, MessageSendEvent::SendStfu, node_id_b); + node_b.node.handle_stfu(node_id_a, &stfu_a); + let stfu_b = get_event_msg!(node_b, MessageSendEvent::SendStfu, node_id_a); + node_a.node.handle_stfu(node_id_b, &stfu_b); +} + #[test] fn test_splice_acceptor_disconnect_emits_events() { // When both nodes contribute to a splice and the negotiation fails due to disconnect, @@ -4078,3 +4092,302 @@ fn test_splice_acceptor_disconnect_emits_events() { reconnect_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_args); } + +#[test] +fn test_splice_rbf_acceptor_basic() { + // Test the happy path for accepting an RBF of a pending splice transaction. + // After completing a splice-in, re-enter quiescence and process tx_init_rbf + // from the counterparty, responding with tx_ack_rbf. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Complete a splice-in from node 0. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_splice_tx, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Re-enter quiescence for RBF (node 0 initiates). + reenter_quiescence(&nodes[0], &nodes[1], &channel_id); + + // Node 0 sends tx_init_rbf with feerate satisfying the 25/24 rule. + // Original feerate was FEERATE_FLOOR_SATS_PER_KW (253). 253 * 25 / 24 = 263.54, so 264 works. + let rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: rbf_feerate as u32, + funding_output_contribution: Some(added_value.to_sat() as i64), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + let tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0); + + assert_eq!(tx_ack_rbf.channel_id, channel_id); + // Acceptor doesn't contribute funds in the RBF. + assert_eq!(tx_ack_rbf.funding_output_contribution, None); +} + +#[test] +fn test_splice_rbf_insufficient_feerate() { + // Test that tx_init_rbf with an insufficient feerate (less than 25/24 of previous) is rejected. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Complete a splice-in. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_splice_tx, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Re-enter quiescence. + reenter_quiescence(&nodes[0], &nodes[1], &channel_id); + + // Send tx_init_rbf with feerate that does NOT satisfy the 25/24 rule. + // Original feerate was 253. Using exactly 253 should fail since 253 * 24 < 253 * 25. + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: FEERATE_FLOOR_SATS_PER_KW, + funding_output_contribution: Some(added_value.to_sat() as i64), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); + assert_eq!(tx_abort.channel_id, channel_id); +} + +#[test] +fn test_splice_rbf_no_pending_splice() { + // Test that tx_init_rbf is rejected when there is no pending splice to RBF. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + // Re-enter quiescence without having done a splice. + reenter_quiescence(&nodes[0], &nodes[1], &channel_id); + + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: 500, + funding_output_contribution: Some(50_000), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + match &msg_events[0] { + MessageSendEvent::HandleError { action, .. } => { + assert_eq!( + *action, + msgs::ErrorAction::DisconnectPeerWithWarning { + msg: msgs::WarningMessage { + channel_id, + data: format!("Channel {} has no pending splice to RBF", channel_id), + }, + } + ); + }, + _ => panic!("Expected HandleError, got {:?}", msg_events[0]), + } +} + +#[test] +fn test_splice_rbf_active_negotiation() { + // Test that tx_init_rbf is rejected when a funding negotiation is already in progress. + // Start a splice but don't complete interactive TX construction, then send tx_init_rbf. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Initiate a splice but only complete the handshake (STFU + splice_init/ack), + // leaving interactive TX construction in progress. + let _funding_contribution = + do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let _new_funding_script = complete_splice_handshake(&nodes[0], &nodes[1]); + + // Now the acceptor (node 1) has a funding_negotiation in progress (ConstructingTransaction). + // Sending tx_init_rbf should be rejected. + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: 500, + funding_output_contribution: Some(added_value.to_sat() as i64), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); + assert_eq!(tx_abort.channel_id, channel_id); + + // Clear the initiator's pending interactive TX messages from the incomplete splice handshake. + nodes[0].node.get_and_clear_pending_msg_events(); +} + +#[test] +fn test_splice_rbf_after_splice_locked() { + // Test that tx_init_rbf is rejected when the counterparty has already sent splice_locked. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Complete a splice-in from node 0. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (splice_tx, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Mine the splice tx on both nodes. + mine_transaction(&nodes[0], &splice_tx); + mine_transaction(&nodes[1], &splice_tx); + + // Connect enough blocks on node 0 only so it sends splice_locked. + connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1); + + let splice_locked = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1); + + // Deliver splice_locked to node 1. Since node 1 hasn't confirmed enough blocks, + // it won't send its own splice_locked back, but it will set received_funding_txid. + nodes[1].node.handle_splice_locked(node_id_0, &splice_locked); + + // Node 1 shouldn't have any messages to send (no splice_locked since it hasn't confirmed). + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert!(msg_events.is_empty(), "Expected no messages, got {:?}", msg_events); + + // Re-enter quiescence (node 0 initiates). + reenter_quiescence(&nodes[0], &nodes[1], &channel_id); + + // Node 0 sends tx_init_rbf, but node 0 already sent splice_locked, so it should be rejected. + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: 500, + funding_output_contribution: Some(added_value.to_sat() as i64), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + match &msg_events[0] { + MessageSendEvent::HandleError { action, .. } => { + assert_eq!( + *action, + msgs::ErrorAction::DisconnectPeerWithWarning { + msg: msgs::WarningMessage { + channel_id, + data: format!( + "Channel {} counterparty already sent splice_locked, cannot RBF", + channel_id, + ), + }, + } + ); + }, + _ => panic!("Expected HandleError, got {:?}", msg_events[0]), + } +} + +#[test] +fn test_splice_rbf_zeroconf_rejected() { + // Test that tx_init_rbf is rejected when option_zeroconf is negotiated. + // The zero-conf check happens before the pending_splice check, so we don't need to complete + // a splice — just enter quiescence and send tx_init_rbf. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut config = test_default_channel_config(); + config.channel_handshake_limits.trust_own_funding_0conf = true; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (funding_tx, channel_id) = + open_zero_conf_channel_with_value(&nodes[0], &nodes[1], None, initial_channel_value_sat, 0); + mine_transaction(&nodes[0], &funding_tx); + mine_transaction(&nodes[1], &funding_tx); + + // Enter quiescence (node 0 initiates). + reenter_quiescence(&nodes[0], &nodes[1], &channel_id); + + // Node 0 sends tx_init_rbf, but the channel has option_zeroconf, so it should be rejected. + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: 500, + funding_output_contribution: Some(50_000), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + match &msg_events[0] { + MessageSendEvent::HandleError { action, .. } => { + assert_eq!( + *action, + msgs::ErrorAction::DisconnectPeerWithWarning { + msg: msgs::WarningMessage { + channel_id, + data: format!( + "Channel {} has option_zeroconf, cannot RBF splice", + channel_id, + ), + }, + } + ); + }, + _ => panic!("Expected HandleError, got {:?}", msg_events[0]), + } +} From 51fa46e944e4b6898d99322d488e3f93d59ca5fa Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 18 Feb 2026 20:32:54 -0600 Subject: [PATCH 161/627] Allow multiple RBF splice candidates in channel monitor The channel monitor previously rejected any new pending funding when one already existed. This prevented adding RBF candidates for a pending splice since each candidate needs its own pending funding entry. Relax the check to only reject new pending funding when its splice parent differs from existing entries, allowing multiple RBF candidates that compete to confirm the same splice. Co-Authored-By: Claude Opus 4.6 --- lightning/src/chain/channelmonitor.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs index a8d055a9c5b..02a3a42b383 100644 --- a/lightning/src/chain/channelmonitor.rs +++ b/lightning/src/chain/channelmonitor.rs @@ -4039,9 +4039,16 @@ impl ChannelMonitorImpl { } if let Some(parent_funding_txid) = channel_parameters.splice_parent_funding_txid.as_ref() { - // Only one splice can be negotiated at a time after we've exchanged `channel_ready` - // (implying our funding is confirmed) that spends our currently locked funding. - if !self.pending_funding.is_empty() { + // Multiple RBF candidates for the same splice are allowed (they share the same + // parent funding txid). A new splice with a different parent while one is pending + // is not allowed. This also ensures a dual-funded channel has exchanged + // `channel_ready` (implying funding is confirmed) before allowing a splice, + // since unconfirmed initial funding has no splice parent. + let has_different_parent = self.pending_funding.iter().any(|funding| { + funding.channel_parameters.splice_parent_funding_txid.as_ref() + != Some(parent_funding_txid) + }); + if has_different_parent { log_error!( logger, "Negotiated splice while channel is pending channel_ready/splice_locked" From 5b6ba4391c78e7a260c4b06b7a57b7a02ca5b11d Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 18 Feb 2026 20:34:56 -0600 Subject: [PATCH 162/627] Add rbf_channel API for initiating splice RBF Expose ChannelManager::rbf_channel as the entry point for bumping the feerate of a pending splice funding transaction. Like splice_channel, it returns a FundingTemplate to be completed and passed to funding_contributed. Validates that a pending splice exists with at least one negotiated candidate, no active funding negotiation, and that the new feerate satisfies the 25/24 increase rule required by the spec. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 143 ++++++++++++++++++++++++++--- lightning/src/ln/channelmanager.rs | 88 ++++++++++++++++++ 2 files changed, 220 insertions(+), 11 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index be8e0e1c307..29efe9a1ce2 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -11827,6 +11827,126 @@ where Ok(FundingTemplate::new(Some(shared_input), min_feerate, max_feerate)) } + /// Initiate an RBF of a pending splice transaction. + pub fn rbf_channel( + &self, min_feerate: FeeRate, max_feerate: FeeRate, + ) -> Result { + if self.holder_commitment_point.current_point().is_none() { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot RBF until a payment is routed", + self.context.channel_id(), + ), + }); + } + + if self.quiescent_action.is_some() { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot RBF as one is waiting to be negotiated", + self.context.channel_id(), + ), + }); + } + + if !self.context.is_usable() { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot RBF as it is either pending open/close", + self.context.channel_id() + ), + }); + } + + if self.context.minimum_depth(&self.funding) == Some(0) { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} has option_zeroconf, cannot RBF splice", + self.context.channel_id(), + ), + }); + } + + if min_feerate > max_feerate { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} min_feerate {} exceeds max_feerate {}", + self.context.channel_id(), + min_feerate, + max_feerate, + ), + }); + } + + self.can_initiate_rbf(min_feerate).map_err(|err| APIError::APIMisuseError { err })?; + + let funding_txo = self.funding.get_funding_txo().expect("funding_txo should be set"); + let previous_utxo = + self.funding.get_funding_output().expect("funding_output should be set"); + let shared_input = Input { + outpoint: funding_txo.into_bitcoin_outpoint(), + previous_utxo, + satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + FUNDING_TRANSACTION_WITNESS_WEIGHT, + }; + + Ok(FundingTemplate::new(Some(shared_input), min_feerate, max_feerate)) + } + + fn can_initiate_rbf(&self, feerate: FeeRate) -> Result<(), String> { + let pending_splice = match &self.pending_splice { + Some(pending_splice) => pending_splice, + None => { + return Err(format!( + "Channel {} has no pending splice to RBF", + self.context.channel_id(), + )); + }, + }; + + if pending_splice.funding_negotiation.is_some() { + return Err(format!( + "Channel {} cannot RBF as a funding negotiation is already in progress", + self.context.channel_id(), + )); + } + + if pending_splice.sent_funding_txid.is_some() { + return Err(format!( + "Channel {} already sent splice_locked, cannot RBF", + self.context.channel_id(), + )); + } + + if pending_splice.received_funding_txid.is_some() { + return Err(format!( + "Channel {} counterparty already sent splice_locked, cannot RBF", + self.context.channel_id(), + )); + } + + if pending_splice.negotiated_candidates.is_empty() { + return Err(format!( + "Channel {} has no negotiated splice candidates to RBF", + self.context.channel_id(), + )); + } + + // Check the 25/24 feerate increase rule + let new_feerate = feerate.to_sat_per_kwu() as u32; + if let Some(prev_feerate) = pending_splice.last_funding_feerate_sat_per_1000_weight { + if (new_feerate as u64) * 24 < (prev_feerate as u64) * 25 { + return Err(format!( + "Channel {} RBF feerate {} is less than 25/24 of the previous feerate {}", + self.context.channel_id(), + new_feerate, + prev_feerate, + )); + } + } + + Ok(()) + } + pub fn funding_contributed( &mut self, contribution: FundingContribution, locktime: LockTime, logger: &L, ) -> Result, QuiescentError> { @@ -13353,17 +13473,18 @@ where } if let Some(action) = self.quiescent_action.as_ref() { - // We can't initiate another splice while ours is pending, so don't bother becoming - // quiescent yet. - // TODO(splicing): Allow the splice as an RBF once supported. - let has_splice_action = matches!(action, QuiescentAction::Splice { .. }); - if has_splice_action && self.pending_splice.is_some() { - log_given_level!( - logger, - logger_level, - "Waiting for pending splice to lock before sending stfu for new splice" - ); - return None; + #[allow(irrefutable_let_patterns)] + if let QuiescentAction::Splice { contribution, .. } = action { + if self.pending_splice.is_some() { + if let Err(msg) = self.can_initiate_rbf(contribution.feerate()) { + log_given_level!( + logger, + logger_level, + "Waiting on sending stfu for splice RBF: {msg}" + ); + return None; + } + } } } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 640dc821fb4..888e9fffd9b 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -4728,6 +4728,94 @@ impl< } } + /// Initiate an RBF of a pending splice transaction for an existing channel. + /// + /// This is used after a splice has been negotiated but before it has been locked, in order + /// to bump the feerate of the funding transaction via replace-by-fee. + /// + /// # Required Feature Flags + /// + /// Initiating an RBF requires that the channel counterparty supports splicing. The + /// counterparty must be currently connected. + /// + /// # Arguments + /// + /// The RBF initiator is responsible for paying fees for common fields, shared inputs, and + /// shared outputs along with any contributed inputs and outputs. When building a + /// [`FundingContribution`], fees are estimated using `min_feerate` and must be covered by the + /// supplied inputs for splice-in or the channel balance for splice-out. If the counterparty + /// also initiates an RBF and wins the tie-break, they become the initiator and choose the + /// feerate. In that case, `max_feerate` is used to reject a feerate that is too high for our + /// contribution. + /// + /// Returns a [`FundingTemplate`] which should be used to build a [`FundingContribution`] via + /// one of its splice methods (e.g., [`FundingTemplate::splice_in_sync`]). The resulting + /// contribution must then be passed to [`ChannelManager::funding_contributed`]. + /// + /// # Events + /// + /// Once the funding transaction has been constructed, an [`Event::SplicePending`] will be + /// emitted. At this point, any inputs contributed to the splice can only be re-spent if an + /// [`Event::DiscardFunding`] is seen. + /// + /// After initial signatures have been exchanged, [`Event::FundingTransactionReadyForSigning`] + /// will be generated and [`ChannelManager::funding_transaction_signed`] should be called. + /// + /// If any failures occur while negotiating the funding transaction, an [`Event::SpliceFailed`] + /// will be emitted. Any contributed inputs no longer used will be included here and thus can + /// be re-spent. + /// + /// Once the splice has been locked by both counterparties, an [`Event::ChannelReady`] will be + /// emitted with the new funding output. At this point, a new splice can be negotiated by + /// calling `splice_channel` again on this channel. + /// + /// [`FundingContribution`]: crate::ln::funding::FundingContribution + pub fn rbf_channel( + &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, min_feerate: FeeRate, + max_feerate: FeeRate, + ) -> Result { + let per_peer_state = self.per_peer_state.read().unwrap(); + + let peer_state_mutex = match per_peer_state + .get(counterparty_node_id) + .ok_or_else(|| APIError::no_such_peer(counterparty_node_id)) + { + Ok(p) => p, + Err(e) => return Err(e), + }; + + let mut peer_state = peer_state_mutex.lock().unwrap(); + if !peer_state.latest_features.supports_splicing() { + return Err(APIError::ChannelUnavailable { + err: "Peer does not support splicing".to_owned(), + }); + } + if !peer_state.latest_features.supports_quiescence() { + return Err(APIError::ChannelUnavailable { + err: "Peer does not support quiescence, a splicing prerequisite".to_owned(), + }); + } + + // Look for the channel + match peer_state.channel_by_id.entry(*channel_id) { + hash_map::Entry::Occupied(chan_phase_entry) => { + if let Some(chan) = chan_phase_entry.get().as_funded() { + chan.rbf_channel(min_feerate, max_feerate) + } else { + Err(APIError::ChannelUnavailable { + err: format!( + "Channel with id {} is not funded, cannot RBF splice", + channel_id + ), + }) + } + }, + hash_map::Entry::Vacant(_) => { + Err(APIError::no_such_channel_for_peer(channel_id, counterparty_node_id)) + }, + } + } + #[cfg(test)] pub(crate) fn abandon_splice( &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, From 5873660a0021a07705a743f4f0de31a83a5ef088 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 18 Feb 2026 20:36:33 -0600 Subject: [PATCH 163/627] Send tx_init_rbf instead of splice_init when a splice is pending When the quiescence initiator has a pending splice and enters the stfu handler with a QuiescentAction::Splice, send tx_init_rbf to bump the existing splice's feerate rather than starting a new splice_init. This reuses the same QuiescentAction::Splice variant for both initial splices and RBF attempts -- the stfu handler distinguishes them by checking whether pending_splice already exists. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 48 +++++++++++++++++-------- lightning/src/ln/channelmanager.rs | 7 ++++ lightning/src/ln/splicing_tests.rs | 57 ++++++++++++++++++++++-------- 3 files changed, 82 insertions(+), 30 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 29efe9a1ce2..d24e60416b4 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -3073,6 +3073,7 @@ impl From for QuiescentError { pub(crate) enum StfuResponse { Stfu(msgs::Stfu), SpliceInit(msgs::SpliceInit), + TxInitRbf(msgs::TxInitRbf), } /// Wrapper around a [`Transaction`] useful for caching the result of [`Transaction::compute_txid`]. @@ -12083,6 +12084,33 @@ where } } + fn send_tx_init_rbf(&mut self, context: FundingNegotiationContext) -> msgs::TxInitRbf { + let pending_splice = + self.pending_splice.as_mut().expect("pending_splice should exist for RBF"); + debug_assert!(!pending_splice.negotiated_candidates.is_empty()); + + let new_holder_funding_key = pending_splice + .negotiated_candidates + .first() + .unwrap() + .get_holder_pubkeys() + .funding_pubkey; + + let funding_feerate_per_kw = context.funding_feerate_sat_per_1000_weight; + let funding_contribution_satoshis = context.our_funding_contribution.to_sat(); + let locktime = context.funding_tx_locktime.to_consensus_u32(); + + pending_splice.funding_negotiation = + Some(FundingNegotiation::AwaitingAck { context, new_holder_funding_key }); + + msgs::TxInitRbf { + channel_id: self.context.channel_id, + locktime, + feerate_sat_per_1000_weight: funding_feerate_per_kw, + funding_output_contribution: Some(funding_contribution_satoshis), + } + } + #[cfg(test)] pub fn abandon_splice( &mut self, @@ -13404,21 +13432,6 @@ where )); }, Some(QuiescentAction::Splice { contribution, locktime }) => { - // TODO(splicing): If the splice has been negotiated but has not been locked, we - // can RBF here to add the contribution. - if self.pending_splice.is_some() { - debug_assert!(false); - self.quiescent_action = - Some(QuiescentAction::Splice { contribution, locktime }); - - return Err(ChannelError::WarnAndDisconnect( - format!( - "Channel {} cannot be spliced as it already has a splice pending", - self.context.channel_id(), - ), - )); - } - let prev_funding_input = self.funding.to_splice_funding_input(); let our_funding_contribution = contribution.net_value(); let funding_feerate_per_kw = contribution.feerate().to_sat_per_kwu() as u32; @@ -13434,6 +13447,11 @@ where our_funding_outputs, }; + if self.pending_splice.is_some() { + let tx_init_rbf = self.send_tx_init_rbf(context); + return Ok(Some(StfuResponse::TxInitRbf(tx_init_rbf))); + } + let splice_init = self.send_splice_init(context); return Ok(Some(StfuResponse::SpliceInit(splice_init))); }, diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 888e9fffd9b..2c416e42486 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -12691,6 +12691,13 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }); Ok(true) }, + Some(StfuResponse::TxInitRbf(msg)) => { + peer_state.pending_msg_events.push(MessageSendEvent::SendTxInitRbf { + node_id: *counterparty_node_id, + msg, + }); + Ok(true) + }, } } else { let msg = "Peer sent `stfu` for an unfunded channel"; diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 9adc318a4d8..d0fb29d1923 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -4096,14 +4096,15 @@ fn test_splice_acceptor_disconnect_emits_events() { #[test] fn test_splice_rbf_acceptor_basic() { // Test the happy path for accepting an RBF of a pending splice transaction. - // After completing a splice-in, re-enter quiescence and process tx_init_rbf - // from the counterparty, responding with tx_ack_rbf. + // After completing a splice-in, initiate an RBF attempt with a higher feerate, + // going through the tx_init_rbf → tx_ack_rbf flow. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); let initial_channel_value_sat = 100_000; let (_, _, channel_id, _) = @@ -4117,18 +4118,27 @@ fn test_splice_rbf_acceptor_basic() { let (_splice_tx, _new_funding_script) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); - // Re-enter quiescence for RBF (node 0 initiates). - reenter_quiescence(&nodes[0], &nodes[1], &channel_id); - - // Node 0 sends tx_init_rbf with feerate satisfying the 25/24 rule. + // Initiate an RBF with a feerate satisfying the 25/24 rule. // Original feerate was FEERATE_FLOOR_SATS_PER_KW (253). 253 * 25 / 24 = 263.54, so 264 works. - let rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); - let tx_init_rbf = msgs::TxInitRbf { - channel_id, - locktime: 0, - feerate_sat_per_1000_weight: rbf_feerate as u32, - funding_output_contribution: Some(added_value.to_sat() as i64), - }; + provide_utxo_reserves(&nodes, 2, added_value * 2); + + let rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); + let funding_template = + nodes[0].node.rbf_channel(&channel_id, &node_id_1, rbf_feerate, FeeRate::MAX).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let funding_contribution = funding_template.splice_in_sync(added_value, &wallet).unwrap(); + + nodes[0].node.funding_contributed(&channel_id, &node_id_1, funding_contribution, None).unwrap(); + + let stfu_a = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_a); + let stfu_b = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_b); + + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + assert_eq!(tx_init_rbf.channel_id, channel_id); + assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, rbf_feerate_sat_per_kwu as u32); nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); let tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0); @@ -4140,13 +4150,15 @@ fn test_splice_rbf_acceptor_basic() { #[test] fn test_splice_rbf_insufficient_feerate() { - // Test that tx_init_rbf with an insufficient feerate (less than 25/24 of previous) is rejected. + // Test that rbf_channel rejects a feerate that doesn't satisfy the 25/24 rule, and that the + // acceptor also rejects tx_init_rbf with an insufficient feerate from a misbehaving peer. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); let initial_channel_value_sat = 100_000; let (_, _, channel_id, _) = @@ -4160,7 +4172,22 @@ fn test_splice_rbf_insufficient_feerate() { let (_splice_tx, _new_funding_script) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); - // Re-enter quiescence. + // Initiator-side: rbf_channel rejects an insufficient feerate. + // Original feerate was 253. Using exactly 253 should fail since 253 * 24 < 253 * 25. + let same_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let err = + nodes[0].node.rbf_channel(&channel_id, &node_id_1, same_feerate, FeeRate::MAX).unwrap_err(); + assert_eq!( + err, + APIError::APIMisuseError { + err: format!( + "Channel {} RBF feerate {} is less than 25/24 of the previous feerate {}", + channel_id, FEERATE_FLOOR_SATS_PER_KW, FEERATE_FLOOR_SATS_PER_KW, + ), + } + ); + + // Acceptor-side: tx_init_rbf with an insufficient feerate is also rejected. reenter_quiescence(&nodes[0], &nodes[1], &channel_id); // Send tx_init_rbf with feerate that does NOT satisfy the 25/24 rule. From b07bfff8b9f0188a73dfd95a36d14ec2e19ec3ed Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 18 Feb 2026 20:38:06 -0600 Subject: [PATCH 164/627] Handle tx_ack_rbf on the initiator side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After sending tx_init_rbf, the initiator receives tx_ack_rbf from the acceptor. Implement the handler to validate the response and begin interactive transaction construction for the RBF funding transaction. Only clear the interactive signing session in `reset_pending_splice_state` when the current funding negotiation is in `AwaitingSignatures`. When an earlier round completed signing and a later RBF round is in `AwaitingAck` or `ConstructingTransaction`, the session belongs to the prior round and must be preserved. Otherwise, disconnecting mid-RBF would destroy the completed prior round's signing session and fire a false debug assertion. Update test_splice_rbf_acceptor_basic to exercise the full initiator flow: rbf_channel → funding_contributed → STFU exchange → tx_init_rbf → tx_ack_rbf → interactive TX → signing → mining → splice_locked. This replaces the previous test that manually constructed tx_init_rbf. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 226 ++++++++++++++++++++++------- lightning/src/ln/channelmanager.rs | 59 +++++++- lightning/src/ln/splicing_tests.rs | 219 ++++++++++++++++++++++++---- 3 files changed, 417 insertions(+), 87 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index d24e60416b4..96a147a9e6b 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2969,6 +2969,32 @@ impl FundingNegotiation { FundingNegotiation::AwaitingSignatures { is_initiator, .. } => *is_initiator, } } + fn for_initiator( + funding: FundingScope, context: &ChannelContext, + funding_negotiation_context: FundingNegotiationContext, entropy_source: &ES, + holder_node_id: &PublicKey, + ) -> (FundingNegotiation, Option) { + let funding_feerate_sat_per_1000_weight = + funding_negotiation_context.funding_feerate_sat_per_1000_weight; + let (interactive_tx_constructor, tx_msg_opt) = funding_negotiation_context + .into_interactive_tx_constructor( + context, + &funding, + entropy_source, + holder_node_id.clone(), + ); + debug_assert!(tx_msg_opt.is_some()); + + ( + FundingNegotiation::ConstructingTransaction { + funding, + funding_feerate_sat_per_1000_weight, + interactive_tx_constructor, + }, + tx_msg_opt, + ) + } + fn for_acceptor( funding: FundingScope, context: &ChannelContext, entropy_source: &ES, holder_node_id: &PublicKey, our_funding_contribution: SignedAmount, @@ -3003,6 +3029,43 @@ impl FundingNegotiation { } impl PendingFunding { + fn awaiting_ack_context( + &self, msg_name: &str, + ) -> Result<(&FundingNegotiationContext, &PublicKey), ChannelError> { + match &self.funding_negotiation { + Some(FundingNegotiation::AwaitingAck { context, new_holder_funding_key }) => { + Ok((context, new_holder_funding_key)) + }, + Some(FundingNegotiation::ConstructingTransaction { .. }) + | Some(FundingNegotiation::AwaitingSignatures { .. }) => Err(ChannelError::WarnAndDisconnect( + format!("Got unexpected {}; funding negotiation already in progress", msg_name,), + )), + None => Err(ChannelError::Ignore(format!( + "Got unexpected {}; no funding negotiation in progress", + msg_name, + ))), + } + } + + fn take_awaiting_ack_context( + &mut self, msg_name: &str, + ) -> Result { + match self.funding_negotiation.take() { + Some(FundingNegotiation::AwaitingAck { context, .. }) => Ok(context), + Some(other) => { + self.funding_negotiation = Some(other); + Err(ChannelError::WarnAndDisconnect(format!( + "Got unexpected {}; funding negotiation already in progress", + msg_name, + ))) + }, + None => Err(ChannelError::Ignore(format!( + "Got unexpected {}; no funding negotiation in progress", + msg_name, + ))), + } + } + fn check_get_splice_locked( &mut self, context: &ChannelContext, confirmed_funding_index: usize, height: u32, ) -> Option { @@ -6791,15 +6854,27 @@ where fn reset_pending_splice_state(&mut self) -> Option { debug_assert!(self.should_reset_pending_splice_state(true)); - debug_assert!( - self.context.interactive_tx_signing_session.is_none() - || !self - .context - .interactive_tx_signing_session - .as_ref() - .expect("We have a pending splice awaiting signatures") - .has_received_commitment_signed() - ); + + // Only clear the signing session if the current round is mid-signing. When an earlier + // round completed signing and a later RBF round is in AwaitingAck or + // ConstructingTransaction, the session belongs to the prior round and must be preserved. + let current_is_awaiting_signatures = self + .pending_splice + .as_ref() + .and_then(|ps| ps.funding_negotiation.as_ref()) + .map(|fn_| matches!(fn_, FundingNegotiation::AwaitingSignatures { .. })) + .unwrap_or(false); + if current_is_awaiting_signatures { + debug_assert!( + self.context.interactive_tx_signing_session.is_none() + || !self + .context + .interactive_tx_signing_session + .as_ref() + .expect("We have a pending splice awaiting signatures") + .has_received_commitment_signed() + ); + } let splice_funding_failed = maybe_create_splice_funding_failed!( self, @@ -6813,7 +6888,9 @@ where } self.context.channel_state.clear_quiescent(); - self.context.interactive_tx_signing_session.take(); + if current_is_awaiting_signatures { + self.context.interactive_tx_signing_session.take(); + } splice_funding_failed } @@ -12518,6 +12595,71 @@ where }) } + fn validate_tx_ack_rbf(&self, msg: &msgs::TxAckRbf) -> Result { + let pending_splice = self + .pending_splice + .as_ref() + .ok_or_else(|| ChannelError::Ignore("Channel is not in pending splice".to_owned()))?; + + let (funding_negotiation_context, _) = pending_splice.awaiting_ack_context("tx_ack_rbf")?; + + let our_funding_contribution = funding_negotiation_context.our_funding_contribution; + let their_funding_contribution = match msg.funding_output_contribution { + Some(value) => SignedAmount::from_sat(value), + None => SignedAmount::ZERO, + }; + self.validate_splice_contributions(our_funding_contribution, their_funding_contribution) + .map_err(|e| ChannelError::WarnAndDisconnect(e))?; + + let last_candidate = pending_splice.negotiated_candidates.last().ok_or_else(|| { + ChannelError::WarnAndDisconnect("No negotiated splice candidates for RBF".to_owned()) + })?; + let holder_pubkeys = last_candidate.get_holder_pubkeys().clone(); + let counterparty_funding_pubkey = *last_candidate.counterparty_funding_pubkey(); + + Ok(FundingScope::for_splice( + &self.funding, + &self.context, + our_funding_contribution, + their_funding_contribution, + counterparty_funding_pubkey, + holder_pubkeys, + )) + } + + pub(crate) fn tx_ack_rbf( + &mut self, msg: &msgs::TxAckRbf, entropy_source: &ES, holder_node_id: &PublicKey, + logger: &L, + ) -> Result, ChannelError> { + let rbf_funding = self.validate_tx_ack_rbf(msg)?; + + log_info!( + logger, + "Starting RBF funding negotiation for channel {} after receiving tx_ack_rbf; channel value: {} sats", + self.context.channel_id, + rbf_funding.get_value_satoshis(), + ); + + let pending_splice = self + .pending_splice + .as_mut() + .expect("pending_splice existence validated in validate_tx_ack_rbf"); + let funding_negotiation_context = pending_splice + .take_awaiting_ack_context("tx_ack_rbf") + .expect("awaiting ack state validated in validate_tx_ack_rbf"); + + let (funding_negotiation, tx_msg_opt) = FundingNegotiation::for_initiator( + rbf_funding, + &self.context, + funding_negotiation_context, + entropy_source, + holder_node_id, + ); + pending_splice.funding_negotiation = Some(funding_negotiation); + + Ok(tx_msg_opt) + } + pub(crate) fn splice_ack( &mut self, msg: &msgs::SpliceAck, entropy_source: &ES, holder_node_id: &PublicKey, logger: &L, @@ -12532,36 +12674,24 @@ where self.funding.get_value_satoshis(), ); - let pending_splice = - self.pending_splice.as_mut().expect("We should have returned an error earlier!"); - // TODO: Good candidate for a let else statement once MSRV >= 1.65 - let funding_negotiation_context = - if let Some(FundingNegotiation::AwaitingAck { context, .. }) = - pending_splice.funding_negotiation.take() - { - context - } else { - panic!("We should have returned an error earlier!"); - }; - - let funding_feerate_sat_per_1000_weight = - funding_negotiation_context.funding_feerate_sat_per_1000_weight; - let (interactive_tx_constructor, tx_msg_opt) = funding_negotiation_context - .into_interactive_tx_constructor( - &self.context, - &splice_funding, - entropy_source, - holder_node_id.clone(), - ); - debug_assert!(tx_msg_opt.is_some()); - debug_assert!(self.context.interactive_tx_signing_session.is_none()); - pending_splice.funding_negotiation = Some(FundingNegotiation::ConstructingTransaction { - funding: splice_funding, - funding_feerate_sat_per_1000_weight, - interactive_tx_constructor, - }); + let pending_splice = self + .pending_splice + .as_mut() + .expect("pending_splice existence validated in validate_splice_ack"); + let funding_negotiation_context = pending_splice + .take_awaiting_ack_context("splice_ack") + .expect("awaiting ack state validated in validate_splice_ack"); + + let (funding_negotiation, tx_msg_opt) = FundingNegotiation::for_initiator( + splice_funding, + &self.context, + funding_negotiation_context, + entropy_source, + holder_node_id, + ); + pending_splice.funding_negotiation = Some(funding_negotiation); Ok(tx_msg_opt) } @@ -12574,24 +12704,8 @@ where .as_ref() .ok_or_else(|| ChannelError::Ignore("Channel is not in pending splice".to_owned()))?; - let (funding_negotiation_context, new_holder_funding_key) = match &pending_splice - .funding_negotiation - { - Some(FundingNegotiation::AwaitingAck { context, new_holder_funding_key, .. }) => { - (context, new_holder_funding_key) - }, - Some(FundingNegotiation::ConstructingTransaction { .. }) - | Some(FundingNegotiation::AwaitingSignatures { .. }) => { - return Err(ChannelError::WarnAndDisconnect( - "Got unexpected splice_ack; splice negotiation already in progress".to_owned(), - )); - }, - None => { - return Err(ChannelError::Ignore( - "Got unexpected splice_ack; no splice negotiation in progress".to_owned(), - )); - }, - }; + let (funding_negotiation_context, new_holder_funding_key) = + pending_splice.awaiting_ack_context("splice_ack")?; let our_funding_contribution = funding_negotiation_context.our_funding_contribution; let their_funding_contribution = SignedAmount::from_sat(msg.funding_contribution_satoshis); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 2c416e42486..330ed386628 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -13064,6 +13064,50 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } } + fn internal_tx_ack_rbf( + &self, counterparty_node_id: &PublicKey, msg: &msgs::TxAckRbf, + ) -> Result<(), MsgHandleErrInternal> { + let per_peer_state = self.per_peer_state.read().unwrap(); + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) + })?; + let mut peer_state_lock = peer_state_mutex.lock().unwrap(); + let peer_state = &mut *peer_state_lock; + + // Look for the channel + match peer_state.channel_by_id.entry(msg.channel_id) { + hash_map::Entry::Vacant(_) => Err(MsgHandleErrInternal::no_such_channel_for_peer( + counterparty_node_id, + msg.channel_id, + )), + hash_map::Entry::Occupied(mut chan_entry) => { + if let Some(ref mut funded_channel) = chan_entry.get_mut().as_funded_mut() { + let tx_ack_rbf_res = funded_channel.tx_ack_rbf( + msg, + &self.entropy_source, + &self.get_our_node_id(), + &self.logger, + ); + let tx_msg_opt = + try_channel_entry!(self, peer_state, tx_ack_rbf_res, chan_entry); + if let Some(tx_msg) = tx_msg_opt { + peer_state + .pending_msg_events + .push(tx_msg.into_msg_send_event(counterparty_node_id.clone())); + } + Ok(()) + } else { + try_channel_entry!( + self, + peer_state, + Err(ChannelError::close("Channel is not funded, cannot RBF splice".into())), + chan_entry + ) + } + }, + } + } + fn internal_splice_locked( &self, counterparty_node_id: &PublicKey, msg: &msgs::SpliceLocked, ) -> Result<(), MsgHandleErrInternal> { @@ -16485,11 +16529,16 @@ impl< } fn handle_tx_ack_rbf(&self, counterparty_node_id: PublicKey, msg: &msgs::TxAckRbf) { - let err = Err(MsgHandleErrInternal::send_err_msg_no_close( - "Dual-funded channels not supported".to_owned(), - msg.channel_id.clone(), - )); - let _: Result<(), _> = self.handle_error(err, counterparty_node_id); + let _persistence_guard = PersistenceNotifierGuard::optionally_notify(self, || { + let res = self.internal_tx_ack_rbf(&counterparty_node_id, msg); + let persist = match &res { + Err(e) if e.closes_channel() => NotifyOption::DoPersist, + Err(_) => NotifyOption::SkipPersistHandleEvents, + Ok(()) => NotifyOption::SkipPersistHandleEvents, + }; + let _ = self.handle_error(res, counterparty_node_id); + persist + }); } fn handle_tx_abort(&self, counterparty_node_id: PublicKey, msg: &msgs::TxAbort) { diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index d0fb29d1923..b09f5aaa4f2 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -234,6 +234,21 @@ pub fn do_initiate_splice_in<'a, 'b, 'c, 'd>( funding_contribution } +pub fn do_initiate_rbf_splice_in<'a, 'b, 'c, 'd>( + node: &'a Node<'b, 'c, 'd>, counterparty: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, + value_added: Amount, feerate: FeeRate, +) -> FundingContribution { + let node_id_counterparty = counterparty.node.get_our_node_id(); + let funding_template = + node.node.rbf_channel(&channel_id, &node_id_counterparty, feerate, FeeRate::MAX).unwrap(); + let wallet = WalletSync::new(Arc::clone(&node.wallet_source), node.logger); + let funding_contribution = funding_template.splice_in_sync(value_added, &wallet).unwrap(); + node.node + .funding_contributed(&channel_id, &node_id_counterparty, funding_contribution.clone(), None) + .unwrap(); + funding_contribution +} + pub fn initiate_splice_out<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, outputs: Vec, @@ -312,6 +327,25 @@ pub fn complete_splice_handshake<'a, 'b, 'c, 'd>( new_funding_script } +pub fn complete_rbf_handshake<'a, 'b, 'c, 'd>( + initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, +) -> msgs::TxAckRbf { + let node_id_initiator = initiator.node.get_our_node_id(); + let node_id_acceptor = acceptor.node.get_our_node_id(); + + let stfu_init = get_event_msg!(initiator, MessageSendEvent::SendStfu, node_id_acceptor); + acceptor.node.handle_stfu(node_id_initiator, &stfu_init); + let stfu_ack = get_event_msg!(acceptor, MessageSendEvent::SendStfu, node_id_initiator); + initiator.node.handle_stfu(node_id_acceptor, &stfu_ack); + + let tx_init_rbf = get_event_msg!(initiator, MessageSendEvent::SendTxInitRbf, node_id_acceptor); + acceptor.node.handle_tx_init_rbf(node_id_initiator, &tx_init_rbf); + let tx_ack_rbf = get_event_msg!(acceptor, MessageSendEvent::SendTxAckRbf, node_id_initiator); + initiator.node.handle_tx_ack_rbf(node_id_acceptor, &tx_ack_rbf); + + tx_ack_rbf +} + pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, initiator_contribution: FundingContribution, new_funding_script: ScriptBuf, @@ -4095,9 +4129,10 @@ fn test_splice_acceptor_disconnect_emits_events() { #[test] fn test_splice_rbf_acceptor_basic() { - // Test the happy path for accepting an RBF of a pending splice transaction. - // After completing a splice-in, initiate an RBF attempt with a higher feerate, - // going through the tx_init_rbf → tx_ack_rbf flow. + // Test the full end-to-end flow for RBF of a pending splice transaction. + // Complete a splice-in, then use rbf_channel API to initiate an RBF attempt + // with a higher feerate, going through the full tx_init_rbf → tx_ack_rbf → + // interactive TX → signing → mining → splice_locked flow. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); @@ -4113,39 +4148,117 @@ fn test_splice_rbf_acceptor_basic() { let added_value = Amount::from_sat(50_000); provide_utxo_reserves(&nodes, 2, added_value * 2); - // Complete a splice-in from node 0. + // Step 1: Complete a splice-in from node 0. let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); - let (_splice_tx, _new_funding_script) = + // Save the pre-splice funding outpoint before splice_channel modifies the monitor. + let original_funding_outpoint = nodes[0] + .chain_monitor + .chain_monitor + .get_monitor(channel_id) + .map(|monitor| (monitor.get_funding_txo(), monitor.get_funding_script())) + .unwrap(); + + let (first_splice_tx, new_funding_script) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); - // Initiate an RBF with a feerate satisfying the 25/24 rule. - // Original feerate was FEERATE_FLOOR_SATS_PER_KW (253). 253 * 25 / 24 = 263.54, so 264 works. + // Step 2: Provide more UTXO reserves for the RBF attempt. provide_utxo_reserves(&nodes, 2, added_value * 2); + // Step 3: Use rbf_channel API to initiate the RBF. + // Original feerate was FEERATE_FLOOR_SATS_PER_KW (253). 253 * 25 / 24 = 263.54, so 264 works. let rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); - let funding_template = - nodes[0].node.rbf_channel(&channel_id, &node_id_1, rbf_feerate, FeeRate::MAX).unwrap(); - let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let funding_contribution = funding_template.splice_in_sync(added_value, &wallet).unwrap(); + let funding_contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); - nodes[0].node.funding_contributed(&channel_id, &node_id_1, funding_contribution, None).unwrap(); + // Steps 4-8: STFU exchange → tx_init_rbf → tx_ack_rbf. + complete_rbf_handshake(&nodes[0], &nodes[1]); - let stfu_a = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); - nodes[1].node.handle_stfu(node_id_0, &stfu_a); - let stfu_b = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); - nodes[0].node.handle_stfu(node_id_1, &stfu_b); + // Step 9: Complete interactive funding negotiation. + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + funding_contribution, + new_funding_script.clone(), + ); - let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); - assert_eq!(tx_init_rbf.channel_id, channel_id); - assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, rbf_feerate_sat_per_kwu as u32); + // Step 10: Sign and broadcast. + let (rbf_tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + assert!(splice_locked.is_none()); - nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); - let tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0); + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Step 11: Mine and lock. + mine_transaction(&nodes[0], &rbf_tx); + mine_transaction(&nodes[1], &rbf_tx); - assert_eq!(tx_ack_rbf.channel_id, channel_id); - // Acceptor doesn't contribute funds in the RBF. - assert_eq!(tx_ack_rbf.funding_output_contribution, None); + // Lock the RBF splice. We can't use lock_splice_after_blocks directly because the splice + // promotion generates DiscardFunding events for the old (replaced) splice candidate. + connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1); + connect_blocks(&nodes[1], ANTI_REORG_DELAY - 1); + + let splice_locked_b = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1); + nodes[1].node.handle_splice_locked(node_id_0, &splice_locked_b); + + let mut msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + let splice_locked_a = + if let MessageSendEvent::SendSpliceLocked { msg, .. } = msg_events.remove(0) { + msg + } else { + panic!("Expected SendSpliceLocked, got {:?}", msg_events[0]); + }; + let announcement_sigs_b = + if let MessageSendEvent::SendAnnouncementSignatures { msg, .. } = msg_events.remove(0) { + msg + } else { + panic!("Expected SendAnnouncementSignatures"); + }; + nodes[0].node.handle_splice_locked(node_id_1, &splice_locked_a); + nodes[0].node.handle_announcement_signatures(node_id_1, &announcement_sigs_b); + + // Expect ChannelReady + DiscardFunding for the old splice candidate on both nodes. + let events_a = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events_a.len(), 2, "{events_a:?}"); + assert!(matches!(events_a[0], Event::ChannelReady { .. })); + assert!(matches!(events_a[1], Event::DiscardFunding { .. })); + check_added_monitors(&nodes[0], 1); + + let events_b = nodes[1].node.get_and_clear_pending_events(); + assert_eq!(events_b.len(), 2, "{events_b:?}"); + assert!(matches!(events_b[0], Event::ChannelReady { .. })); + assert!(matches!(events_b[1], Event::DiscardFunding { .. })); + check_added_monitors(&nodes[1], 1); + + // Complete the announcement exchange. + let mut msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + if let MessageSendEvent::SendAnnouncementSignatures { msg, .. } = msg_events.remove(0) { + nodes[1].node.handle_announcement_signatures(node_id_0, &msg); + } else { + panic!("Expected SendAnnouncementSignatures"); + } + assert!(matches!(msg_events.remove(0), MessageSendEvent::BroadcastChannelAnnouncement { .. })); + + let mut msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + assert!(matches!(msg_events.remove(0), MessageSendEvent::BroadcastChannelAnnouncement { .. })); + + // Clean up old watched outpoints from the chain source. + // The original channel's funding outpoint and the first (replaced) splice's funding outpoint + // are still being watched but are no longer tracked by the deserialized monitor. + let (orig_outpoint, orig_script) = original_funding_outpoint; + let first_splice_funding_idx = + first_splice_tx.output.iter().position(|o| o.script_pubkey == new_funding_script).unwrap(); + let first_splice_outpoint = + OutPoint { txid: first_splice_tx.compute_txid(), index: first_splice_funding_idx as u16 }; + for node in &nodes { + node.chain_source.remove_watched_txn_and_outputs(orig_outpoint, orig_script.clone()); + node.chain_source + .remove_watched_txn_and_outputs(first_splice_outpoint, new_funding_script.clone()); + } } #[test] @@ -4190,8 +4303,6 @@ fn test_splice_rbf_insufficient_feerate() { // Acceptor-side: tx_init_rbf with an insufficient feerate is also rejected. reenter_quiescence(&nodes[0], &nodes[1], &channel_id); - // Send tx_init_rbf with feerate that does NOT satisfy the 25/24 rule. - // Original feerate was 253. Using exactly 253 should fail since 253 * 24 < 253 * 25. let tx_init_rbf = msgs::TxInitRbf { channel_id, locktime: 0, @@ -4418,3 +4529,59 @@ fn test_splice_rbf_zeroconf_rejected() { _ => panic!("Expected HandleError, got {:?}", msg_events[0]), } } + +#[test] +fn test_splice_rbf_not_quiescence_initiator() { + // Test that tx_init_rbf from the non-quiescence-initiator is rejected because the + // quiescence initiator's RBF flow has already set funding_negotiation to AwaitingAck. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Complete a splice-in from node 0. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_splice_tx, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Provide more UTXO reserves for the RBF attempt. + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Initiate RBF from node 0 (quiescence initiator). + let rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); + let _funding_contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + + // STFU exchange: node 0 initiates quiescence. + let stfu_init = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_init); + let stfu_ack = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_ack); + + // Node 0 sends tx_init_rbf as the quiescence initiator — grab and discard. + let _tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + + // Now craft a competing tx_init_rbf from node 1 (the non-initiator). + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: 500, + funding_output_contribution: Some(added_value.to_sat() as i64), + }; + + nodes[0].node.handle_tx_init_rbf(node_id_1, &tx_init_rbf); + + let tx_abort = get_event_msg!(nodes[0], MessageSendEvent::SendTxAbort, node_id_1); + assert_eq!(tx_abort.channel_id, channel_id); +} From d8081559a8e3f61a59d480cfe12a580fcf09fbf5 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 18 Feb 2026 21:51:37 -0600 Subject: [PATCH 165/627] Allow acceptor contribution to RBF splice via tx_init_rbf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the tx_init_rbf acceptor always contributed zero to the RBF transaction. This is incorrect when both parties try to RBF simultaneously and one loses the quiescence tie-breaker — the loser becomes the acceptor but still has a pending QuiescentAction::Splice with inputs/outputs that should be included in the RBF transaction. Consume the acceptor's QuiescentAction in the tx_init_rbf handler, just as is already done in the splice_init handler, and report the contribution in the TxAckRbf response. --- .../src/upgrade_downgrade_tests.rs | 2 +- lightning/src/ln/channel.rs | 33 +- lightning/src/ln/splicing_tests.rs | 462 ++++++++++++++---- lightning/src/util/test_utils.rs | 4 + 4 files changed, 403 insertions(+), 98 deletions(-) diff --git a/lightning-tests/src/upgrade_downgrade_tests.rs b/lightning-tests/src/upgrade_downgrade_tests.rs index f68615dbb87..7f607bba848 100644 --- a/lightning-tests/src/upgrade_downgrade_tests.rs +++ b/lightning-tests/src/upgrade_downgrade_tests.rs @@ -466,7 +466,7 @@ fn do_test_0_1_htlc_forward_after_splice(fail_htlc: bool) { } let splice_locked = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_b_id); - lock_splice(&nodes[0], &nodes[1], &splice_locked, false); + lock_splice(&nodes[0], &nodes[1], &splice_locked, false, &[]); for node in nodes.iter() { connect_blocks(node, EXTRA_BLOCKS_BEFORE_FAIL - ANTI_REORG_DELAY); diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 96a147a9e6b..5587d429b24 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12562,9 +12562,26 @@ where &mut self, msg: &msgs::TxInitRbf, entropy_source: &ES, holder_node_id: &PublicKey, fee_estimator: &LowerBoundedFeeEstimator, logger: &L, ) -> Result { - let our_funding_contribution = SignedAmount::ZERO; - let rbf_funding = - self.validate_tx_init_rbf(msg, our_funding_contribution, fee_estimator)?; + let feerate = FeeRate::from_sat_per_kwu(msg.feerate_sat_per_1000_weight as u64); + let (our_funding_contribution, holder_balance) = + self.resolve_queued_contribution(feerate, logger); + + let rbf_funding = self.validate_tx_init_rbf( + msg, + our_funding_contribution.unwrap_or(SignedAmount::ZERO), + fee_estimator, + )?; + + let (our_funding_inputs, our_funding_outputs) = if our_funding_contribution.is_some() { + self.take_queued_funding_contribution() + .expect("queued_funding_contribution was Some") + .for_acceptor_at_feerate(feerate, holder_balance.unwrap()) + .expect("feerate compatibility already checked") + .into_tx_parts() + } else { + Default::default() + }; + let our_funding_contribution = our_funding_contribution.unwrap_or(SignedAmount::ZERO); log_info!( logger, @@ -12583,15 +12600,19 @@ where prev_funding_input, msg.locktime, msg.feerate_sat_per_1000_weight, - Vec::new(), - Vec::new(), + our_funding_inputs, + our_funding_outputs, ); let pending_splice = self.pending_splice.as_mut().expect("pending_splice should exist"); pending_splice.funding_negotiation = Some(funding_negotiation); Ok(msgs::TxAckRbf { channel_id: self.context.channel_id, - funding_output_contribution: None, + funding_output_contribution: if our_funding_contribution != SignedAmount::ZERO { + Some(our_funding_contribution.to_sat()) + } else { + None + }, }) } diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index b09f5aaa4f2..f7b867ca928 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -40,7 +40,8 @@ use bitcoin::secp256k1::ecdsa::Signature; use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use bitcoin::transaction::Version; use bitcoin::{ - Amount, FeeRate, OutPoint as BitcoinOutPoint, Psbt, ScriptBuf, Transaction, TxOut, WPubkeyHash, + Amount, FeeRate, OutPoint as BitcoinOutPoint, Psbt, ScriptBuf, Transaction, TxOut, Txid, + WPubkeyHash, }; #[test] @@ -664,18 +665,18 @@ pub fn lock_splice_after_blocks<'a, 'b, 'c, 'd>( let node_id_b = node_b.node.get_our_node_id(); let splice_locked_for_node_b = get_event_msg!(node_a, MessageSendEvent::SendSpliceLocked, node_id_b); - lock_splice(node_a, node_b, &splice_locked_for_node_b, false) + lock_splice(node_a, node_b, &splice_locked_for_node_b, false, &[]) } pub fn lock_splice<'a, 'b, 'c, 'd>( node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>, - splice_locked_for_node_b: &msgs::SpliceLocked, is_0conf: bool, + splice_locked_for_node_b: &msgs::SpliceLocked, is_0conf: bool, expected_discard_txids: &[Txid], ) -> Option { - let (prev_funding_outpoint, prev_funding_script) = node_a + let prev_funding_txid = node_a .chain_monitor .chain_monitor .get_monitor(splice_locked_for_node_b.channel_id) - .map(|monitor| (monitor.get_funding_txo(), monitor.get_funding_script())) + .map(|monitor| monitor.get_funding_txo().txid) .unwrap(); let node_id_a = node_a.node.get_our_node_id(); @@ -707,10 +708,32 @@ pub fn lock_splice<'a, 'b, 'c, 'd>( } } - expect_channel_ready_event(&node_a, &node_id_b); - check_added_monitors(&node_a, 1); - expect_channel_ready_event(&node_b, &node_id_a); - check_added_monitors(&node_b, 1); + let mut all_discard_txids = Vec::new(); + let expected_num_events = 1 + expected_discard_txids.len(); + for node in [node_a, node_b] { + let events = node.node.get_and_clear_pending_events(); + assert_eq!(events.len(), expected_num_events, "{events:?}"); + assert!(matches!(events[0], Event::ChannelReady { .. })); + let discard_txids: Vec<_> = events[1..] + .iter() + .map(|e| match e { + Event::DiscardFunding { funding_info: FundingInfo::Tx { transaction }, .. } => { + transaction.compute_txid() + }, + Event::DiscardFunding { + funding_info: FundingInfo::OutPoint { outpoint }, .. + } => outpoint.txid, + other => panic!("Expected DiscardFunding, got {:?}", other), + }) + .collect(); + for txid in expected_discard_txids { + assert!(discard_txids.contains(txid), "Missing DiscardFunding for txid {}", txid); + } + if all_discard_txids.is_empty() { + all_discard_txids = discard_txids; + } + check_added_monitors(node, 1); + } if !is_0conf { let mut msg_events = node_a.node.get_and_clear_pending_msg_events(); @@ -735,14 +758,32 @@ pub fn lock_splice<'a, 'b, 'c, 'd>( // Remove the corresponding outputs and transactions the chain source is watching for the // old funding as it is no longer being tracked. - node_a - .chain_source - .remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script.clone()); - node_b.chain_source.remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script); + for node in [node_a, node_b] { + node.chain_source.remove_watched_by_txid(prev_funding_txid); + for txid in &all_discard_txids { + node.chain_source.remove_watched_by_txid(*txid); + } + } node_b_stfu } +pub fn lock_rbf_splice_after_blocks<'a, 'b, 'c, 'd>( + node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>, tx: &Transaction, num_blocks: u32, + expected_discard_txids: &[Txid], +) -> Option { + mine_transaction(node_a, tx); + mine_transaction(node_b, tx); + + connect_blocks(node_a, num_blocks); + connect_blocks(node_b, num_blocks); + + let node_id_b = node_b.node.get_our_node_id(); + let splice_locked_for_node_b = + get_event_msg!(node_a, MessageSendEvent::SendSpliceLocked, node_id_b); + lock_splice(node_a, node_b, &splice_locked_for_node_b, false, expected_discard_txids) +} + #[test] fn test_splice_state_reset_on_disconnect() { do_test_splice_state_reset_on_disconnect(false); @@ -2962,13 +3003,13 @@ fn do_test_splice_with_inflight_htlc_forward_and_resolution(expire_scid_pre_forw connect_blocks(node, ANTI_REORG_DELAY - 2); } let splice_locked = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1); - lock_splice(&nodes[0], &nodes[1], &splice_locked, false); + lock_splice(&nodes[0], &nodes[1], &splice_locked, false, &[]); for node in &nodes { connect_blocks(node, 1); } let splice_locked = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceLocked, node_id_2); - lock_splice(&nodes[1], &nodes[2], &splice_locked, false); + lock_splice(&nodes[1], &nodes[2], &splice_locked, false, &[]); if expire_scid_pre_forward { for node in &nodes { @@ -4150,13 +4191,6 @@ fn test_splice_rbf_acceptor_basic() { // Step 1: Complete a splice-in from node 0. let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); - // Save the pre-splice funding outpoint before splice_channel modifies the monitor. - let original_funding_outpoint = nodes[0] - .chain_monitor - .chain_monitor - .get_monitor(channel_id) - .map(|monitor| (monitor.get_funding_txo(), monitor.get_funding_script())) - .unwrap(); let (first_splice_tx, new_funding_script) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); @@ -4190,75 +4224,14 @@ fn test_splice_rbf_acceptor_basic() { expect_splice_pending_event(&nodes[0], &node_id_1); expect_splice_pending_event(&nodes[1], &node_id_0); - // Step 11: Mine and lock. - mine_transaction(&nodes[0], &rbf_tx); - mine_transaction(&nodes[1], &rbf_tx); - - // Lock the RBF splice. We can't use lock_splice_after_blocks directly because the splice - // promotion generates DiscardFunding events for the old (replaced) splice candidate. - connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1); - connect_blocks(&nodes[1], ANTI_REORG_DELAY - 1); - - let splice_locked_b = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1); - nodes[1].node.handle_splice_locked(node_id_0, &splice_locked_b); - - let mut msg_events = nodes[1].node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), 2, "{msg_events:?}"); - let splice_locked_a = - if let MessageSendEvent::SendSpliceLocked { msg, .. } = msg_events.remove(0) { - msg - } else { - panic!("Expected SendSpliceLocked, got {:?}", msg_events[0]); - }; - let announcement_sigs_b = - if let MessageSendEvent::SendAnnouncementSignatures { msg, .. } = msg_events.remove(0) { - msg - } else { - panic!("Expected SendAnnouncementSignatures"); - }; - nodes[0].node.handle_splice_locked(node_id_1, &splice_locked_a); - nodes[0].node.handle_announcement_signatures(node_id_1, &announcement_sigs_b); - - // Expect ChannelReady + DiscardFunding for the old splice candidate on both nodes. - let events_a = nodes[0].node.get_and_clear_pending_events(); - assert_eq!(events_a.len(), 2, "{events_a:?}"); - assert!(matches!(events_a[0], Event::ChannelReady { .. })); - assert!(matches!(events_a[1], Event::DiscardFunding { .. })); - check_added_monitors(&nodes[0], 1); - - let events_b = nodes[1].node.get_and_clear_pending_events(); - assert_eq!(events_b.len(), 2, "{events_b:?}"); - assert!(matches!(events_b[0], Event::ChannelReady { .. })); - assert!(matches!(events_b[1], Event::DiscardFunding { .. })); - check_added_monitors(&nodes[1], 1); - - // Complete the announcement exchange. - let mut msg_events = nodes[0].node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), 2, "{msg_events:?}"); - if let MessageSendEvent::SendAnnouncementSignatures { msg, .. } = msg_events.remove(0) { - nodes[1].node.handle_announcement_signatures(node_id_0, &msg); - } else { - panic!("Expected SendAnnouncementSignatures"); - } - assert!(matches!(msg_events.remove(0), MessageSendEvent::BroadcastChannelAnnouncement { .. })); - - let mut msg_events = nodes[1].node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), 1, "{msg_events:?}"); - assert!(matches!(msg_events.remove(0), MessageSendEvent::BroadcastChannelAnnouncement { .. })); - - // Clean up old watched outpoints from the chain source. - // The original channel's funding outpoint and the first (replaced) splice's funding outpoint - // are still being watched but are no longer tracked by the deserialized monitor. - let (orig_outpoint, orig_script) = original_funding_outpoint; - let first_splice_funding_idx = - first_splice_tx.output.iter().position(|o| o.script_pubkey == new_funding_script).unwrap(); - let first_splice_outpoint = - OutPoint { txid: first_splice_tx.compute_txid(), index: first_splice_funding_idx as u16 }; - for node in &nodes { - node.chain_source.remove_watched_txn_and_outputs(orig_outpoint, orig_script.clone()); - node.chain_source - .remove_watched_txn_and_outputs(first_splice_outpoint, new_funding_script.clone()); - } + // Step 11: Mine, lock, and verify DiscardFunding for the replaced splice candidate. + lock_rbf_splice_after_blocks( + &nodes[0], + &nodes[1], + &rbf_tx, + ANTI_REORG_DELAY - 1, + &[first_splice_tx.compute_txid()], + ); } #[test] @@ -4585,3 +4558,310 @@ fn test_splice_rbf_not_quiescence_initiator() { let tx_abort = get_event_msg!(nodes[0], MessageSendEvent::SendTxAbort, node_id_1); assert_eq!(tx_abort.channel_id, channel_id); } + +#[test] +fn test_splice_rbf_both_contribute_tiebreak() { + let min_rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let feerate = FeeRate::from_sat_per_kwu(min_rbf_feerate); + let added_value = Amount::from_sat(50_000); + do_test_splice_rbf_tiebreak(feerate, feerate, added_value, true); +} + +#[test] +fn test_splice_rbf_tiebreak_higher_feerate() { + // Node 0 (winner) uses a higher feerate than node 1 (loser). Node 1's change output is + // adjusted (reduced) to accommodate the higher feerate. Negotiation succeeds. + let min_rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + do_test_splice_rbf_tiebreak( + FeeRate::from_sat_per_kwu(min_rbf_feerate * 3), + FeeRate::from_sat_per_kwu(min_rbf_feerate), + Amount::from_sat(50_000), + true, + ); +} + +#[test] +fn test_splice_rbf_tiebreak_lower_feerate() { + // Node 0 (winner) uses a lower feerate than node 1 (loser). Since the initiator's feerate + // is below node 1's minimum, node 1 proceeds without contribution and will retry via a new + // splice at its preferred feerate after the RBF locks. + let min_rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + do_test_splice_rbf_tiebreak( + FeeRate::from_sat_per_kwu(min_rbf_feerate), + FeeRate::from_sat_per_kwu(min_rbf_feerate * 3), + Amount::from_sat(50_000), + false, + ); +} + +#[test] +fn test_splice_rbf_tiebreak_feerate_too_high() { + // Node 0 (winner) uses a feerate high enough that node 1's (loser) contribution cannot + // cover the fees. Node 1 proceeds without its contribution (QuiescentAction is preserved + // for a future splice). The RBF completes with only node 0's inputs/outputs. + let min_rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + do_test_splice_rbf_tiebreak( + FeeRate::from_sat_per_kwu(20_000), + FeeRate::from_sat_per_kwu(min_rbf_feerate), + Amount::from_sat(95_000), + false, + ); +} + +/// Runs the tie-breaker test with the given per-node feerates and node 1's splice value. +/// +/// Both nodes call `rbf_channel` + `funding_contributed`, both send STFU, and node 0 (the outbound +/// channel funder) wins the quiescence tie-break. The loser (node 1) becomes the acceptor. Whether +/// node 1 contributes to the RBF transaction depends on the feerate and budget constraints. +/// +/// `expect_acceptor_contributes` asserts the expected outcome: whether node 1's `tx_ack_rbf` +/// includes a funding output contribution. +pub fn do_test_splice_rbf_tiebreak( + rbf_feerate_0: FeeRate, rbf_feerate_1: FeeRate, node_1_splice_value: Amount, + expect_acceptor_contributes: bool, +) { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + // Complete an initial splice-in from node 0. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (first_splice_tx, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Provide more UTXOs for both nodes' RBF attempts. + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Node 0 calls rbf_channel + funding_contributed. + let node_0_funding_contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate_0); + + // Node 1 calls rbf_channel + funding_contributed. + let node_1_funding_contribution = do_initiate_rbf_splice_in( + &nodes[1], + &nodes[0], + channel_id, + node_1_splice_value, + rbf_feerate_1, + ); + + // Both nodes sent STFU (both have awaiting_quiescence set). + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + assert!(stfu_0.initiator); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + assert!(stfu_1.initiator); + + // Exchange STFUs. Node 0 is the outbound channel funder and wins the tie-break. + // Node 1 handles node 0's STFU first — it already sent its own STFU (local_stfu_sent is set), + // so this goes through the tie-break path. Node 1 loses (is_outbound = false) and becomes the + // acceptor. Its quiescent_action is preserved for the tx_init_rbf handler. + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + + // Node 0 handles node 1's STFU — it already sent its own STFU, so tie-break again. + // Node 0 wins (is_outbound = true), consumes its quiescent_action, and sends tx_init_rbf. + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + // Node 0 sends tx_init_rbf. + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + assert_eq!(tx_init_rbf.channel_id, channel_id); + assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, rbf_feerate_0.to_sat_per_kwu() as u32); + + // Node 1 handles tx_init_rbf — its quiescent_action is consumed, adjusting its contribution + // for node 0's feerate. Whether it contributes depends on the feerate and budget constraints. + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + let tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0); + assert_eq!(tx_ack_rbf.channel_id, channel_id); + + // Node 0 handles tx_ack_rbf. + let acceptor_contributes = tx_ack_rbf.funding_output_contribution.is_some(); + assert_eq!( + acceptor_contributes, expect_acceptor_contributes, + "Expected acceptor contribution: {}, got: {}", + expect_acceptor_contributes, acceptor_contributes, + ); + nodes[0].node.handle_tx_ack_rbf(node_id_1, &tx_ack_rbf); + + if acceptor_contributes { + // Capture change output values for assertions. + let node_0_change = node_0_funding_contribution + .change_output() + .expect("splice-in should have a change output") + .clone(); + let node_1_change = node_1_funding_contribution + .change_output() + .expect("splice-in should have a change output") + .clone(); + + // Complete interactive funding negotiation with both parties' inputs/outputs. + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + node_0_funding_contribution, + Some(node_1_funding_contribution), + tx_ack_rbf.funding_output_contribution.unwrap(), + new_funding_script.clone(), + ); + + // Sign (acceptor has contribution) and broadcast. + let (rbf_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( + &nodes[0], &nodes[1], false, true, + ); + assert!(splice_locked.is_none()); + + // The initiator's change output should remain unchanged (no feerate adjustment). + let initiator_change_in_tx = rbf_tx + .output + .iter() + .find(|o| o.script_pubkey == node_0_change.script_pubkey) + .expect("Initiator's change output should be in the RBF transaction"); + assert_eq!( + initiator_change_in_tx.value, node_0_change.value, + "Initiator's change output should remain unchanged", + ); + + // The acceptor's change output should be adjusted based on the feerate difference. + let acceptor_change_in_tx = rbf_tx + .output + .iter() + .find(|o| o.script_pubkey == node_1_change.script_pubkey) + .expect("Acceptor's change output should be in the RBF transaction"); + if rbf_feerate_0 <= rbf_feerate_1 { + // Initiator's feerate <= acceptor's original: the acceptor's change increases because + // is_initiator=false has lower weight, and the feerate is the same or lower. + assert!( + acceptor_change_in_tx.value > node_1_change.value, + "Acceptor's change should increase when initiator feerate ({}) <= acceptor \ + feerate ({}): adjusted {} vs original {}", + rbf_feerate_0.to_sat_per_kwu(), + rbf_feerate_1.to_sat_per_kwu(), + acceptor_change_in_tx.value, + node_1_change.value, + ); + } else { + // Initiator's feerate > acceptor's original: the higher feerate more than compensates + // for the lower weight, so the acceptor's change decreases. + assert!( + acceptor_change_in_tx.value < node_1_change.value, + "Acceptor's change should decrease when initiator feerate ({}) > acceptor \ + feerate ({}): adjusted {} vs original {}", + rbf_feerate_0.to_sat_per_kwu(), + rbf_feerate_1.to_sat_per_kwu(), + acceptor_change_in_tx.value, + node_1_change.value, + ); + } + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Mine, lock, and verify DiscardFunding for the replaced splice candidate. + lock_rbf_splice_after_blocks( + &nodes[0], + &nodes[1], + &rbf_tx, + ANTI_REORG_DELAY - 1, + &[first_splice_tx.compute_txid()], + ); + } else { + // Acceptor does not contribute — complete with only node 0's inputs/outputs. + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + node_0_funding_contribution, + None, + 0, + new_funding_script.clone(), + ); + + // Sign (acceptor has no contribution) and broadcast. + let (rbf_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( + &nodes[0], &nodes[1], false, false, + ); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Mine, lock, and verify DiscardFunding for the replaced splice candidate. + // Node 1's QuiescentAction was preserved, so after splice_locked it re-initiates + // quiescence to retry its contribution in a future splice. + let node_b_stfu = lock_rbf_splice_after_blocks( + &nodes[0], + &nodes[1], + &rbf_tx, + ANTI_REORG_DELAY - 1, + &[first_splice_tx.compute_txid()], + ); + let stfu_1 = if let Some(MessageSendEvent::SendStfu { msg, .. }) = node_b_stfu { + msg + } else { + panic!("Expected SendStfu from node 1"); + }; + assert!(stfu_1.initiator); + + // === Part 2: Node 1's preserved QuiescentAction leads to a new splice === + // + // After splice_locked, pending_splice is None. So when stfu() consumes the + // QuiescentAction, it sends SpliceInit (not TxInitRbf), starting a brand new splice. + + // Node 0 receives node 1's STFU and responds with its own STFU. + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + + // Node 1 receives STFU → quiescence established → node 1 is the initiator → + // sends SpliceInit. + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + let splice_init = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceInit, node_id_0); + + // Node 0 handles SpliceInit → sends SpliceAck. + nodes[0].node.handle_splice_init(node_id_1, &splice_init); + let splice_ack = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceAck, node_id_1); + + // Node 1 handles SpliceAck → starts interactive tx construction. + nodes[1].node.handle_splice_ack(node_id_0, &splice_ack); + + // Compute the new funding script from the splice pubkeys. + let new_funding_script_2 = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + + // Complete interactive funding negotiation with node 1 as initiator (only node 1 + // contributes). + complete_interactive_funding_negotiation( + &nodes[1], + &nodes[0], + channel_id, + node_1_funding_contribution, + new_funding_script_2, + ); + + // Sign (no acceptor contribution) and broadcast. + let (new_splice_tx, splice_locked) = + sign_interactive_funding_tx(&nodes[1], &nodes[0], false); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[1], &node_id_0); + expect_splice_pending_event(&nodes[0], &node_id_1); + + // Mine and lock. + mine_transaction(&nodes[1], &new_splice_tx); + mine_transaction(&nodes[0], &new_splice_tx); + + lock_splice_after_blocks(&nodes[1], &nodes[0], ANTI_REORG_DELAY - 1); + } +} diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 22be4367c7a..6c19af55f60 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -2143,6 +2143,10 @@ impl TestChainSource { self.watched_outputs.lock().unwrap().remove(&(outpoint, script_pubkey.clone())); self.watched_txn.lock().unwrap().remove(&(outpoint.txid, script_pubkey)); } + pub fn remove_watched_by_txid(&self, txid: Txid) { + self.watched_outputs.lock().unwrap().retain(|(op, _)| op.txid != txid); + self.watched_txn.lock().unwrap().retain(|(tid, _)| *tid != txid); + } } impl UtxoLookup for TestChainSource { From 99390f0156ff78ea80c1806a51edf992f32bf395 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 23 Feb 2026 15:17:25 -0600 Subject: [PATCH 166/627] Preserve our funding contribution across counterparty RBF attempts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the counterparty initiates an RBF and we have no new contribution queued via QuiescentAction, we must re-use our prior contribution so that our splice is not lost. Track contributions in a new field on PendingFunding so the last entry can be re-used in this scenario. Each entry stores the feerate-adjusted version because that reflects what was actually negotiated and allows correct feerate re-adjustment on subsequent RBFs. Only explicitly provided contributions (from a QuiescentAction) append to the vec. Re-used contributions are replaced in-place with the version adjusted for the new feerate so they remain accurate for further RBF rounds, without growing the vec. Add test_splice_rbf_acceptor_recontributes to verify that when the counterparty initiates an RBF and we have no new QuiescentAction queued, our prior contribution is automatically re-used so the splice is preserved. Add test_splice_rbf_recontributes_feerate_too_high to verify that when the counterparty RBFs at a feerate too high for our prior contribution to cover, the RBF is rejected rather than proceeding without our contribution. Add test for sequential RBF splice attempts Add test_splice_rbf_sequential that exercises three consecutive RBF rounds on the same splice (initial → RBF #1 → RBF #2) to verify: - Each round requires the 25/24 feerate increase (253 → 264 → 275) - DiscardFunding events reference the correct funding txid from each replaced candidate - The final RBF splice can be mined and splice_locked successfully Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 97 ++++++- lightning/src/ln/funding.rs | 11 + lightning/src/ln/splicing_tests.rs | 444 +++++++++++++++++++++++++++++ 3 files changed, 537 insertions(+), 15 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 5587d429b24..741da76d047 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2902,6 +2902,13 @@ struct PendingFunding { /// The feerate used in the last successfully negotiated funding transaction. /// Used for validating the 25/24 feerate increase rule on RBF attempts. last_funding_feerate_sat_per_1000_weight: Option, + + /// The funding contributions from all explicit splice/RBF attempts on this channel. + /// Each entry reflects the feerate-adjusted contribution that was actually used in that + /// negotiation. The last entry is re-used when the counterparty initiates an RBF and we + /// have no pending `QuiescentAction`. When re-used as acceptor, the last entry is replaced + /// with the version adjusted for the new feerate. + contributions: Vec, } impl_writeable_tlv_based!(PendingFunding, { @@ -2910,6 +2917,7 @@ impl_writeable_tlv_based!(PendingFunding, { (5, sent_funding_txid, option), (7, received_funding_txid, option), (8, last_funding_feerate_sat_per_1000_weight, option), + (10, contributions, optional_vec), }); #[derive(Debug)] @@ -12149,6 +12157,7 @@ where sent_funding_txid: None, received_funding_txid: None, last_funding_feerate_sat_per_1000_weight: None, + contributions: vec![], }); msgs::SpliceInit { @@ -12415,15 +12424,19 @@ where let splice_funding = self.validate_splice_init(msg, our_funding_contribution.unwrap_or(SignedAmount::ZERO))?; - let (our_funding_inputs, our_funding_outputs) = if our_funding_contribution.is_some() { - self.take_queued_funding_contribution() - .expect("queued_funding_contribution was Some") - .for_acceptor_at_feerate(feerate, holder_balance.unwrap()) - .expect("feerate compatibility already checked") - .into_tx_parts() - } else { - Default::default() - }; + // Adjust for the feerate and clone so we can store it for future RBF re-use. + let (adjusted_contribution, our_funding_inputs, our_funding_outputs) = + if our_funding_contribution.is_some() { + let adjusted_contribution = self + .take_queued_funding_contribution() + .expect("queued_funding_contribution was Some") + .for_acceptor_at_feerate(feerate, holder_balance.unwrap()) + .expect("feerate compatibility already checked"); + let (inputs, outputs) = adjusted_contribution.clone().into_tx_parts(); + (Some(adjusted_contribution), inputs, outputs) + } else { + (None, Default::default(), Default::default()) + }; let our_funding_contribution = our_funding_contribution.unwrap_or(SignedAmount::ZERO); log_info!( @@ -12454,6 +12467,7 @@ where received_funding_txid: None, sent_funding_txid: None, last_funding_feerate_sat_per_1000_weight: None, + contributions: adjusted_contribution.into_iter().collect(), }); Ok(msgs::SpliceAck { @@ -12563,8 +12577,30 @@ where fee_estimator: &LowerBoundedFeeEstimator, logger: &L, ) -> Result { let feerate = FeeRate::from_sat_per_kwu(msg.feerate_sat_per_1000_weight as u64); - let (our_funding_contribution, holder_balance) = - self.resolve_queued_contribution(feerate, logger); + let (queued_net_value, holder_balance) = self.resolve_queued_contribution(feerate, logger); + + // If no queued contribution, try prior contribution from previous negotiation. + // Failing here means the RBF would erase our splice — reject it. + let prior_net_value = if queued_net_value.is_some() { + None + } else if let Some(prior) = self + .pending_splice + .as_ref() + .and_then(|pending_splice| pending_splice.contributions.last()) + { + let net_value = holder_balance + .ok_or_else(|| ChannelError::Abort(AbortReason::InsufficientRbfFeerate)) + .and_then(|holder_balance| { + prior + .net_value_for_acceptor_at_feerate(feerate, holder_balance) + .map_err(|_| ChannelError::Abort(AbortReason::InsufficientRbfFeerate)) + })?; + Some(net_value) + } else { + None + }; + + let our_funding_contribution = queued_net_value.or(prior_net_value); let rbf_funding = self.validate_tx_init_rbf( msg, @@ -12572,15 +12608,40 @@ where fee_estimator, )?; - let (our_funding_inputs, our_funding_outputs) = if our_funding_contribution.is_some() { - self.take_queued_funding_contribution() + // Consume the appropriate contribution source. + let (our_funding_inputs, our_funding_outputs) = if queued_net_value.is_some() { + let adjusted_contribution = self + .take_queued_funding_contribution() .expect("queued_funding_contribution was Some") .for_acceptor_at_feerate(feerate, holder_balance.unwrap()) - .expect("feerate compatibility already checked") - .into_tx_parts() + .expect("feerate compatibility already checked"); + self.pending_splice + .as_mut() + .expect("pending_splice is Some") + .contributions + .push(adjusted_contribution.clone()); + adjusted_contribution.into_tx_parts() + } else if prior_net_value.is_some() { + let prior_contribution = self + .pending_splice + .as_mut() + .expect("pending_splice is Some") + .contributions + .pop() + .expect("prior_net_value was Some"); + let adjusted_contribution = prior_contribution + .for_acceptor_at_feerate(feerate, holder_balance.unwrap()) + .expect("feerate compatibility already checked"); + self.pending_splice + .as_mut() + .expect("pending_splice is Some") + .contributions + .push(adjusted_contribution.clone()); + adjusted_contribution.into_tx_parts() } else { Default::default() }; + let our_funding_contribution = our_funding_contribution.unwrap_or(SignedAmount::ZERO); log_info!( @@ -13567,6 +13628,7 @@ where )); }, Some(QuiescentAction::Splice { contribution, locktime }) => { + let prior_contribution = contribution.clone(); let prev_funding_input = self.funding.to_splice_funding_input(); let our_funding_contribution = contribution.net_value(); let funding_feerate_per_kw = contribution.feerate().to_sat_per_kwu() as u32; @@ -13584,10 +13646,15 @@ where if self.pending_splice.is_some() { let tx_init_rbf = self.send_tx_init_rbf(context); + self.pending_splice.as_mut().unwrap() + .contributions.push(prior_contribution); return Ok(Some(StfuResponse::TxInitRbf(tx_init_rbf))); } let splice_init = self.send_splice_init(context); + debug_assert!(self.pending_splice.is_some()); + self.pending_splice.as_mut().unwrap() + .contributions.push(prior_contribution); return Ok(Some(StfuResponse::SpliceInit(splice_init))); }, #[cfg(any(test, fuzzing, feature = "_test_utils"))] diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 84c9d4dd343..7c1bada94c3 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -388,6 +388,17 @@ pub struct FundingContribution { is_splice: bool, } +impl_writeable_tlv_based!(FundingContribution, { + (1, value_added, required), + (3, estimated_fee, required), + (5, inputs, optional_vec), + (7, outputs, optional_vec), + (9, change_output, option), + (11, feerate, required), + (13, max_feerate, required), + (15, is_splice, required), +}); + impl FundingContribution { pub(super) fn feerate(&self) -> FeeRate { self.feerate diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index f7b867ca928..656d3c14057 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -4865,3 +4865,447 @@ pub fn do_test_splice_rbf_tiebreak( lock_splice_after_blocks(&nodes[1], &nodes[0], ANTI_REORG_DELAY - 1); } } + +#[test] +fn test_splice_rbf_acceptor_recontributes() { + // When the counterparty RBFs a splice and we have no pending QuiescentAction, + // our prior contribution should be automatically re-used. This tests the scenario: + // 1. Both nodes contribute to a splice (tiebreak: node 0 wins). + // 2. Only node 0 initiates an RBF — node 1 has no QuiescentAction. + // 3. Node 1 should re-contribute its prior inputs/outputs via our_prior_contribution. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000)); + + // Step 1: Both nodes initiate a splice at floor feerate. + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + + let funding_template_0 = + nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); + let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let node_0_funding_contribution = + funding_template_0.splice_in_sync(added_value, &wallet_0).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) + .unwrap(); + + let funding_template_1 = + nodes[1].node.splice_channel(&channel_id, &node_id_0, feerate, FeeRate::MAX).unwrap(); + let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let node_1_funding_contribution = + funding_template_1.splice_in_sync(added_value, &wallet_1).unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) + .unwrap(); + + // Step 2: Both send STFU; tiebreak: node 0 wins. + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + // Step 3: Node 0 sends SpliceInit, node 1 handles as acceptor (QuiescentAction consumed). + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + assert_ne!(splice_ack.funding_contribution_satoshis, 0); + nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); + + let new_funding_script = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + + // Complete interactive funding with both contributions. + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + node_0_funding_contribution, + Some(node_1_funding_contribution.clone()), + splice_ack.funding_contribution_satoshis, + new_funding_script.clone(), + ); + + let (first_splice_tx, splice_locked) = + sign_interactive_funding_tx_with_acceptor_contribution(&nodes[0], &nodes[1], false, true); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Step 4: Provide new UTXOs for node 0's RBF (node 1 does NOT initiate RBF). + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Step 5: Only node 0 calls rbf_channel + funding_contributed. + let rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); + let rbf_funding_contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + + // Steps 6-9: STFU exchange → tx_init_rbf → tx_ack_rbf. + // Node 1 should re-contribute via our_prior_contribution. + let tx_ack_rbf = complete_rbf_handshake(&nodes[0], &nodes[1]); + assert!( + tx_ack_rbf.funding_output_contribution.is_some(), + "Acceptor should re-contribute via our_prior_contribution" + ); + + // Step 10: Complete interactive funding with both contributions. + // Node 1's prior contribution is re-used — pass a clone for matching. + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + rbf_funding_contribution, + Some(node_1_funding_contribution), + tx_ack_rbf.funding_output_contribution.unwrap(), + new_funding_script.clone(), + ); + + // Step 11: Sign (acceptor has contribution) and broadcast. + let (rbf_tx, splice_locked) = + sign_interactive_funding_tx_with_acceptor_contribution(&nodes[0], &nodes[1], false, true); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Step 12: Mine, lock, and verify DiscardFunding for the replaced splice candidate. + lock_rbf_splice_after_blocks( + &nodes[0], + &nodes[1], + &rbf_tx, + ANTI_REORG_DELAY - 1, + &[first_splice_tx.compute_txid()], + ); +} + +#[test] +fn test_splice_rbf_recontributes_feerate_too_high() { + // When the counterparty RBFs at a feerate too high for our prior contribution, + // we should reject the RBF rather than proceeding without our contribution. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000)); + + // Step 1: Both nodes initiate a splice. Node 0 at floor feerate, node 1 splices in 95k + // from a 100k UTXO (tight budget: ~5k for change/fees). + let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + + let funding_template_0 = + nodes[0].node.splice_channel(&channel_id, &node_id_1, floor_feerate, FeeRate::MAX).unwrap(); + let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let node_0_funding_contribution = + funding_template_0.splice_in_sync(Amount::from_sat(50_000), &wallet_0).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) + .unwrap(); + + let node_1_added_value = Amount::from_sat(95_000); + let funding_template_1 = + nodes[1].node.splice_channel(&channel_id, &node_id_0, floor_feerate, FeeRate::MAX).unwrap(); + let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let node_1_funding_contribution = + funding_template_1.splice_in_sync(node_1_added_value, &wallet_1).unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) + .unwrap(); + + // Step 2: Both send STFU; tiebreak: node 0 wins. + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + // Step 3: Complete the initial splice with both contributing. + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + assert_ne!(splice_ack.funding_contribution_satoshis, 0); + nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); + + let new_funding_script = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + node_0_funding_contribution, + Some(node_1_funding_contribution), + splice_ack.funding_contribution_satoshis, + new_funding_script.clone(), + ); + + let (_first_splice_tx, splice_locked) = + sign_interactive_funding_tx_with_acceptor_contribution(&nodes[0], &nodes[1], false, true); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Step 4: Provide new UTXOs. Node 0 initiates RBF at 20,000 sat/kwu. + provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000)); + + let high_feerate = FeeRate::from_sat_per_kwu(20_000); + let funding_template = + nodes[0].node.rbf_channel(&channel_id, &node_id_1, high_feerate, FeeRate::MAX).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let rbf_funding_contribution = + funding_template.splice_in_sync(Amount::from_sat(50_000), &wallet).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, rbf_funding_contribution.clone(), None) + .unwrap(); + + // Step 5: STFU exchange. + let stfu_a = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_a); + let stfu_b = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_b); + + // Step 6: Node 0 sends tx_init_rbf at 20,000 sat/kwu. + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, high_feerate.to_sat_per_kwu() as u32); + + // Step 7: Node 1's prior contribution (95k from 100k UTXO) can't cover fees at 20k sat/kwu. + // Should reject with tx_abort rather than proceeding without contribution. + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); + assert_eq!(tx_abort.channel_id, channel_id); +} + +#[test] +fn test_splice_rbf_sequential() { + // Three consecutive RBF rounds on the same splice (initial → RBF #1 → RBF #2). + // Node 0 is the quiescence initiator; node 1 is the acceptor with no contribution. + // Verifies: + // - Each round satisfies the 25/24 feerate rule + // - DiscardFunding events reference the correct txids from previous rounds + // - The final RBF can be mined and splice_locked successfully + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // --- Round 0: Initial splice-in from node 0 at floor feerate (253). --- + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (splice_tx_0, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Feerate progression: 253 → ceil(253*25/24) = 264 → ceil(264*25/24) = 275 + let feerate_1_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); // 264 + let feerate_2_sat_per_kwu = (feerate_1_sat_per_kwu * 25).div_ceil(24); + + // --- Round 1: RBF #1 at feerate 264. --- + provide_utxo_reserves(&nodes, 2, added_value * 2); + + let rbf_feerate_1 = FeeRate::from_sat_per_kwu(feerate_1_sat_per_kwu); + let funding_contribution_1 = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate_1); + complete_rbf_handshake(&nodes[0], &nodes[1]); + + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + funding_contribution_1, + new_funding_script.clone(), + ); + let (splice_tx_1, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + assert!(splice_locked.is_none()); + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // --- Round 2: RBF #2 at feerate 275. --- + provide_utxo_reserves(&nodes, 2, added_value * 2); + + let rbf_feerate_2 = FeeRate::from_sat_per_kwu(feerate_2_sat_per_kwu); + let funding_contribution_2 = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate_2); + complete_rbf_handshake(&nodes[0], &nodes[1]); + + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + funding_contribution_2, + new_funding_script.clone(), + ); + let (rbf_tx_final, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + assert!(splice_locked.is_none()); + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // --- Mine and lock the final RBF, verifying DiscardFunding for both replaced candidates. --- + let splice_tx_0_txid = splice_tx_0.compute_txid(); + let splice_tx_1_txid = splice_tx_1.compute_txid(); + lock_rbf_splice_after_blocks( + &nodes[0], + &nodes[1], + &rbf_tx_final, + ANTI_REORG_DELAY - 1, + &[splice_tx_0_txid, splice_tx_1_txid], + ); +} + +#[test] +fn test_splice_rbf_acceptor_contributes_then_disconnects() { + // When both nodes contribute to a splice and the initiator RBFs (with the acceptor + // re-contributing via prior contribution), disconnecting mid-interactive-TX should emit + // SpliceFailed + DiscardFunding for both nodes so each can reclaim their UTXOs. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000)); + + // --- Round 0: Both nodes initiate splice-in (tiebreak: node 0 wins). --- + let node_0_funding_contribution = + do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let node_1_funding_contribution = + do_initiate_splice_in(&nodes[1], &nodes[0], channel_id, added_value); + + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + assert_ne!(splice_ack.funding_contribution_satoshis, 0); + nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); + + let new_funding_script = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + node_0_funding_contribution, + Some(node_1_funding_contribution.clone()), + splice_ack.funding_contribution_satoshis, + new_funding_script.clone(), + ); + + let (_first_splice_tx, splice_locked) = + sign_interactive_funding_tx_with_acceptor_contribution(&nodes[0], &nodes[1], false, true); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // --- Round 1: Node 0 initiates RBF; node 1 re-contributes via prior. --- + provide_utxo_reserves(&nodes, 2, added_value * 2); + + let rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); + let _rbf_funding_contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + + let tx_ack_rbf = complete_rbf_handshake(&nodes[0], &nodes[1]); + assert!( + tx_ack_rbf.funding_output_contribution.is_some(), + "Acceptor should re-contribute via prior contribution" + ); + + // Disconnect mid-interactive-TX negotiation. + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + + // The initiator should get SpliceFailed + DiscardFunding. + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2, "{events:?}"); + match &events[0] { + Event::SpliceFailed { channel_id: cid, .. } => assert_eq!(*cid, channel_id), + other => panic!("Expected SpliceFailed, got {:?}", other), + } + match &events[1] { + Event::DiscardFunding { funding_info: FundingInfo::Contribution { .. }, .. } => {}, + other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), + } + + // The acceptor should also get SpliceFailed + DiscardFunding with its contributed + // inputs/outputs so it can reclaim its UTXOs. + let events = nodes[1].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2, "{events:?}"); + match &events[0] { + Event::SpliceFailed { channel_id: cid, .. } => assert_eq!(*cid, channel_id), + other => panic!("Expected SpliceFailed, got {:?}", other), + } + match &events[1] { + Event::DiscardFunding { + funding_info: FundingInfo::Contribution { inputs, outputs }, + .. + } => { + assert!(!inputs.is_empty(), "Expected acceptor inputs, got empty"); + assert!(!outputs.is_empty(), "Expected acceptor outputs, got empty"); + }, + other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), + } + + // Reconnect. + let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); + reconnect_args.send_announcement_sigs = (true, true); + reconnect_nodes(reconnect_args); +} From 7ad073d586633c403011724941a00fa460d79638 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 23 Feb 2026 20:32:32 -0600 Subject: [PATCH 167/627] Consider prior contributions when filtering unique inputs/outputs When funding_contributed is called while a splice negotiation is already in progress, unique contributions are computed to determine what to return via FailSplice or DiscardFunding. Without considering negotiated candidates stored in PendingFunding::contributions, UTXOs locked in earlier candidates could be incorrectly returned as reclaimable. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 741da76d047..f5272e26305 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -3074,6 +3074,14 @@ impl PendingFunding { } } + fn contributed_inputs(&self) -> impl Iterator + '_ { + self.contributions.iter().flat_map(|c| c.contributed_inputs()) + } + + fn contributed_outputs(&self) -> impl Iterator + '_ { + self.contributions.iter().flat_map(|c| c.contributed_outputs()) + } + fn check_get_splice_locked( &mut self, context: &ChannelContext, confirmed_funding_index: usize, height: u32, ) -> Option { @@ -12040,9 +12048,16 @@ where if let Some(QuiescentAction::Splice { contribution: existing, .. }) = &self.quiescent_action { + let pending_splice = self.pending_splice.as_ref(); + let prior_inputs = pending_splice + .into_iter() + .flat_map(|pending_splice| pending_splice.contributed_inputs()); + let prior_outputs = pending_splice + .into_iter() + .flat_map(|pending_splice| pending_splice.contributed_outputs()); return match contribution.into_unique_contributions( - existing.contributed_inputs(), - existing.contributed_outputs(), + existing.contributed_inputs().chain(prior_inputs), + existing.contributed_outputs().chain(prior_outputs), ) { None => Err(QuiescentError::DoNothing), Some((inputs, outputs)) => Err(QuiescentError::DiscardFunding { inputs, outputs }), @@ -12056,17 +12071,21 @@ where .filter(|funding_negotiation| funding_negotiation.is_initiator()); if let Some(funding_negotiation) = initiated_funding_negotiation { + let pending_splice = + self.pending_splice.as_ref().expect("funding negotiation implies pending splice"); + let prior_inputs = pending_splice.contributed_inputs(); + let prior_outputs = pending_splice.contributed_outputs(); let unique_contributions = match funding_negotiation { FundingNegotiation::AwaitingAck { context, .. } => contribution .into_unique_contributions( - context.contributed_inputs(), - context.contributed_outputs(), + context.contributed_inputs().chain(prior_inputs), + context.contributed_outputs().chain(prior_outputs), ), FundingNegotiation::ConstructingTransaction { interactive_tx_constructor, .. } => contribution.into_unique_contributions( - interactive_tx_constructor.contributed_inputs(), - interactive_tx_constructor.contributed_outputs(), + interactive_tx_constructor.contributed_inputs().chain(prior_inputs), + interactive_tx_constructor.contributed_outputs().chain(prior_outputs), ), FundingNegotiation::AwaitingSignatures { .. } => { let session = self @@ -12075,8 +12094,8 @@ where .as_ref() .expect("pending splice awaiting signatures"); contribution.into_unique_contributions( - session.contributed_inputs(), - session.contributed_outputs(), + session.contributed_inputs().chain(prior_inputs), + session.contributed_outputs().chain(prior_outputs), ) }, }; From c327ef929ee77401155cfc53d6d795b155f5244f Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 24 Feb 2026 22:32:35 -0600 Subject: [PATCH 168/627] Filter prior contributions from SpliceFundingFailed events SpliceFundingFailed events return contributed inputs and outputs to the user so they can unlock the associated UTXOs. When an RBF attempt is in progress, inputs/outputs already consumed by prior contributions must be excluded to avoid the user prematurely unlocking UTXOs that are still needed by the active funding negotiation. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 81 +++++++----- lightning/src/ln/splicing_tests.rs | 206 +++++++++++++++++++++++++++-- 2 files changed, 248 insertions(+), 39 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index f5272e26305..0ab729209a9 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -3082,6 +3082,16 @@ impl PendingFunding { self.contributions.iter().flat_map(|c| c.contributed_outputs()) } + fn prior_contributed_inputs(&self) -> impl Iterator + '_ { + let len = self.contributions.len(); + self.contributions[..len.saturating_sub(1)].iter().flat_map(|c| c.contributed_inputs()) + } + + fn prior_contributed_outputs(&self) -> impl Iterator + '_ { + let len = self.contributions.len(); + self.contributions[..len.saturating_sub(1)].iter().flat_map(|c| c.contributed_outputs()) + } + fn check_get_splice_locked( &mut self, context: &ChannelContext, confirmed_funding_index: usize, height: u32, ) -> Option { @@ -3130,25 +3140,6 @@ pub(super) enum QuiescentError { FailSplice(SpliceFundingFailed), } -impl From for QuiescentError { - fn from(action: QuiescentAction) -> Self { - match action { - QuiescentAction::Splice { contribution, .. } => { - let (contributed_inputs, contributed_outputs) = - contribution.into_contributed_inputs_and_outputs(); - return QuiescentError::FailSplice(SpliceFundingFailed { - funding_txo: None, - channel_type: None, - contributed_inputs, - contributed_outputs, - }); - }, - #[cfg(any(test, fuzzing, feature = "_test_utils"))] - QuiescentAction::DoNothing => QuiescentError::DoNothing, - } - } -} - pub(crate) enum StfuResponse { Stfu(msgs::Stfu), SpliceInit(msgs::SpliceInit), @@ -6686,7 +6677,7 @@ pub struct SpliceFundingFailed { } macro_rules! maybe_create_splice_funding_failed { - ($funded_channel: expr, $pending_splice: expr, $get: ident, $contributed_inputs_and_outputs: ident) => {{ + ($funded_channel: expr, $pending_splice: expr, $pending_splice_ref: expr, $get: ident, $contributed_inputs_and_outputs: ident) => {{ $pending_splice .and_then(|pending_splice| pending_splice.funding_negotiation.$get()) .and_then(|funding_negotiation| { @@ -6701,7 +6692,7 @@ macro_rules! maybe_create_splice_funding_failed { .as_funding() .map(|funding| funding.get_channel_type().clone()); - let (contributed_inputs, contributed_outputs) = match funding_negotiation { + let (mut contributed_inputs, mut contributed_outputs) = match funding_negotiation { FundingNegotiation::AwaitingAck { context, .. } => { context.$contributed_inputs_and_outputs() }, @@ -6717,6 +6708,15 @@ macro_rules! maybe_create_splice_funding_failed { .$contributed_inputs_and_outputs(), }; + if let Some(pending_splice) = $pending_splice_ref { + for input in pending_splice.prior_contributed_inputs() { + contributed_inputs.retain(|i| *i != input); + } + for output in pending_splice.prior_contributed_outputs() { + contributed_outputs.retain(|o| o.script_pubkey != output.script_pubkey); + } + } + if !is_initiator && contributed_inputs.is_empty() && contributed_outputs.is_empty() { return None; @@ -6755,11 +6755,19 @@ where shutdown_result } - fn abandon_quiescent_action(&mut self) -> Option { - match self.quiescent_action.take() { - Some(QuiescentAction::Splice { contribution, .. }) => { - let (inputs, outputs) = contribution.into_contributed_inputs_and_outputs(); - Some(SpliceFundingFailed { + fn quiescent_action_into_error(&self, action: QuiescentAction) -> QuiescentError { + match action { + QuiescentAction::Splice { contribution, .. } => { + let (mut inputs, mut outputs) = contribution.into_contributed_inputs_and_outputs(); + if let Some(ref pending_splice) = self.pending_splice { + for input in pending_splice.contributed_inputs() { + inputs.retain(|i| *i != input); + } + for output in pending_splice.contributed_outputs() { + outputs.retain(|o| o.script_pubkey != output.script_pubkey); + } + } + QuiescentError::FailSplice(SpliceFundingFailed { funding_txo: None, channel_type: None, contributed_inputs: inputs, @@ -6767,11 +6775,20 @@ where }) }, #[cfg(any(test, fuzzing, feature = "_test_utils"))] - Some(quiescent_action) => { - self.quiescent_action = Some(quiescent_action); + QuiescentAction::DoNothing => QuiescentError::DoNothing, + } + } + + fn abandon_quiescent_action(&mut self) -> Option { + let action = self.quiescent_action.take()?; + match self.quiescent_action_into_error(action) { + QuiescentError::FailSplice(failed) => Some(failed), + #[cfg(any(test, fuzzing, feature = "_test_utils"))] + QuiescentError::DoNothing => None, + _ => { + debug_assert!(false); None }, - None => None, } } @@ -6895,6 +6912,7 @@ where let splice_funding_failed = maybe_create_splice_funding_failed!( self, self.pending_splice.as_mut(), + self.pending_splice.as_ref(), take, into_contributed_inputs_and_outputs ); @@ -6919,6 +6937,7 @@ where maybe_create_splice_funding_failed!( self, self.pending_splice.as_ref(), + self.pending_splice.as_ref(), as_ref, to_contributed_inputs_and_outputs ) @@ -13549,14 +13568,14 @@ where if !self.context.is_usable() { log_debug!(logger, "Channel is not in a usable state to propose quiescence"); - return Err(action.into()); + return Err(self.quiescent_action_into_error(action)); } if self.quiescent_action.is_some() { log_debug!( logger, "Channel already has a pending quiescent action and cannot start another", ); - return Err(action.into()); + return Err(self.quiescent_action_into_error(action)); } // Since we don't have a pending quiescent action, we should never be in a state where we // sent `stfu` without already having become quiescent. diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 656d3c14057..bf689ae6262 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -250,6 +250,22 @@ pub fn do_initiate_rbf_splice_in<'a, 'b, 'c, 'd>( funding_contribution } +pub fn do_initiate_rbf_splice_in_and_out<'a, 'b, 'c, 'd>( + node: &'a Node<'b, 'c, 'd>, counterparty: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, + value_added: Amount, outputs: Vec, feerate: FeeRate, +) -> FundingContribution { + let node_id_counterparty = counterparty.node.get_our_node_id(); + let funding_template = + node.node.rbf_channel(&channel_id, &node_id_counterparty, feerate, FeeRate::MAX).unwrap(); + let wallet = WalletSync::new(Arc::clone(&node.wallet_source), node.logger); + let funding_contribution = + funding_template.splice_in_and_out_sync(value_added, outputs, &wallet).unwrap(); + node.node + .funding_contributed(&channel_id, &node_id_counterparty, funding_contribution.clone(), None) + .unwrap(); + funding_contribution +} + pub fn initiate_splice_out<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, outputs: Vec, @@ -2865,12 +2881,14 @@ fn fail_quiescent_action_on_channel_close() { #[test] fn abandon_splice_quiescent_action_on_shutdown() { - do_abandon_splice_quiescent_action_on_shutdown(true); - do_abandon_splice_quiescent_action_on_shutdown(false); + do_abandon_splice_quiescent_action_on_shutdown(true, false); + do_abandon_splice_quiescent_action_on_shutdown(false, false); + do_abandon_splice_quiescent_action_on_shutdown(true, true); + do_abandon_splice_quiescent_action_on_shutdown(false, true); } #[cfg(test)] -fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) { +fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool, pending_splice: bool) { let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); @@ -2884,6 +2902,19 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) { let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0); + // When testing with a prior pending splice, complete splice A first so that + // `quiescent_action_into_error` filters against `pending_splice.contributed_inputs/outputs`. + if pending_splice { + let funding_contribution = do_initiate_splice_in( + &nodes[0], + &nodes[1], + channel_id, + Amount::from_sat(initial_channel_capacity / 2), + ); + let (_splice_tx, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + } + // Since we cannot close after having sent `stfu`, send an HTLC so that when we attempt to // splice, the `stfu` message is held back. let payment_amount = 1_000_000; @@ -2896,7 +2927,8 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) { check_added_monitors(&nodes[0], 1); nodes[1].node.handle_update_add_htlc(node_id_0, &update.update_add_htlcs[0]); - nodes[1].node.handle_commitment_signed(node_id_0, &update.commitment_signed[0]); + // After a splice, commitment_signed messages are batched across funding scopes. + nodes[1].node.handle_commitment_signed_batch_test(node_id_0, &update.commitment_signed); check_added_monitors(&nodes[1], 1); let (revoke_and_ack, _) = get_revoke_commit_msgs(&nodes[1], &node_id_0); @@ -2904,9 +2936,29 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) { check_added_monitors(&nodes[0], 1); // Attempt the splice. `stfu` should not go out yet as the state machine is pending. - let splice_in_amount = initial_channel_capacity / 2; - let funding_contribution = - initiate_splice_in(&nodes[0], &nodes[1], channel_id, Amount::from_sat(splice_in_amount)); + // When there's a prior splice, include a splice-out output with a different script_pubkey + // so the test can verify selective filtering: the change output (same script_pubkey as + // the prior splice) is filtered, while the splice-out output (different script_pubkey) + // survives. + let splice_in_amount = + if pending_splice { initial_channel_capacity / 4 } else { initial_channel_capacity / 2 }; + let splice_out_output = if pending_splice { + let script_pubkey = nodes[1].wallet_source.get_change_script().unwrap(); + Some(TxOut { value: Amount::from_sat(1_000), script_pubkey }) + } else { + None + }; + let funding_contribution = if let Some(ref output) = splice_out_output { + initiate_splice_in_and_out( + &nodes[0], + &nodes[1], + channel_id, + Amount::from_sat(splice_in_amount), + vec![output.clone()], + ) + } else { + initiate_splice_in(&nodes[0], &nodes[1], channel_id, Amount::from_sat(splice_in_amount)) + }; assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); // Close the channel. We should see a `SpliceFailed` event for the pending splice @@ -2920,7 +2972,33 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) { let shutdown = get_event_msg!(closer_node, MessageSendEvent::SendShutdown, closee_node_id); closee_node.node.handle_shutdown(closer_node_id, &shutdown); - expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); + if pending_splice { + // With a prior pending splice, contributions are filtered against committed inputs/outputs. + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2, "{events:?}"); + match &events[0] { + Event::SpliceFailed { channel_id: cid, .. } => { + assert_eq!(*cid, channel_id); + }, + other => panic!("Expected SpliceFailed, got {:?}", other), + } + match &events[1] { + Event::DiscardFunding { + funding_info: FundingInfo::Contribution { inputs, outputs }, + .. + } => { + // The UTXO was filtered: it's still committed to the prior splice. + assert!(inputs.is_empty(), "Expected empty inputs (filtered), got {:?}", inputs); + // The change output was filtered (same script_pubkey as the prior splice's + // change output), but the splice-out output survives (different script_pubkey). + let expected_outputs: Vec<_> = splice_out_output.into_iter().collect(); + assert_eq!(*outputs, expected_outputs); + }, + other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), + } + } else { + expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); + } let _ = get_event_msg!(closee_node, MessageSendEvent::SendShutdown, closer_node_id); } @@ -5309,3 +5387,115 @@ fn test_splice_rbf_acceptor_contributes_then_disconnects() { reconnect_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_args); } + +#[test] +fn test_splice_rbf_disconnect_filters_prior_contributions() { + // When disconnecting during an RBF round that reuses the same UTXOs as a prior round, + // the SpliceFundingFailed event should filter out inputs/outputs still committed to the prior + // round. This exercises the `reset_pending_splice_state` → `maybe_create_splice_funding_failed` + // macro path. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + // Provide exactly 1 UTXO per node so coin selection is deterministic. + provide_utxo_reserves(&nodes, 1, added_value * 2); + + // --- Round 0: Initial splice-in at floor feerate (253). --- + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_splice_tx_0, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // --- Round 1: RBF at higher feerate without providing new UTXOs. --- + // The wallet reselects the same UTXO since the splice tx hasn't been mined. + // Include a splice-out output with a different script_pubkey so the test can verify + // selective filtering: the change output (same script_pubkey as round 0) is filtered, + // while the splice-out output (different script_pubkey) survives. + let feerate_1_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let rbf_feerate = FeeRate::from_sat_per_kwu(feerate_1_sat_per_kwu); + let splice_out_output = TxOut { + value: Amount::from_sat(1_000), + script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), + }; + let _funding_contribution_1 = do_initiate_rbf_splice_in_and_out( + &nodes[0], + &nodes[1], + channel_id, + added_value, + vec![splice_out_output.clone()], + rbf_feerate, + ); + + // STFU exchange + RBF handshake to start interactive TX. + complete_rbf_handshake(&nodes[0], &nodes[1]); + + // Disconnect mid-negotiation. Stale interactive TX messages are cleared by peer_disconnected. + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + + // The initiator should get SpliceFailed + DiscardFunding with filtered contributions. + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2, "{events:?}"); + match &events[0] { + Event::SpliceFailed { channel_id: cid, .. } => { + assert_eq!(*cid, channel_id); + }, + other => panic!("Expected SpliceFailed, got {:?}", other), + } + match &events[1] { + Event::DiscardFunding { + funding_info: FundingInfo::Contribution { inputs, outputs }, + .. + } => { + // The UTXO was filtered out: it's still committed to round 0's splice. + assert!(inputs.is_empty(), "Expected empty inputs (filtered), got {:?}", inputs); + // The change output was filtered (same script_pubkey as round 0's change output), + // but the splice-out output survives (different script_pubkey). + assert_eq!(*outputs, vec![splice_out_output.clone()]); + }, + other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), + } + + // Reconnect. After a completed splice, channel_ready is not re-sent. + let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); + reconnect_args.send_announcement_sigs = (true, true); + reconnect_nodes(reconnect_args); + + // --- Round 2: RBF at the same feerate as the failed round 1 (264). --- + // This should succeed because the failed round never updated the feerate floor, which + // remains at round 0's rate (253), and 264 >= ceil(253 * 25/24). + provide_utxo_reserves(&nodes, 1, added_value * 2); + + let rbf_feerate_2 = FeeRate::from_sat_per_kwu(feerate_1_sat_per_kwu); + let _funding_contribution_2 = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate_2); + complete_rbf_handshake(&nodes[0], &nodes[1]); + + // Disconnect again to clean up the in-progress interactive TX negotiation. + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2, "{events:?}"); + match &events[0] { + Event::SpliceFailed { channel_id: cid, .. } => assert_eq!(*cid, channel_id), + other => panic!("Expected SpliceFailed, got {:?}", other), + } + match &events[1] { + Event::DiscardFunding { .. } => {}, + other => panic!("Expected DiscardFunding, got {:?}", other), + } + + let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); + reconnect_args.send_announcement_sigs = (true, true); + reconnect_nodes(reconnect_args); +} From 5e521ac1f2a5e670231503a051a22ef92c9b9daa Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 25 Feb 2026 15:54:05 -0600 Subject: [PATCH 169/627] Handle FeeRateAdjustmentError variants in splice acceptor path Replace the generic error handling in splice_init and tx_init_rbf with explicit matching on FeeRateAdjustmentError variants: - FeeRateTooLow: initiator's feerate is below our minimum. Proceed without contribution and preserve QuiescentAction for an RBF retry at our preferred feerate. - FeeRateTooHigh: initiator's feerate exceeds our maximum and would consume too much of our change output. Reject the splice with WarnAndDisconnect. - FeeBufferInsufficient: our fee buffer can't cover the acceptor's estimated fee at this feerate. Proceed without contribution. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 37 ++++--- lightning/src/ln/funding.rs | 18 +++- lightning/src/ln/interactivetxs.rs | 5 + lightning/src/ln/splicing_tests.rs | 152 +++++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 18 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 0ab729209a9..0e8f46bde04 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -55,7 +55,9 @@ use crate::ln::channelmanager::{ PendingHTLCStatus, RAACommitmentOrder, SentHTLCId, BREAKDOWN_TIMEOUT, MAX_LOCAL_BREAKDOWN_TIMEOUT, MIN_CLTV_EXPIRY_DELTA, }; -use crate::ln::funding::{FundingContribution, FundingTemplate, FundingTxInput}; +use crate::ln::funding::{ + FeeRateAdjustmentError, FundingContribution, FundingTemplate, FundingTxInput, +}; use crate::ln::interactivetxs::{ AbortReason, HandleTxCompleteValue, InteractiveTxConstructor, InteractiveTxConstructorArgs, InteractiveTxMessageSend, InteractiveTxSigningSession, SharedOwnedInput, SharedOwnedOutput, @@ -12417,7 +12419,7 @@ where fn resolve_queued_contribution( &self, feerate: FeeRate, logger: &L, - ) -> (Option, Option) { + ) -> Result<(Option, Option), ChannelError> { let holder_balance = self .get_holder_counterparty_balances_floor_incl_fee(&self.funding) .map(|(holder, _)| holder) @@ -12432,23 +12434,29 @@ where }) .ok(); - let net_value = - holder_balance.and_then(|_| self.queued_funding_contribution()).and_then(|c| { - c.net_value_for_acceptor_at_feerate(feerate, holder_balance.unwrap()) - .map_err(|e| { + let net_value = match holder_balance.and_then(|_| self.queued_funding_contribution()) { + Some(c) => { + match c.net_value_for_acceptor_at_feerate(feerate, holder_balance.unwrap()) { + Ok(net_value) => Some(net_value), + Err(FeeRateAdjustmentError::FeeRateTooHigh { .. }) => { + return Err(ChannelError::Abort(AbortReason::FeeRateTooHigh)); + }, + Err(e) => { log_info!( logger, - "Cannot accommodate initiator's feerate ({}) for channel {}: {}; \ - proceeding without contribution", + "Cannot accommodate initiator's feerate ({}) for channel {}: {}", feerate, self.context.channel_id(), e, ); - }) - .ok() - }); + None + }, + } + }, + None => None, + }; - (net_value, holder_balance) + Ok((net_value, holder_balance)) } pub(crate) fn splice_init( @@ -12457,7 +12465,7 @@ where ) -> Result { let feerate = FeeRate::from_sat_per_kwu(msg.funding_feerate_per_kw as u64); let (our_funding_contribution, holder_balance) = - self.resolve_queued_contribution(feerate, logger); + self.resolve_queued_contribution(feerate, logger)?; let splice_funding = self.validate_splice_init(msg, our_funding_contribution.unwrap_or(SignedAmount::ZERO))?; @@ -12615,7 +12623,8 @@ where fee_estimator: &LowerBoundedFeeEstimator, logger: &L, ) -> Result { let feerate = FeeRate::from_sat_per_kwu(msg.feerate_sat_per_1000_weight as u64); - let (queued_net_value, holder_balance) = self.resolve_queued_contribution(feerate, logger); + let (queued_net_value, holder_balance) = + self.resolve_queued_contribution(feerate, logger)?; // If no queued contribution, try prior contribution from previous negotiation. // Failing here means the RBF would erase our splice — reject it. diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 7c1bada94c3..c81024ca080 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -42,7 +42,7 @@ use crate::util::wallet_utils::{ #[derive(Debug)] pub(super) enum FeeRateAdjustmentError { /// The counterparty's proposed feerate is below `min_feerate`, which was used as the feerate - /// during coin selection. + /// during coin selection. We'll retry via RBF at our preferred feerate. FeeRateTooLow { target_feerate: FeeRate, min_feerate: FeeRate }, /// The counterparty's proposed feerate is above `max_feerate` and the re-estimated fee for /// our contributed inputs and outputs exceeds the original fee estimate (computed at @@ -68,7 +68,12 @@ impl core::fmt::Display for FeeRateAdjustmentError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { FeeRateAdjustmentError::FeeRateTooLow { target_feerate, min_feerate } => { - write!(f, "Target feerate {} is below our minimum {}", target_feerate, min_feerate) + write!( + f, + "Target feerate {} is below our minimum {}; \ + proceeding without contribution, will RBF later", + target_feerate, min_feerate, + ) }, FeeRateAdjustmentError::FeeRateTooHigh { target_feerate, @@ -83,12 +88,17 @@ impl core::fmt::Display for FeeRateAdjustmentError { ) }, FeeRateAdjustmentError::FeeBufferOverflow => { - write!(f, "Arithmetic overflow when computing available fee buffer") + write!( + f, + "Arithmetic overflow when computing available fee buffer; \ + proceeding without contribution", + ) }, FeeRateAdjustmentError::FeeBufferInsufficient { source, available, required } => { write!( f, - "Fee buffer {} ({}) is insufficient for required fee {}", + "Fee buffer {} ({}) is insufficient for required fee {}; \ + proceeding without contribution", available, source, required, ) }, diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index 5a9964a6063..36367611abb 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -141,6 +141,8 @@ pub(crate) enum AbortReason { InsufficientRbfFeerate, /// A funding negotiation is already in progress. NegotiationInProgress, + /// The initiator's feerate exceeds our maximum. + FeeRateTooHigh, /// Internal error InternalError(&'static str), } @@ -204,6 +206,9 @@ impl Display for AbortReason { AbortReason::NegotiationInProgress => { f.write_str("A funding negotiation is already in progress") }, + AbortReason::FeeRateTooHigh => { + f.write_str("The initiator's feerate exceeds our maximum") + }, AbortReason::InternalError(text) => { f.write_fmt(format_args!("Internal error: {}", text)) }, diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index bf689ae6262..bdfe14635e0 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -1782,6 +1782,78 @@ fn do_test_splice_tiebreak( } } +#[test] +fn test_splice_tiebreak_feerate_too_high_rejected() { + // Node 0 (winner) proposes a feerate far above node 1's (loser) max_feerate, and node 1's + // fair fee at that feerate exceeds its budget. This triggers FeeRateAdjustmentError::TooHigh, + // causing node 1 to reject with tx_abort. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000)); + + // Node 0 uses an extremely high feerate (100,000 sat/kwu). Node 1 uses the floor feerate + // with a moderate splice-in (50,000 sats from a 100,000 sat UTXO) and a low max_feerate + // (3,000 sat/kwu). The target (100k) far exceeds node 1's max (3k), and the fair fee at + // 100k exceeds node 1's budget, triggering TooHigh. + let high_feerate = FeeRate::from_sat_per_kwu(100_000); + let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let node_0_added_value = Amount::from_sat(50_000); + let node_1_added_value = Amount::from_sat(50_000); + let node_1_max_feerate = FeeRate::from_sat_per_kwu(3_000); + + // Node 0: very high feerate, moderate splice-in. + let funding_template_0 = + nodes[0].node.splice_channel(&channel_id, &node_id_1, high_feerate, FeeRate::MAX).unwrap(); + let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let node_0_funding_contribution = + funding_template_0.splice_in_sync(node_0_added_value, &wallet_0).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) + .unwrap(); + + // Node 1: floor feerate, moderate splice-in, low max_feerate. + let funding_template_1 = nodes[1] + .node + .splice_channel(&channel_id, &node_id_0, floor_feerate, node_1_max_feerate) + .unwrap(); + let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let node_1_funding_contribution = + funding_template_1.splice_in_sync(node_1_added_value, &wallet_1).unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) + .unwrap(); + + // Both emit STFU. + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + + // Tie-break: node 0 wins. + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + // Node 0 sends SpliceInit at 100,000 sat/kwu. + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + + // Node 1 handles SpliceInit — TooHigh: target (100k) >> max (3k) and fair fee > budget. + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + + let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); + assert_eq!(tx_abort.channel_id, channel_id); +} + #[cfg(test)] #[derive(PartialEq)] enum SpliceStatus { @@ -4944,6 +5016,86 @@ pub fn do_test_splice_rbf_tiebreak( } } +#[test] +fn test_splice_rbf_tiebreak_feerate_too_high_rejected() { + // Node 0 (winner) proposes an RBF feerate far above node 1's (loser) max_feerate, and + // node 1's fair fee at that feerate exceeds its budget. This triggers + // FeeRateAdjustmentError::TooHigh in the queued contribution path, causing node 1 to + // reject with tx_abort. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Complete an initial splice-in from node 0. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_first_splice_tx, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Provide more UTXOs for both nodes' RBF attempts. + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Node 0 uses an extremely high feerate (100,000 sat/kwu). Node 1 uses the minimum RBF + // feerate with a moderate splice-in (50,000 sats) and a low max_feerate (3,000 sat/kwu). + // The target (100k) far exceeds node 1's max (3k), and the fair fee at 100k exceeds + // node 1's budget, triggering TooHigh. + let high_feerate = FeeRate::from_sat_per_kwu(100_000); + let min_rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let min_rbf_feerate = FeeRate::from_sat_per_kwu(min_rbf_feerate_sat_per_kwu); + let node_1_max_feerate = FeeRate::from_sat_per_kwu(3_000); + + let funding_template_0 = + nodes[0].node.rbf_channel(&channel_id, &node_id_1, high_feerate, FeeRate::MAX).unwrap(); + let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let node_0_funding_contribution = + funding_template_0.splice_in_sync(added_value, &wallet_0).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) + .unwrap(); + + let funding_template_1 = nodes[1] + .node + .rbf_channel(&channel_id, &node_id_0, min_rbf_feerate, node_1_max_feerate) + .unwrap(); + let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let node_1_funding_contribution = + funding_template_1.splice_in_sync(added_value, &wallet_1).unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) + .unwrap(); + + // Both sent STFU. + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + + // Tie-break: node 0 wins. + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + // Node 0 sends tx_init_rbf at 100,000 sat/kwu. + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, high_feerate.to_sat_per_kwu() as u32); + + // Node 1 handles tx_init_rbf — TooHigh: target (100k) >> max (3k) and fair fee > budget. + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); + assert_eq!(tx_abort.channel_id, channel_id); +} + #[test] fn test_splice_rbf_acceptor_recontributes() { // When the counterparty RBFs a splice and we have no pending QuiescentAction, From 747788b8c38b1910330aeb4d842f350a30cad5d8 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Thu, 12 Mar 2026 12:47:55 -0700 Subject: [PATCH 170/627] Remove TaprootChannelSigner We plan to have a single channel signer type going forward, so this is unnecessary. --- fuzz/src/chanmon_consistency.rs | 2 - fuzz/src/full_stack.rs | 2 - fuzz/src/onion_message.rs | 2 - lightning-background-processor/src/lib.rs | 9 +- lightning/src/ln/channel.rs | 43 +----- lightning/src/sign/mod.rs | 83 ------------ lightning/src/sign/taproot.rs | 155 ---------------------- lightning/src/sign/type_resolver.rs | 6 - lightning/src/util/dyn_signer.rs | 87 ------------ lightning/src/util/test_channel_signer.rs | 63 --------- lightning/src/util/test_utils.rs | 4 - 11 files changed, 5 insertions(+), 451 deletions(-) delete mode 100644 lightning/src/sign/taproot.rs diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 22006897a0f..a6288e1a7c3 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -447,8 +447,6 @@ impl NodeSigner for KeyProvider { impl SignerProvider for KeyProvider { type EcdsaSigner = TestChannelSigner; - #[cfg(taproot)] - type TaprootSigner = TestChannelSigner; fn generate_channel_keys_id(&self, _inbound: bool, _user_channel_id: u128) -> [u8; 32] { let id = self.rand_bytes_id.fetch_add(1, atomic::Ordering::Relaxed) as u8; diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index 5dfa51079d8..35b1632ae7c 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -457,8 +457,6 @@ impl NodeSigner for KeyProvider { impl SignerProvider for KeyProvider { type EcdsaSigner = TestChannelSigner; - #[cfg(taproot)] - type TaprootSigner = TestChannelSigner; fn generate_channel_keys_id(&self, inbound: bool, _user_channel_id: u128) -> [u8; 32] { let ctr = self.counter.fetch_add(1, Ordering::Relaxed) as u8; diff --git a/fuzz/src/onion_message.rs b/fuzz/src/onion_message.rs index 70dfb0753d3..4859f7379fb 100644 --- a/fuzz/src/onion_message.rs +++ b/fuzz/src/onion_message.rs @@ -296,8 +296,6 @@ impl NodeSigner for KeyProvider { impl SignerProvider for KeyProvider { type EcdsaSigner = TestChannelSigner; - #[cfg(taproot)] - type TaprootSigner = TestChannelSigner; fn generate_channel_keys_id(&self, _inbound: bool, _user_channel_id: u128) -> [u8; 32] { unreachable!() diff --git a/lightning-background-processor/src/lib.rs b/lightning-background-processor/src/lib.rs index da415c70a32..be0d7ee6faf 100644 --- a/lightning-background-processor/src/lib.rs +++ b/lightning-background-processor/src/lib.rs @@ -378,18 +378,11 @@ type DynMessageRouter = lightning::onion_message::messenger::DefaultMessageRoute &'static (dyn EntropySource + Send + Sync), >; -#[cfg(all(not(c_bindings), not(taproot)))] +#[cfg(not(c_bindings))] type DynSignerProvider = dyn lightning::sign::SignerProvider + Send + Sync; -#[cfg(all(not(c_bindings), taproot))] -type DynSignerProvider = (dyn lightning::sign::SignerProvider< - EcdsaSigner = lightning::sign::InMemorySigner, - TaprootSigner = lightning::sign::InMemorySigner, -> + Send - + Sync); - #[cfg(not(c_bindings))] type DynChannelManager = lightning::ln::channelmanager::ChannelManager< &'static (dyn chain::Watch + Send + Sync), diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 9361cd3c749..1a69f52d4f6 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2229,8 +2229,6 @@ where splice_input_index as usize, &context.secp_ctx, ), - #[cfg(taproot)] - ChannelSignerType::Taproot(_) => todo!(), }; Some(sig) } else { @@ -5988,13 +5986,9 @@ impl ChannelContext { // We sign "counterparty" commitment transaction, allowing them to broadcast the tx if they wish. let signature = match &self.holder_signer { - // TODO (arik): move match into calling method for Taproot ChannelSignerType::Ecdsa(ecdsa) => ecdsa.sign_counterparty_commitment( channel_parameters, &counterparty_initial_commitment_tx, Vec::new(), Vec::new(), &self.secp_ctx ).ok(), - // TODO (taproot|arik) - #[cfg(taproot)] - _ => todo!() }; if signature.is_some() && self.signer_pending_funding { @@ -6104,7 +6098,6 @@ impl ChannelContext { ); let counterparty_initial_commitment_tx = commitment_data.tx; match self.holder_signer { - // TODO (taproot|arik): move match into calling method for Taproot ChannelSignerType::Ecdsa(ref ecdsa) => { let channel_parameters = &funding.channel_transaction_parameters; ecdsa @@ -6117,9 +6110,6 @@ impl ChannelContext { ) .ok() }, - // TODO (taproot|arik) - #[cfg(taproot)] - _ => todo!(), } } @@ -8427,9 +8417,6 @@ where ChannelError::close("Failed to validate revocation from peer".to_owned()) })?; }, - // TODO (taproot|arik) - #[cfg(taproot)] - _ => todo!(), }; self.context @@ -10384,9 +10371,6 @@ where &self.context.secp_ctx, ) .ok(), - // TODO (taproot|arik) - #[cfg(taproot)] - _ => todo!(), }; if sig.is_none() { log_trace!(logger, "Closing transaction signature unavailable, waiting on signer"); @@ -11505,10 +11489,7 @@ where node_signature: our_node_sig, bitcoin_signature: our_bitcoin_sig, }) - }, - // TODO (taproot|arik) - #[cfg(taproot)] - _ => todo!() + } } } @@ -11538,10 +11519,7 @@ where bitcoin_signature_2: if were_node_one { their_bitcoin_sig } else { our_bitcoin_sig }, contents: announcement, }) - }, - // TODO (taproot|arik) - #[cfg(taproot)] - _ => todo!() + } } } else { Err(ChannelError::Ignore("Attempted to sign channel announcement before we'd received announcement_signatures".to_string())) @@ -11883,8 +11861,6 @@ where (Some(prev_funding_txid), ChannelSignerType::Ecdsa(ecdsa)) => { ecdsa.new_funding_pubkey(prev_funding_txid, &self.context.secp_ctx) }, - #[cfg(taproot)] - _ => todo!(), }; let funding_feerate_per_kw = context.funding_feerate_sat_per_1000_weight; @@ -11989,8 +11965,6 @@ where (Some(prev_funding_txid), ChannelSignerType::Ecdsa(ecdsa)) => { ecdsa.new_funding_pubkey(prev_funding_txid, &self.context.secp_ctx) }, - #[cfg(taproot)] - _ => todo!(), }; let mut new_keys = self.funding.get_holder_pubkeys().clone(); new_keys.funding_pubkey = funding_pubkey; @@ -12758,11 +12732,8 @@ where #[cfg(taproot)] partial_signature_with_nonce: None, }) - }, - // TODO (taproot|arik) - #[cfg(taproot)] - _ => todo!() - } + } + } } /// Adds a pending outbound HTLC to this channel, and builds a new remote commitment @@ -13319,15 +13290,11 @@ impl OutboundV1Channel { &self.context.counterparty_next_commitment_point.unwrap(), false, false, logger); let counterparty_initial_commitment_tx = commitment_data.tx; let signature = match &self.context.holder_signer { - // TODO (taproot|arik): move match into calling method for Taproot ChannelSignerType::Ecdsa(ecdsa) => { let channel_parameters = &self.funding.channel_transaction_parameters; ecdsa.sign_counterparty_commitment(channel_parameters, &counterparty_initial_commitment_tx, Vec::new(), Vec::new(), &self.context.secp_ctx) .map(|(sig, _)| sig).ok() }, - // TODO (taproot|arik) - #[cfg(taproot)] - _ => todo!() }; if signature.is_some() && self.context.signer_pending_funding { @@ -15775,8 +15742,6 @@ mod tests { #[cfg(ldk_test_vectors)] impl SignerProvider for Keys { type EcdsaSigner = InMemorySigner; - #[cfg(taproot)] - type TaprootSigner = InMemorySigner; fn generate_channel_keys_id(&self, _inbound: bool, _user_channel_id: u128) -> [u8; 32] { self.signer.channel_keys_id() diff --git a/lightning/src/sign/mod.rs b/lightning/src/sign/mod.rs index 84bfbb902ea..91e4a679d7c 100644 --- a/lightning/src/sign/mod.rs +++ b/lightning/src/sign/mod.rs @@ -65,8 +65,6 @@ use crate::util::transaction_utils; use crate::crypto::chacha20::ChaCha20; use crate::prelude::*; use crate::sign::ecdsa::EcdsaChannelSigner; -#[cfg(taproot)] -use crate::sign::taproot::TaprootChannelSigner; use crate::util::atomic_counter::AtomicCounter; use core::convert::TryInto; @@ -79,8 +77,6 @@ use musig2::types::{PartialSignature, PublicNonce}; pub(crate) mod type_resolver; pub mod ecdsa; -#[cfg(taproot)] -pub mod taproot; pub mod tx_builder; pub(crate) const COMPRESSED_PUBLIC_KEY_SIZE: usize = bitcoin::secp256k1::constants::PUBLIC_KEY_SIZE; @@ -1084,18 +1080,7 @@ impl> OutputSpender for O { /// A dynamic [`SignerProvider`] temporarily needed for doc tests. /// /// This is not exported to bindings users as it is not intended for public consumption. -#[cfg(taproot)] #[doc(hidden)] -#[deprecated(note = "Remove once taproot cfg is removed")] -pub type DynSignerProvider = - dyn SignerProvider; - -/// A dynamic [`SignerProvider`] temporarily needed for doc tests. -/// -/// This is not exported to bindings users as it is not intended for public consumption. -#[cfg(not(taproot))] -#[doc(hidden)] -#[deprecated(note = "Remove once taproot cfg is removed")] pub type DynSignerProvider = dyn SignerProvider; /// A trait that can return signer instances for individual channels. @@ -1109,9 +1094,6 @@ pub type DynSignerProvider = dyn SignerProvider; pub trait SignerProvider { /// A type which implements [`EcdsaChannelSigner`] which will be returned by [`Self::derive_channel_signer`]. type EcdsaSigner: EcdsaChannelSigner; - #[cfg(taproot)] - /// A type which implements [`TaprootChannelSigner`] - type TaprootSigner: TaprootChannelSigner; /// Generates a unique `channel_keys_id` that can be used to obtain a [`Self::EcdsaSigner`] through /// [`SignerProvider::derive_channel_signer`]. The `user_channel_id` is provided to allow @@ -1151,8 +1133,6 @@ pub trait SignerProvider { impl> SignerProvider for SP { type EcdsaSigner = T::EcdsaSigner; - #[cfg(taproot)] - type TaprootSigner = T::TaprootSigner; fn generate_channel_keys_id(&self, inbound: bool, user_channel_id: u128) -> [u8; 32] { self.deref().generate_channel_keys_id(inbound, user_channel_id) @@ -1983,65 +1963,6 @@ impl EcdsaChannelSigner for InMemorySigner { } } -#[cfg(taproot)] -#[allow(unused)] -impl TaprootChannelSigner for InMemorySigner { - fn generate_local_nonce_pair( - &self, commitment_number: u64, secp_ctx: &Secp256k1, - ) -> PublicNonce { - todo!() - } - - fn partially_sign_counterparty_commitment( - &self, counterparty_nonce: PublicNonce, commitment_tx: &CommitmentTransaction, - inbound_htlc_preimages: Vec, - outbound_htlc_preimages: Vec, secp_ctx: &Secp256k1, - ) -> Result<(PartialSignatureWithNonce, Vec), ()> { - todo!() - } - - fn finalize_holder_commitment( - &self, commitment_tx: &HolderCommitmentTransaction, - counterparty_partial_signature: PartialSignatureWithNonce, secp_ctx: &Secp256k1, - ) -> Result { - todo!() - } - - fn sign_justice_revoked_output( - &self, justice_tx: &Transaction, input: usize, amount: u64, per_commitment_key: &SecretKey, - secp_ctx: &Secp256k1, - ) -> Result { - todo!() - } - - fn sign_justice_revoked_htlc( - &self, justice_tx: &Transaction, input: usize, amount: u64, per_commitment_key: &SecretKey, - htlc: &HTLCOutputInCommitment, secp_ctx: &Secp256k1, - ) -> Result { - todo!() - } - - fn sign_holder_htlc_transaction( - &self, htlc_tx: &Transaction, input: usize, htlc_descriptor: &HTLCDescriptor, - secp_ctx: &Secp256k1, - ) -> Result { - todo!() - } - - fn sign_counterparty_htlc_transaction( - &self, htlc_tx: &Transaction, input: usize, amount: u64, per_commitment_point: &PublicKey, - htlc: &HTLCOutputInCommitment, secp_ctx: &Secp256k1, - ) -> Result { - todo!() - } - - fn partially_sign_closing_transaction( - &self, closing_tx: &ClosingTransaction, secp_ctx: &Secp256k1, - ) -> Result { - todo!() - } -} - /// Simple implementation of [`EntropySource`], [`NodeSigner`], and [`SignerProvider`] that takes a /// 32-byte seed for use as a BIP 32 extended key and derives keys from that. /// @@ -2548,8 +2469,6 @@ impl OutputSpender for KeysManager { impl SignerProvider for KeysManager { type EcdsaSigner = InMemorySigner; - #[cfg(taproot)] - type TaprootSigner = InMemorySigner; fn generate_channel_keys_id(&self, _inbound: bool, user_channel_id: u128) -> [u8; 32] { let child_idx = self.channel_child_index.fetch_add(1, Ordering::AcqRel); @@ -2697,8 +2616,6 @@ impl OutputSpender for PhantomKeysManager { impl SignerProvider for PhantomKeysManager { type EcdsaSigner = InMemorySigner; - #[cfg(taproot)] - type TaprootSigner = InMemorySigner; fn generate_channel_keys_id(&self, inbound: bool, user_channel_id: u128) -> [u8; 32] { self.inner.generate_channel_keys_id(inbound, user_channel_id) diff --git a/lightning/src/sign/taproot.rs b/lightning/src/sign/taproot.rs deleted file mode 100644 index 22470f4f8b6..00000000000 --- a/lightning/src/sign/taproot.rs +++ /dev/null @@ -1,155 +0,0 @@ -//! Defines a Taproot-specific signer type. - -use alloc::vec::Vec; -use bitcoin::secp256k1; -use bitcoin::secp256k1::{schnorr::Signature, PublicKey, Secp256k1, SecretKey}; -use bitcoin::transaction::Transaction; - -use musig2::types::{PartialSignature, PublicNonce}; - -use crate::ln::chan_utils::{ - ClosingTransaction, CommitmentTransaction, HTLCOutputInCommitment, HolderCommitmentTransaction, -}; -use crate::ln::msgs::PartialSignatureWithNonce; -use crate::sign::{ChannelSigner, HTLCDescriptor}; -use crate::types::payment::PaymentPreimage; - -/// A Taproot-specific signer type that defines signing-related methods that are either unique to -/// Taproot or have argument or return types that differ from the ones an ECDSA signer would be -/// expected to have. -pub trait TaprootChannelSigner: ChannelSigner { - /// Generate a local nonce pair, which requires committing to ahead of time. - /// The counterparty needs the public nonce generated herein to compute a partial signature. - fn generate_local_nonce_pair( - &self, commitment_number: u64, secp_ctx: &Secp256k1, - ) -> PublicNonce; - - /// Create a signature for a counterparty's commitment transaction and associated HTLC transactions. - /// - /// Note that if signing fails or is rejected, the channel will be force-closed. - /// - /// Policy checks should be implemented in this function, including checking the amount - /// sent to us and checking the HTLCs. - /// - /// The preimages of outbound and inbound HTLCs that were fulfilled since the last commitment - /// are provided. A validating signer should ensure that an outbound HTLC output is removed - /// only when the matching preimage is provided and after the corresponding inbound HTLC has - /// been removed for forwarded payments. - /// - /// Note that all the relevant preimages will be provided, but there may also be additional - /// irrelevant or duplicate preimages. - // - // TODO: Document the things someone using this interface should enforce before signing. - fn partially_sign_counterparty_commitment( - &self, counterparty_nonce: PublicNonce, commitment_tx: &CommitmentTransaction, - inbound_htlc_preimages: Vec, - outbound_htlc_preimages: Vec, secp_ctx: &Secp256k1, - ) -> Result<(PartialSignatureWithNonce, Vec), ()>; - - /// Creates a signature for a holder's commitment transaction. - /// - /// This will be called - /// - with a non-revoked `commitment_tx`. - /// - with the latest `commitment_tx` when we initiate a force-close. - /// - /// This may be called multiple times for the same transaction. - /// - /// An external signer implementation should check that the commitment has not been revoked. - /// - // TODO: Document the things someone using this interface should enforce before signing. - fn finalize_holder_commitment( - &self, commitment_tx: &HolderCommitmentTransaction, - counterparty_partial_signature: PartialSignatureWithNonce, - secp_ctx: &Secp256k1, - ) -> Result; - - /// Create a signature for the given input in a transaction spending an HTLC transaction output - /// or a commitment transaction `to_local` output when our counterparty broadcasts an old state. - /// - /// A justice transaction may claim multiple outputs at the same time if timelocks are - /// similar, but only a signature for the input at index `input` should be signed for here. - /// It may be called multiple times for same output(s) if a fee-bump is needed with regards - /// to an upcoming timelock expiration. - /// - /// Amount is value of the output spent by this input, committed to in the BIP 341 signature. - /// - /// `per_commitment_key` is revocation secret which was provided by our counterparty when they - /// revoked the state which they eventually broadcast. It's not a _holder_ secret key and does - /// not allow the spending of any funds by itself (you need our holder `revocation_secret` to do - /// so). - fn sign_justice_revoked_output( - &self, justice_tx: &Transaction, input: usize, amount: u64, per_commitment_key: &SecretKey, - secp_ctx: &Secp256k1, - ) -> Result; - - /// Create a signature for the given input in a transaction spending a commitment transaction - /// HTLC output when our counterparty broadcasts an old state. - /// - /// A justice transaction may claim multiple outputs at the same time if timelocks are - /// similar, but only a signature for the input at index `input` should be signed for here. - /// It may be called multiple times for same output(s) if a fee-bump is needed with regards - /// to an upcoming timelock expiration. - /// - /// `amount` is the value of the output spent by this input, committed to in the BIP 341 - /// signature. - /// - /// `per_commitment_key` is revocation secret which was provided by our counterparty when they - /// revoked the state which they eventually broadcast. It's not a _holder_ secret key and does - /// not allow the spending of any funds by itself (you need our holder revocation_secret to do - /// so). - /// - /// `htlc` holds HTLC elements (hash, timelock), thus changing the format of the witness script - /// (which is committed to in the BIP 341 signatures). - fn sign_justice_revoked_htlc( - &self, justice_tx: &Transaction, input: usize, amount: u64, per_commitment_key: &SecretKey, - htlc: &HTLCOutputInCommitment, secp_ctx: &Secp256k1, - ) -> Result; - - /// Computes the signature for a commitment transaction's HTLC output used as an input within - /// `htlc_tx`, which spends the commitment transaction at index `input`. The signature returned - /// must be be computed using [`TapSighashType::Default`]. - /// - /// Note that this may be called for HTLCs in the penultimate commitment transaction if a - /// [`ChannelMonitor`] [replica](https://github.com/lightningdevkit/rust-lightning/blob/main/GLOSSARY.md#monitor-replicas) - /// broadcasts it before receiving the update for the latest commitment transaction. - /// - /// - /// [`TapSighashType::Default`]: bitcoin::sighash::TapSighashType::Default - /// [`ChannelMonitor`]: crate::chain::channelmonitor::ChannelMonitor - fn sign_holder_htlc_transaction( - &self, htlc_tx: &Transaction, input: usize, htlc_descriptor: &HTLCDescriptor, - secp_ctx: &Secp256k1, - ) -> Result; - - /// Create a signature for a claiming transaction for a HTLC output on a counterparty's commitment - /// transaction, either offered or received. - /// - /// Such a transaction may claim multiples offered outputs at same time if we know the - /// preimage for each when we create it, but only the input at index `input` should be - /// signed for here. It may be called multiple times for same output(s) if a fee-bump is - /// needed with regards to an upcoming timelock expiration. - /// - /// `witness_script` is either an offered or received script as defined in BOLT3 for HTLC - /// outputs. - /// - /// `amount` is value of the output spent by this input, committed to in the BIP 341 signature. - /// - /// `per_commitment_point` is the dynamic point corresponding to the channel state - /// detected onchain. It has been generated by our counterparty and is used to derive - /// channel state keys, which are then included in the witness script and committed to in the - /// BIP 341 signature. - fn sign_counterparty_htlc_transaction( - &self, htlc_tx: &Transaction, input: usize, amount: u64, per_commitment_point: &PublicKey, - htlc: &HTLCOutputInCommitment, secp_ctx: &Secp256k1, - ) -> Result; - - /// Create a signature for a (proposed) closing transaction. - /// - /// Note that, due to rounding, there may be one "missing" satoshi, and either party may have - /// chosen to forgo their output as dust. - fn partially_sign_closing_transaction( - &self, closing_tx: &ClosingTransaction, secp_ctx: &Secp256k1, - ) -> Result; - - // TODO: sign channel announcement -} diff --git a/lightning/src/sign/type_resolver.rs b/lightning/src/sign/type_resolver.rs index 405e346dda6..3e784893bed 100644 --- a/lightning/src/sign/type_resolver.rs +++ b/lightning/src/sign/type_resolver.rs @@ -3,9 +3,6 @@ use crate::sign::{ChannelSigner, SignerProvider}; pub(crate) enum ChannelSignerType { // in practice, this will only ever be an EcdsaChannelSigner (specifically, Writeable) Ecdsa(SP::EcdsaSigner), - #[cfg(taproot)] - #[allow(unused)] - Taproot(SP::TaprootSigner), } #[cfg(test)] @@ -19,9 +16,6 @@ impl ChannelSignerType { pub(crate) fn as_ref(&self) -> &dyn ChannelSigner { match self { ChannelSignerType::Ecdsa(ecs) => ecs, - #[cfg(taproot)] - #[allow(unused)] - ChannelSignerType::Taproot(tcs) => tcs, } } diff --git a/lightning/src/util/dyn_signer.rs b/lightning/src/util/dyn_signer.rs index cf1cac37903..436eaabda34 100644 --- a/lightning/src/util/dyn_signer.rs +++ b/lightning/src/util/dyn_signer.rs @@ -12,8 +12,6 @@ use crate::ln::inbound_payment::ExpandedKey; use crate::ln::msgs::{UnsignedChannelAnnouncement, UnsignedGossipMessage}; use crate::ln::script::ShutdownScript; use crate::sign::ecdsa::EcdsaChannelSigner; -#[cfg(taproot)] -use crate::sign::taproot::TaprootChannelSigner; use crate::sign::InMemorySigner; use crate::sign::{ChannelSigner, ReceiveAuthKey}; use crate::sign::{EntropySource, HTLCDescriptor, OutputSpender, PhantomKeysManager}; @@ -25,20 +23,13 @@ use bitcoin::absolute::LockTime; use bitcoin::secp256k1::All; use bitcoin::{secp256k1, ScriptBuf, Transaction, TxOut, Txid}; use lightning_invoice::RawBolt11Invoice; -#[cfg(taproot)] -use musig2::types::{PartialSignature, PublicNonce}; use secp256k1::ecdsa::RecoverableSignature; use secp256k1::{ecdh::SharedSecret, ecdsa::Signature, PublicKey, Scalar, Secp256k1, SecretKey}; use types::payment::PaymentPreimage; -#[cfg(not(taproot))] /// A super-trait for all the traits that a dyn signer backing implements pub trait DynSignerTrait: EcdsaChannelSigner + Send + Sync {} -#[cfg(taproot)] -/// A super-trait for all the traits that a dyn signer backing implements -pub trait DynSignerTrait: EcdsaChannelSigner + TaprootChannelSigner + Send + Sync {} - /// Helper to allow DynSigner to clone itself pub trait InnerSign: DynSignerTrait { /// Clone into a Box @@ -60,67 +51,6 @@ impl DynSigner { } } -#[cfg(taproot)] -#[allow(unused_variables)] -impl TaprootChannelSigner for DynSigner { - fn generate_local_nonce_pair( - &self, commitment_number: u64, secp_ctx: &Secp256k1, - ) -> PublicNonce { - todo!() - } - - fn partially_sign_counterparty_commitment( - &self, counterparty_nonce: PublicNonce, commitment_tx: &CommitmentTransaction, - inbound_htlc_preimages: Vec, - outbound_htlc_preimages: Vec, secp_ctx: &Secp256k1, - ) -> Result<(crate::ln::msgs::PartialSignatureWithNonce, Vec), ()> - { - todo!(); - } - - fn finalize_holder_commitment( - &self, commitment_tx: &HolderCommitmentTransaction, - counterparty_partial_signature: crate::ln::msgs::PartialSignatureWithNonce, - secp_ctx: &Secp256k1, - ) -> Result { - todo!(); - } - - fn sign_justice_revoked_output( - &self, justice_tx: &Transaction, input: usize, amount: u64, per_commitment_key: &SecretKey, - secp_ctx: &Secp256k1, - ) -> Result { - todo!(); - } - - fn sign_justice_revoked_htlc( - &self, justice_tx: &Transaction, input: usize, amount: u64, per_commitment_key: &SecretKey, - htlc: &HTLCOutputInCommitment, secp_ctx: &Secp256k1, - ) -> Result { - todo!(); - } - - fn sign_holder_htlc_transaction( - &self, htlc_tx: &Transaction, input: usize, htlc_descriptor: &HTLCDescriptor, - secp_ctx: &Secp256k1, - ) -> Result { - todo!(); - } - - fn sign_counterparty_htlc_transaction( - &self, htlc_tx: &Transaction, input: usize, amount: u64, per_commitment_point: &PublicKey, - htlc: &HTLCOutputInCommitment, secp_ctx: &Secp256k1, - ) -> Result { - todo!(); - } - - fn partially_sign_closing_transaction( - &self, closing_tx: &ClosingTransaction, secp_ctx: &Secp256k1, - ) -> Result { - todo!(); - } -} - impl Clone for DynSigner { fn clone(&self) -> Self { DynSigner { inner: self.inner.box_clone() } @@ -231,8 +161,6 @@ delegate!(DynKeysInterface, SignerProvider, fn generate_channel_keys_id(, _inbound: bool, _user_channel_id: u128) -> [u8; 32], fn derive_channel_signer(, _channel_keys_id: [u8; 32]) -> Self::EcdsaSigner; type EcdsaSigner = DynSigner, - #[cfg(taproot)] - type TaprootSigner = DynSigner ); delegate!(DynKeysInterface, EntropySource, inner, @@ -246,25 +174,12 @@ delegate!(DynKeysInterface, OutputSpender, inner, locktime: Option, secp_ctx: &Secp256k1 ) -> Result ); -#[cfg(not(taproot))] /// A supertrait for all the traits that a keys interface implements pub trait DynKeysInterfaceTrait: NodeSigner + OutputSpender + SignerProvider + EntropySource + Send + Sync { } -#[cfg(taproot)] -/// A supertrait for all the traits that a keys interface implements -pub trait DynKeysInterfaceTrait: - NodeSigner - + OutputSpender - + SignerProvider - + EntropySource - + Send - + Sync -{ -} - /// A dyn wrapper for PhantomKeysManager pub struct DynPhantomKeysInterface { inner: Box, @@ -293,8 +208,6 @@ delegate!(DynPhantomKeysInterface, NodeSigner, impl SignerProvider for DynPhantomKeysInterface { type EcdsaSigner = DynSigner; - #[cfg(taproot)] - type TaprootSigner = DynSigner; fn get_destination_script(&self, channel_keys_id: [u8; 32]) -> Result { self.inner.get_destination_script(channel_keys_id) diff --git a/lightning/src/util/test_channel_signer.rs b/lightning/src/util/test_channel_signer.rs index 70eb3223bc4..b1912bd3f21 100644 --- a/lightning/src/util/test_channel_signer.rs +++ b/lightning/src/util/test_channel_signer.rs @@ -36,13 +36,9 @@ use bitcoin::Txid; #[cfg(taproot)] use crate::ln::msgs::PartialSignatureWithNonce; -#[cfg(taproot)] -use crate::sign::taproot::TaprootChannelSigner; use crate::sign::HTLCDescriptor; use crate::util::dyn_signer::DynSigner; use bitcoin::secp256k1; -#[cfg(taproot)] -use bitcoin::secp256k1::All; use bitcoin::secp256k1::{ecdsa::Signature, Secp256k1}; use bitcoin::secp256k1::{PublicKey, SecretKey}; #[cfg(taproot)] @@ -520,65 +516,6 @@ impl EcdsaChannelSigner for TestChannelSigner { } } -#[cfg(taproot)] -#[allow(unused)] -impl TaprootChannelSigner for TestChannelSigner { - fn generate_local_nonce_pair( - &self, commitment_number: u64, secp_ctx: &Secp256k1, - ) -> PublicNonce { - todo!() - } - - fn partially_sign_counterparty_commitment( - &self, counterparty_nonce: PublicNonce, commitment_tx: &CommitmentTransaction, - inbound_htlc_preimages: Vec, - outbound_htlc_preimages: Vec, secp_ctx: &Secp256k1, - ) -> Result<(PartialSignatureWithNonce, Vec), ()> { - todo!() - } - - fn finalize_holder_commitment( - &self, commitment_tx: &HolderCommitmentTransaction, - counterparty_partial_signature: PartialSignatureWithNonce, secp_ctx: &Secp256k1, - ) -> Result { - todo!() - } - - fn sign_justice_revoked_output( - &self, justice_tx: &Transaction, input: usize, amount: u64, per_commitment_key: &SecretKey, - secp_ctx: &Secp256k1, - ) -> Result { - todo!() - } - - fn sign_justice_revoked_htlc( - &self, justice_tx: &Transaction, input: usize, amount: u64, per_commitment_key: &SecretKey, - htlc: &HTLCOutputInCommitment, secp_ctx: &Secp256k1, - ) -> Result { - todo!() - } - - fn sign_holder_htlc_transaction( - &self, htlc_tx: &Transaction, input: usize, htlc_descriptor: &HTLCDescriptor, - secp_ctx: &Secp256k1, - ) -> Result { - todo!() - } - - fn sign_counterparty_htlc_transaction( - &self, htlc_tx: &Transaction, input: usize, amount: u64, per_commitment_point: &PublicKey, - htlc: &HTLCOutputInCommitment, secp_ctx: &Secp256k1, - ) -> Result { - todo!() - } - - fn partially_sign_closing_transaction( - &self, closing_tx: &ClosingTransaction, secp_ctx: &Secp256k1, - ) -> Result { - todo!() - } -} - impl TestChannelSigner { fn verify_counterparty_commitment_tx<'a, T: secp256k1::Signing + secp256k1::Verification>( &self, channel_parameters: &ChannelTransactionParameters, diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 22be4367c7a..47f40edcc45 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -457,8 +457,6 @@ impl EntropySource for OnlyReadsKeysInterface { impl SignerProvider for OnlyReadsKeysInterface { type EcdsaSigner = TestChannelSigner; - #[cfg(taproot)] - type TaprootSigner = TestChannelSigner; fn generate_channel_keys_id(&self, _inbound: bool, _user_channel_id: u128) -> [u8; 32] { unreachable!(); @@ -1926,8 +1924,6 @@ impl NodeSigner for TestKeysInterface { impl SignerProvider for TestKeysInterface { type EcdsaSigner = TestChannelSigner; - #[cfg(taproot)] - type TaprootSigner = TestChannelSigner; fn generate_channel_keys_id(&self, inbound: bool, user_channel_id: u128) -> [u8; 32] { let mut override_keys = self.override_next_keys_id.lock().unwrap(); From 85aa95be91e0c6965382805bbe578a02d52eebc3 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Thu, 12 Mar 2026 12:59:55 -0700 Subject: [PATCH 171/627] Remove musig2 crate Taproot support is not planned we see an increase in demand for it by users. The `musig2` crate is now owned by a third-party, and ultimately won't be used by the production version of Taproot that we end up releasing. --- Cargo.toml | 1 - ci/ci-tests-cfg-flags.sh | 2 - fuzz/Cargo.toml | 1 - lightning-tests/Cargo.toml | 4 +- lightning-tests/src/lib.rs | 2 +- lightning/Cargo.toml | 3 - lightning/src/ln/channel.rs | 16 +-- lightning/src/ln/functional_tests.rs | 2 - lightning/src/ln/htlc_reserve_unit_tests.rs | 8 -- lightning/src/ln/msgs.rs | 104 +------------------- lightning/src/ln/update_fee_tests.rs | 4 - lightning/src/sign/mod.rs | 4 - lightning/src/util/ser.rs | 37 ------- lightning/src/util/test_channel_signer.rs | 4 - 14 files changed, 5 insertions(+), 187 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1eb7b572d8b..7978d9de6a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,7 +63,6 @@ check-cfg = [ "cfg(c_bindings)", "cfg(ldk_bench)", "cfg(ldk_test_vectors)", - "cfg(taproot)", "cfg(require_route_graph_test)", "cfg(simple_close)", "cfg(peer_storage)", diff --git a/ci/ci-tests-cfg-flags.sh b/ci/ci-tests-cfg-flags.sh index 5380c986f3f..e6a22a83491 100755 --- a/ci/ci-tests-cfg-flags.sh +++ b/ci/ci-tests-cfg-flags.sh @@ -5,8 +5,6 @@ set -eox pipefail source "$(dirname "$0")/ci-tests-common.sh" echo -e "\n\nTest cfg-flag builds" -RUSTFLAGS="--cfg=taproot" cargo test --quiet --color always -p lightning -[ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean RUSTFLAGS="--cfg=simple_close" cargo test --quiet --color always -p lightning [ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean RUSTFLAGS="--cfg=lsps1_service" cargo test --quiet --color always -p lightning-liquidity diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 5bf899f34b1..5a2e397a064 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -62,5 +62,4 @@ check-cfg = [ "cfg(fuzzing)", "cfg(secp256k1_fuzz)", "cfg(hashes_fuzz)", - "cfg(taproot)", ] diff --git a/lightning-tests/Cargo.toml b/lightning-tests/Cargo.toml index 4e8d330089d..05a5bd55ce5 100644 --- a/lightning-tests/Cargo.toml +++ b/lightning-tests/Cargo.toml @@ -29,6 +29,4 @@ level = "forbid" # # Note that Cargo automatically declares corresponding cfgs for every feature # defined in the member-level [features] tables as "expected". -check-cfg = [ - "cfg(taproot)", -] +check-cfg = [] diff --git a/lightning-tests/src/lib.rs b/lightning-tests/src/lib.rs index c028193d692..80c95299d5b 100644 --- a/lightning-tests/src/lib.rs +++ b/lightning-tests/src/lib.rs @@ -1,5 +1,5 @@ #[cfg_attr(test, macro_use)] extern crate lightning; -#[cfg(all(test, not(taproot)))] +#[cfg(test)] pub mod upgrade_downgrade_tests; diff --git a/lightning/Cargo.toml b/lightning/Cargo.toml index fd6c5052359..2f2f01bc401 100644 --- a/lightning/Cargo.toml +++ b/lightning/Cargo.toml @@ -65,8 +65,5 @@ features = ["bitcoinconsensus", "secp-recovery"] [target.'cfg(ldk_bench)'.dependencies] criterion = { version = "0.4", optional = true, default-features = false } -[target.'cfg(taproot)'.dependencies] -musig2 = { git = "https://github.com/arik-so/rust-musig2", rev = "6f95a05718cbb44d8fe3fa6021aea8117aa38d50" } - [lints] workspace = true diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 1a69f52d4f6..b939c3d2d96 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -6002,8 +6002,6 @@ impl ChannelContext { signature.map(|(signature, _)| msgs::FundingSigned { channel_id: self.channel_id(), signature, - #[cfg(taproot)] - partial_signature_with_nonce: None, }) } @@ -6129,8 +6127,6 @@ impl ChannelContext { htlc_signatures, signature, funding_txid: funding.get_funding_txo().map(|funding_txo| funding_txo.txid), - #[cfg(taproot)] - partial_signature_with_nonce: None, }) } else { log_debug!( @@ -9463,8 +9459,6 @@ where channel_id: self.context.channel_id, per_commitment_secret, next_per_commitment_point: self.holder_commitment_point.next_point(), - #[cfg(taproot)] - next_local_nonce: None, release_htlc_message_paths, }); } @@ -11519,7 +11513,7 @@ where bitcoin_signature_2: if were_node_one { their_bitcoin_sig } else { our_bitcoin_sig }, contents: announcement, }) - } + }, } } else { Err(ChannelError::Ignore("Attempted to sign channel announcement before we'd received announcement_signatures".to_string())) @@ -12729,8 +12723,6 @@ where signature, htlc_signatures, funding_txid: funding.get_funding_txo().map(|funding_txo| funding_txo.txid), - #[cfg(taproot)] - partial_signature_with_nonce: None, }) } } @@ -13310,10 +13302,6 @@ impl OutboundV1Channel { funding_txid: self.funding.channel_transaction_parameters.funding_outpoint.as_ref().unwrap().txid, funding_output_index: self.funding.channel_transaction_parameters.funding_outpoint.as_ref().unwrap().index, signature, - #[cfg(taproot)] - partial_signature_with_nonce: None, - #[cfg(taproot)] - next_local_nonce: None, }) } @@ -13718,8 +13706,6 @@ impl InboundV1Channel { channel_type: Some(self.funding.get_channel_type().clone()), }, channel_reserve_satoshis: self.funding.holder_selected_channel_reserve_satoshis, - #[cfg(taproot)] - next_local_nonce: None, }) } diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index 17fbc1fce28..eb868d268ac 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -6699,8 +6699,6 @@ pub fn test_counterparty_raa_skip_no_crash() { channel_id, per_commitment_secret, next_per_commitment_point, - #[cfg(taproot)] - next_local_nonce: None, release_htlc_message_paths: Vec::new(), }; nodes[1].node.handle_revoke_and_ack(node_a_id, &raa); diff --git a/lightning/src/ln/htlc_reserve_unit_tests.rs b/lightning/src/ln/htlc_reserve_unit_tests.rs index d88b9a2dc3f..80b3ebd1921 100644 --- a/lightning/src/ln/htlc_reserve_unit_tests.rs +++ b/lightning/src/ln/htlc_reserve_unit_tests.rs @@ -930,8 +930,6 @@ pub fn do_test_fee_spike_buffer(cfg: Option, htlc_fails: bool) { signature: res.0, htlc_signatures: res.1, funding_txid: None, - #[cfg(taproot)] - partial_signature_with_nonce: None, }; // Send the commitment_signed message to the nodes[1]. @@ -943,8 +941,6 @@ pub fn do_test_fee_spike_buffer(cfg: Option, htlc_fails: bool) { channel_id: chan.2, per_commitment_secret: local_secret, next_per_commitment_point: next_local_point, - #[cfg(taproot)] - next_local_nonce: None, release_htlc_message_paths: Vec::new(), }; nodes[1].node.handle_revoke_and_ack(node_a_id, &raa_msg); @@ -2388,8 +2384,6 @@ pub fn do_test_dust_limit_fee_accounting(can_afford: bool) { signature: res.0, htlc_signatures: res.1, funding_txid: None, - #[cfg(taproot)] - partial_signature_with_nonce: None, }; // Send the commitment_signed message to the nodes[1]. @@ -2401,8 +2395,6 @@ pub fn do_test_dust_limit_fee_accounting(can_afford: bool) { channel_id: chan_id, per_commitment_secret: local_secret, next_per_commitment_point: next_local_point, - #[cfg(taproot)] - next_local_nonce: None, release_htlc_message_paths: Vec::new(), }; nodes[1].node.handle_revoke_and_ack(node_a_id, &raa_msg); diff --git a/lightning/src/ln/msgs.rs b/lightning/src/ln/msgs.rs index ac549ddd50c..29089032843 100644 --- a/lightning/src/ln/msgs.rs +++ b/lightning/src/ln/msgs.rs @@ -69,14 +69,6 @@ use crate::routing::gossip::{NodeAlias, NodeId}; /// 21 million * 10^8 * 1000 pub(crate) const MAX_VALUE_MSAT: u64 = 21_000_000_0000_0000_000; -#[cfg(taproot)] -/// A partial signature that also contains the Musig2 nonce its signer used -#[derive(Clone, Debug, Hash, PartialEq, Eq)] -pub struct PartialSignatureWithNonce( - pub musig2::types::PartialSignature, - pub musig2::types::PublicNonce, -); - /// An error in decoding a message or struct. #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub enum DecodeError { @@ -370,9 +362,6 @@ pub struct AcceptChannel { pub common_fields: CommonAcceptChannelFields, /// The minimum value unencumbered by HTLCs for the counterparty to keep in the channel pub channel_reserve_satoshis: u64, - #[cfg(taproot)] - /// Next nonce the channel initiator should use to create a funding output signature against - pub next_local_nonce: Option, } /// An [`accept_channel2`] message to be sent by or received from the channel accepter. @@ -407,12 +396,6 @@ pub struct FundingCreated { pub funding_output_index: u16, /// The signature of the channel initiator (funder) on the initial commitment transaction pub signature: Signature, - #[cfg(taproot)] - /// The partial signature of the channel initiator (funder) - pub partial_signature_with_nonce: Option, - #[cfg(taproot)] - /// Next nonce the channel acceptor should use to finalize the funding output signature - pub next_local_nonce: Option, } /// A [`funding_signed`] message to be sent to or received from a peer. @@ -426,9 +409,6 @@ pub struct FundingSigned { pub channel_id: ChannelId, /// The signature of the channel acceptor (fundee) on the initial commitment transaction pub signature: Signature, - #[cfg(taproot)] - /// The partial signature of the channel acceptor (fundee) - pub partial_signature_with_nonce: Option, } /// A [`channel_ready`] message to be sent to or received from a peer. @@ -906,9 +886,6 @@ pub struct CommitmentSigned { pub htlc_signatures: Vec, /// The funding transaction, to discriminate among multiple pending funding transactions (e.g. in case of splicing) pub funding_txid: Option, - #[cfg(taproot)] - /// The partial Taproot signature on the commitment transaction - pub partial_signature_with_nonce: Option, } /// A [`revoke_and_ack`] message to be sent to or received from a peer. @@ -922,9 +899,6 @@ pub struct RevokeAndACK { pub per_commitment_secret: [u8; 32], /// The next sender-broadcast commitment transaction's per-commitment point pub next_per_commitment_point: PublicKey, - #[cfg(taproot)] - /// Musig nonce the recipient should use in their next commitment signature message - pub next_local_nonce: Option, /// A list of `(htlc_id, blinded_path)`. The receiver of this message will use the blinded paths /// as reply paths to [`HeldHtlcAvailable`] onion messages that they send to the often-offline /// receiver of this HTLC. The `htlc_id` is used by the receiver of this message to identify which @@ -2909,17 +2883,10 @@ impl Writeable for AcceptChannel { self.common_fields.delayed_payment_basepoint.write(w)?; self.common_fields.htlc_basepoint.write(w)?; self.common_fields.first_per_commitment_point.write(w)?; - #[cfg(not(taproot))] encode_tlv_stream!(w, { (0, self.common_fields.shutdown_scriptpubkey.as_ref().map(|s| WithoutLength(s)), option), // Don't encode length twice. (1, self.common_fields.channel_type, option), }); - #[cfg(taproot)] - encode_tlv_stream!(w, { - (0, self.common_fields.shutdown_scriptpubkey.as_ref().map(|s| WithoutLength(s)), option), // Don't encode length twice. - (1, self.common_fields.channel_type, option), - (4, self.next_local_nonce, option), - }); Ok(()) } } @@ -2943,18 +2910,9 @@ impl LengthReadable for AcceptChannel { let mut shutdown_scriptpubkey: Option = None; let mut channel_type: Option = None; - #[cfg(not(taproot))] - decode_tlv_stream!(r, { - (0, shutdown_scriptpubkey, (option, encoding: (ScriptBuf, WithoutLength))), - (1, channel_type, option), - }); - #[cfg(taproot)] - let mut next_local_nonce: Option = None; - #[cfg(taproot)] decode_tlv_stream!(r, { (0, shutdown_scriptpubkey, (option, encoding: (ScriptBuf, WithoutLength))), (1, channel_type, option), - (4, next_local_nonce, option), }); Ok(AcceptChannel { @@ -2976,8 +2934,6 @@ impl LengthReadable for AcceptChannel { channel_type, }, channel_reserve_satoshis, - #[cfg(taproot)] - next_local_nonce, }) } } @@ -3245,7 +3201,6 @@ impl_writeable!(ClosingSignedFeeRange, { max_fee_satoshis }); -#[cfg(not(taproot))] impl_writeable_msg!(CommitmentSigned, { channel_id, signature, @@ -3254,54 +3209,24 @@ impl_writeable_msg!(CommitmentSigned, { (1, funding_txid, option), }); -#[cfg(taproot)] -impl_writeable_msg!(CommitmentSigned, { - channel_id, - signature, - htlc_signatures -}, { - (1, funding_txid, option), - (2, partial_signature_with_nonce, option), -}); - impl_writeable!(DecodedOnionErrorPacket, { hmac, failuremsg, pad }); -#[cfg(not(taproot))] impl_writeable_msg!(FundingCreated, { temporary_channel_id, funding_txid, funding_output_index, signature }, {}); -#[cfg(taproot)] -impl_writeable_msg!(FundingCreated, { - temporary_channel_id, - funding_txid, - funding_output_index, - signature -}, { - (2, partial_signature_with_nonce, option), - (4, next_local_nonce, option) -}); -#[cfg(not(taproot))] impl_writeable_msg!(FundingSigned, { channel_id, signature }, {}); -#[cfg(taproot)] -impl_writeable_msg!(FundingSigned, { - channel_id, - signature -}, { - (2, partial_signature_with_nonce, option) -}); - impl_writeable_msg!(ChannelReady, { channel_id, next_per_commitment_point, @@ -3529,7 +3454,6 @@ impl LengthReadable for OpenChannelV2 { } } -#[cfg(not(taproot))] impl_writeable_msg!(RevokeAndACK, { channel_id, per_commitment_secret, @@ -3538,16 +3462,6 @@ impl_writeable_msg!(RevokeAndACK, { (75537, release_htlc_message_paths, optional_vec) }); -#[cfg(taproot)] -impl_writeable_msg!(RevokeAndACK, { - channel_id, - per_commitment_secret, - next_per_commitment_point -}, { - (4, next_local_nonce, option), - (75537, release_htlc_message_paths, optional_vec) -}); - impl_writeable_msg!(Shutdown, { channel_id, scriptpubkey @@ -5504,8 +5418,6 @@ mod tests { channel_type: None, }, channel_reserve_satoshis: 3608586615801332854, - #[cfg(taproot)] - next_local_nonce: None, }; let encoded_value = accept_channel.encode(); let mut target_value = >::from_hex("020202020202020202020202020202020202020202020202020202020202020212345678901234562334032891223698321446687011447600083a840000034d000c89d4c0bcc0bc031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d076602531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe33703462779ad4aad39514614751a71085f2f10e1c7a593e4e030efb5b8721ce55b0b0362c0a046dacce86ddd0343c6d3c7c79c2208ba0d9c9cf24a6d046d21d21f90f703f006a18d5653c4edf5391ff23a61f03ff83d237e880ee61187fa9f379a028e0a").unwrap(); @@ -5671,10 +5583,6 @@ mod tests { .unwrap(), funding_output_index: 255, signature: sig_1, - #[cfg(taproot)] - partial_signature_with_nonce: None, - #[cfg(taproot)] - next_local_nonce: None, }; let encoded_value = funding_created.encode(); let target_value = >::from_hex("02020202020202020202020202020202020202020202020202020202020202026e96fe9f8b0ddcd729ba03cfafa5a27b050b39d354dd980814268dfa9a44d4c200ffd977cb9b53d93a6ff64bb5f1e158b4094b66e798fb12911168a3ccdf80a83096340a6a95da0ae8d9f776528eecdbb747eb6b545495a4319ed5378e35b21e073a").unwrap(); @@ -5690,12 +5598,8 @@ mod tests { ); let sig_1 = get_sig_on!(privkey_1, secp_ctx, String::from("01010101010101010101010101010101")); - let funding_signed = msgs::FundingSigned { - channel_id: ChannelId::from_bytes([2; 32]), - signature: sig_1, - #[cfg(taproot)] - partial_signature_with_nonce: None, - }; + let funding_signed = + msgs::FundingSigned { channel_id: ChannelId::from_bytes([2; 32]), signature: sig_1 }; let encoded_value = funding_signed.encode(); let target_value = >::from_hex("0202020202020202020202020202020202020202020202020202020202020202d977cb9b53d93a6ff64bb5f1e158b4094b66e798fb12911168a3ccdf80a83096340a6a95da0ae8d9f776528eecdbb747eb6b545495a4319ed5378e35b21e073a").unwrap(); assert_eq!(encoded_value, target_value); @@ -6234,8 +6138,6 @@ mod tests { Txid::from_str("c2d4449afa8d26140898dd54d3390b057ba2a5afcf03ba29d7dc0d8b9ffe966e") .unwrap(), ), - #[cfg(taproot)] - partial_signature_with_nonce: None, }; let encoded_value = commitment_signed.encode(); let mut target_value = "0202020202020202020202020202020202020202020202020202020202020202d977cb9b53d93a6ff64bb5f1e158b4094b66e798fb12911168a3ccdf80a83096340a6a95da0ae8d9f776528eecdbb747eb6b545495a4319ed5378e35b21e073a".to_string(); @@ -6270,8 +6172,6 @@ mod tests { 1, 1, 1, 1, ], next_per_commitment_point: pubkey_1, - #[cfg(taproot)] - next_local_nonce: None, release_htlc_message_paths: Vec::new(), }; let encoded_value = raa.encode(); diff --git a/lightning/src/ln/update_fee_tests.rs b/lightning/src/ln/update_fee_tests.rs index ac566393bdb..77a4c322736 100644 --- a/lightning/src/ln/update_fee_tests.rs +++ b/lightning/src/ln/update_fee_tests.rs @@ -508,8 +508,6 @@ pub fn do_test_update_fee_that_funder_cannot_afford(channel_type_features: Chann signature: res.0, htlc_signatures: res.1, funding_txid: None, - #[cfg(taproot)] - partial_signature_with_nonce: None, }; let update_fee = msgs::UpdateFee { channel_id: chan.2, feerate_per_kw: non_buffer_feerate + 4 }; @@ -608,8 +606,6 @@ pub fn test_update_fee_that_saturates_subs() { signature: res.0, htlc_signatures: res.1, funding_txid: None, - #[cfg(taproot)] - partial_signature_with_nonce: None, }; let update_fee = msgs::UpdateFee { channel_id: chan_id, feerate_per_kw: FEERATE }; diff --git a/lightning/src/sign/mod.rs b/lightning/src/sign/mod.rs index 91e4a679d7c..adab37286ca 100644 --- a/lightning/src/sign/mod.rs +++ b/lightning/src/sign/mod.rs @@ -51,8 +51,6 @@ use crate::ln::channel_keys::{ RevocationBasepoint, RevocationKey, }; use crate::ln::inbound_payment::ExpandedKey; -#[cfg(taproot)] -use crate::ln::msgs::PartialSignatureWithNonce; use crate::ln::msgs::{UnsignedChannelAnnouncement, UnsignedGossipMessage}; use crate::ln::script::ShutdownScript; use crate::offers::invoice::UnsignedBolt12Invoice; @@ -71,8 +69,6 @@ use core::convert::TryInto; use core::future::Future; use core::ops::Deref; use core::sync::atomic::{AtomicUsize, Ordering}; -#[cfg(taproot)] -use musig2::types::{PartialSignature, PublicNonce}; pub(crate) mod type_resolver; diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs index b226332ae93..ec5d9a0f3a1 100644 --- a/lightning/src/util/ser.rs +++ b/lightning/src/util/ser.rs @@ -47,8 +47,6 @@ use bitcoin::{consensus, Sequence, TxIn, Weight, Witness}; use dnssec_prover::rr::Name; use crate::chain::ClaimId; -#[cfg(taproot)] -use crate::ln::msgs::PartialSignatureWithNonce; use crate::ln::msgs::{DecodeError, SerialId}; use crate::types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; use crate::types::string::UntrustedString; @@ -734,7 +732,6 @@ impl_array!(16, u8); // for IPv6 impl_array!(32, u8); // for channel id & hmac impl_array!(PUBLIC_KEY_SIZE, u8); // for PublicKey impl_array!(64, u8); // for ecdsa::Signature and schnorr::Signature -impl_array!(66, u8); // for MuSig2 nonces impl_array!(1300, u8); // for OnionPacket.hop_data impl_array!(8, u16); @@ -1204,40 +1201,6 @@ impl Readable for SecretKey { } } -#[cfg(taproot)] -impl Writeable for musig2::types::PublicNonce { - fn write(&self, w: &mut W) -> Result<(), io::Error> { - self.serialize().write(w) - } -} - -#[cfg(taproot)] -impl Readable for musig2::types::PublicNonce { - fn read(r: &mut R) -> Result { - let buf: [u8; PUBLIC_KEY_SIZE * 2] = Readable::read(r)?; - musig2::types::PublicNonce::from_slice(&buf).map_err(|_| DecodeError::InvalidValue) - } -} - -#[cfg(taproot)] -impl Writeable for PartialSignatureWithNonce { - fn write(&self, w: &mut W) -> Result<(), io::Error> { - self.0.serialize().write(w)?; - self.1.write(w) - } -} - -#[cfg(taproot)] -impl Readable for PartialSignatureWithNonce { - fn read(r: &mut R) -> Result { - let partial_signature_buf: [u8; SECRET_KEY_SIZE] = Readable::read(r)?; - let partial_signature = musig2::types::PartialSignature::from_slice(&partial_signature_buf) - .map_err(|_| DecodeError::InvalidValue)?; - let public_nonce: musig2::types::PublicNonce = Readable::read(r)?; - Ok(PartialSignatureWithNonce(partial_signature, public_nonce)) - } -} - impl Writeable for Hmac { fn write(&self, w: &mut W) -> Result<(), io::Error> { w.write_all(&self[..]) diff --git a/lightning/src/util/test_channel_signer.rs b/lightning/src/util/test_channel_signer.rs index b1912bd3f21..8435e7fa437 100644 --- a/lightning/src/util/test_channel_signer.rs +++ b/lightning/src/util/test_channel_signer.rs @@ -34,15 +34,11 @@ use bitcoin::sighash::EcdsaSighashType; use bitcoin::transaction::Transaction; use bitcoin::Txid; -#[cfg(taproot)] -use crate::ln::msgs::PartialSignatureWithNonce; use crate::sign::HTLCDescriptor; use crate::util::dyn_signer::DynSigner; use bitcoin::secp256k1; use bitcoin::secp256k1::{ecdsa::Signature, Secp256k1}; use bitcoin::secp256k1::{PublicKey, SecretKey}; -#[cfg(taproot)] -use musig2::types::{PartialSignature, PublicNonce}; /// Initial value for revoked commitment downward counter pub const INITIAL_REVOKED_COMMITMENT_NUMBER: u64 = 1 << 48; From 961b451587d3d8a3e4675c7ae7449bc5b5327baf Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Thu, 12 Mar 2026 12:20:27 -0700 Subject: [PATCH 172/627] Remove ChannelSignerType We plan to have a single channel signer type going forward, so this is unnecessary. --- lightning/src/ln/async_signer_tests.rs | 2 +- lightning/src/ln/channel.rs | 340 ++++++++++---------- lightning/src/ln/functional_test_utils.rs | 2 +- lightning/src/ln/functional_tests.rs | 14 +- lightning/src/ln/htlc_reserve_unit_tests.rs | 24 +- lightning/src/ln/update_fee_tests.rs | 9 +- lightning/src/sign/mod.rs | 5 +- lightning/src/sign/type_resolver.rs | 37 --- 8 files changed, 192 insertions(+), 241 deletions(-) delete mode 100644 lightning/src/sign/type_resolver.rs diff --git a/lightning/src/ln/async_signer_tests.rs b/lightning/src/ln/async_signer_tests.rs index 451af3918bf..b9e57632b2b 100644 --- a/lightning/src/ln/async_signer_tests.rs +++ b/lightning/src/ln/async_signer_tests.rs @@ -1246,7 +1246,7 @@ fn do_test_closing_signed(extra_closing_signed: bool, reconnect: bool) { let channel = chan_lock.channel_by_id.get_mut(&chan_id).unwrap(); let (funding, context) = channel.funding_and_context_mut(); - let signer = context.get_mut_signer().as_mut_ecdsa().unwrap(); + let signer = context.get_mut_signer(); let signature = signer .sign_closing_transaction( &funding.channel_transaction_parameters, diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index b939c3d2d96..353bfafbed0 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -91,7 +91,6 @@ use alloc::collections::{btree_map, BTreeMap}; use crate::io; use crate::prelude::*; -use crate::sign::type_resolver::ChannelSignerType; #[cfg(any(test, fuzzing, debug_assertions))] use crate::sync::Mutex; use core::time::Duration; @@ -1286,14 +1285,14 @@ struct HolderCommitmentPoint { impl HolderCommitmentPoint { #[rustfmt::skip] - pub fn new(signer: &ChannelSignerType, secp_ctx: &Secp256k1) -> Option { + pub fn new(signer: &S, secp_ctx: &Secp256k1) -> Option { Some(HolderCommitmentPoint { next_transaction_number: INITIAL_COMMITMENT_NUMBER, previous_revoked_point: None, last_revoked_point: None, current_point: None, - next_point: signer.as_ref().get_per_commitment_point(INITIAL_COMMITMENT_NUMBER, secp_ctx).ok()?, - pending_next_point: signer.as_ref().get_per_commitment_point(INITIAL_COMMITMENT_NUMBER - 1, secp_ctx).ok(), + next_point: signer.get_per_commitment_point(INITIAL_COMMITMENT_NUMBER, secp_ctx).ok()?, + pending_next_point: signer.get_per_commitment_point(INITIAL_COMMITMENT_NUMBER - 1, secp_ctx).ok(), }) } @@ -1327,13 +1326,12 @@ impl HolderCommitmentPoint { /// If we are pending advancing the next commitment point, this method tries asking the signer /// again. - pub fn try_resolve_pending( - &mut self, signer: &ChannelSignerType, secp_ctx: &Secp256k1, logger: &L, + pub fn try_resolve_pending( + &mut self, signer: &S, secp_ctx: &Secp256k1, logger: &L, ) { if !self.can_advance() { - let pending_next_point = signer - .as_ref() - .get_per_commitment_point(self.next_transaction_number - 1, secp_ctx); + let pending_next_point = + signer.get_per_commitment_point(self.next_transaction_number - 1, secp_ctx); if let Ok(point) = pending_next_point { log_trace!( logger, @@ -1361,8 +1359,8 @@ impl HolderCommitmentPoint { /// /// If our signer is ready to provide the next commitment point, the next call to `advance` will /// succeed. - pub fn advance( - &mut self, signer: &ChannelSignerType, secp_ctx: &Secp256k1, logger: &L, + pub fn advance( + &mut self, signer: &S, secp_ctx: &Secp256k1, logger: &L, ) -> Result<(), ()> { if let Some(next_point) = self.pending_next_point { *self = Self { @@ -2222,14 +2220,12 @@ where let shared_input_signature = if let Some(splice_input_index) = signing_session.unsigned_tx().shared_input_index() { - let sig = match &context.holder_signer { - ChannelSignerType::Ecdsa(signer) => signer.sign_splice_shared_input( - &funding.channel_transaction_parameters, - tx, - splice_input_index as usize, - &context.secp_ctx, - ), - }; + let sig = context.holder_signer.sign_splice_shared_input( + &funding.channel_transaction_parameters, + tx, + splice_input_index as usize, + &context.secp_ctx, + ); Some(sig) } else { None @@ -3057,7 +3053,6 @@ impl<'a> From<&'a Transaction> for ConfirmedTransaction<'a> { } /// Contains everything about the channel including state, and various flags. -#[cfg_attr(test, derive(Debug))] pub(super) struct ChannelContext { config: LegacyChannelConfig, @@ -3093,7 +3088,7 @@ pub(super) struct ChannelContext { latest_monitor_update_id: u64, - holder_signer: ChannelSignerType, + holder_signer: SP::EcdsaSigner, shutdown_scriptpubkey: Option, destination_script: ScriptBuf, @@ -3346,6 +3341,13 @@ pub(super) struct ChannelContext { pub interactive_tx_signing_session: Option, } +#[cfg(test)] +impl fmt::Debug for ChannelContext { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ChannelContext").finish() + } +} + /// A channel struct implementing this trait can receive an initial counterparty commitment /// transaction signature. trait InitialRemoteCommitmentReceiver { @@ -3421,7 +3423,7 @@ trait InitialRemoteCommitmentReceiver { &self.funding().counterparty_funding_pubkey() ); - if context.holder_signer.as_ref().validate_holder_commitment(&holder_commitment_tx, Vec::new()).is_err() { + if context.holder_signer.validate_holder_commitment(&holder_commitment_tx, Vec::new()).is_err() { return Err(ChannelError::close("Failed to validate our commitment".to_owned())); } @@ -3782,7 +3784,7 @@ impl ChannelContext { latest_monitor_update_id: 0, - holder_signer: ChannelSignerType::Ecdsa(holder_signer), + holder_signer, shutdown_scriptpubkey, destination_script, @@ -4027,7 +4029,7 @@ impl ChannelContext { latest_monitor_update_id: 0, - holder_signer: ChannelSignerType::Ecdsa(holder_signer), + holder_signer, shutdown_scriptpubkey, destination_script, @@ -4342,7 +4344,7 @@ impl ChannelContext { /// Returns the holder signer for this channel. #[cfg(any(test, feature = "_test_utils"))] - pub fn get_mut_signer(&mut self) -> &mut ChannelSignerType { + pub fn get_mut_signer(&mut self) -> &mut SP::EcdsaSigner { return &mut self.holder_signer; } @@ -5242,7 +5244,6 @@ impl ChannelContext { ); self.holder_signer - .as_ref() .validate_holder_commitment( &holder_commitment_tx, commitment_data.outbound_htlc_preimages, @@ -5985,11 +5986,16 @@ impl ChannelContext { &self.channel_id(), counterparty_initial_bitcoin_tx.txid, encode::serialize_hex(&counterparty_initial_bitcoin_tx.transaction)); // We sign "counterparty" commitment transaction, allowing them to broadcast the tx if they wish. - let signature = match &self.holder_signer { - ChannelSignerType::Ecdsa(ecdsa) => ecdsa.sign_counterparty_commitment( - channel_parameters, &counterparty_initial_commitment_tx, Vec::new(), Vec::new(), &self.secp_ctx - ).ok(), - }; + let signature = self + .holder_signer + .sign_counterparty_commitment( + channel_parameters, + &counterparty_initial_commitment_tx, + Vec::new(), + Vec::new(), + &self.secp_ctx, + ) + .ok(); if signature.is_some() && self.signer_pending_funding { log_trace!(logger, "Counterparty commitment signature available for funding_signed message; clearing signer_pending_funding"); @@ -6095,20 +6101,16 @@ impl ChannelContext { logger, ); let counterparty_initial_commitment_tx = commitment_data.tx; - match self.holder_signer { - ChannelSignerType::Ecdsa(ref ecdsa) => { - let channel_parameters = &funding.channel_transaction_parameters; - ecdsa - .sign_counterparty_commitment( - channel_parameters, - &counterparty_initial_commitment_tx, - Vec::new(), - Vec::new(), - &self.secp_ctx, - ) - .ok() - }, - } + let channel_parameters = &funding.channel_transaction_parameters; + self.holder_signer + .sign_counterparty_commitment( + channel_parameters, + &counterparty_initial_commitment_tx, + Vec::new(), + Vec::new(), + &self.secp_ctx, + ) + .ok() } fn get_initial_commitment_signed_v2( @@ -8402,18 +8404,15 @@ where return Err(ChannelError::close("Received an unexpected revoke_and_ack".to_owned())); } - match &self.context.holder_signer { - ChannelSignerType::Ecdsa(ecdsa) => { - ecdsa - .validate_counterparty_revocation( - self.context.counterparty_next_commitment_transaction_number + 1, - &secret, - ) - .map_err(|_| { - ChannelError::close("Failed to validate revocation from peer".to_owned()) - })?; - }, - }; + self.context + .holder_signer + .validate_counterparty_revocation( + self.context.counterparty_next_commitment_transaction_number + 1, + &secret, + ) + .map_err(|_| { + ChannelError::close("Failed to validate revocation from peer".to_owned()) + })?; self.context .commitment_secrets @@ -9275,7 +9274,9 @@ where &mut self, logger: &L, path_for_release_htlc: CBP ) -> Result where CBP: Fn(u64) -> BlindedMessagePath { if let Some((commitment_number, commitment_secret)) = self.context.signer_pending_stale_state_verification.clone() { - if let Ok(expected_point) = self.context.holder_signer.as_ref() + if let Ok(expected_point) = self + .context + .holder_signer .get_per_commitment_point(commitment_number, &self.context.secp_ctx) { self.context.signer_pending_stale_state_verification.take(); @@ -9441,7 +9442,6 @@ where let signer = &self.context.holder_signer; self.holder_commitment_point.try_resolve_pending(signer, &self.context.secp_ctx, logger); let per_commitment_secret = signer - .as_ref() .release_commitment_secret(self.holder_commitment_point.next_transaction_number() + 2) .ok(); if let Some(per_commitment_secret) = per_commitment_secret { @@ -9650,7 +9650,7 @@ where .map_err(|_| ChannelError::close("Peer sent a garbage channel_reestablish with unparseable secret key".to_owned()))?; if msg.next_remote_commitment_number > our_commitment_transaction { let given_commitment_number = INITIAL_COMMITMENT_NUMBER - msg.next_remote_commitment_number + 1; - let expected_point = self.context.holder_signer.as_ref() + let expected_point = self.context.holder_signer .get_per_commitment_point(given_commitment_number, &self.context.secp_ctx) .ok(); if expected_point.is_none() { @@ -10357,15 +10357,15 @@ where &mut self, closing_tx: &ClosingTransaction, skip_remote_output: bool, fee_satoshis: u64, min_fee_satoshis: u64, max_fee_satoshis: u64, logger: &L, ) -> Option { - let sig = match &self.context.holder_signer { - ChannelSignerType::Ecdsa(ecdsa) => ecdsa - .sign_closing_transaction( - &self.funding.channel_transaction_parameters, - closing_tx, - &self.context.secp_ctx, - ) - .ok(), - }; + let sig = self + .context + .holder_signer + .sign_closing_transaction( + &self.funding.channel_transaction_parameters, + closing_tx, + &self.context.secp_ctx, + ) + .ok(); if sig.is_none() { log_trace!(logger, "Closing transaction signature unavailable, waiting on signer"); self.context.signer_pending_closing = true; @@ -10687,7 +10687,7 @@ where } #[cfg(any(test, feature = "_externalize_tests"))] - pub fn get_signer(&self) -> &ChannelSignerType { + pub fn get_signer(&self) -> &SP::EcdsaSigner { &self.context.holder_signer } @@ -11459,32 +11459,30 @@ where }, Ok(v) => v }; - match &self.context.holder_signer { - ChannelSignerType::Ecdsa(ecdsa) => { - let our_bitcoin_sig = match ecdsa.sign_channel_announcement_with_funding_key( - &self.funding.channel_transaction_parameters, &announcement, &self.context.secp_ctx, - ) { - Err(_) => { - log_error!(logger, "Signer rejected channel_announcement signing. Channel will not be announced!"); - return None; - }, - Ok(v) => v - }; - let short_channel_id = match self.funding.get_short_channel_id() { - Some(scid) => scid, - None => return None, - }; + let our_bitcoin_sig = match self.context.holder_signer.sign_channel_announcement_with_funding_key( + &self.funding.channel_transaction_parameters, + &announcement, + &self.context.secp_ctx, + ) { + Err(_) => { + log_error!(logger, "Signer rejected channel_announcement signing. Channel will not be announced!"); + return None; + }, + Ok(v) => v + }; + let short_channel_id = match self.funding.get_short_channel_id() { + Some(scid) => scid, + None => return None, + }; - self.context.announcement_sigs_state = AnnouncementSigsState::MessageSent; + self.context.announcement_sigs_state = AnnouncementSigsState::MessageSent; - Some(msgs::AnnouncementSignatures { - channel_id: self.context.channel_id(), - short_channel_id, - node_signature: our_node_sig, - bitcoin_signature: our_bitcoin_sig, - }) - } - } + Some(msgs::AnnouncementSignatures { + channel_id: self.context.channel_id(), + short_channel_id, + node_signature: our_node_sig, + bitcoin_signature: our_bitcoin_sig, + }) } /// Signs the given channel announcement, returning a ChannelError::Ignore if no keys are @@ -11500,21 +11498,20 @@ where let our_node_sig = node_signer.sign_gossip_message(msgs::UnsignedGossipMessage::ChannelAnnouncement(&announcement)) .map_err(|_| ChannelError::Ignore("Failed to generate node signature for channel_announcement".to_owned()))?; - match &self.context.holder_signer { - ChannelSignerType::Ecdsa(ecdsa) => { - let our_bitcoin_sig = ecdsa.sign_channel_announcement_with_funding_key( - &self.funding.channel_transaction_parameters, &announcement, &self.context.secp_ctx, - ) - .map_err(|_| ChannelError::Ignore("Signer rejected channel_announcement".to_owned()))?; - Ok(msgs::ChannelAnnouncement { - node_signature_1: if were_node_one { our_node_sig } else { their_node_sig }, - node_signature_2: if were_node_one { their_node_sig } else { our_node_sig }, - bitcoin_signature_1: if were_node_one { our_bitcoin_sig } else { their_bitcoin_sig }, - bitcoin_signature_2: if were_node_one { their_bitcoin_sig } else { our_bitcoin_sig }, - contents: announcement, - }) - }, - } + let our_bitcoin_sig = self.context.holder_signer + .sign_channel_announcement_with_funding_key( + &self.funding.channel_transaction_parameters, + &announcement, + &self.context.secp_ctx, + ) + .map_err(|_| ChannelError::Ignore("Signer rejected channel_announcement".to_owned()))?; + Ok(msgs::ChannelAnnouncement { + node_signature_1: if were_node_one { our_node_sig } else { their_node_sig }, + node_signature_2: if were_node_one { their_node_sig } else { our_node_sig }, + bitcoin_signature_1: if were_node_one { our_bitcoin_sig } else { their_bitcoin_sig }, + bitcoin_signature_2: if were_node_one { their_bitcoin_sig } else { our_bitcoin_sig }, + contents: announcement, + }) } else { Err(ChannelError::Ignore("Attempted to sign channel announcement before we'd received announcement_signatures".to_string())) } @@ -11847,14 +11844,15 @@ where debug_assert!(self.pending_splice.is_none()); // Rotate the funding pubkey using the prev_funding_txid as a tweak let prev_funding_txid = self.funding.get_funding_txid(); - let funding_pubkey = match (prev_funding_txid, &self.context.holder_signer) { - (None, _) => { + let funding_pubkey = match prev_funding_txid { + None => { debug_assert!(false); self.funding.get_holder_pubkeys().funding_pubkey }, - (Some(prev_funding_txid), ChannelSignerType::Ecdsa(ecdsa)) => { - ecdsa.new_funding_pubkey(prev_funding_txid, &self.context.secp_ctx) - }, + Some(prev_funding_txid) => self + .context + .holder_signer + .new_funding_pubkey(prev_funding_txid, &self.context.secp_ctx), }; let funding_feerate_per_kw = context.funding_feerate_sat_per_1000_weight; @@ -11951,14 +11949,15 @@ where // Rotate the pubkeys using the prev_funding_txid as a tweak let prev_funding_txid = self.funding.get_funding_txid(); - let funding_pubkey = match (prev_funding_txid, &self.context.holder_signer) { - (None, _) => { + let funding_pubkey = match prev_funding_txid { + None => { debug_assert!(false); self.funding.get_holder_pubkeys().funding_pubkey }, - (Some(prev_funding_txid), ChannelSignerType::Ecdsa(ecdsa)) => { - ecdsa.new_funding_pubkey(prev_funding_txid, &self.context.secp_ctx) - }, + Some(prev_funding_txid) => self + .context + .holder_signer + .new_funding_pubkey(prev_funding_txid, &self.context.secp_ctx), }; let mut new_keys = self.funding.get_holder_pubkeys().clone(); new_keys.funding_pubkey = funding_pubkey; @@ -12686,46 +12685,44 @@ where ); let counterparty_commitment_tx = commitment_data.tx; - match &self.context.holder_signer { - ChannelSignerType::Ecdsa(ecdsa) => { - let (signature, htlc_signatures); - - { - let res = ecdsa.sign_counterparty_commitment( - &funding.channel_transaction_parameters, - &counterparty_commitment_tx, - commitment_data.inbound_htlc_preimages, - commitment_data.outbound_htlc_preimages, - &self.context.secp_ctx, - ).map_err(|_| ChannelError::Ignore("Failed to get signatures for new commitment_signed".to_owned()))?; - signature = res.0; - htlc_signatures = res.1; - - let trusted_tx = counterparty_commitment_tx.trust(); - log_trace!(logger, "Signed remote commitment tx {} (txid {}) with redeemscript {} -> {}", - encode::serialize_hex(&trusted_tx.built_transaction().transaction), - &trusted_tx.txid(), encode::serialize_hex(&funding.get_funding_redeemscript()), - log_bytes!(signature.serialize_compact()[..])); - - let counterparty_keys = trusted_tx.keys(); - debug_assert_eq!(htlc_signatures.len(), trusted_tx.nondust_htlcs().len()); - for (ref htlc_sig, ref htlc) in htlc_signatures.iter().zip(trusted_tx.nondust_htlcs()) { - log_trace!(logger, "Signed remote HTLC tx {} with redeemscript {} with pubkey {} -> {}", - encode::serialize_hex(&chan_utils::build_htlc_transaction(&trusted_tx.txid(), trusted_tx.negotiated_feerate_per_kw(), funding.get_holder_selected_contest_delay(), htlc, funding.get_channel_type(), &counterparty_keys.broadcaster_delayed_payment_key, &counterparty_keys.revocation_key)), - encode::serialize_hex(&chan_utils::get_htlc_redeemscript(&htlc, funding.get_channel_type(), &counterparty_keys)), - log_bytes!(counterparty_keys.broadcaster_htlc_key.to_public_key().serialize()), - log_bytes!(htlc_sig.serialize_compact()[..])); - } - } + let (signature, htlc_signatures); - Ok(msgs::CommitmentSigned { - channel_id: self.context.channel_id, - signature, - htlc_signatures, - funding_txid: funding.get_funding_txo().map(|funding_txo| funding_txo.txid), - }) - } + { + let res = self.context.holder_signer + .sign_counterparty_commitment( + &funding.channel_transaction_parameters, + &counterparty_commitment_tx, + commitment_data.inbound_htlc_preimages, + commitment_data.outbound_htlc_preimages, + &self.context.secp_ctx, + ) + .map_err(|_| ChannelError::Ignore("Failed to get signatures for new commitment_signed".to_owned()))?; + signature = res.0; + htlc_signatures = res.1; + + let trusted_tx = counterparty_commitment_tx.trust(); + log_trace!(logger, "Signed remote commitment tx {} (txid {}) with redeemscript {} -> {}", + encode::serialize_hex(&trusted_tx.built_transaction().transaction), + &trusted_tx.txid(), encode::serialize_hex(&funding.get_funding_redeemscript()), + log_bytes!(signature.serialize_compact()[..])); + + let counterparty_keys = trusted_tx.keys(); + debug_assert_eq!(htlc_signatures.len(), trusted_tx.nondust_htlcs().len()); + for (ref htlc_sig, ref htlc) in htlc_signatures.iter().zip(trusted_tx.nondust_htlcs()) { + log_trace!(logger, "Signed remote HTLC tx {} with redeemscript {} with pubkey {} -> {}", + encode::serialize_hex(&chan_utils::build_htlc_transaction(&trusted_tx.txid(), trusted_tx.negotiated_feerate_per_kw(), funding.get_holder_selected_contest_delay(), htlc, funding.get_channel_type(), &counterparty_keys.broadcaster_delayed_payment_key, &counterparty_keys.revocation_key)), + encode::serialize_hex(&chan_utils::get_htlc_redeemscript(&htlc, funding.get_channel_type(), &counterparty_keys)), + log_bytes!(counterparty_keys.broadcaster_htlc_key.to_public_key().serialize()), + log_bytes!(htlc_sig.serialize_compact()[..])); } + } + + Ok(msgs::CommitmentSigned { + channel_id: self.context.channel_id, + signature, + htlc_signatures, + funding_txid: funding.get_funding_txo().map(|funding_txo| funding_txo.txid), + }) } /// Adds a pending outbound HTLC to this channel, and builds a new remote commitment @@ -13281,12 +13278,19 @@ impl OutboundV1Channel { self.context.counterparty_next_commitment_transaction_number, &self.context.counterparty_next_commitment_point.unwrap(), false, false, logger); let counterparty_initial_commitment_tx = commitment_data.tx; - let signature = match &self.context.holder_signer { - ChannelSignerType::Ecdsa(ecdsa) => { - let channel_parameters = &self.funding.channel_transaction_parameters; - ecdsa.sign_counterparty_commitment(channel_parameters, &counterparty_initial_commitment_tx, Vec::new(), Vec::new(), &self.context.secp_ctx) - .map(|(sig, _)| sig).ok() - }, + let signature = { + let channel_parameters = &self.funding.channel_transaction_parameters; + self.context + .holder_signer + .sign_counterparty_commitment( + channel_parameters, + &counterparty_initial_commitment_tx, + Vec::new(), + Vec::new(), + &self.context.secp_ctx, + ) + .map(|(sig, _)| sig) + .ok() }; if signature.is_some() && self.context.signer_pending_funding { @@ -13936,11 +13940,11 @@ impl PendingV2Channel { debug_assert!(false, "Tried to send an open_channel2 for a channel that has already advanced"); } - let first_per_commitment_point = self.context.holder_signer.as_ref() + let first_per_commitment_point = self.context.holder_signer .get_per_commitment_point(self.unfunded_context.transaction_number(), &self.context.secp_ctx) .expect("TODO: async signing is not yet supported for commitment points in v2 channel establishment"); - let second_per_commitment_point = self.context.holder_signer.as_ref() + let second_per_commitment_point = self.context.holder_signer .get_per_commitment_point(self.unfunded_context.transaction_number() - 1, &self.context.secp_ctx) .expect("TODO: async signing is not yet supported for commitment points in v2 channel establishment"); @@ -14105,10 +14109,10 @@ impl PendingV2Channel { /// [`msgs::AcceptChannelV2`]: crate::ln::msgs::AcceptChannelV2 #[allow(dead_code)] // TODO(dual_funding): Remove once V2 channels is enabled. fn generate_accept_channel_v2_message(&self) -> msgs::AcceptChannelV2 { - let first_per_commitment_point = self.context.holder_signer.as_ref().get_per_commitment_point( + let first_per_commitment_point = self.context.holder_signer.get_per_commitment_point( self.unfunded_context.transaction_number(), &self.context.secp_ctx) .expect("TODO: async signing is not yet supported for commitment points in v2 channel establishment"); - let second_per_commitment_point = self.context.holder_signer.as_ref().get_per_commitment_point( + let second_per_commitment_point = self.context.holder_signer.get_per_commitment_point( self.unfunded_context.transaction_number() - 1, &self.context.secp_ctx) .expect("TODO: async signing is not yet supported for commitment points in v2 channel establishment"); let keys = self.funding.get_holder_pubkeys(); @@ -15521,7 +15525,7 @@ impl<'a, 'b, 'c, ES: EntropySource, SP: SignerProvider> latest_monitor_update_id, - holder_signer: ChannelSignerType::Ecdsa(holder_signer), + holder_signer, shutdown_scriptpubkey, destination_script, diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 641842ddaff..72d566ef4fb 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -677,7 +677,7 @@ impl<'a, 'b, 'c> Node<'a, 'b, 'c> { if let Some(context) = chan_lock.channel_by_id.get_mut(chan_id).map(|chan| chan.context_mut()) { - let signer = context.get_mut_signer().as_mut_ecdsa().unwrap(); + let signer = context.get_mut_signer(); if available { signer.enable_op(signer_op); } else { diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index eb868d268ac..4b82c9b1877 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -48,6 +48,7 @@ use crate::routing::gossip::{NetworkGraph, NetworkUpdate}; use crate::routing::router::{ get_route, Path, PaymentParameters, Route, RouteHop, RouteParameters, }; +use crate::sign::ChannelSigner; use crate::sign::{EntropySource, OutputSpender, SignerProvider}; use crate::types::features::{ChannelFeatures, ChannelTypeFeatures, NodeFeatures}; use crate::types::payment::{PaymentHash, PaymentSecret}; @@ -6681,16 +6682,15 @@ pub fn test_counterparty_raa_skip_no_crash() { const INITIAL_COMMITMENT_NUMBER: u64 = (1 << 48) - 1; // Make signer believe we got a counterparty signature, so that it allows the revocation - keys.as_ecdsa().unwrap().get_enforcement_state().last_holder_commitment -= 1; - per_commitment_secret = - keys.as_ref().release_commitment_secret(INITIAL_COMMITMENT_NUMBER).unwrap(); + keys.get_enforcement_state().last_holder_commitment -= 1; + per_commitment_secret = keys.release_commitment_secret(INITIAL_COMMITMENT_NUMBER).unwrap(); // Must revoke without gaps - keys.as_ecdsa().unwrap().get_enforcement_state().last_holder_commitment -= 1; - keys.as_ref().release_commitment_secret(INITIAL_COMMITMENT_NUMBER - 1).unwrap(); + keys.get_enforcement_state().last_holder_commitment -= 1; + keys.release_commitment_secret(INITIAL_COMMITMENT_NUMBER - 1).unwrap(); - keys.as_ecdsa().unwrap().get_enforcement_state().last_holder_commitment -= 1; - let sec = keys.as_ref().release_commitment_secret(INITIAL_COMMITMENT_NUMBER - 2).unwrap(); + keys.get_enforcement_state().last_holder_commitment -= 1; + let sec = keys.release_commitment_secret(INITIAL_COMMITMENT_NUMBER - 2).unwrap(); let key = SecretKey::from_slice(&sec).unwrap(); next_per_commitment_point = PublicKey::from_secret_key(&Secp256k1::new(), &key); } diff --git a/lightning/src/ln/htlc_reserve_unit_tests.rs b/lightning/src/ln/htlc_reserve_unit_tests.rs index 80b3ebd1921..495a8698dfb 100644 --- a/lightning/src/ln/htlc_reserve_unit_tests.rs +++ b/lightning/src/ln/htlc_reserve_unit_tests.rs @@ -17,6 +17,7 @@ use crate::ln::outbound_payment::RecipientOnionFields; use crate::routing::router::PaymentParameters; use crate::sign::ecdsa::EcdsaChannelSigner; use crate::sign::tx_builder::{SpecTxBuilder, TxBuilder}; +use crate::sign::ChannelSigner; use crate::types::features::ChannelTypeFeatures; use crate::types::payment::PaymentPreimage; use crate::util::config::UserConfig; @@ -863,14 +864,11 @@ pub fn do_test_fee_spike_buffer(cfg: Option, htlc_fails: bool) { let local_chan = chan_lock.channel_by_id.get(&chan.2).and_then(Channel::as_funded).unwrap(); let chan_signer = local_chan.get_signer(); // Make the signer believe we validated another commitment, so we can release the secret - chan_signer.as_ecdsa().unwrap().get_enforcement_state().last_holder_commitment -= 1; + chan_signer.get_enforcement_state().last_holder_commitment -= 1; ( - chan_signer.as_ref().release_commitment_secret(INITIAL_COMMITMENT_NUMBER).unwrap(), - chan_signer - .as_ref() - .get_per_commitment_point(INITIAL_COMMITMENT_NUMBER - 2, &secp_ctx) - .unwrap(), + chan_signer.release_commitment_secret(INITIAL_COMMITMENT_NUMBER).unwrap(), + chan_signer.get_per_commitment_point(INITIAL_COMMITMENT_NUMBER - 2, &secp_ctx).unwrap(), ) }; let remote_point = { @@ -879,10 +877,7 @@ pub fn do_test_fee_spike_buffer(cfg: Option, htlc_fails: bool) { let channel = get_channel_ref!(nodes[1], nodes[0], per_peer_lock, peer_state_lock, chan.2); let chan_signer = channel.as_funded().unwrap().get_signer(); - chan_signer - .as_ref() - .get_per_commitment_point(INITIAL_COMMITMENT_NUMBER - 1, &secp_ctx) - .unwrap() + chan_signer.get_per_commitment_point(INITIAL_COMMITMENT_NUMBER - 1, &secp_ctx).unwrap() }; // Build the remote commitment transaction so we can sign it, and then later use the @@ -919,8 +914,6 @@ pub fn do_test_fee_spike_buffer(cfg: Option, htlc_fails: bool) { ); let params = &channel.funding().channel_transaction_parameters; chan_signer - .as_ecdsa() - .unwrap() .sign_counterparty_commitment(params, &commitment_tx, Vec::new(), Vec::new(), &secp_ctx) .unwrap() }; @@ -2291,17 +2284,15 @@ pub fn do_test_dust_limit_fee_accounting(can_afford: bool) { chan_lock.channel_by_id.get(&chan_id).and_then(Channel::as_funded).unwrap(); let chan_signer = local_chan.get_signer(); // Make the signer believe we validated another commitment, so we can release the secret - chan_signer.as_ecdsa().unwrap().get_enforcement_state().last_holder_commitment -= 1; + chan_signer.get_enforcement_state().last_holder_commitment -= 1; ( chan_signer - .as_ref() .release_commitment_secret( INITIAL_COMMITMENT_NUMBER - MIN_AFFORDABLE_HTLC_COUNT as u64 + 1, ) .unwrap(), chan_signer - .as_ref() .get_per_commitment_point( INITIAL_COMMITMENT_NUMBER - MIN_AFFORDABLE_HTLC_COUNT as u64, &secp_ctx, @@ -2317,7 +2308,6 @@ pub fn do_test_dust_limit_fee_accounting(can_afford: bool) { get_channel_ref!(nodes[1], nodes[0], per_peer_lock, peer_state_lock, chan_id); let chan_signer = channel.as_funded().unwrap().get_signer(); chan_signer - .as_ref() .get_per_commitment_point( INITIAL_COMMITMENT_NUMBER - MIN_AFFORDABLE_HTLC_COUNT as u64, &secp_ctx, @@ -2367,8 +2357,6 @@ pub fn do_test_dust_limit_fee_accounting(can_afford: bool) { ); let params = &channel.funding().channel_transaction_parameters; chan_signer - .as_ecdsa() - .unwrap() .sign_counterparty_commitment( params, &commitment_tx, diff --git a/lightning/src/ln/update_fee_tests.rs b/lightning/src/ln/update_fee_tests.rs index 77a4c322736..515c23e127a 100644 --- a/lightning/src/ln/update_fee_tests.rs +++ b/lightning/src/ln/update_fee_tests.rs @@ -16,6 +16,7 @@ use crate::ln::msgs::{ }; use crate::ln::outbound_payment::RecipientOnionFields; use crate::sign::ecdsa::EcdsaChannelSigner; +use crate::sign::ChannelSigner; use crate::types::features::ChannelTypeFeatures; use crate::util::config::UserConfig; use crate::util::errors::APIError; @@ -471,7 +472,7 @@ pub fn do_test_update_fee_that_funder_cannot_afford(channel_type_features: Chann let channel = get_channel_ref!(nodes[1], nodes[0], per_peer_lock, peer_state_lock, chan.2); let chan_signer = channel.as_funded().unwrap().get_signer(); let point_number = INITIAL_COMMITMENT_NUMBER - 1; - chan_signer.as_ref().get_per_commitment_point(point_number, &secp_ctx).unwrap() + chan_signer.get_per_commitment_point(point_number, &secp_ctx).unwrap() }; let res = { @@ -497,8 +498,6 @@ pub fn do_test_update_fee_that_funder_cannot_afford(channel_type_features: Chann ); let params = &local_chan.funding().channel_transaction_parameters; local_chan_signer - .as_ecdsa() - .unwrap() .sign_counterparty_commitment(params, &commitment_tx, Vec::new(), Vec::new(), &secp_ctx) .unwrap() }; @@ -570,7 +569,7 @@ pub fn test_update_fee_that_saturates_subs() { let channel = get_channel_ref!(nodes[1], nodes[0], per_peer_lock, peer_state_lock, chan_id); let chan_signer = channel.as_funded().unwrap().get_signer(); - chan_signer.as_ref().get_per_commitment_point(INITIAL_COMMITMENT_NUMBER, &secp_ctx).unwrap() + chan_signer.get_per_commitment_point(INITIAL_COMMITMENT_NUMBER, &secp_ctx).unwrap() }; let res = { @@ -595,8 +594,6 @@ pub fn test_update_fee_that_saturates_subs() { ); let params = &local_chan.funding().channel_transaction_parameters; local_chan_signer - .as_ecdsa() - .unwrap() .sign_counterparty_commitment(params, &commitment_tx, Vec::new(), Vec::new(), &secp_ctx) .unwrap() }; diff --git a/lightning/src/sign/mod.rs b/lightning/src/sign/mod.rs index adab37286ca..fa77b3c0ba1 100644 --- a/lightning/src/sign/mod.rs +++ b/lightning/src/sign/mod.rs @@ -70,8 +70,6 @@ use core::future::Future; use core::ops::Deref; use core::sync::atomic::{AtomicUsize, Ordering}; -pub(crate) mod type_resolver; - pub mod ecdsa; pub mod tx_builder; @@ -1088,7 +1086,8 @@ pub type DynSignerProvider = dyn SignerProvider; /// [`ChannelManager`]: crate::ln::channelmanager::ChannelManager /// [`MonitorUpdatingPersister`]: crate::util::persist::MonitorUpdatingPersister pub trait SignerProvider { - /// A type which implements [`EcdsaChannelSigner`] which will be returned by [`Self::derive_channel_signer`]. + /// A type which implements [`EcdsaChannelSigner`] which will be returned by + /// [`Self::derive_channel_signer`]. type EcdsaSigner: EcdsaChannelSigner; /// Generates a unique `channel_keys_id` that can be used to obtain a [`Self::EcdsaSigner`] through diff --git a/lightning/src/sign/type_resolver.rs b/lightning/src/sign/type_resolver.rs deleted file mode 100644 index 3e784893bed..00000000000 --- a/lightning/src/sign/type_resolver.rs +++ /dev/null @@ -1,37 +0,0 @@ -use crate::sign::{ChannelSigner, SignerProvider}; - -pub(crate) enum ChannelSignerType { - // in practice, this will only ever be an EcdsaChannelSigner (specifically, Writeable) - Ecdsa(SP::EcdsaSigner), -} - -#[cfg(test)] -impl std::fmt::Debug for ChannelSignerType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ChannelSignerType").finish() - } -} - -impl ChannelSignerType { - pub(crate) fn as_ref(&self) -> &dyn ChannelSigner { - match self { - ChannelSignerType::Ecdsa(ecs) => ecs, - } - } - - #[allow(unused)] - pub(crate) fn as_ecdsa(&self) -> Option<&SP::EcdsaSigner> { - match self { - ChannelSignerType::Ecdsa(ecs) => Some(ecs), - _ => None, - } - } - - #[allow(unused)] - pub(crate) fn as_mut_ecdsa(&mut self) -> Option<&mut SP::EcdsaSigner> { - match self { - ChannelSignerType::Ecdsa(ecs) => Some(ecs), - _ => None, - } - } -} From 4ae44e7337216a315c566cadb56add763de7c9f2 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 11 Mar 2026 16:54:54 +0000 Subject: [PATCH 173/627] Clamp our selected reserve to the counterparty's dust limit In a subsequent commit, we will allow the counterparty's dust limit to be greater than our `MIN_THEIR_CHANNEL_RESERVE_SATOSHIS`. Our selected reserve must always be greater than their dust limit, so we set our selected reserve to be equal to or greater than their dust limit. --- lightning/src/ln/channel.rs | 25 ++++++++++++++++----- lightning/src/ln/channel_open_tests.rs | 2 +- lightning/src/ln/functional_tests.rs | 2 +- lightning/src/ln/htlc_reserve_unit_tests.rs | 8 +++---- lightning/src/ln/payment_tests.rs | 2 +- lightning/src/ln/update_fee_tests.rs | 5 +++-- 6 files changed, 30 insertions(+), 14 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 9361cd3c749..704039b9a43 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -6280,15 +6280,18 @@ fn get_holder_max_htlc_value_in_flight_msat( /// Guaranteed to return a value no larger than channel_value_satoshis /// /// This is used both for outbound and inbound channels and has lower bound -/// of `MIN_THEIR_CHAN_RESERVE_SATOSHIS`. +/// of `MIN_THEIR_CHAN_RESERVE_SATOSHIS`, and the `dust_limit_satoshis` of +/// the counterparty. pub(crate) fn get_holder_selected_channel_reserve_satoshis( - channel_value_satoshis: u64, config: &UserConfig, + channel_value_satoshis: u64, their_dust_limit_satoshis: u64, config: &UserConfig, ) -> u64 { let counterparty_chan_reserve_prop_mil = config.channel_handshake_config.their_channel_reserve_proportional_millionths as u64; let calculated_reserve = channel_value_satoshis.saturating_mul(counterparty_chan_reserve_prop_mil) / 1_000_000; - cmp::min(channel_value_satoshis, cmp::max(calculated_reserve, MIN_THEIR_CHAN_RESERVE_SATOSHIS)) + let channel_reserve_satoshis = cmp::max(calculated_reserve, MIN_THEIR_CHAN_RESERVE_SATOSHIS); + let channel_reserve_satoshis = cmp::max(channel_reserve_satoshis, their_dust_limit_satoshis); + cmp::min(channel_value_satoshis, channel_reserve_satoshis) } /// This is for legacy reasons, present for forward-compatibility. @@ -13267,7 +13270,15 @@ impl OutboundV1Channel { channel_value_satoshis: u64, push_msat: u64, user_id: u128, config: &UserConfig, current_chain_height: u32, outbound_scid_alias: u64, temporary_channel_id: Option, logger: L ) -> Result, APIError> { - let holder_selected_channel_reserve_satoshis = get_holder_selected_channel_reserve_satoshis(channel_value_satoshis, config); + // At this point, we do not know what `dust_limit_satoshis` the counterparty will want for themselves, + // so we set the channel reserve with no regard for their dust limit, and fail the channel if they want + // a dust limit higher than our selected reserve. + let their_dust_limit_satoshis = 0; + let holder_selected_channel_reserve_satoshis = get_holder_selected_channel_reserve_satoshis( + channel_value_satoshis, + their_dust_limit_satoshis, + config + ); if holder_selected_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS { // Protocol level safety check in place, although it should never happen because // of `MIN_THEIR_CHAN_RESERVE_SATOSHIS` @@ -13649,7 +13660,11 @@ impl InboundV1Channel { // support this channel type. let channel_type = channel_type_from_open_channel(&msg.common_fields, our_supported_features)?; - let holder_selected_channel_reserve_satoshis = get_holder_selected_channel_reserve_satoshis(msg.common_fields.funding_satoshis, config); + let holder_selected_channel_reserve_satoshis = get_holder_selected_channel_reserve_satoshis( + msg.common_fields.funding_satoshis, + msg.common_fields.dust_limit_satoshis, + config + ); let counterparty_pubkeys = ChannelPublicKeys { funding_pubkey: msg.common_fields.funding_pubkey, revocation_basepoint: RevocationBasepoint::from(msg.common_fields.revocation_basepoint), diff --git a/lightning/src/ln/channel_open_tests.rs b/lightning/src/ln/channel_open_tests.rs index 08cabc053c5..9a65e341b66 100644 --- a/lightning/src/ln/channel_open_tests.rs +++ b/lightning/src/ln/channel_open_tests.rs @@ -470,7 +470,7 @@ pub fn test_insane_channel_opens() { // funding satoshis let channel_value_sat = 31337; // same as funding satoshis let channel_reserve_satoshis = - get_holder_selected_channel_reserve_satoshis(channel_value_sat, &legacy_cfg); + get_holder_selected_channel_reserve_satoshis(channel_value_sat, 0, &legacy_cfg); let push_msat = (channel_value_sat - channel_reserve_satoshis) * 1000; // Have node0 initiate a channel to node1 with aforementioned parameters diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index 17fbc1fce28..c98cfa53b86 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -413,7 +413,7 @@ pub fn test_inbound_outbound_capacity_is_not_zero() { assert_eq!(channels0.len(), 1); assert_eq!(channels1.len(), 1); - let reserve = get_holder_selected_channel_reserve_satoshis(100_000, &default_config); + let reserve = get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config); assert_eq!(channels0[0].inbound_capacity_msat, 95000000 - reserve * 1000); assert_eq!(channels1[0].outbound_capacity_msat, 95000000 - reserve * 1000); diff --git a/lightning/src/ln/htlc_reserve_unit_tests.rs b/lightning/src/ln/htlc_reserve_unit_tests.rs index d88b9a2dc3f..2dfd191d9ea 100644 --- a/lightning/src/ln/htlc_reserve_unit_tests.rs +++ b/lightning/src/ln/htlc_reserve_unit_tests.rs @@ -50,7 +50,7 @@ fn do_test_counterparty_no_reserve(send_from_initiator: bool) { push_amt -= feerate_per_kw as u64 * (commitment_tx_base_weight(&channel_type_features) + 4 * COMMITMENT_TX_WEIGHT_PER_HTLC) / 1000 * 1000; - push_amt -= get_holder_selected_channel_reserve_satoshis(100_000, &default_config) * 1000; + push_amt -= get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config) * 1000; let push = if send_from_initiator { 0 } else { push_amt }; let temp_channel_id = @@ -1008,7 +1008,7 @@ pub fn test_chan_reserve_violation_outbound_htlc_inbound_chan() { &channel_type_features, ); - push_amt -= get_holder_selected_channel_reserve_satoshis(100_000, &default_config) * 1000; + push_amt -= get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config) * 1000; let _ = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, push_amt); @@ -1052,7 +1052,7 @@ pub fn test_chan_reserve_violation_inbound_htlc_outbound_channel() { MIN_AFFORDABLE_HTLC_COUNT as u64, &channel_type_features, ); - push_amt -= get_holder_selected_channel_reserve_satoshis(100_000, &default_config) * 1000; + push_amt -= get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config) * 1000; let chan = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, push_amt); // Send four HTLCs to cover the initial push_msat buffer we're required to include @@ -1130,7 +1130,7 @@ pub fn test_chan_reserve_dust_inbound_htlcs_outbound_chan() { MIN_AFFORDABLE_HTLC_COUNT as u64, &channel_type_features, ); - push_amt -= get_holder_selected_channel_reserve_satoshis(100_000, &default_config) * 1000; + push_amt -= get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config) * 1000; create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100000, push_amt); let (htlc_success_tx_fee_sat, _) = diff --git a/lightning/src/ln/payment_tests.rs b/lightning/src/ln/payment_tests.rs index b5cbe0fee98..7d198d2d70d 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -4985,7 +4985,7 @@ fn test_htlc_forward_considers_anchor_outputs_value() { create_announced_chan_between_nodes_with_value(&nodes, 1, 2, CHAN_AMT, PUSH_MSAT); let channel_reserve_msat = - get_holder_selected_channel_reserve_satoshis(CHAN_AMT, &config) * 1000; + get_holder_selected_channel_reserve_satoshis(CHAN_AMT, 0, &config) * 1000; let commitment_fee_msat = chan_utils::commit_tx_fee_sat( *nodes[1].fee_estimator.sat_per_kw.lock().unwrap(), 2, diff --git a/lightning/src/ln/update_fee_tests.rs b/lightning/src/ln/update_fee_tests.rs index ac566393bdb..5a50120d764 100644 --- a/lightning/src/ln/update_fee_tests.rs +++ b/lightning/src/ln/update_fee_tests.rs @@ -408,7 +408,8 @@ pub fn do_test_update_fee_that_funder_cannot_afford(channel_type_features: Chann ); let channel_id = chan.2; let secp_ctx = Secp256k1::new(); - let bs_channel_reserve_sats = get_holder_selected_channel_reserve_satoshis(channel_value, &cfg); + let bs_channel_reserve_sats = + get_holder_selected_channel_reserve_satoshis(channel_value, 0, &cfg); let (anchor_outputs_value_sats, outputs_num_no_htlcs) = if channel_type_features.supports_anchors_zero_fee_htlc_tx() { (ANCHOR_OUTPUT_VALUE_SATOSHI * 2, 4) @@ -892,7 +893,7 @@ pub fn test_chan_init_feerate_unaffordability() { // During open, we don't have a "counterparty channel reserve" to check against, so that // requirement only comes into play on the open_channel handling side. - push_amt -= get_holder_selected_channel_reserve_satoshis(100_000, &default_config) * 1000; + push_amt -= get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config) * 1000; nodes[0].node.create_channel(node_b_id, 100_000, push_amt, 42, None, None).unwrap(); let mut open_channel_msg = get_event_msg!(nodes[0], MessageSendEvent::SendOpenChannel, node_b_id); From 2d5f77b01630e9f8a5b70381c97aba154161e007 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 11 Mar 2026 16:55:19 +0000 Subject: [PATCH 174/627] Set max channel dust limit to 10,000 sats for all zero-fee-htlc-tx chans This includes both keyed anchor channels, and 0FC channels. The dust limit for HTLCs in such channels is independent of the negotiated feerate, so a party may set a higher dust limit for such channels. Fixes #4225 --- lightning/src/ln/channel.rs | 25 ++++++++++++++++++++----- lightning/src/ln/channel_open_tests.rs | 5 ++--- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 704039b9a43..ab3627225d3 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -982,8 +982,11 @@ pub const TOTAL_BITCOIN_SUPPLY_SATOSHIS: u64 = 21_000_000 * 1_0000_0000; /// implementations use this value for their dust limit today. pub const MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS: u64 = 546; +/// The maximum channel dust limit we will accept from our counterparty for non-anchor channels. +pub const MAX_LEGACY_CHAN_DUST_LIMIT_SATOSHIS: u64 = MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS; + /// The maximum channel dust limit we will accept from our counterparty. -pub const MAX_CHAN_DUST_LIMIT_SATOSHIS: u64 = MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS; +pub const MAX_CHAN_DUST_LIMIT_SATOSHIS: u64 = 10_000; /// The dust limit is used for both the commitment transaction outputs as well as the closing /// transactions. For cooperative closing transactions, we require segwit outputs, though accept @@ -3644,8 +3647,14 @@ impl ChannelContext { if open_channel_fields.dust_limit_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS { return Err(ChannelError::close(format!("dust_limit_satoshis ({}) is less than the implementation limit ({})", open_channel_fields.dust_limit_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS))); } - if open_channel_fields.dust_limit_satoshis > MAX_CHAN_DUST_LIMIT_SATOSHIS { - return Err(ChannelError::close(format!("dust_limit_satoshis ({}) is greater than the implementation limit ({})", open_channel_fields.dust_limit_satoshis, MAX_CHAN_DUST_LIMIT_SATOSHIS))); + + let max_chan_dust_limit_satoshis = if channel_type.supports_anchors_zero_fee_htlc_tx() || channel_type.supports_anchor_zero_fee_commitments() { + MAX_CHAN_DUST_LIMIT_SATOSHIS + } else { + MAX_LEGACY_CHAN_DUST_LIMIT_SATOSHIS + }; + if open_channel_fields.dust_limit_satoshis > max_chan_dust_limit_satoshis { + return Err(ChannelError::close(format!("dust_limit_satoshis ({}) is greater than the implementation limit ({})", open_channel_fields.dust_limit_satoshis, max_chan_dust_limit_satoshis))); } // Convert things into internal flags and prep our state: @@ -4426,8 +4435,14 @@ impl ChannelContext { if common_fields.dust_limit_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS { return Err(ChannelError::close(format!("dust_limit_satoshis ({}) is less than the implementation limit ({})", common_fields.dust_limit_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS))); } - if common_fields.dust_limit_satoshis > MAX_CHAN_DUST_LIMIT_SATOSHIS { - return Err(ChannelError::close(format!("dust_limit_satoshis ({}) is greater than the implementation limit ({})", common_fields.dust_limit_satoshis, MAX_CHAN_DUST_LIMIT_SATOSHIS))); + + let max_chan_dust_limit_satoshis = if channel_type.supports_anchors_zero_fee_htlc_tx() || channel_type.supports_anchor_zero_fee_commitments() { + MAX_CHAN_DUST_LIMIT_SATOSHIS + } else { + MAX_LEGACY_CHAN_DUST_LIMIT_SATOSHIS + }; + if common_fields.dust_limit_satoshis > max_chan_dust_limit_satoshis { + return Err(ChannelError::close(format!("dust_limit_satoshis ({}) is greater than the implementation limit ({})", common_fields.dust_limit_satoshis, max_chan_dust_limit_satoshis))); } if common_fields.minimum_depth > peer_limits.max_minimum_depth { return Err(ChannelError::close(format!("We consider the minimum depth to be unreasonably large. Expected minimum: ({}). Actual: ({})", peer_limits.max_minimum_depth, common_fields.minimum_depth))); diff --git a/lightning/src/ln/channel_open_tests.rs b/lightning/src/ln/channel_open_tests.rs index 9a65e341b66..e13343ade76 100644 --- a/lightning/src/ln/channel_open_tests.rs +++ b/lightning/src/ln/channel_open_tests.rs @@ -880,8 +880,7 @@ pub fn bolt2_open_channel_sane_dust_limit() { nodes[0].node.create_channel(node_b_id, value_sats, push_msat, 42, None, None).unwrap(); let mut node0_to_1_send_open_channel = get_event_msg!(nodes[0], MessageSendEvent::SendOpenChannel, node_b_id); - node0_to_1_send_open_channel.common_fields.dust_limit_satoshis = 547; - node0_to_1_send_open_channel.channel_reserve_satoshis = 100001; + node0_to_1_send_open_channel.common_fields.dust_limit_satoshis = 10_001; nodes[1].node.handle_open_channel(node_a_id, &node0_to_1_send_open_channel); let events = nodes[1].node.get_and_clear_pending_events(); @@ -893,7 +892,7 @@ pub fn bolt2_open_channel_sane_dust_limit() { { Err(APIError::ChannelUnavailable { err }) => assert_eq!( err, - "dust_limit_satoshis (547) is greater than the implementation limit (546)" + "dust_limit_satoshis (10001) is greater than the implementation limit (10000)" ), _ => panic!(), }, From e8211554979aef412f426fbc8bdbb55af7552ef2 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Thu, 12 Mar 2026 11:14:47 -0700 Subject: [PATCH 175/627] Refactor reorg_tests.rs to make formatting not span as many lines --- lightning/src/ln/reorg_tests.rs | 327 ++++++++++++-------------------- 1 file changed, 126 insertions(+), 201 deletions(-) diff --git a/lightning/src/ln/reorg_tests.rs b/lightning/src/ln/reorg_tests.rs index d4ef5fba668..5b5160148d7 100644 --- a/lightning/src/ln/reorg_tests.rs +++ b/lightning/src/ln/reorg_tests.rs @@ -50,6 +50,9 @@ fn do_test_onchain_htlc_reorg(local_commitment: bool, claim: bool) { let legacy_cfg = test_legacy_channel_config(); let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, Some(legacy_cfg), None]); let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + let node_id_2 = nodes[2].node.get_our_node_id(); create_announced_chan_between_nodes(&nodes, 0, 1); let chan_2 = create_announced_chan_between_nodes(&nodes, 1, 2); @@ -66,7 +69,7 @@ fn do_test_onchain_htlc_reorg(local_commitment: bool, claim: bool) { nodes[2].node.claim_funds(our_payment_preimage); expect_payment_claimed!(nodes[2], our_payment_hash, 1_000_000); check_added_monitors(&nodes[2], 1); - get_htlc_update_msgs(&nodes[2], &nodes[1].node.get_our_node_id()); + get_htlc_update_msgs(&nodes[2], &node_id_1); let claim_txn = if local_commitment { // Broadcast node 1 commitment txn to broadcast the HTLC-Timeout @@ -77,31 +80,23 @@ fn do_test_onchain_htlc_reorg(local_commitment: bool, claim: bool) { check_spends!(node_1_commitment_txn[1], node_1_commitment_txn[0]); // Give node 2 node 1's transactions and get its response (claiming the HTLC instead). - connect_block( - &nodes[2], - &create_dummy_block(nodes[2].best_block_hash(), 42, node_1_commitment_txn.clone()), - ); + let block = + create_dummy_block(nodes[2].best_block_hash(), 42, node_1_commitment_txn.clone()); + connect_block(&nodes[2], &block); check_closed_broadcast(&nodes[2], 1, true); // We should get a BroadcastChannelUpdate (and *only* a BroadcstChannelUpdate) check_added_monitors(&nodes[2], 1); - check_closed_event( - &nodes[2], - 1, - ClosureReason::CommitmentTxConfirmed, - &[nodes[1].node.get_our_node_id()], - 100000, - ); - let node_2_commitment_txn = - nodes[2].tx_broadcaster.txn_broadcasted.lock().unwrap().split_off(0); + let reason = ClosureReason::CommitmentTxConfirmed; + check_closed_event(&nodes[2], 1, reason, &[node_id_1], 100000); + let node_2_commitment_txn = nodes[2].tx_broadcaster.txn_broadcast(); assert_eq!(node_2_commitment_txn.len(), 1); // ChannelMonitor: 1 offered HTLC-Claim check_spends!(node_2_commitment_txn[0], node_1_commitment_txn[0]); // Make sure node 1's height is the same as the !local_commitment case connect_blocks(&nodes[1], 1); // Confirm node 1's commitment txn (and HTLC-Timeout) on node 1 - connect_block( - &nodes[1], - &create_dummy_block(nodes[1].best_block_hash(), 42, node_1_commitment_txn.clone()), - ); + let block = + create_dummy_block(nodes[1].best_block_hash(), 42, node_1_commitment_txn.clone()); + connect_block(&nodes[1], &block); // ...but return node 1's commitment tx in case claim is set and we're preparing to reorg vec![node_1_commitment_txn[0].clone(), node_2_commitment_txn[0].clone()] @@ -127,13 +122,7 @@ fn do_test_onchain_htlc_reorg(local_commitment: bool, claim: bool) { }; check_closed_broadcast(&nodes[1], 1, true); // We should get a BroadcastChannelUpdate (and *only* a BroadcstChannelUpdate) check_added_monitors(&nodes[1], 1); - check_closed_event( - &nodes[1], - 1, - ClosureReason::CommitmentTxConfirmed, - &[nodes[2].node.get_our_node_id()], - 100000, - ); + check_closed_event(&nodes[1], 1, ClosureReason::CommitmentTxConfirmed, &[node_id_2], 100000); // Connect ANTI_REORG_DELAY - 2 blocks, giving us a confirmation count of ANTI_REORG_DELAY - 1. connect_blocks(&nodes[1], ANTI_REORG_DELAY - 2); check_added_monitors(&nodes[1], 0); @@ -154,40 +143,27 @@ fn do_test_onchain_htlc_reorg(local_commitment: bool, claim: bool) { connect_block(&nodes[1], &create_dummy_block(nodes[1].best_block_hash(), 42, Vec::new())); expect_and_process_pending_htlcs_and_htlc_handling_failed( &nodes[1], - &[HTLCHandlingFailureType::Forward { - node_id: Some(nodes[2].node.get_our_node_id()), - channel_id: chan_2.2, - }], + &[HTLCHandlingFailureType::Forward { node_id: Some(node_id_2), channel_id: chan_2.2 }], ); } check_added_monitors(&nodes[1], 1); // Which should result in an immediate claim/fail of the HTLC: - let mut htlc_updates = get_htlc_update_msgs(&nodes[1], &nodes[0].node.get_our_node_id()); + let mut htlc_updates = get_htlc_update_msgs(&nodes[1], &node_id_0); if claim { assert_eq!(htlc_updates.update_fulfill_htlcs.len(), 1); - nodes[0].node.handle_update_fulfill_htlc( - nodes[1].node.get_our_node_id(), - htlc_updates.update_fulfill_htlcs.remove(0), - ); + let update_fulfill = htlc_updates.update_fulfill_htlcs.remove(0); + nodes[0].node.handle_update_fulfill_htlc(node_id_1, update_fulfill); } else { assert_eq!(htlc_updates.update_fail_htlcs.len(), 1); - nodes[0].node.handle_update_fail_htlc( - nodes[1].node.get_our_node_id(), - &htlc_updates.update_fail_htlcs[0], - ); + nodes[0].node.handle_update_fail_htlc(node_id_1, &htlc_updates.update_fail_htlcs[0]); } do_commitment_signed_dance(&nodes[0], &nodes[1], &htlc_updates.commitment_signed, false, true); if claim { expect_payment_sent!(nodes[0], our_payment_preimage); } else { - expect_payment_failed_with_update!( - nodes[0], - our_payment_hash, - false, - chan_2.0.contents.short_channel_id, - true - ); + let scid = chan_2.0.contents.short_channel_id; + expect_payment_failed_with_update!(nodes[0], our_payment_hash, false, scid, true); } } @@ -219,6 +195,8 @@ fn test_counterparty_revoked_reorg() { let legacy_cfg = test_legacy_channel_config(); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(legacy_cfg), None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); let chan = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 500_000_000); @@ -234,13 +212,14 @@ fn test_counterparty_revoked_reorg() { let payment_hash_4 = route_payment(&nodes[1], &[&nodes[0]], 4_000).1; nodes[0].node.claim_funds(payment_preimage_3); - let _ = get_htlc_update_msgs(&nodes[0], &nodes[1].node.get_our_node_id()); + let _ = get_htlc_update_msgs(&nodes[0], &node_id_1); check_added_monitors(&nodes[0], 1); expect_payment_claimed!(nodes[0], payment_hash_3, 4_000_000); let mut unrevoked_local_txn = get_local_commitment_txn!(nodes[0], chan.2); - assert_eq!(unrevoked_local_txn.len(), 3); // commitment + 2 HTLC txn - // Sort the unrevoked transactions in reverse order, ie commitment tx, then HTLC 1 then HTLC 3 + // There should be the commitment transaction and two HTLC transactions. + assert_eq!(unrevoked_local_txn.len(), 3); + // Sort the unrevoked transactions in reverse order, ie commitment tx, then HTLC 1 then HTLC 3 unrevoked_local_txn.sort_unstable_by_key(|tx| { 1_000_000 - tx.output.iter().map(|outp| outp.value.to_sat()).sum::() }); @@ -250,13 +229,7 @@ fn test_counterparty_revoked_reorg() { mine_transaction(&nodes[1], &revoked_local_txn[0]); check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); - check_closed_event( - &nodes[1], - 1, - ClosureReason::CommitmentTxConfirmed, - &[nodes[0].node.get_our_node_id()], - 1000000, - ); + check_closed_event(&nodes[1], 1, ClosureReason::CommitmentTxConfirmed, &[node_id_0], 1000000); // Connect up to one block before the revoked transaction would be considered final, then do a // reorg that disconnects the full chain and goes up to the height at which the revoked @@ -309,14 +282,15 @@ fn do_test_unconf_chan( let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); *nodes[0].connect_style.borrow_mut() = connect_style; + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); let chan_conf_height = core::cmp::max(nodes[0].best_block_info().1 + 1, nodes[1].best_block_info().1 + 1); let chan = create_announced_chan_between_nodes(&nodes, 0, 1); { let per_peer_state = nodes[0].node.per_peer_state.read().unwrap(); - let peer_state = - per_peer_state.get(&nodes[1].node.get_our_node_id()).unwrap().lock().unwrap(); + let peer_state = per_peer_state.get(&node_id_1).unwrap().lock().unwrap(); assert_eq!(peer_state.channel_by_id.len(), 1); assert_eq!(nodes[0].node.short_to_chan_info.read().unwrap().len(), 2); } @@ -353,13 +327,12 @@ fn do_test_unconf_chan( let relevant_txids = nodes[0].node.get_relevant_txids(); assert_eq!(relevant_txids.len(), 0); - let txn = nodes[0].tx_broadcaster.txn_broadcasted.lock().unwrap().split_off(0); + let txn = nodes[0].tx_broadcaster.txn_broadcast(); assert_eq!(txn.len(), 1); { let per_peer_state = nodes[0].node.per_peer_state.read().unwrap(); - let peer_state = - per_peer_state.get(&nodes[1].node.get_our_node_id()).unwrap().lock().unwrap(); + let peer_state = per_peer_state.get(&node_id_1).unwrap().lock().unwrap(); assert_eq!(peer_state.channel_by_id.len(), 0); assert_eq!(nodes[0].node.short_to_chan_info.read().unwrap().len(), 0); } @@ -368,16 +341,18 @@ fn do_test_unconf_chan( } let expected_err = "Funding transaction was un-confirmed, originally locked at 6 confs."; + let broadcast_close_msg = + "Channel closed because of an exception: Funding transaction was un-confirmed, originally locked at 6 confs."; + let counterparty_force_closed_reason = || ClosureReason::CounterpartyForceClosed { + peer_msg: UntrustedString(format!( + "Channel closed because of an exception: {}", + expected_err + )), + }; if reload_node && !reorg_after_reload { - handle_announce_close_broadcast_events(&nodes, 0, 1, true, "Channel closed because of an exception: Funding transaction was un-confirmed, originally locked at 6 confs."); + handle_announce_close_broadcast_events(&nodes, 0, 1, true, broadcast_close_msg); check_added_monitors(&nodes[1], 1); - let reason = ClosureReason::CounterpartyForceClosed { - peer_msg: UntrustedString(format!( - "Channel closed because of an exception: {}", - expected_err - )), - }; - check_closed_event(&nodes[1], 1, reason, &[nodes[0].node.get_our_node_id()], 100000); + check_closed_event(&nodes[1], 1, counterparty_force_closed_reason(), &[node_id_0], 100000); } if reload_node { @@ -387,18 +362,20 @@ fn do_test_unconf_chan( // it when we go to deserialize, and then use the ChannelManager. let nodes_0_serialized = nodes[0].node.encode(); let chan_0_monitor_serialized = get_monitor!(nodes[0], chan.2).encode(); + let current_config = nodes[0].node.get_current_config(); + let serialized_monitors = [&chan_0_monitor_serialized[..]]; reload_node!( nodes[0], - nodes[0].node.get_current_config(), + current_config, &nodes_0_serialized, - &[&chan_0_monitor_serialized], + &serialized_monitors, persister, new_chain_monitor, nodes_0_deserialized ); - nodes[1].node.peer_disconnected(nodes[0].node.get_our_node_id()); + nodes[1].node.peer_disconnected(node_id_0); if reorg_after_reload { // If we haven't yet closed the channel, reconnect the peers so that nodes[0] will @@ -442,8 +419,7 @@ fn do_test_unconf_chan( { let per_peer_state = nodes[0].node.per_peer_state.read().unwrap(); - let peer_state = - per_peer_state.get(&nodes[1].node.get_our_node_id()).unwrap().lock().unwrap(); + let peer_state = per_peer_state.get(&node_id_1).unwrap().lock().unwrap(); assert_eq!(peer_state.channel_by_id.len(), 0); assert_eq!(nodes[0].node.short_to_chan_info.read().unwrap().len(), 0); } @@ -455,59 +431,36 @@ fn do_test_unconf_chan( } check_added_monitors(&nodes[0], 1); - let txn = nodes[0].tx_broadcaster.txn_broadcasted.lock().unwrap().split_off(0); + let txn = nodes[0].tx_broadcaster.txn_broadcast(); assert_eq!(txn.len(), 1); } if reorg_after_reload || !reload_node { - handle_announce_close_broadcast_events(&nodes, 0, 1, true, "Channel closed because of an exception: Funding transaction was un-confirmed, originally locked at 6 confs."); + handle_announce_close_broadcast_events(&nodes, 0, 1, true, broadcast_close_msg); check_added_monitors(&nodes[1], 1); - let reason = ClosureReason::CounterpartyForceClosed { - peer_msg: UntrustedString(format!( - "Channel closed because of an exception: {}", - expected_err - )), - }; - check_closed_event(&nodes[1], 1, reason, &[nodes[0].node.get_our_node_id()], 100000); + check_closed_event(&nodes[1], 1, counterparty_force_closed_reason(), &[node_id_0], 100000); } - check_closed_event( - &nodes[0], - 1, - ClosureReason::ProcessingError { err: expected_err.to_owned() }, - &[nodes[1].node.get_our_node_id()], - 100000, - ); + let processing_error = ClosureReason::ProcessingError { err: expected_err.to_owned() }; + check_closed_event(&nodes[0], 1, processing_error, &[node_id_1], 100000); // Now check that we can create a new channel if reload_node && !reorg_after_reload { // If we dropped the channel before reloading the node, nodes[1] was also dropped from // nodes[0] storage, and hence not connected again on startup. We therefore need to // reconnect to the node before attempting to create a new channel. - nodes[0] - .node - .peer_connected( - nodes[1].node.get_our_node_id(), - &Init { - features: nodes[1].node.init_features(), - networks: None, - remote_network_address: None, - }, - true, - ) - .unwrap(); - nodes[1] - .node - .peer_connected( - nodes[0].node.get_our_node_id(), - &Init { - features: nodes[0].node.init_features(), - networks: None, - remote_network_address: None, - }, - true, - ) - .unwrap(); + let node_1_init = Init { + features: nodes[1].node.init_features(), + networks: None, + remote_network_address: None, + }; + let node_0_init = Init { + features: nodes[0].node.init_features(), + networks: None, + remote_network_address: None, + }; + nodes[0].node.peer_connected(node_id_1, &node_1_init, true).unwrap(); + nodes[1].node.peer_connected(node_id_0, &node_0_init, true).unwrap(); } create_announced_chan_between_nodes(&nodes, 0, 1); @@ -563,6 +516,8 @@ fn test_set_outpoints_partial_claiming() { let legacy_cfg = test_legacy_channel_config(); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(legacy_cfg), None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); let chan = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1000000, 59000000); let (payment_preimage_1, payment_hash_1, ..) = @@ -591,13 +546,7 @@ fn test_set_outpoints_partial_claiming() { // Connect blocks on node A commitment transaction mine_transaction(&nodes[0], &remote_txn[0]); check_closed_broadcast(&nodes[0], 1, true); - check_closed_event( - &nodes[0], - 1, - ClosureReason::CommitmentTxConfirmed, - &[nodes[1].node.get_our_node_id()], - 1000000, - ); + check_closed_event(&nodes[0], 1, ClosureReason::CommitmentTxConfirmed, &[node_id_1], 1000000); check_added_monitors(&nodes[0], 1); // Verify node A broadcast tx claiming both HTLCs { @@ -612,19 +561,17 @@ fn test_set_outpoints_partial_claiming() { // Connect blocks on node B connect_blocks(&nodes[1], TEST_FINAL_CLTV + LATENCY_GRACE_PERIOD_BLOCKS + 1); check_closed_broadcast(&nodes[1], 1, true); - check_closed_events( - &nodes[1], - &[ExpectedCloseEvent { - channel_capacity_sats: Some(1_000_000), - channel_id: Some(chan.2), - counterparty_node_id: Some(nodes[0].node.get_our_node_id()), - discard_funding: false, - splice_failed: false, - reason: None, // Could be due to either HTLC timing out, so don't bother checking - channel_funding_txo: None, - user_channel_id: None, - }], - ); + let expected_close = ExpectedCloseEvent { + channel_capacity_sats: Some(1_000_000), + channel_id: Some(chan.2), + counterparty_node_id: Some(node_id_0), + discard_funding: false, + splice_failed: false, + reason: None, // Could be due to either HTLC timing out, so don't bother checking + channel_funding_txo: None, + user_channel_id: None, + }; + check_closed_events(&nodes[1], &[expected_close]); check_added_monitors(&nodes[1], 1); // Verify node B broadcast 2 HTLC-timeout txn let partial_claim_tx = { @@ -683,6 +630,8 @@ fn do_test_to_remote_after_local_detection(style: ConnectStyle) { let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); *nodes[0].connect_style.borrow_mut() = style; *nodes[1].connect_style.borrow_mut() = style; @@ -701,23 +650,11 @@ fn do_test_to_remote_after_local_detection(style: ConnectStyle) { check_closed_broadcast(&nodes[0], 1, true); assert!(nodes[0].node.list_channels().is_empty()); check_added_monitors(&nodes[0], 1); - check_closed_event( - &nodes[0], - 1, - ClosureReason::CommitmentTxConfirmed, - &[nodes[1].node.get_our_node_id()], - 1000000, - ); + check_closed_event(&nodes[0], 1, ClosureReason::CommitmentTxConfirmed, &[node_id_1], 1000000); check_closed_broadcast(&nodes[1], 1, true); assert!(nodes[1].node.list_channels().is_empty()); check_added_monitors(&nodes[1], 1); - check_closed_event( - &nodes[1], - 1, - ClosureReason::CommitmentTxConfirmed, - &[nodes[0].node.get_our_node_id()], - 1000000, - ); + check_closed_event(&nodes[1], 1, ClosureReason::CommitmentTxConfirmed, &[node_id_0], 1000000); assert!(nodes[0].chain_monitor.chain_monitor.get_and_clear_pending_events().is_empty()); assert!(nodes[1].chain_monitor.chain_monitor.get_and_clear_pending_events().is_empty()); @@ -756,13 +693,15 @@ fn do_test_to_remote_after_local_detection(style: ConnectStyle) { { assert_eq!(outputs.len(), 1); assert_eq!(channel_id, Some(chan_id)); + let spendable_outputs = [&outputs[0]]; + let destination_script = Builder::new().push_opcode(opcodes::all::OP_RETURN).into_script(); let spend_tx = nodes[0] .keys_manager .backing .spend_spendable_outputs( - &[&outputs[0]], + &spendable_outputs, Vec::new(), - Builder::new().push_opcode(opcodes::all::OP_RETURN).into_script(), + destination_script, 253, None, &Secp256k1::new(), @@ -789,13 +728,15 @@ fn do_test_to_remote_after_local_detection(style: ConnectStyle) { { assert_eq!(outputs.len(), 1); assert_eq!(channel_id, Some(chan_id)); + let spendable_outputs = [&outputs[0]]; + let destination_script = Builder::new().push_opcode(opcodes::all::OP_RETURN).into_script(); let spend_tx = nodes[1] .keys_manager .backing .spend_spendable_outputs( - &[&outputs[0]], + &spendable_outputs, Vec::new(), - Builder::new().push_opcode(opcodes::all::OP_RETURN).into_script(), + destination_script, 253, None, &Secp256k1::new(), @@ -826,6 +767,8 @@ fn test_htlc_preimage_claim_holder_commitment_after_counterparty_commitment_reor let legacy_cfg = test_legacy_channel_config(); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(legacy_cfg), None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); let (_, _, chan_id, funding_tx) = create_announced_chan_between_nodes(&nodes, 0, 1); @@ -837,11 +780,7 @@ fn test_htlc_preimage_claim_holder_commitment_after_counterparty_commitment_reor // holder commitment. nodes[0] .node - .force_close_broadcasting_latest_txn( - &chan_id, - &nodes[1].node.get_our_node_id(), - message.clone(), - ) + .force_close_broadcasting_latest_txn(&chan_id, &node_id_1, message.clone()) .unwrap(); check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); @@ -849,20 +788,16 @@ fn test_htlc_preimage_claim_holder_commitment_after_counterparty_commitment_reor broadcasted_latest_txn: Some(true), message: message.clone(), }; - check_closed_event(&nodes[0], 1, reason, &[nodes[1].node.get_our_node_id()], 100000); + check_closed_event(&nodes[0], 1, reason, &[node_id_1], 100000); nodes[1] .node - .force_close_broadcasting_latest_txn( - &chan_id, - &nodes[0].node.get_our_node_id(), - message.clone(), - ) + .force_close_broadcasting_latest_txn(&chan_id, &node_id_0, message.clone()) .unwrap(); check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); let reason = ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }; - check_closed_event(&nodes[1], 1, reason, &[nodes[0].node.get_our_node_id()], 100000); + check_closed_event(&nodes[1], 1, reason, &[node_id_0], 100000); let mut txn = nodes[0].tx_broadcaster.txn_broadcast(); assert_eq!(txn.len(), 1); @@ -914,6 +849,8 @@ fn test_htlc_preimage_claim_prev_counterparty_commitment_after_current_counterpa let legacy_cfg = test_legacy_channel_config(); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(legacy_cfg), None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); let (_, _, chan_id, funding_tx) = create_announced_chan_between_nodes(&nodes, 0, 1); @@ -928,19 +865,18 @@ fn test_htlc_preimage_claim_prev_counterparty_commitment_after_current_counterpa check_added_monitors(&nodes[0], 1); let mut msg_events = nodes[0].node.get_and_clear_pending_msg_events(); assert_eq!(msg_events.len(), 1); - let (update_fee, commit_sig) = - if let MessageSendEvent::UpdateHTLCs { node_id, channel_id: _, mut updates } = - msg_events.pop().unwrap() - { - assert_eq!(node_id, nodes[1].node.get_our_node_id()); - (updates.update_fee.take().unwrap(), updates.commitment_signed) - } else { - panic!("Unexpected message send event"); - }; + let MessageSendEvent::UpdateHTLCs { node_id, channel_id: _, mut updates } = + msg_events.pop().unwrap() + else { + panic!("Unexpected message send event"); + }; + assert_eq!(node_id, node_id_1); + let update_fee = updates.update_fee.take().unwrap(); + let commit_sig = updates.commitment_signed; // Handle the fee update on the other side, but don't send the last RAA such that the previous // commitment is still valid (unrevoked). - nodes[1].node().handle_update_fee(nodes[0].node.get_our_node_id(), &update_fee); + nodes[1].node().handle_update_fee(node_id_0, &update_fee); let _last_revoke_and_ack = commitment_signed_dance_return_raa(&nodes[1], &nodes[0], &commit_sig, false); @@ -949,16 +885,12 @@ fn test_htlc_preimage_claim_prev_counterparty_commitment_after_current_counterpa // Force close with the latest commitment, confirm it, and reorg it with the previous commitment. nodes[0] .node - .force_close_broadcasting_latest_txn( - &chan_id, - &nodes[1].node.get_our_node_id(), - message.clone(), - ) + .force_close_broadcasting_latest_txn(&chan_id, &node_id_1, message.clone()) .unwrap(); check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); let reason = ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }; - check_closed_event(&nodes[0], 1, reason, &[nodes[1].node.get_our_node_id()], 100000); + check_closed_event(&nodes[0], 1, reason, &[node_id_1], 100000); let mut txn = nodes[0].tx_broadcaster.txn_broadcast(); assert_eq!(txn.len(), 1); @@ -971,13 +903,7 @@ fn test_htlc_preimage_claim_prev_counterparty_commitment_after_current_counterpa check_closed_broadcast(&nodes[1], 1, true); check_added_monitors(&nodes[1], 1); - check_closed_event( - &nodes[1], - 1, - ClosureReason::CommitmentTxConfirmed, - &[nodes[0].node.get_our_node_id()], - 100000, - ); + check_closed_event(&nodes[1], 1, ClosureReason::CommitmentTxConfirmed, &[node_id_0], 100000); disconnect_blocks(&nodes[0], 1); disconnect_blocks(&nodes[1], 1); @@ -1026,6 +952,8 @@ fn do_test_retries_own_commitment_broadcast_after_reorg( create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config.clone())]); let nodes_1_deserialized; let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); let coinbase_tx = provide_anchor_reserves(&nodes); @@ -1038,6 +966,7 @@ fn do_test_retries_own_commitment_broadcast_after_reorg( // Trigger a new commitment by routing a dummy HTLC. We will have B broadcast the previous commitment. let serialized_node = nodes[1].node.encode(); let serialized_monitor = get_monitor!(nodes[1], chan_id).encode(); + let serialized_monitors = [&serialized_monitor[..]]; let _ = route_payment(&nodes[0], &[&nodes[1]], 1000); @@ -1045,7 +974,7 @@ fn do_test_retries_own_commitment_broadcast_after_reorg( nodes[1], config, &serialized_node, - &[&serialized_monitor], + &serialized_monitors, persister, new_chain_monitor, nodes_1_deserialized @@ -1057,7 +986,7 @@ fn do_test_retries_own_commitment_broadcast_after_reorg( check_closed_broadcast(&nodes[0], 1, true); check_added_monitors(&nodes[0], 1); let reason = ClosureReason::HTLCsTimedOut { payment_hash: Some(payment_hash) }; - check_closed_event(&nodes[0], 1, reason, &[nodes[1].node.get_our_node_id()], 100_000); + check_closed_event(&nodes[0], 1, reason, &[node_id_1], 100_000); if keyed_anchors || p2a_anchor { handle_bump_close_event(&nodes[0]); } @@ -1087,16 +1016,12 @@ fn do_test_retries_own_commitment_broadcast_after_reorg( let message = "Channel force-closed".to_owned(); nodes[1] .node - .force_close_broadcasting_latest_txn( - &chan_id, - &nodes[0].node.get_our_node_id(), - message.clone(), - ) + .force_close_broadcasting_latest_txn(&chan_id, &node_id_0, message.clone()) .unwrap(); check_closed_broadcast(&nodes[1], 1, !revoked_counterparty_commitment); check_added_monitors(&nodes[1], 1); let reason = ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }; - check_closed_event(&nodes[1], 1, reason, &[nodes[0].node.get_our_node_id()], 100_000); + check_closed_event(&nodes[1], 1, reason, &[node_id_0], 100_000); if keyed_anchors || p2a_anchor { handle_bump_close_event(&nodes[1]); } @@ -1194,8 +1119,8 @@ fn do_test_split_htlc_expiry_tracking(use_third_htlc: bool, reorg_out: bool, p2a let coinbase_tx = provide_anchor_reserves(&nodes); - let node_a_id = nodes[0].node.get_our_node_id(); - let node_b_id = nodes[1].node.get_our_node_id(); + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); let (_, _, chan_id, funding_tx) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 0); @@ -1213,8 +1138,8 @@ fn do_test_split_htlc_expiry_tracking(use_third_htlc: bool, reorg_out: bool, p2a }; // First disconnect peers so that we don't have to deal with messages: - nodes[0].node.peer_disconnected(node_b_id); - nodes[1].node.peer_disconnected(node_a_id); + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); // Give node B preimages so that it will claim the first two HTLCs on-chain. nodes[1].node.claim_funds(preimage_a); @@ -1227,12 +1152,12 @@ fn do_test_split_htlc_expiry_tracking(use_third_htlc: bool, reorg_out: bool, p2a // Force-close and fetch node B's commitment transaction and the transaction claiming the first // two HTLCs. - nodes[1].node.force_close_broadcasting_latest_txn(&chan_id, &node_a_id, err).unwrap(); + nodes[1].node.force_close_broadcasting_latest_txn(&chan_id, &node_id_0, err).unwrap(); check_closed_broadcast(&nodes[1], 1, false); check_added_monitors(&nodes[1], 1); let message = "Channel force-closed".to_owned(); let reason = ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }; - check_closed_event(&nodes[1], 1, reason, &[node_a_id], 10_000_000); + check_closed_event(&nodes[1], 1, reason, &[node_id_0], 10_000_000); handle_bump_close_event(&nodes[1]); let mut txn = nodes[1].tx_broadcaster.txn_broadcast(); @@ -1257,7 +1182,7 @@ fn do_test_split_htlc_expiry_tracking(use_third_htlc: bool, reorg_out: bool, p2a } check_closed_broadcast(&nodes[0], 1, false); let reason = ClosureReason::CommitmentTxConfirmed; - check_closed_event(&nodes[0], 1, reason, &[node_b_id], 10_000_000); + check_closed_event(&nodes[0], 1, reason, &[node_id_1], 10_000_000); check_added_monitors(&nodes[0], 1); if let Some(ref a_tx) = anchor_tx { From 83da0a1350763dab182ee2803c8418aa6560507a Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 12 Mar 2026 21:02:13 +0000 Subject: [PATCH 176/627] Drop claude-review github action and just run it externally instead --- .github/workflows/claude-review.yml | 39 ----------------------------- 1 file changed, 39 deletions(-) delete mode 100644 .github/workflows/claude-review.yml diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml deleted file mode 100644 index 0d6d6451860..00000000000 --- a/.github/workflows/claude-review.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Claude Auto Review -on: - pull_request: - types: [opened, synchronize] - -jobs: - review: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - id-token: write - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 1 - - - uses: anthropics/claude-code-action@v1 - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - github_token: ${{ secrets.GITHUB_TOKEN }} - prompt: | - REPO: ${{ github.repository }} - PR NUMBER: ${{ github.event.pull_request.number }} - - Please review this pull request with a focus on: - - Code quality and best practices - - Potential bugs or issues - - Security implications - - Performance considerations - - Note: The PR branch is already checked out in the current working directory. - - Use `gh pr comment` for top-level feedback. - Use `mcp__github_inline_comment__create_inline_comment` to highlight specific code issues. - Only post GitHub comments - don't submit review text as messages. - - claude_args: | - --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)" From bf549289c35daa3a9ef6a685c31b0b183c82c6a1 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Sun, 15 Mar 2026 12:51:07 -0500 Subject: [PATCH 177/627] Move feerate parameters from splice_channel/rbf_channel to FundingTemplate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user doesn't choose the feerate at splice_channel/rbf_channel time — they choose it when performing coin selection. Moving feerate to the FundingTemplate::splice_* methods gives users more control and lets rbf_channel expose the minimum RBF feerate (25/24 of previous) on the template so users can choose an appropriate feerate. splice_channel and rbf_channel no longer take min_feerate/max_feerate. Instead, FundingTemplate gains a min_rbf_feerate() accessor that returns the RBF floor when applicable (from negotiated candidates or in-progress funding negotiations). The feerate parameters move to the splice_in_sync, splice_out_sync, and splice_in_and_out_sync methods (and their async variants), which validate that min_feerate >= min_rbf_feerate before coin selection. Fee estimation documentation moves from splice_channel/rbf_channel to funding_contributed, where the contribution (and its feerate range) is actually provided and the splice process begins. Co-Authored-By: Claude Opus 4.6 (1M context) --- fuzz/src/chanmon_consistency.rs | 50 ++-- fuzz/src/full_stack.rs | 35 +-- lightning/src/ln/channel.rs | 87 ++++--- lightning/src/ln/channelmanager.rs | 132 ++++------- lightning/src/ln/funding.rs | 127 +++++++--- lightning/src/ln/splicing_tests.rs | 362 +++++++++++++++-------------- 6 files changed, 402 insertions(+), 391 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 22006897a0f..5d46cf26031 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1391,14 +1391,8 @@ pub fn do_test( let splice_channel = |node: &ChanMan, counterparty_node_id: &PublicKey, channel_id: &ChannelId, - f: &dyn Fn(FundingTemplate) -> Result, - funding_feerate_sat_per_kw: FeeRate| { - match node.splice_channel( - channel_id, - counterparty_node_id, - funding_feerate_sat_per_kw, - FeeRate::MAX, - ) { + f: &dyn Fn(FundingTemplate) -> Result| { + match node.splice_channel(channel_id, counterparty_node_id) { Ok(funding_template) => { if let Ok(contribution) = f(funding_template) { let _ = node.funding_contributed( @@ -1425,15 +1419,10 @@ pub fn do_test( channel_id: &ChannelId, wallet: &WalletSync<&TestWalletSource, Arc>, funding_feerate_sat_per_kw: FeeRate| { - splice_channel( - node, - counterparty_node_id, - channel_id, - &move |funding_template: FundingTemplate| { - funding_template.splice_in_sync(Amount::from_sat(10_000), wallet) - }, - funding_feerate_sat_per_kw, - ); + splice_channel(node, counterparty_node_id, channel_id, &move |funding_template: FundingTemplate| { + let feerate = funding_template.min_rbf_feerate().unwrap_or(funding_feerate_sat_per_kw); + funding_template.splice_in_sync(Amount::from_sat(10_000), feerate, FeeRate::MAX, wallet) + }); }; let splice_out = |node: &ChanMan, @@ -1454,19 +1443,20 @@ pub fn do_test( if outbound_capacity_msat < 20_000_000 { return; } - splice_channel( - node, - counterparty_node_id, - channel_id, - &move |funding_template| { - let outputs = vec![TxOut { - value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), - script_pubkey: wallet.get_change_script().unwrap(), - }]; - funding_template.splice_out_sync(outputs, &WalletSync::new(wallet, logger.clone())) - }, - funding_feerate_sat_per_kw, - ); + splice_channel(node, counterparty_node_id, channel_id, &move |funding_template| { + let feerate = + funding_template.min_rbf_feerate().unwrap_or(funding_feerate_sat_per_kw); + let outputs = vec![TxOut { + value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), + script_pubkey: wallet.get_change_script().unwrap(), + }]; + funding_template.splice_out_sync( + outputs, + feerate, + FeeRate::MAX, + &WalletSync::new(wallet, logger.clone()), + ) + }); }; loop { diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index 5dfa51079d8..9700390f8ef 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -1032,16 +1032,19 @@ pub fn do_test(mut data: &[u8], logger: &Arc } let chan_id = chan.channel_id; let counterparty = chan.counterparty.node_id; - if let Ok(funding_template) = channelmanager.splice_channel( - &chan_id, - &counterparty, - FeeRate::from_sat_per_kwu(253), - FeeRate::MAX, - ) { + if let Ok(funding_template) = + channelmanager.splice_channel(&chan_id, &counterparty) + { + let feerate = funding_template + .min_rbf_feerate() + .unwrap_or(FeeRate::from_sat_per_kwu(253)); let wallet_sync = WalletSync::new(&wallet, Arc::clone(&logger)); - if let Ok(contribution) = funding_template - .splice_in_sync(Amount::from_sat(splice_in_sats.min(900_000)), &wallet_sync) - { + if let Ok(contribution) = funding_template.splice_in_sync( + Amount::from_sat(splice_in_sats.min(900_000)), + feerate, + FeeRate::MAX, + &wallet_sync, + ) { let _ = channelmanager.funding_contributed( &chan_id, &counterparty, @@ -1073,19 +1076,19 @@ pub fn do_test(mut data: &[u8], logger: &Arc let splice_out_sats = splice_out_sats.min(max_splice_out).max(546); // At least dust limit let chan_id = chan.channel_id; let counterparty = chan.counterparty.node_id; - if let Ok(funding_template) = channelmanager.splice_channel( - &chan_id, - &counterparty, - FeeRate::from_sat_per_kwu(253), - FeeRate::MAX, - ) { + if let Ok(funding_template) = + channelmanager.splice_channel(&chan_id, &counterparty) + { + let feerate = funding_template + .min_rbf_feerate() + .unwrap_or(FeeRate::from_sat_per_kwu(253)); let outputs = vec![TxOut { value: Amount::from_sat(splice_out_sats), script_pubkey: wallet.get_change_script().unwrap(), }]; let wallet_sync = WalletSync::new(&wallet, Arc::clone(&logger)); if let Ok(contribution) = - funding_template.splice_out_sync(outputs, &wallet_sync) + funding_template.splice_out_sync(outputs, feerate, FeeRate::MAX, &wallet_sync) { let _ = channelmanager.funding_contributed( &chan_id, diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 82d7d3bb92f..6f23aa7857f 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2973,6 +2973,21 @@ impl FundingNegotiation { } } + fn funding_feerate_sat_per_1000_weight(&self) -> u32 { + match self { + FundingNegotiation::AwaitingAck { context, .. } => { + context.funding_feerate_sat_per_1000_weight + }, + FundingNegotiation::ConstructingTransaction { + funding_feerate_sat_per_1000_weight, + .. + } => *funding_feerate_sat_per_1000_weight, + FundingNegotiation::AwaitingSignatures { + funding_feerate_sat_per_1000_weight, .. + } => *funding_feerate_sat_per_1000_weight, + } + } + fn is_initiator(&self) -> bool { match self { FundingNegotiation::AwaitingAck { context, .. } => context.is_initiator, @@ -11893,9 +11908,7 @@ where } /// Initiate splicing. - pub fn splice_channel( - &self, min_feerate: FeeRate, max_feerate: FeeRate, - ) -> Result { + pub fn splice_channel(&self) -> Result { if self.holder_commitment_point.current_point().is_none() { return Err(APIError::APIMisuseError { err: format!( @@ -11937,16 +11950,19 @@ where }); } - if min_feerate > max_feerate { - return Err(APIError::APIMisuseError { - err: format!( - "Channel {} min_feerate {} exceeds max_feerate {}", - self.context.channel_id(), - min_feerate, - max_feerate, - ), - }); - } + // Compute the RBF feerate floor from either negotiated candidates (via + // can_initiate_rbf) or an in-progress funding negotiation (which will become a + // negotiated candidate once it completes). + let min_rbf_feerate = self.can_initiate_rbf().ok().flatten().or_else(|| { + self.pending_splice + .as_ref() + .and_then(|pending_splice| pending_splice.funding_negotiation.as_ref()) + .map(|negotiation| { + let prev_feerate = negotiation.funding_feerate_sat_per_1000_weight(); + let min_feerate_kwu = ((prev_feerate as u64) * 25).div_ceil(24); + FeeRate::from_sat_per_kwu(min_feerate_kwu) + }) + }); let funding_txo = self.funding.get_funding_txo().expect("funding_txo should be set"); let previous_utxo = @@ -11957,13 +11973,11 @@ where satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + FUNDING_TRANSACTION_WITNESS_WEIGHT, }; - Ok(FundingTemplate::new(Some(shared_input), min_feerate, max_feerate)) + Ok(FundingTemplate::new(Some(shared_input), min_rbf_feerate)) } /// Initiate an RBF of a pending splice transaction. - pub fn rbf_channel( - &self, min_feerate: FeeRate, max_feerate: FeeRate, - ) -> Result { + pub fn rbf_channel(&self) -> Result { if self.holder_commitment_point.current_point().is_none() { return Err(APIError::APIMisuseError { err: format!( @@ -12000,18 +12014,8 @@ where }); } - if min_feerate > max_feerate { - return Err(APIError::APIMisuseError { - err: format!( - "Channel {} min_feerate {} exceeds max_feerate {}", - self.context.channel_id(), - min_feerate, - max_feerate, - ), - }); - } - - self.can_initiate_rbf(min_feerate).map_err(|err| APIError::APIMisuseError { err })?; + let min_rbf_feerate = + self.can_initiate_rbf().map_err(|err| APIError::APIMisuseError { err })?; let funding_txo = self.funding.get_funding_txo().expect("funding_txo should be set"); let previous_utxo = @@ -12022,10 +12026,10 @@ where satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + FUNDING_TRANSACTION_WITNESS_WEIGHT, }; - Ok(FundingTemplate::new(Some(shared_input), min_feerate, max_feerate)) + Ok(FundingTemplate::new(Some(shared_input), min_rbf_feerate)) } - fn can_initiate_rbf(&self, feerate: FeeRate) -> Result<(), String> { + fn can_initiate_rbf(&self) -> Result, String> { let pending_splice = match &self.pending_splice { Some(pending_splice) => pending_splice, None => { @@ -12064,20 +12068,13 @@ where )); } - // Check the 25/24 feerate increase rule - let new_feerate = feerate.to_sat_per_kwu() as u32; - if let Some(prev_feerate) = pending_splice.last_funding_feerate_sat_per_1000_weight { - if (new_feerate as u64) * 24 < (prev_feerate as u64) * 25 { - return Err(format!( - "Channel {} RBF feerate {} is less than 25/24 of the previous feerate {}", - self.context.channel_id(), - new_feerate, - prev_feerate, - )); - } - } + let min_rbf_feerate = + pending_splice.last_funding_feerate_sat_per_1000_weight.map(|prev_feerate| { + let min_feerate_kwu = ((prev_feerate as u64) * 25).div_ceil(24); + FeeRate::from_sat_per_kwu(min_feerate_kwu) + }); - Ok(()) + Ok(min_rbf_feerate) } pub fn funding_contributed( @@ -13761,7 +13758,7 @@ where #[allow(irrefutable_let_patterns)] if let QuiescentAction::Splice { contribution, .. } = action { if self.pending_splice.is_some() { - if let Err(msg) = self.can_initiate_rbf(contribution.feerate()) { + if let Err(msg) = self.can_initiate_rbf() { log_given_level!( logger, logger_level, diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index f8b5ef32fc3..223d74ce780 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -30,7 +30,7 @@ use bitcoin::hashes::{Hash, HashEngine, HmacEngine}; use bitcoin::secp256k1::Secp256k1; use bitcoin::secp256k1::{PublicKey, SecretKey}; -use bitcoin::{secp256k1, FeeRate, Sequence, SignedAmount}; +use bitcoin::{secp256k1, Sequence, SignedAmount}; use crate::blinded_path::message::{ AsyncPaymentsContext, BlindedMessagePath, MessageForwardNode, OffersContext, @@ -4710,52 +4710,18 @@ impl< /// channel (no matter the type) can be spliced, as long as the counterparty is currently /// connected. /// - /// # Arguments - /// - /// The splice initiator is responsible for paying fees for common fields, shared inputs, and - /// shared outputs along with any contributed inputs and outputs. When building a - /// [`FundingContribution`], fees are estimated at `min_feerate` assuming initiator - /// responsibility and must be covered by the supplied inputs for splice-in or the channel - /// balance for splice-out. If the counterparty also initiates a splice and wins the - /// tie-break, they become the initiator and choose the feerate. The fee is then - /// re-estimated at the counterparty's feerate for only our contributed inputs and outputs, - /// which may be higher or lower than the original estimate. The contribution is dropped and - /// the splice proceeds without it when: - /// - the counterparty's feerate is below `min_feerate` - /// - the counterparty's feerate is above `max_feerate` and the re-estimated fee exceeds the - /// original fee estimate - /// - the re-estimated fee exceeds the *fee buffer* regardless of `max_feerate` - /// - /// The fee buffer is the maximum fee that can be accommodated: - /// - **splice-in**: the selected inputs' value minus the contributed amount - /// - **splice-out**: the channel balance minus the withdrawal outputs - /// /// Returns a [`FundingTemplate`] which should be used to build a [`FundingContribution`] via - /// one of its splice methods (e.g., [`FundingTemplate::splice_in_sync`]). The resulting + /// one of its splice methods (e.g., [`FundingTemplate::splice_in_sync`]). The `min_feerate` + /// and `max_feerate` parameters are provided when calling those splice methods. The resulting /// contribution must then be passed to [`ChannelManager::funding_contributed`]. /// - /// # Events - /// - /// Once the funding transaction has been constructed, an [`Event::SplicePending`] will be - /// emitted. At this point, any inputs contributed to the splice can only be re-spent if an - /// [`Event::DiscardFunding`] is seen. - /// - /// After initial signatures have been exchanged, [`Event::FundingTransactionReadyForSigning`] - /// will be generated and [`ChannelManager::funding_transaction_signed`] should be called. - /// - /// If any failures occur while negotiating the funding transaction, an [`Event::SpliceFailed`] - /// will be emitted. Any contributed inputs no longer used will be included here and thus can - /// be re-spent. - /// - /// Once the splice has been locked by both counterparties, an [`Event::ChannelReady`] will be - /// emitted with the new funding output. At this point, a new splice can be negotiated by - /// calling `splice_channel` again on this channel. - /// - /// [`FundingContribution`]: crate::ln::funding::FundingContribution + /// When a pending splice exists with negotiated candidates (i.e., a splice that hasn't been + /// locked yet), [`FundingTemplate::min_rbf_feerate`] will return the minimum feerate required + /// for an RBF attempt (25/24 of the previous feerate). This can be used to choose an + /// appropriate `min_feerate` when calling the splice methods. #[rustfmt::skip] pub fn splice_channel( &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, - min_feerate: FeeRate, max_feerate: FeeRate, ) -> Result { let per_peer_state = self.per_peer_state.read().unwrap(); @@ -4783,7 +4749,7 @@ impl< match peer_state.channel_by_id.entry(*channel_id) { hash_map::Entry::Occupied(chan_phase_entry) => { if let Some(chan) = chan_phase_entry.get().as_funded() { - chan.splice_channel(min_feerate, max_feerate) + chan.splice_channel() } else { Err(APIError::ChannelUnavailable { err: format!( @@ -4809,41 +4775,14 @@ impl< /// Initiating an RBF requires that the channel counterparty supports splicing. The /// counterparty must be currently connected. /// - /// # Arguments - /// - /// The RBF initiator is responsible for paying fees for common fields, shared inputs, and - /// shared outputs along with any contributed inputs and outputs. When building a - /// [`FundingContribution`], fees are estimated using `min_feerate` and must be covered by the - /// supplied inputs for splice-in or the channel balance for splice-out. If the counterparty - /// also initiates an RBF and wins the tie-break, they become the initiator and choose the - /// feerate. In that case, `max_feerate` is used to reject a feerate that is too high for our - /// contribution. - /// /// Returns a [`FundingTemplate`] which should be used to build a [`FundingContribution`] via - /// one of its splice methods (e.g., [`FundingTemplate::splice_in_sync`]). The resulting - /// contribution must then be passed to [`ChannelManager::funding_contributed`]. - /// - /// # Events - /// - /// Once the funding transaction has been constructed, an [`Event::SplicePending`] will be - /// emitted. At this point, any inputs contributed to the splice can only be re-spent if an - /// [`Event::DiscardFunding`] is seen. - /// - /// After initial signatures have been exchanged, [`Event::FundingTransactionReadyForSigning`] - /// will be generated and [`ChannelManager::funding_transaction_signed`] should be called. - /// - /// If any failures occur while negotiating the funding transaction, an [`Event::SpliceFailed`] - /// will be emitted. Any contributed inputs no longer used will be included here and thus can - /// be re-spent. - /// - /// Once the splice has been locked by both counterparties, an [`Event::ChannelReady`] will be - /// emitted with the new funding output. At this point, a new splice can be negotiated by - /// calling `splice_channel` again on this channel. - /// - /// [`FundingContribution`]: crate::ln::funding::FundingContribution + /// one of its splice methods (e.g., [`FundingTemplate::splice_in_sync`]). The `min_feerate` + /// and `max_feerate` parameters are provided when calling those splice methods. + /// [`FundingTemplate::min_rbf_feerate`] returns the minimum feerate required for the RBF + /// (25/24 of the previous feerate). The resulting contribution must then be passed to + /// [`ChannelManager::funding_contributed`]. pub fn rbf_channel( - &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, min_feerate: FeeRate, - max_feerate: FeeRate, + &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, ) -> Result { let per_peer_state = self.per_peer_state.read().unwrap(); @@ -4871,7 +4810,7 @@ impl< match peer_state.channel_by_id.entry(*channel_id) { hash_map::Entry::Occupied(chan_phase_entry) => { if let Some(chan) = chan_phase_entry.get().as_funded() { - chan.rbf_channel(min_feerate, max_feerate) + chan.rbf_channel() } else { Err(APIError::ChannelUnavailable { err: format!( @@ -6622,20 +6561,43 @@ impl< /// An optional `locktime` for the funding transaction may be specified. If not given, the /// current best block height is used. /// + /// # Fee Estimation + /// + /// The splice initiator is responsible for paying fees for common fields, shared inputs, and + /// shared outputs along with any contributed inputs and outputs. When building a + /// [`FundingContribution`], fees are estimated at `min_feerate` assuming initiator + /// responsibility and must be covered by the supplied inputs for splice-in or the channel + /// balance for splice-out. If the counterparty also initiates a splice and wins the + /// tie-break, they become the initiator and choose the feerate. The fee is then + /// re-estimated at the counterparty's feerate for only our contributed inputs and outputs, + /// which may be higher or lower than the original estimate. The contribution is dropped and + /// the splice proceeds without it when: + /// - the counterparty's feerate is below `min_feerate` + /// - the counterparty's feerate is above `max_feerate` and the re-estimated fee exceeds the + /// original fee estimate + /// - the re-estimated fee exceeds the *fee buffer* regardless of `max_feerate` + /// + /// The fee buffer is the maximum fee that can be accommodated: + /// - **splice-in**: the selected inputs' value minus the contributed amount + /// - **splice-out**: the channel balance minus the withdrawal outputs + /// /// # Events /// /// Calling this method will commence the process of creating a new funding transaction for the - /// channel. An [`Event::FundingTransactionReadyForSigning`] will be generated once the - /// transaction is successfully constructed interactively with the counterparty. + /// channel. Once the funding transaction has been constructed, an [`Event::SplicePending`] + /// will be emitted. At this point, any inputs contributed to the splice can only be re-spent + /// if an [`Event::DiscardFunding`] is seen. /// - /// If unsuccessful, an [`Event::SpliceFailed`] will be produced if there aren't any earlier - /// splice attempts for the channel outstanding (i.e., haven't yet produced either - /// [`Event::SplicePending`] or [`Event::SpliceFailed`]). + /// If any failures occur while negotiating the funding transaction, an [`Event::SpliceFailed`] + /// will be emitted. Any contributed inputs no longer used will be included in an + /// [`Event::DiscardFunding`] and thus can be re-spent. /// - /// If unsuccessful, an [`Event::DiscardFunding`] will be produced for any contributions - /// passed in that are not found in any outstanding attempts for the channel. If there are no - /// such contributions, then the [`Event::DiscardFunding`] will not be produced since these - /// contributions must not be reused yet. + /// After initial signatures have been exchanged, [`Event::FundingTransactionReadyForSigning`] + /// will be generated and [`ChannelManager::funding_transaction_signed`] should be called. + /// + /// Once the splice has been locked by both counterparties, an [`Event::ChannelReady`] will be + /// emitted with the new funding output. At this point, a new splice can be negotiated by + /// calling [`ChannelManager::splice_channel`] again on this channel. /// /// # Errors /// diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index c81024ca080..52aabe5a12a 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -121,31 +121,45 @@ pub struct FundingTemplate { /// transaction. shared_input: Option, - /// The minimum fee rate for the splice transaction, used to propose as initiator. - min_feerate: FeeRate, - - /// The maximum fee rate to accept as acceptor before declining to add our contribution to the - /// splice. - max_feerate: FeeRate, + /// The minimum RBF feerate (25/24 of the previous feerate), if this template is for an + /// RBF attempt. `None` for fresh splices with no pending splice candidates. + min_rbf_feerate: Option, } impl FundingTemplate { /// Constructs a [`FundingTemplate`] for a splice using the provided shared input. - pub(super) fn new( - shared_input: Option, min_feerate: FeeRate, max_feerate: FeeRate, - ) -> Self { - Self { shared_input, min_feerate, max_feerate } + pub(super) fn new(shared_input: Option, min_rbf_feerate: Option) -> Self { + Self { shared_input, min_rbf_feerate } + } + + /// Returns the minimum RBF feerate, if this template is for an RBF attempt. + /// + /// When set, the `min_feerate` passed to the splice methods (e.g., + /// [`FundingTemplate::splice_in_sync`]) must be at least this value. + pub fn min_rbf_feerate(&self) -> Option { + self.min_rbf_feerate } } macro_rules! build_funding_contribution { - ($value_added:expr, $outputs:expr, $shared_input:expr, $feerate:expr, $max_feerate:expr, $wallet:ident, $($await:tt)*) => {{ + ($value_added:expr, $outputs:expr, $shared_input:expr, $min_rbf_feerate:expr, $feerate:expr, $max_feerate:expr, $wallet:ident, $($await:tt)*) => {{ let value_added: Amount = $value_added; let outputs: Vec = $outputs; let shared_input: Option = $shared_input; + let min_rbf_feerate: Option = $min_rbf_feerate; let feerate: FeeRate = $feerate; let max_feerate: FeeRate = $max_feerate; + if feerate > max_feerate { + return Err(()); + } + + if let Some(min_rbf_feerate) = min_rbf_feerate { + if feerate < min_rbf_feerate { + return Err(()); + } + } + // Validate user-provided amounts are within MAX_MONEY before coin selection to // ensure FundingContribution::net_value() arithmetic cannot overflow. With all // amounts bounded by MAX_MONEY (~2.1e15 sat), the worst-case net_value() @@ -224,28 +238,29 @@ impl FundingTemplate { /// Creates a [`FundingContribution`] for adding funds to a channel using `wallet` to perform /// coin selection. pub async fn splice_in( - self, value_added: Amount, wallet: W, + self, value_added: Amount, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, ) -> Result { if value_added == Amount::ZERO { return Err(()); } - let FundingTemplate { shared_input, min_feerate, max_feerate } = self; - build_funding_contribution!(value_added, vec![], shared_input, min_feerate, max_feerate, wallet, await) + let FundingTemplate { shared_input, min_rbf_feerate } = self; + build_funding_contribution!(value_added, vec![], shared_input, min_rbf_feerate, min_feerate, max_feerate, wallet, await) } /// Creates a [`FundingContribution`] for adding funds to a channel using `wallet` to perform /// coin selection. pub fn splice_in_sync( - self, value_added: Amount, wallet: W, + self, value_added: Amount, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, ) -> Result { if value_added == Amount::ZERO { return Err(()); } - let FundingTemplate { shared_input, min_feerate, max_feerate } = self; + let FundingTemplate { shared_input, min_rbf_feerate } = self; build_funding_contribution!( value_added, vec![], shared_input, + min_rbf_feerate, min_feerate, max_feerate, wallet, @@ -255,28 +270,29 @@ impl FundingTemplate { /// Creates a [`FundingContribution`] for removing funds from a channel using `wallet` to /// perform coin selection. pub async fn splice_out( - self, outputs: Vec, wallet: W, + self, outputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, ) -> Result { if outputs.is_empty() { return Err(()); } - let FundingTemplate { shared_input, min_feerate, max_feerate } = self; - build_funding_contribution!(Amount::ZERO, outputs, shared_input, min_feerate, max_feerate, wallet, await) + let FundingTemplate { shared_input, min_rbf_feerate } = self; + build_funding_contribution!(Amount::ZERO, outputs, shared_input, min_rbf_feerate, min_feerate, max_feerate, wallet, await) } /// Creates a [`FundingContribution`] for removing funds from a channel using `wallet` to /// perform coin selection. pub fn splice_out_sync( - self, outputs: Vec, wallet: W, + self, outputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, ) -> Result { if outputs.is_empty() { return Err(()); } - let FundingTemplate { shared_input, min_feerate, max_feerate } = self; + let FundingTemplate { shared_input, min_rbf_feerate } = self; build_funding_contribution!( Amount::ZERO, outputs, shared_input, + min_rbf_feerate, min_feerate, max_feerate, wallet, @@ -286,28 +302,31 @@ impl FundingTemplate { /// Creates a [`FundingContribution`] for both adding and removing funds from a channel using /// `wallet` to perform coin selection. pub async fn splice_in_and_out( - self, value_added: Amount, outputs: Vec, wallet: W, + self, value_added: Amount, outputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, + wallet: W, ) -> Result { if value_added == Amount::ZERO && outputs.is_empty() { return Err(()); } - let FundingTemplate { shared_input, min_feerate, max_feerate } = self; - build_funding_contribution!(value_added, outputs, shared_input, min_feerate, max_feerate, wallet, await) + let FundingTemplate { shared_input, min_rbf_feerate } = self; + build_funding_contribution!(value_added, outputs, shared_input, min_rbf_feerate, min_feerate, max_feerate, wallet, await) } /// Creates a [`FundingContribution`] for both adding and removing funds from a channel using /// `wallet` to perform coin selection. pub fn splice_in_and_out_sync( - self, value_added: Amount, outputs: Vec, wallet: W, + self, value_added: Amount, outputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, + wallet: W, ) -> Result { if value_added == Amount::ZERO && outputs.is_empty() { return Err(()); } - let FundingTemplate { shared_input, min_feerate, max_feerate } = self; + let FundingTemplate { shared_input, min_rbf_feerate } = self; build_funding_contribution!( value_added, outputs, shared_input, + min_rbf_feerate, min_feerate, max_feerate, wallet, @@ -1082,41 +1101,77 @@ mod tests { // splice_in_sync with value_added > MAX_MONEY { - let template = FundingTemplate::new(None, feerate, feerate); - assert!(template.splice_in_sync(over_max, UnreachableWallet).is_err()); + let template = FundingTemplate::new(None, None); + assert!(template + .splice_in_sync(over_max, feerate, feerate, UnreachableWallet) + .is_err()); } // splice_out_sync with single output value > MAX_MONEY { - let template = FundingTemplate::new(None, feerate, feerate); + let template = FundingTemplate::new(None, None); let outputs = vec![funding_output_sats(over_max.to_sat())]; - assert!(template.splice_out_sync(outputs, UnreachableWallet).is_err()); + assert!(template + .splice_out_sync(outputs, feerate, feerate, UnreachableWallet) + .is_err()); } // splice_out_sync with multiple outputs summing > MAX_MONEY { - let template = FundingTemplate::new(None, feerate, feerate); + let template = FundingTemplate::new(None, None); let half_over = Amount::MAX_MONEY / 2 + Amount::from_sat(1); let outputs = vec![ funding_output_sats(half_over.to_sat()), funding_output_sats(half_over.to_sat()), ]; - assert!(template.splice_out_sync(outputs, UnreachableWallet).is_err()); + assert!(template + .splice_out_sync(outputs, feerate, feerate, UnreachableWallet) + .is_err()); } // splice_in_and_out_sync with value_added > MAX_MONEY { - let template = FundingTemplate::new(None, feerate, feerate); + let template = FundingTemplate::new(None, None); let outputs = vec![funding_output_sats(1_000)]; - assert!(template.splice_in_and_out_sync(over_max, outputs, UnreachableWallet).is_err()); + assert!(template + .splice_in_and_out_sync(over_max, outputs, feerate, feerate, UnreachableWallet) + .is_err()); } // splice_in_and_out_sync with output sum > MAX_MONEY { - let template = FundingTemplate::new(None, feerate, feerate); + let template = FundingTemplate::new(None, None); let outputs = vec![funding_output_sats(over_max.to_sat())]; assert!(template - .splice_in_and_out_sync(Amount::from_sat(1_000), outputs, UnreachableWallet) + .splice_in_and_out_sync( + Amount::from_sat(1_000), + outputs, + feerate, + feerate, + UnreachableWallet, + ) + .is_err()); + } + } + + #[test] + fn test_build_funding_contribution_validates_feerate_range() { + let low = FeeRate::from_sat_per_kwu(1000); + let high = FeeRate::from_sat_per_kwu(2000); + + // min_feerate > max_feerate is rejected + { + let template = FundingTemplate::new(None, None); + assert!(template + .splice_in_sync(Amount::from_sat(10_000), high, low, UnreachableWallet) + .is_err()); + } + + // min_feerate < min_rbf_feerate is rejected + { + let template = FundingTemplate::new(None, Some(high)); + assert!(template + .splice_in_sync(Amount::from_sat(10_000), low, FeeRate::MAX, UnreachableWallet) .is_err()); } } diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index bdfe14635e0..fbc2a81969c 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -59,8 +59,7 @@ fn test_splicing_not_supported_api_error() { let (_, _, channel_id, _) = create_announced_chan_between_nodes(&nodes, 0, 1); - let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let res = nodes[1].node.splice_channel(&channel_id, &node_id_0, feerate, FeeRate::MAX); + let res = nodes[1].node.splice_channel(&channel_id, &node_id_0); match res { Err(APIError::ChannelUnavailable { err }) => { assert!(err.contains("Peer does not support splicing")) @@ -81,7 +80,7 @@ fn test_splicing_not_supported_api_error() { reconnect_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_args); - let res = nodes[1].node.splice_channel(&channel_id, &node_id_0, feerate, FeeRate::MAX); + let res = nodes[1].node.splice_channel(&channel_id, &node_id_0); match res { Err(APIError::ChannelUnavailable { err }) => { assert!(err.contains("Peer does not support quiescence, a splicing prerequisite")) @@ -111,13 +110,13 @@ fn test_v1_splice_in_negative_insufficient_inputs() { let feerate = FeeRate::from_sat_per_kwu(1024); // Initiate splice-in, with insufficient input contribution - let funding_template = nodes[0] - .node - .splice_channel(&channel_id, &nodes[1].node.get_our_node_id(), feerate, FeeRate::MAX) - .unwrap(); + let funding_template = + nodes[0].node.splice_channel(&channel_id, &nodes[1].node.get_our_node_id()).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - assert!(funding_template.splice_in_sync(splice_in_value, &wallet).is_err()); + assert!(funding_template + .splice_in_sync(splice_in_value, feerate, FeeRate::MAX, &wallet) + .is_err()); } /// A mock wallet that returns a pre-configured [`CoinSelection`] with a single input and change @@ -176,10 +175,8 @@ fn test_validate_accounts_for_change_output_weight() { create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); let feerate = FeeRate::from_sat_per_kwu(2000); - let funding_template = nodes[0] - .node - .splice_channel(&channel_id, &nodes[1].node.get_our_node_id(), feerate, FeeRate::MAX) - .unwrap(); + let funding_template = + nodes[0].node.splice_channel(&channel_id, &nodes[1].node.get_our_node_id()).unwrap(); // Input value = value_added + 1800: above 1736/1740 (fee without change), below 1984/1988 // (fee with change). @@ -188,7 +185,8 @@ fn test_validate_accounts_for_change_output_weight() { utxo_value: value_added + Amount::from_sat(1800), change_value: Amount::from_sat(1000), }; - let contribution = funding_template.splice_in_sync(value_added, &wallet).unwrap(); + let contribution = + funding_template.splice_in_sync(value_added, feerate, FeeRate::MAX, &wallet).unwrap(); assert!(contribution.change_output().is_some()); assert!(contribution.validate().is_err()); @@ -221,13 +219,12 @@ pub fn do_initiate_splice_in<'a, 'b, 'c, 'd>( value_added: Amount, ) -> FundingContribution { let node_id_acceptor = acceptor.node.get_our_node_id(); - let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = initiator - .node - .splice_channel(&channel_id, &node_id_acceptor, feerate, FeeRate::MAX) - .unwrap(); + let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = initiator.node.splice_channel(&channel_id, &node_id_acceptor).unwrap(); + let feerate = funding_template.min_rbf_feerate().unwrap_or(floor_feerate); let wallet = WalletSync::new(Arc::clone(&initiator.wallet_source), initiator.logger); - let funding_contribution = funding_template.splice_in_sync(value_added, &wallet).unwrap(); + let funding_contribution = + funding_template.splice_in_sync(value_added, feerate, FeeRate::MAX, &wallet).unwrap(); initiator .node .funding_contributed(&channel_id, &node_id_acceptor, funding_contribution.clone(), None) @@ -240,10 +237,10 @@ pub fn do_initiate_rbf_splice_in<'a, 'b, 'c, 'd>( value_added: Amount, feerate: FeeRate, ) -> FundingContribution { let node_id_counterparty = counterparty.node.get_our_node_id(); - let funding_template = - node.node.rbf_channel(&channel_id, &node_id_counterparty, feerate, FeeRate::MAX).unwrap(); + let funding_template = node.node.rbf_channel(&channel_id, &node_id_counterparty).unwrap(); let wallet = WalletSync::new(Arc::clone(&node.wallet_source), node.logger); - let funding_contribution = funding_template.splice_in_sync(value_added, &wallet).unwrap(); + let funding_contribution = + funding_template.splice_in_sync(value_added, feerate, FeeRate::MAX, &wallet).unwrap(); node.node .funding_contributed(&channel_id, &node_id_counterparty, funding_contribution.clone(), None) .unwrap(); @@ -255,11 +252,11 @@ pub fn do_initiate_rbf_splice_in_and_out<'a, 'b, 'c, 'd>( value_added: Amount, outputs: Vec, feerate: FeeRate, ) -> FundingContribution { let node_id_counterparty = counterparty.node.get_our_node_id(); - let funding_template = - node.node.rbf_channel(&channel_id, &node_id_counterparty, feerate, FeeRate::MAX).unwrap(); + let funding_template = node.node.rbf_channel(&channel_id, &node_id_counterparty).unwrap(); let wallet = WalletSync::new(Arc::clone(&node.wallet_source), node.logger); - let funding_contribution = - funding_template.splice_in_and_out_sync(value_added, outputs, &wallet).unwrap(); + let funding_contribution = funding_template + .splice_in_and_out_sync(value_added, outputs, feerate, FeeRate::MAX, &wallet) + .unwrap(); node.node .funding_contributed(&channel_id, &node_id_counterparty, funding_contribution.clone(), None) .unwrap(); @@ -271,13 +268,12 @@ pub fn initiate_splice_out<'a, 'b, 'c, 'd>( outputs: Vec, ) -> Result { let node_id_acceptor = acceptor.node.get_our_node_id(); - let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = initiator - .node - .splice_channel(&channel_id, &node_id_acceptor, feerate, FeeRate::MAX) - .unwrap(); + let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = initiator.node.splice_channel(&channel_id, &node_id_acceptor).unwrap(); + let feerate = funding_template.min_rbf_feerate().unwrap_or(floor_feerate); let wallet = WalletSync::new(Arc::clone(&initiator.wallet_source), initiator.logger); - let funding_contribution = funding_template.splice_out_sync(outputs, &wallet).unwrap(); + let funding_contribution = + funding_template.splice_out_sync(outputs, feerate, FeeRate::MAX, &wallet).unwrap(); match initiator.node.funding_contributed( &channel_id, &node_id_acceptor, @@ -304,14 +300,13 @@ pub fn do_initiate_splice_in_and_out<'a, 'b, 'c, 'd>( value_added: Amount, outputs: Vec, ) -> FundingContribution { let node_id_acceptor = acceptor.node.get_our_node_id(); - let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = initiator - .node - .splice_channel(&channel_id, &node_id_acceptor, feerate, FeeRate::MAX) - .unwrap(); + let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = initiator.node.splice_channel(&channel_id, &node_id_acceptor).unwrap(); + let feerate = funding_template.min_rbf_feerate().unwrap_or(floor_feerate); let wallet = WalletSync::new(Arc::clone(&initiator.wallet_source), initiator.logger); - let funding_contribution = - funding_template.splice_in_and_out_sync(value_added, outputs, &wallet).unwrap(); + let funding_contribution = funding_template + .splice_in_and_out_sync(value_added, outputs, feerate, FeeRate::MAX, &wallet) + .unwrap(); initiator .node .funding_contributed(&channel_id, &node_id_acceptor, funding_contribution.clone(), None) @@ -1363,17 +1358,17 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { }]; let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = - nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate, FeeRate::MAX).unwrap(); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_1_id).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let funding_contribution = funding_template.splice_out_sync(outputs.clone(), &wallet).unwrap(); + let funding_contribution = + funding_template.splice_out_sync(outputs.clone(), feerate, FeeRate::MAX, &wallet).unwrap(); nodes[0] .node .funding_contributed(&channel_id, &node_1_id, funding_contribution.clone(), None) .unwrap(); assert_eq!( - nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate, FeeRate::MAX), + nodes[0].node.splice_channel(&channel_id, &node_1_id), Err(APIError::APIMisuseError { err: format!( "Channel {} cannot be spliced as one is waiting to be negotiated", @@ -1385,7 +1380,7 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { let new_funding_script = complete_splice_handshake(&nodes[0], &nodes[1]); assert_eq!( - nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate, FeeRate::MAX), + nodes[0].node.splice_channel(&channel_id, &node_1_id), Err(APIError::APIMisuseError { err: format!( "Channel {} cannot be spliced as one is currently being negotiated", @@ -1394,18 +1389,6 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { }), ); - // The acceptor can enqueue a quiescent action while the current splice is pending. - let added_value = Amount::from_sat(initial_channel_value_sat); - let acceptor_template = - nodes[1].node.splice_channel(&channel_id, &node_0_id, feerate, FeeRate::MAX).unwrap(); - let acceptor_wallet = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); - let acceptor_contribution = - acceptor_template.splice_in_sync(added_value, &acceptor_wallet).unwrap(); - nodes[1] - .node - .funding_contributed(&channel_id, &node_0_id, acceptor_contribution, None) - .unwrap(); - complete_interactive_funding_negotiation( &nodes[0], &nodes[1], @@ -1415,7 +1398,7 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { ); assert_eq!( - nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate, FeeRate::MAX), + nodes[0].node.splice_channel(&channel_id, &node_1_id), Err(APIError::APIMisuseError { err: format!( "Channel {} cannot be spliced as one is currently being negotiated", @@ -1430,9 +1413,8 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { expect_splice_pending_event(&nodes[0], &node_1_id); expect_splice_pending_event(&nodes[1], &node_0_id); - // Now that the splice is pending, another splice may be initiated, but we must wait until - // the `splice_locked` exchange to send the initiator `stfu`. - assert!(nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate, FeeRate::MAX).is_ok()); + // Now that the splice is pending, another splice may be initiated. + assert!(nodes[0].node.splice_channel(&channel_id, &node_1_id).is_ok()); if reconnect { nodes[0].node.peer_disconnected(node_1_id); @@ -1446,54 +1428,35 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { mine_transaction(&nodes[0], &splice_tx); mine_transaction(&nodes[1], &splice_tx); let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); - - assert!( - matches!(stfu, Some(MessageSendEvent::SendStfu { node_id, .. }) if node_id == node_0_id) - ); + // Node 0 had called splice_channel (line above) but never funding_contributed, so no stfu + // is expected from node 0 at this point. + assert!(stfu.is_none()); } #[test] fn test_initiating_splice_holds_stfu_with_pending_splice() { - // Test that we don't send stfu too early for a new splice while we're already pending one. + // Test that a splice can be completed and locked successfully. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - let config = test_default_channel_config(); - let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, Some(config)]); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); - let node_0_id = nodes[0].node.get_our_node_id(); provide_utxo_reserves(&nodes, 2, Amount::ONE_BTC); let initial_channel_value_sat = 100_000; let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); - // Have both nodes attempt a splice, but only node 0 will call back and negotiate the splice. + // Node 0 initiates a splice, completing the full flow. let value_added = Amount::from_sat(10_000); let funding_contribution_0 = initiate_splice_in(&nodes[0], &nodes[1], channel_id, value_added); - - let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = - nodes[1].node.splice_channel(&channel_id, &node_0_id, feerate, FeeRate::MAX).unwrap(); - let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution_0); - // With the splice negotiated, have node 1 call back. This will queue the quiescent action, but - // it shouldn't send stfu yet as there's a pending splice. - let wallet = WalletSync::new(Arc::clone(&nodes[1].wallet_source), &nodes[1].logger); - let funding_contribution = funding_template.splice_in_sync(value_added, &wallet).unwrap(); - nodes[1] - .node - .funding_contributed(&channel_id, &node_0_id, funding_contribution.clone(), None) - .unwrap(); - assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); - + // Mine and lock the splice. mine_transaction(&nodes[0], &splice_tx); mine_transaction(&nodes[1], &splice_tx); let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], 5); - assert!( - matches!(stfu, Some(MessageSendEvent::SendStfu { node_id, .. }) if node_id == node_0_id) - ); + assert!(stfu.is_none()); } #[test] @@ -1569,26 +1532,22 @@ fn do_test_splice_tiebreak( provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000)); // Node 0 calls splice_channel + splice_in_sync + funding_contributed. - let funding_template_0 = nodes[0] - .node - .splice_channel(&channel_id, &node_id_1, node_0_feerate, FeeRate::MAX) - .unwrap(); + let funding_template_0 = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let node_0_funding_contribution = - funding_template_0.splice_in_sync(added_value, &wallet_0).unwrap(); + let node_0_funding_contribution = funding_template_0 + .splice_in_sync(added_value, node_0_feerate, FeeRate::MAX, &wallet_0) + .unwrap(); nodes[0] .node .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) .unwrap(); // Node 1 calls splice_channel + splice_in_sync + funding_contributed. - let funding_template_1 = nodes[1] - .node - .splice_channel(&channel_id, &node_id_0, node_1_feerate, FeeRate::MAX) - .unwrap(); + let funding_template_1 = nodes[1].node.splice_channel(&channel_id, &node_id_0).unwrap(); let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); - let node_1_funding_contribution = - funding_template_1.splice_in_sync(node_1_splice_value, &wallet_1).unwrap(); + let node_1_funding_contribution = funding_template_1 + .splice_in_sync(node_1_splice_value, node_1_feerate, FeeRate::MAX, &wallet_1) + .unwrap(); nodes[1] .node .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) @@ -1812,24 +1771,22 @@ fn test_splice_tiebreak_feerate_too_high_rejected() { let node_1_max_feerate = FeeRate::from_sat_per_kwu(3_000); // Node 0: very high feerate, moderate splice-in. - let funding_template_0 = - nodes[0].node.splice_channel(&channel_id, &node_id_1, high_feerate, FeeRate::MAX).unwrap(); + let funding_template_0 = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let node_0_funding_contribution = - funding_template_0.splice_in_sync(node_0_added_value, &wallet_0).unwrap(); + let node_0_funding_contribution = funding_template_0 + .splice_in_sync(node_0_added_value, high_feerate, FeeRate::MAX, &wallet_0) + .unwrap(); nodes[0] .node .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) .unwrap(); // Node 1: floor feerate, moderate splice-in, low max_feerate. - let funding_template_1 = nodes[1] - .node - .splice_channel(&channel_id, &node_id_0, floor_feerate, node_1_max_feerate) - .unwrap(); + let funding_template_1 = nodes[1].node.splice_channel(&channel_id, &node_id_0).unwrap(); let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); - let node_1_funding_contribution = - funding_template_1.splice_in_sync(node_1_added_value, &wallet_1).unwrap(); + let node_1_funding_contribution = funding_template_1 + .splice_in_sync(node_1_added_value, floor_feerate, node_1_max_feerate, &wallet_1) + .unwrap(); nodes[1] .node .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) @@ -3530,10 +3487,10 @@ fn test_funding_contributed_counterparty_not_found() { provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = - nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let funding_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + let funding_contribution = + funding_template.splice_in_sync(splice_in_amount, feerate, FeeRate::MAX, &wallet).unwrap(); // Use a fake/unknown public key as counterparty let fake_node_id = @@ -3570,10 +3527,10 @@ fn test_funding_contributed_channel_not_found() { provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = - nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let funding_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + let funding_contribution = + funding_template.splice_in_sync(splice_in_amount, feerate, FeeRate::MAX, &wallet).unwrap(); // Use a random/unknown channel_id let fake_channel_id = ChannelId::from_bytes([42; 32]); @@ -3615,11 +3572,16 @@ fn test_funding_contributed_splice_already_pending() { script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_raw_hash(Hash::all_zeros())), }; let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = - nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); let first_contribution = funding_template - .splice_in_and_out_sync(splice_in_amount, vec![first_splice_out.clone()], &wallet) + .splice_in_and_out_sync( + splice_in_amount, + vec![first_splice_out.clone()], + feerate, + FeeRate::MAX, + &wallet, + ) .unwrap(); // Initiate a second splice with a DIFFERENT output to test that different outputs @@ -3638,11 +3600,16 @@ fn test_funding_contributed_splice_already_pending() { nodes[0].wallet_source.clear_utxos(); provide_utxo_reserves(&nodes, 1, splice_in_amount * 3); - let funding_template = - nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); let second_contribution = funding_template - .splice_in_and_out_sync(splice_in_amount, vec![second_splice_out.clone()], &wallet) + .splice_in_and_out_sync( + splice_in_amount, + vec![second_splice_out.clone()], + feerate, + FeeRate::MAX, + &wallet, + ) .unwrap(); // First funding_contributed - this sets up the quiescent action @@ -3708,10 +3675,10 @@ fn test_funding_contributed_duplicate_contribution_no_event() { provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = - nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + let contribution = + funding_template.splice_in_sync(splice_in_amount, feerate, FeeRate::MAX, &wallet).unwrap(); // First funding_contributed - this sets up the quiescent action nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution.clone(), None).unwrap(); @@ -3767,19 +3734,19 @@ fn do_test_funding_contributed_active_funding_negotiation(state: u8) { // Build first contribution let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = - nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let first_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + let first_contribution = + funding_template.splice_in_sync(splice_in_amount, feerate, FeeRate::MAX, &wallet).unwrap(); // Build second contribution with different UTXOs so inputs/outputs don't overlap nodes[0].wallet_source.clear_utxos(); provide_utxo_reserves(&nodes, 1, splice_in_amount * 3); - let funding_template = - nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let second_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + let second_contribution = + funding_template.splice_in_sync(splice_in_amount, feerate, FeeRate::MAX, &wallet).unwrap(); // First funding_contributed - sets up the quiescent action and queues STFU nodes[0] @@ -3897,10 +3864,10 @@ fn test_funding_contributed_channel_shutdown() { provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = - nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let funding_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + let funding_contribution = + funding_template.splice_in_sync(splice_in_amount, feerate, FeeRate::MAX, &wallet).unwrap(); // Initiate channel shutdown - this makes is_usable() return false nodes[0].node.close_channel(&channel_id, &node_id_1).unwrap(); @@ -3951,12 +3918,10 @@ fn test_funding_contributed_unfunded_channel() { provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = nodes[0] - .node - .splice_channel(&funded_channel_id, &node_id_1, feerate, FeeRate::MAX) - .unwrap(); + let funding_template = nodes[0].node.splice_channel(&funded_channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let funding_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + let funding_contribution = + funding_template.splice_in_sync(splice_in_amount, feerate, FeeRate::MAX, &wallet).unwrap(); // Call funding_contributed with the unfunded channel's ID instead of the funded one. // Returns APIMisuseError because the channel is not funded. @@ -4386,7 +4351,7 @@ fn test_splice_rbf_acceptor_basic() { #[test] fn test_splice_rbf_insufficient_feerate() { - // Test that rbf_channel rejects a feerate that doesn't satisfy the 25/24 rule, and that the + // Test that splice_in_sync rejects a feerate that doesn't satisfy the 25/24 rule, and that the // acceptor also rejects tx_init_rbf with an insufficient feerate from a misbehaving peer. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); @@ -4408,20 +4373,27 @@ fn test_splice_rbf_insufficient_feerate() { let (_splice_tx, _new_funding_script) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); - // Initiator-side: rbf_channel rejects an insufficient feerate. + // Initiator-side: splice_in_sync rejects an insufficient feerate. // Original feerate was 253. Using exactly 253 should fail since 253 * 24 < 253 * 25. let same_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let err = - nodes[0].node.rbf_channel(&channel_id, &node_id_1, same_feerate, FeeRate::MAX).unwrap_err(); - assert_eq!( - err, - APIError::APIMisuseError { - err: format!( - "Channel {} RBF feerate {} is less than 25/24 of the previous feerate {}", - channel_id, FEERATE_FLOOR_SATS_PER_KW, FEERATE_FLOOR_SATS_PER_KW, - ), - } - ); + let funding_template = nodes[0].node.rbf_channel(&channel_id, &node_id_1).unwrap(); + + // Verify that the template exposes the RBF floor. + let min_rbf_feerate = funding_template.min_rbf_feerate().unwrap(); + let expected_floor = + FeeRate::from_sat_per_kwu(((FEERATE_FLOOR_SATS_PER_KW as u64) * 25).div_ceil(24)); + assert_eq!(min_rbf_feerate, expected_floor); + + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + assert!(funding_template + .splice_in_sync(added_value, same_feerate, FeeRate::MAX, &wallet) + .is_err()); + + // Verify that the floor feerate succeeds. + let funding_template = nodes[0].node.rbf_channel(&channel_id, &node_id_1).unwrap(); + assert!(funding_template + .splice_in_sync(added_value, min_rbf_feerate, FeeRate::MAX, &wallet) + .is_ok()); // Acceptor-side: tx_init_rbf with an insufficient feerate is also rejected. reenter_quiescence(&nodes[0], &nodes[1], &channel_id); @@ -5054,23 +5026,21 @@ fn test_splice_rbf_tiebreak_feerate_too_high_rejected() { let min_rbf_feerate = FeeRate::from_sat_per_kwu(min_rbf_feerate_sat_per_kwu); let node_1_max_feerate = FeeRate::from_sat_per_kwu(3_000); - let funding_template_0 = - nodes[0].node.rbf_channel(&channel_id, &node_id_1, high_feerate, FeeRate::MAX).unwrap(); + let funding_template_0 = nodes[0].node.rbf_channel(&channel_id, &node_id_1).unwrap(); let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let node_0_funding_contribution = - funding_template_0.splice_in_sync(added_value, &wallet_0).unwrap(); + let node_0_funding_contribution = funding_template_0 + .splice_in_sync(added_value, high_feerate, FeeRate::MAX, &wallet_0) + .unwrap(); nodes[0] .node .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) .unwrap(); - let funding_template_1 = nodes[1] - .node - .rbf_channel(&channel_id, &node_id_0, min_rbf_feerate, node_1_max_feerate) - .unwrap(); + let funding_template_1 = nodes[1].node.rbf_channel(&channel_id, &node_id_0).unwrap(); let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); - let node_1_funding_contribution = - funding_template_1.splice_in_sync(added_value, &wallet_1).unwrap(); + let node_1_funding_contribution = funding_template_1 + .splice_in_sync(added_value, min_rbf_feerate, node_1_max_feerate, &wallet_1) + .unwrap(); nodes[1] .node .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) @@ -5121,21 +5091,19 @@ fn test_splice_rbf_acceptor_recontributes() { // Step 1: Both nodes initiate a splice at floor feerate. let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template_0 = - nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); + let funding_template_0 = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); let node_0_funding_contribution = - funding_template_0.splice_in_sync(added_value, &wallet_0).unwrap(); + funding_template_0.splice_in_sync(added_value, feerate, FeeRate::MAX, &wallet_0).unwrap(); nodes[0] .node .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) .unwrap(); - let funding_template_1 = - nodes[1].node.splice_channel(&channel_id, &node_id_0, feerate, FeeRate::MAX).unwrap(); + let funding_template_1 = nodes[1].node.splice_channel(&channel_id, &node_id_0).unwrap(); let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); let node_1_funding_contribution = - funding_template_1.splice_in_sync(added_value, &wallet_1).unwrap(); + funding_template_1.splice_in_sync(added_value, feerate, FeeRate::MAX, &wallet_1).unwrap(); nodes[1] .node .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) @@ -5249,22 +5217,22 @@ fn test_splice_rbf_recontributes_feerate_too_high() { // from a 100k UTXO (tight budget: ~5k for change/fees). let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template_0 = - nodes[0].node.splice_channel(&channel_id, &node_id_1, floor_feerate, FeeRate::MAX).unwrap(); + let funding_template_0 = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let node_0_funding_contribution = - funding_template_0.splice_in_sync(Amount::from_sat(50_000), &wallet_0).unwrap(); + let node_0_funding_contribution = funding_template_0 + .splice_in_sync(Amount::from_sat(50_000), floor_feerate, FeeRate::MAX, &wallet_0) + .unwrap(); nodes[0] .node .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) .unwrap(); let node_1_added_value = Amount::from_sat(95_000); - let funding_template_1 = - nodes[1].node.splice_channel(&channel_id, &node_id_0, floor_feerate, FeeRate::MAX).unwrap(); + let funding_template_1 = nodes[1].node.splice_channel(&channel_id, &node_id_0).unwrap(); let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); - let node_1_funding_contribution = - funding_template_1.splice_in_sync(node_1_added_value, &wallet_1).unwrap(); + let node_1_funding_contribution = funding_template_1 + .splice_in_sync(node_1_added_value, floor_feerate, FeeRate::MAX, &wallet_1) + .unwrap(); nodes[1] .node .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) @@ -5312,11 +5280,11 @@ fn test_splice_rbf_recontributes_feerate_too_high() { provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000)); let high_feerate = FeeRate::from_sat_per_kwu(20_000); - let funding_template = - nodes[0].node.rbf_channel(&channel_id, &node_id_1, high_feerate, FeeRate::MAX).unwrap(); + let funding_template = nodes[0].node.rbf_channel(&channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let rbf_funding_contribution = - funding_template.splice_in_sync(Amount::from_sat(50_000), &wallet).unwrap(); + let rbf_funding_contribution = funding_template + .splice_in_sync(Amount::from_sat(50_000), high_feerate, FeeRate::MAX, &wallet) + .unwrap(); nodes[0] .node .funding_contributed(&channel_id, &node_id_1, rbf_funding_contribution.clone(), None) @@ -5651,3 +5619,39 @@ fn test_splice_rbf_disconnect_filters_prior_contributions() { reconnect_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_args); } + +#[test] +fn test_splice_channel_with_pending_splice_includes_rbf_floor() { + // Test that splice_channel (not rbf_channel) includes the RBF floor when a pending splice + // exists with negotiated candidates. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Complete a splice-in at floor feerate. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Call splice_channel (not rbf_channel) — the pending splice should cause + // min_rbf_feerate to be set. + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + let expected_floor = + FeeRate::from_sat_per_kwu(((FEERATE_FLOOR_SATS_PER_KW as u64) * 25).div_ceil(24)); + assert_eq!(funding_template.min_rbf_feerate(), Some(expected_floor)); + + // Successfully build a contribution at the floor feerate. + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + assert!(funding_template + .splice_in_sync(added_value, expected_floor, FeeRate::MAX, &wallet) + .is_ok()); +} From f156648074dd689912793af80ec099a582b5162d Mon Sep 17 00:00:00 2001 From: elnosh Date: Fri, 6 Mar 2026 15:20:28 -0500 Subject: [PATCH 178/627] Move log channel_reestablish event when needed --- lightning/src/ln/channelmanager.rs | 39 +++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index ada27af749f..f475a037b86 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -15112,8 +15112,6 @@ impl< } } - log_debug!(logger, "Generating channel_reestablish events"); - let per_peer_state = self.per_peer_state.read().unwrap(); if let Some(peer_state_mutex) = per_peer_state.get(&counterparty_node_id) { let mut peer_state_lock = peer_state_mutex.lock().unwrap(); @@ -15131,22 +15129,45 @@ impl< let logger = WithChannelContext::from(&self.logger, &chan.context(), None); match chan.peer_connected_get_handshake(self.chain_hash, &&logger) { ReconnectionMsg::Reestablish(msg) => { + log_debug!( + logger, + "Generated channel_reestablish event for channel {}", + chan.context().channel_id() + ); pending_msg_events.push(MessageSendEvent::SendChannelReestablish { node_id: chan.context().get_counterparty_node_id(), msg, }) }, - ReconnectionMsg::Open(OpenChannelMessage::V1(msg)) => pending_msg_events - .push(MessageSendEvent::SendOpenChannel { + ReconnectionMsg::Open(OpenChannelMessage::V1(msg)) => { + log_debug!( + logger, + "Generated open_channel event for channel {}", + chan.context().channel_id() + ); + pending_msg_events.push(MessageSendEvent::SendOpenChannel { node_id: chan.context().get_counterparty_node_id(), msg, - }), - ReconnectionMsg::Open(OpenChannelMessage::V2(msg)) => pending_msg_events - .push(MessageSendEvent::SendOpenChannelV2 { + }); + }, + ReconnectionMsg::Open(OpenChannelMessage::V2(msg)) => { + log_debug!( + logger, + "Generated open_channel_v2 event for channel {}", + chan.context().channel_id() + ); + pending_msg_events.push(MessageSendEvent::SendOpenChannelV2 { node_id: chan.context().get_counterparty_node_id(), msg, - }), - ReconnectionMsg::None => {}, + }); + }, + ReconnectionMsg::None => { + log_debug!( + logger, + "Peer reconnected. No reconnection message for channel {}", + chan.context().channel_id() + ); + }, } } } From cfde1cac2e9478576d6bc4e26c71010d0a7ce2f6 Mon Sep 17 00:00:00 2001 From: elnosh Date: Fri, 6 Mar 2026 15:21:48 -0500 Subject: [PATCH 179/627] remove unnecessary clone in channel.rs --- lightning/src/ln/channel.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 9361cd3c749..478b7de812c 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12525,9 +12525,9 @@ where self.context.pending_outbound_htlcs.push(OutboundHTLCOutput { htlc_id: self.context.next_holder_htlc_id, amount_msat, - payment_hash: payment_hash.clone(), + payment_hash, cltv_expiry, - state: OutboundHTLCState::LocalAnnounced(Box::new(onion_routing_packet.clone())), + state: OutboundHTLCState::LocalAnnounced(Box::new(onion_routing_packet)), source, blinding_point, skimmed_fee_msat, From 16cba0376d3e1a28661688a7c68e66afd1cdd1f1 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Wed, 18 Feb 2026 19:48:33 -0800 Subject: [PATCH 180/627] Re-enable signer op for one channel at a time in chanmon_consistency Node B has two channels: one with A and another with C. Re-enabling signer ops only one channel at a time allows us to have them in different states, which may be helpful for fuzzing coverage. --- fuzz/src/chanmon_consistency.rs | 36 ++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 22006897a0f..53591adfe6e 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -2496,33 +2496,51 @@ pub fn do_test( }, 0xc4 => { keys_manager_b.enable_op_for_all_signers(SignerOp::SignCounterpartyCommitment); - nodes[1].signer_unblocked(None); + let filter = Some((nodes[0].get_our_node_id(), chan_a_id)); + nodes[1].signer_unblocked(filter); }, 0xc5 => { + keys_manager_b.enable_op_for_all_signers(SignerOp::SignCounterpartyCommitment); + let filter = Some((nodes[2].get_our_node_id(), chan_b_id)); + nodes[1].signer_unblocked(filter); + }, + 0xc6 => { keys_manager_c.enable_op_for_all_signers(SignerOp::SignCounterpartyCommitment); nodes[2].signer_unblocked(None); }, - 0xc6 => { + 0xc7 => { keys_manager_a.enable_op_for_all_signers(SignerOp::GetPerCommitmentPoint); nodes[0].signer_unblocked(None); }, - 0xc7 => { + 0xc8 => { keys_manager_b.enable_op_for_all_signers(SignerOp::GetPerCommitmentPoint); - nodes[1].signer_unblocked(None); + let filter = Some((nodes[0].get_our_node_id(), chan_a_id)); + nodes[1].signer_unblocked(filter); }, - 0xc8 => { + 0xc9 => { + keys_manager_b.enable_op_for_all_signers(SignerOp::GetPerCommitmentPoint); + let filter = Some((nodes[2].get_our_node_id(), chan_b_id)); + nodes[1].signer_unblocked(filter); + }, + 0xca => { keys_manager_c.enable_op_for_all_signers(SignerOp::GetPerCommitmentPoint); nodes[2].signer_unblocked(None); }, - 0xc9 => { + 0xcb => { keys_manager_a.enable_op_for_all_signers(SignerOp::ReleaseCommitmentSecret); nodes[0].signer_unblocked(None); }, - 0xca => { + 0xcc => { keys_manager_b.enable_op_for_all_signers(SignerOp::ReleaseCommitmentSecret); - nodes[1].signer_unblocked(None); + let filter = Some((nodes[0].get_our_node_id(), chan_a_id)); + nodes[1].signer_unblocked(filter); }, - 0xcb => { + 0xcd => { + keys_manager_b.enable_op_for_all_signers(SignerOp::ReleaseCommitmentSecret); + let filter = Some((nodes[2].get_our_node_id(), chan_b_id)); + nodes[1].signer_unblocked(filter); + }, + 0xce => { keys_manager_c.enable_op_for_all_signers(SignerOp::ReleaseCommitmentSecret); nodes[2].signer_unblocked(None); }, From 8760c1c5e73f4c6540c888606c0c0c6123a210a5 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 17 Mar 2026 20:03:50 +0000 Subject: [PATCH 181/627] Drop Arik from SECURITY.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sadly Arik hasn't contributed to LDK in some time, so its time to drop him 😭. --- SECURITY.md | 1 - 1 file changed, 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index ed19bc544aa..b4cfa1bf92e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -16,4 +16,3 @@ your own public key as an attachment or inline for replies. * 0A156842CF60B58BD826ABDD808FC696767C6147 (Wilmer Paulino) * BD6EED4D339EDBF7E7CE7F8836153082BDF676FD (Elias Rohrer) * 6E0287D8849AE741E47CC586FD3E106A2CE099B4 (Valentine Wallace) - * 69CFEA635D0E6E6F13FD9D9136D932FCAC0305F0 (Arik Sosman) From bc8deb371d09740dc1f7c9717f2277bd9160e510 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Sun, 16 Nov 2025 13:35:36 +0100 Subject: [PATCH 182/627] Use `PeerState::{get_order, has_active_orders}` instead of map Previously, we'd directly access the internal `outbound_` map of `PeerState`. Here we refactor the code to avoid this. Note this also highlighted a bug in that we currently don't actually update/persist the order state in `update_order_state`. We don't fix this here, but just improve isolation for now, as all state update behavior will be reworked later. Signed-off-by: Elias Rohrer --- lightning-liquidity/src/lsps1/peer_state.rs | 27 ++++++++----- lightning-liquidity/src/lsps1/service.rs | 45 ++++++++++----------- lightning-liquidity/src/manager.rs | 8 ++-- 3 files changed, 42 insertions(+), 38 deletions(-) diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 729d6827330..172ace6db8f 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -16,7 +16,7 @@ use crate::prelude::HashMap; #[derive(Default)] pub(super) struct PeerState { - pub(super) outbound_channels_by_order_id: HashMap, + outbound_channels_by_order_id: HashMap, pub(super) pending_requests: HashMap, } @@ -26,25 +26,32 @@ impl PeerState { created_at: LSPSDateTime, payment_details: LSPS1PaymentInfo, ) { let channel = OutboundCRChannel::new(order_params, created_at, payment_details); - self.outbound_channels_by_order_id.insert(order_id, channel); } + + pub(super) fn get_order<'a>(&'a self, order_id: &LSPS1OrderId) -> Option<&'a ChannelOrder> { + self.outbound_channels_by_order_id.get(order_id).map(|channel| &channel.order) + } + + pub(super) fn has_active_orders(&self) -> bool { + !self.outbound_channels_by_order_id.is_empty() + } } -pub(super) struct OutboundLSPS1Config { - pub(super) order: LSPS1OrderParams, +pub(super) struct ChannelOrder { + pub(super) order_params: LSPS1OrderParams, pub(super) created_at: LSPSDateTime, - pub(super) payment: LSPS1PaymentInfo, + pub(super) payment_details: LSPS1PaymentInfo, } -pub(super) struct OutboundCRChannel { - pub(super) config: OutboundLSPS1Config, +struct OutboundCRChannel { + order: ChannelOrder, } impl OutboundCRChannel { - pub(super) fn new( - order: LSPS1OrderParams, created_at: LSPSDateTime, payment: LSPS1PaymentInfo, + fn new( + order_params: LSPS1OrderParams, created_at: LSPSDateTime, payment_details: LSPS1PaymentInfo, ) -> Self { - Self { config: OutboundLSPS1Config { order, created_at, payment } } + Self { order: ChannelOrder { order_params, created_at, payment_details } } } } diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index bda7d6125dd..0e5eacc7666 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -92,11 +92,11 @@ where /// `CreateOrder` request and replied with a `CreateOrder` response containing /// an `order_id`. /// Pending requests that are still awaiting our response are deliberately NOT counted. - pub(crate) fn has_active_requests(&self, counterparty_node_id: &PublicKey) -> bool { + pub(crate) fn has_active_orders(&self, counterparty_node_id: &PublicKey) -> bool { let outer_state_lock = self.per_peer_state.read().unwrap(); outer_state_lock.get(counterparty_node_id).map_or(false, |inner| { let peer_state = inner.lock().unwrap(); - !peer_state.outbound_channels_by_order_id.is_empty() + peer_state.has_active_orders() }) } @@ -270,29 +270,26 @@ where match outer_state_lock.get(&counterparty_node_id) { Some(inner_state_lock) => { - let mut peer_state_lock = inner_state_lock.lock().unwrap(); - - if let Some(outbound_channel) = - peer_state_lock.outbound_channels_by_order_id.get_mut(&order_id) - { - let config = &outbound_channel.config; - - let response = LSPS1Response::GetOrder(LSPS1CreateOrderResponse { - order_id, - order: config.order.clone(), - order_state, - created_at: config.created_at.clone(), - payment: config.payment.clone(), - channel, - }); - let msg = LSPS1Message::Response(request_id, response).into(); - message_queue_notifier.enqueue(&counterparty_node_id, msg); - Ok(()) - } else { - Err(APIError::APIMisuseError { + let peer_state_lock = inner_state_lock.lock().unwrap(); + let order = + peer_state_lock.get_order(&order_id).ok_or(APIError::APIMisuseError { err: format!("Channel with order_id {} not found", order_id.0), - }) - } + })?; + + // FIXME: we need to actually remember the order state (and eventually persist it) + // here. + + let response = LSPS1Response::GetOrder(LSPS1CreateOrderResponse { + order_id, + order: order.order_params.clone(), + order_state, + created_at: order.created_at.clone(), + payment: order.payment_details.clone(), + channel, + }); + let msg = LSPS1Message::Response(request_id, response).into(); + message_queue_notifier.enqueue(&counterparty_node_id, msg); + Ok(()) }, None => Err(APIError::APIMisuseError { err: format!("No existing state with counterparty {}", counterparty_node_id), diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index 45a85e72003..db05d71a524 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -716,17 +716,17 @@ where .as_ref() .is_some_and(|h| h.has_active_requests(sender_node_id)); #[cfg(lsps1_service)] - let lsps1_has_active_requests = self + let lsps1_has_active_orders = self .lsps1_service_handler .as_ref() - .is_some_and(|h| h.has_active_requests(sender_node_id)); + .is_some_and(|h| h.has_active_orders(sender_node_id)); #[cfg(not(lsps1_service))] - let lsps1_has_active_requests = false; + let lsps1_has_active_orders = false; lsps5_service_handler.enforce_prior_activity_or_reject( sender_node_id, lsps2_has_active_requests, - lsps1_has_active_requests, + lsps1_has_active_orders, req_id.clone(), )? } From 15130047febb932db707b6bf452206b098fa492a Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Sun, 16 Nov 2025 14:11:22 +0100 Subject: [PATCH 183/627] Use `PeerState::{register,remove}_request` instead of map access We introduce two new methods on `PeerState` to avoid direct access to the internal `pending_requests` map. --- lightning-liquidity/src/lsps1/peer_state.rs | 35 +++++++++++++- lightning-liquidity/src/lsps1/service.rs | 52 +++++++++++++++------ 2 files changed, 71 insertions(+), 16 deletions(-) diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 172ace6db8f..9adc3c9f6fb 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -14,10 +14,12 @@ use super::msgs::{LSPS1OrderId, LSPS1OrderParams, LSPS1PaymentInfo, LSPS1Request use crate::lsps0::ser::{LSPSDateTime, LSPSRequestId}; use crate::prelude::HashMap; +use core::fmt; + #[derive(Default)] pub(super) struct PeerState { outbound_channels_by_order_id: HashMap, - pub(super) pending_requests: HashMap, + pending_requests: HashMap, } impl PeerState { @@ -33,11 +35,42 @@ impl PeerState { self.outbound_channels_by_order_id.get(order_id).map(|channel| &channel.order) } + pub(super) fn register_request( + &mut self, request_id: LSPSRequestId, request: LSPS1Request, + ) -> Result<(), PeerStateError> { + if self.pending_requests.contains_key(&request_id) { + return Err(PeerStateError::DuplicateRequestId); + } + self.pending_requests.insert(request_id, request); + Ok(()) + } + + pub(super) fn remove_request( + &mut self, request_id: &LSPSRequestId, + ) -> Result { + self.pending_requests.remove(request_id).ok_or(PeerStateError::UnknownRequestId) + } + pub(super) fn has_active_orders(&self) -> bool { !self.outbound_channels_by_order_id.is_empty() } } +#[derive(Debug, Copy, Clone)] +pub(super) enum PeerStateError { + UnknownRequestId, + DuplicateRequestId, +} + +impl fmt::Display for PeerStateError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownRequestId => write!(f, "unknown request id"), + Self::DuplicateRequestId => write!(f, "duplicate request id"), + } + } +} + pub(super) struct ChannelOrder { pub(super) order_params: LSPS1OrderParams, pub(super) created_at: LSPSDateTime, diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 0e5eacc7666..a75db346682 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -157,9 +157,12 @@ where .or_insert(Mutex::new(PeerState::default())); let mut peer_state_lock = inner_state_lock.lock().unwrap(); - peer_state_lock - .pending_requests - .insert(request_id.clone(), LSPS1Request::CreateOrder(params.clone())); + let request = LSPS1Request::CreateOrder(params.clone()); + peer_state_lock.register_request(request_id.clone(), request).map_err(|e| { + let err = format!("Failed to handle request due to: {}", e); + let action = ErrorAction::IgnoreAndLog(Level::Error); + LightningError { err, action } + })?; } event_queue_notifier.enqueue(LSPS1ServiceEvent::RequestForPaymentDetails { @@ -186,11 +189,15 @@ where match outer_state_lock.get(counterparty_node_id) { Some(inner_state_lock) => { let mut peer_state_lock = inner_state_lock.lock().unwrap(); - - match peer_state_lock.pending_requests.remove(&request_id) { - Some(LSPS1Request::CreateOrder(params)) => { + let request = peer_state_lock.remove_request(&request_id).map_err(|e| { + debug_assert!(false, "Failed to send response due to: {}", e); + let err = format!("Failed to send response due to: {}", e); + APIError::APIMisuseError { err } + })?; + + match request { + LSPS1Request::CreateOrder(params) => { let order_id = self.generate_order_id(); - peer_state_lock.new_order( order_id.clone(), params.order.clone(), @@ -201,6 +208,9 @@ where let response = LSPS1Response::CreateOrder(LSPS1CreateOrderResponse { order: params.order, order_id, + + // TODO, we need to set this in the peer/channel state, and send the + // set value here: order_state: LSPS1OrderState::Created, created_at, payment, @@ -210,14 +220,22 @@ where message_queue_notifier.enqueue(counterparty_node_id, msg); Ok(()) }, - - _ => Err(APIError::APIMisuseError { - err: format!("No pending buy request for request_id: {:?}", request_id), - }), + t => { + debug_assert!( + false, + "Failed to send response due to unexpected request type: {:?}", + t + ); + let err = format!( + "Failed to send response due to unexpected request type: {:?}", + t + ); + return Err(APIError::APIMisuseError { err }); + }, } }, None => Err(APIError::APIMisuseError { - err: format!("No state for the counterparty exists: {:?}", counterparty_node_id), + err: format!("No state for the counterparty exists: {}", counterparty_node_id), }), } } @@ -231,9 +249,13 @@ where match outer_state_lock.get(counterparty_node_id) { Some(inner_state_lock) => { let mut peer_state_lock = inner_state_lock.lock().unwrap(); - peer_state_lock - .pending_requests - .insert(request_id.clone(), LSPS1Request::GetOrder(params.clone())); + + let request = LSPS1Request::GetOrder(params.clone()); + peer_state_lock.register_request(request_id.clone(), request).map_err(|e| { + let err = format!("Failed to handle request due to: {}", e); + let action = ErrorAction::IgnoreAndLog(Level::Error); + LightningError { err, action } + })?; event_queue_notifier.enqueue(LSPS1ServiceEvent::CheckPaymentConfirmation { request_id, From 9cc5257eca166f5b2389ccaf550609aed47a67c0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Sun, 16 Nov 2025 14:16:00 +0100 Subject: [PATCH 184/627] Drop `OutboundCRChannel` The `OutboundChannel` construct simply wrapped `ChannelOrder` which we can now simply use directly. --- lightning-liquidity/src/lsps1/peer_state.rs | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 9adc3c9f6fb..8f7c5a9c7ba 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -18,7 +18,7 @@ use core::fmt; #[derive(Default)] pub(super) struct PeerState { - outbound_channels_by_order_id: HashMap, + outbound_channels_by_order_id: HashMap, pending_requests: HashMap, } @@ -27,12 +27,12 @@ impl PeerState { &mut self, order_id: LSPS1OrderId, order_params: LSPS1OrderParams, created_at: LSPSDateTime, payment_details: LSPS1PaymentInfo, ) { - let channel = OutboundCRChannel::new(order_params, created_at, payment_details); - self.outbound_channels_by_order_id.insert(order_id, channel); + let channel_order = ChannelOrder { order_params, created_at, payment_details }; + self.outbound_channels_by_order_id.insert(order_id, channel_order); } pub(super) fn get_order<'a>(&'a self, order_id: &LSPS1OrderId) -> Option<&'a ChannelOrder> { - self.outbound_channels_by_order_id.get(order_id).map(|channel| &channel.order) + self.outbound_channels_by_order_id.get(order_id) } pub(super) fn register_request( @@ -76,15 +76,3 @@ pub(super) struct ChannelOrder { pub(super) created_at: LSPSDateTime, pub(super) payment_details: LSPS1PaymentInfo, } - -struct OutboundCRChannel { - order: ChannelOrder, -} - -impl OutboundCRChannel { - fn new( - order_params: LSPS1OrderParams, created_at: LSPSDateTime, payment_details: LSPS1PaymentInfo, - ) -> Self { - Self { order: ChannelOrder { order_params, created_at, payment_details } } - } -} From eb21bfd4944dcc44277a17d9e0cee3f8341b79a2 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Sun, 16 Nov 2025 14:44:46 +0100 Subject: [PATCH 185/627] Actually remember the order state in `ChannelOrder` We here remember and update the order state and channel details in `ChannelOrder` --- lightning-liquidity/src/lsps1/peer_state.rs | 38 ++++++++++++++++---- lightning-liquidity/src/lsps1/service.rs | 40 ++++++++++----------- 2 files changed, 50 insertions(+), 28 deletions(-) diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 8f7c5a9c7ba..a3d2000b62e 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -9,7 +9,10 @@ //! Contains peer state objects that are used by `LSPS1ServiceHandler`. -use super::msgs::{LSPS1OrderId, LSPS1OrderParams, LSPS1PaymentInfo, LSPS1Request}; +use super::msgs::{ + LSPS1ChannelInfo, LSPS1OrderId, LSPS1OrderParams, LSPS1OrderState, LSPS1PaymentInfo, + LSPS1Request, +}; use crate::lsps0::ser::{LSPSDateTime, LSPSRequestId}; use crate::prelude::HashMap; @@ -26,13 +29,31 @@ impl PeerState { pub(super) fn new_order( &mut self, order_id: LSPS1OrderId, order_params: LSPS1OrderParams, created_at: LSPSDateTime, payment_details: LSPS1PaymentInfo, - ) { - let channel_order = ChannelOrder { order_params, created_at, payment_details }; - self.outbound_channels_by_order_id.insert(order_id, channel_order); + ) -> ChannelOrder { + let order_state = LSPS1OrderState::Created; + let channel_details = None; + let channel_order = ChannelOrder { + order_params, + order_state, + created_at, + payment_details, + channel_details, + }; + self.outbound_channels_by_order_id.insert(order_id, channel_order.clone()); + channel_order } - pub(super) fn get_order<'a>(&'a self, order_id: &LSPS1OrderId) -> Option<&'a ChannelOrder> { - self.outbound_channels_by_order_id.get(order_id) + pub(super) fn update_order<'a>( + &'a mut self, order_id: &LSPS1OrderId, order_state: LSPS1OrderState, + channel_details: Option, + ) -> Result<&'a ChannelOrder, PeerStateError> { + let order = self + .outbound_channels_by_order_id + .get_mut(order_id) + .ok_or(PeerStateError::UnknownOrderId)?; + order.order_state = order_state; + order.channel_details = channel_details; + Ok(order) } pub(super) fn register_request( @@ -60,6 +81,7 @@ impl PeerState { pub(super) enum PeerStateError { UnknownRequestId, DuplicateRequestId, + UnknownOrderId, } impl fmt::Display for PeerStateError { @@ -67,12 +89,16 @@ impl fmt::Display for PeerStateError { match self { Self::UnknownRequestId => write!(f, "unknown request id"), Self::DuplicateRequestId => write!(f, "duplicate request id"), + Self::UnknownOrderId => write!(f, "unknown order id"), } } } +#[derive(Debug, Clone)] pub(super) struct ChannelOrder { pub(super) order_params: LSPS1OrderParams, + pub(super) order_state: LSPS1OrderState, pub(super) created_at: LSPSDateTime, pub(super) payment_details: LSPS1PaymentInfo, + pub(super) channel_details: Option, } diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index a75db346682..52d97157798 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -181,7 +181,7 @@ where /// [`LSPS1ServiceEvent::RequestForPaymentDetails`]: crate::lsps1::event::LSPS1ServiceEvent::RequestForPaymentDetails pub fn send_payment_details( &self, request_id: LSPSRequestId, counterparty_node_id: &PublicKey, - payment: LSPS1PaymentInfo, created_at: LSPSDateTime, + payment_details: LSPS1PaymentInfo, created_at: LSPSDateTime, ) -> Result<(), APIError> { let mut message_queue_notifier = self.pending_messages.notifier(); @@ -198,23 +198,21 @@ where match request { LSPS1Request::CreateOrder(params) => { let order_id = self.generate_order_id(); - peer_state_lock.new_order( + let order = peer_state_lock.new_order( order_id.clone(), - params.order.clone(), + params.order, created_at, - payment.clone(), + payment_details, ); let response = LSPS1Response::CreateOrder(LSPS1CreateOrderResponse { - order: params.order, + order: order.order_params, order_id, - // TODO, we need to set this in the peer/channel state, and send the - // set value here: - order_state: LSPS1OrderState::Created, - created_at, - payment, - channel: None, + order_state: order.order_state, + created_at: order.created_at, + payment: order.payment_details, + channel: order.channel_details, }); let msg = LSPS1Message::Response(request_id, response).into(); message_queue_notifier.enqueue(counterparty_node_id, msg); @@ -284,7 +282,7 @@ where /// [`LSPS1ServiceEvent::CheckPaymentConfirmation`]: crate::lsps1::event::LSPS1ServiceEvent::CheckPaymentConfirmation pub fn update_order_status( &self, request_id: LSPSRequestId, counterparty_node_id: PublicKey, order_id: LSPS1OrderId, - order_state: LSPS1OrderState, channel: Option, + order_state: LSPS1OrderState, channel_details: Option, ) -> Result<(), APIError> { let mut message_queue_notifier = self.pending_messages.notifier(); @@ -292,22 +290,20 @@ where match outer_state_lock.get(&counterparty_node_id) { Some(inner_state_lock) => { - let peer_state_lock = inner_state_lock.lock().unwrap(); - let order = - peer_state_lock.get_order(&order_id).ok_or(APIError::APIMisuseError { - err: format!("Channel with order_id {} not found", order_id.0), - })?; - - // FIXME: we need to actually remember the order state (and eventually persist it) - // here. + let mut peer_state_lock = inner_state_lock.lock().unwrap(); + let order = peer_state_lock + .update_order(&order_id, order_state, channel_details) + .map_err(|e| APIError::APIMisuseError { + err: format!("Failed to update order: {:?}", e), + })?; let response = LSPS1Response::GetOrder(LSPS1CreateOrderResponse { order_id, order: order.order_params.clone(), - order_state, + order_state: order.order_state.clone(), created_at: order.created_at.clone(), payment: order.payment_details.clone(), - channel, + channel: order.channel_details.clone(), }); let msg = LSPS1Message::Response(request_id, response).into(); message_queue_notifier.enqueue(&counterparty_node_id, msg); From 5db2fcc2d42a54aa81082181ff09da6116a4b8e4 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Dec 2025 09:49:46 +0100 Subject: [PATCH 186/627] `LSPS1ServiceHandler`: Use `TimeProvider` when creating new orders Since we by now have the `TimeProvider` trait, we might as well use it in `LSPS1ServiceHandler` instead of requiring the user to provide a `created_at` manually. Signed-off-by: Elias Rohrer --- lightning-liquidity/src/lsps1/service.rs | 29 ++++++++++++++----- lightning-liquidity/src/manager.rs | 11 +++---- .../tests/lsps1_integration_tests.rs | 8 ++--- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 52d97157798..0b0adc0947c 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -30,6 +30,7 @@ use crate::lsps0::ser::{ use crate::prelude::{new_hash_map, HashMap}; use crate::sync::{Arc, Mutex, RwLock}; use crate::utils; +use crate::utils::time::TimeProvider; use lightning::ln::channelmanager::AChannelManager; use lightning::ln::msgs::{ErrorAction, LightningError}; @@ -50,26 +51,35 @@ pub struct LSPS1ServiceConfig { } /// The main object allowing to send and receive bLIP-51 / LSPS1 messages. -pub struct LSPS1ServiceHandler -where +pub struct LSPS1ServiceHandler< + ES: EntropySource, + CM: Deref + Clone, + K: KVStore + Clone, + TP: Deref + Clone, +> where CM::Target: AChannelManager, + TP::Target: TimeProvider, { entropy_source: ES, _channel_manager: CM, pending_messages: Arc, pending_events: Arc>, per_peer_state: RwLock>>, + time_provider: TP, config: LSPS1ServiceConfig, } -impl LSPS1ServiceHandler +impl + LSPS1ServiceHandler where CM::Target: AChannelManager, + TP::Target: TimeProvider, { /// Constructs a `LSPS1ServiceHandler`. pub(crate) fn new( entropy_source: ES, pending_messages: Arc, - pending_events: Arc>, channel_manager: CM, config: LSPS1ServiceConfig, + pending_events: Arc>, channel_manager: CM, time_provider: TP, + config: LSPS1ServiceConfig, ) -> Self { Self { entropy_source, @@ -77,6 +87,7 @@ where pending_messages, pending_events, per_peer_state: RwLock::new(new_hash_map()), + time_provider, config, } } @@ -181,7 +192,7 @@ where /// [`LSPS1ServiceEvent::RequestForPaymentDetails`]: crate::lsps1::event::LSPS1ServiceEvent::RequestForPaymentDetails pub fn send_payment_details( &self, request_id: LSPSRequestId, counterparty_node_id: &PublicKey, - payment_details: LSPS1PaymentInfo, created_at: LSPSDateTime, + payment_details: LSPS1PaymentInfo, ) -> Result<(), APIError> { let mut message_queue_notifier = self.pending_messages.notifier(); @@ -198,6 +209,9 @@ where match request { LSPS1Request::CreateOrder(params) => { let order_id = self.generate_order_id(); + let created_at = LSPSDateTime::new_from_duration_since_epoch( + self.time_provider.duration_since_epoch(), + ); let order = peer_state_lock.new_order( order_id.clone(), params.order, @@ -321,10 +335,11 @@ where } } -impl LSPSProtocolMessageHandler - for LSPS1ServiceHandler +impl + LSPSProtocolMessageHandler for LSPS1ServiceHandler where CM::Target: AChannelManager, + TP::Target: TimeProvider, { type ProtocolMessage = LSPS1Message; const PROTOCOL_NUMBER: Option = Some(1); diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index db05d71a524..85c8ba3ebe2 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -283,7 +283,7 @@ pub struct LiquidityManager< lsps0_client_handler: LSPS0ClientHandler, lsps0_service_handler: Option, #[cfg(lsps1_service)] - lsps1_service_handler: Option>, + lsps1_service_handler: Option>, lsps1_client_handler: Option>, lsps2_service_handler: Option>, lsps2_client_handler: Option>, @@ -429,7 +429,7 @@ where kv_store.clone(), node_signer, lsps5_service_config.clone(), - time_provider, + time_provider.clone(), )) } else { None @@ -452,7 +452,7 @@ where #[cfg(lsps1_service)] let lsps1_service_handler = service_config.as_ref().and_then(|config| { if let Some(number) = - as LSPSProtocolMessageHandler>::PROTOCOL_NUMBER + as LSPSProtocolMessageHandler>::PROTOCOL_NUMBER { supported_protocols.push(number); } @@ -462,6 +462,7 @@ where Arc::clone(&pending_messages), Arc::clone(&pending_events), channel_manager.clone(), + time_provider, config.clone(), ) }) @@ -519,7 +520,7 @@ where /// Returns a reference to the LSPS1 server-side handler. #[cfg(lsps1_service)] - pub fn lsps1_service_handler(&self) -> Option<&LSPS1ServiceHandler> { + pub fn lsps1_service_handler(&self) -> Option<&LSPS1ServiceHandler> { self.lsps1_service_handler.as_ref() } @@ -1032,7 +1033,7 @@ where #[cfg(lsps1_service)] pub fn lsps1_service_handler( &self, - ) -> Option<&LSPS1ServiceHandler>> { + ) -> Option<&LSPS1ServiceHandler, TP>> { self.inner.lsps1_service_handler() } diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index 5e842c6a111..0db96f591e9 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -7,7 +7,6 @@ use common::{get_lsps_message, LSPSNodes}; use lightning::ln::peer_handler::CustomMessageHandler; use lightning_liquidity::events::LiquidityEvent; -use lightning_liquidity::lsps0::ser::LSPSDateTime; use lightning_liquidity::lsps1::client::LSPS1ClientConfig; use lightning_liquidity::lsps1::event::LSPS1ClientEvent; use lightning_liquidity::lsps1::event::LSPS1ServiceEvent; @@ -24,7 +23,6 @@ use lightning::ln::functional_test_utils::{ }; use lightning::util::test_utils::TestStore; -use std::str::FromStr; use std::sync::Arc; use lightning::ln::functional_test_utils::{create_network, Node}; @@ -177,10 +175,8 @@ fn lsps1_happy_path() { let onchain: LSPS1OnchainPaymentInfo = serde_json::from_str(json_str).expect("Failed to parse JSON"); let payment_info = LSPS1PaymentInfo { bolt11: None, bolt12: None, onchain: Some(onchain) }; - let _now = LSPSDateTime::from_str("2024-01-01T00:00:00Z").expect("Failed to parse date"); - - let _ = service_handler - .send_payment_details(_create_order_id.clone(), &client_node_id, payment_info.clone(), _now) + service_handler + .send_payment_details(_create_order_id.clone(), &client_node_id, payment_info.clone()) .unwrap(); let create_order_response = get_lsps_message!(service_node, client_node_id); From 7adf95409ee396cd8464059b3243fe808792ca03 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Dec 2025 10:35:12 +0100 Subject: [PATCH 187/627] Require `supported_options` in `LSPS1ServiceConfig` In the future we might want to inline the fields in `LSPS1ServiceConfig` (especially once some are added that we'd want to always/never set for the user), but for now we just make the `supported_options` field in `LSPS1ServiceConfig` required, avoiding some dangerous `unwrap`s. --- lightning-liquidity/src/lsps1/service.rs | 19 ++++--------------- .../tests/lsps0_integration_tests.rs | 18 +++++++++++++++++- .../tests/lsps1_integration_tests.rs | 3 +-- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 0b0adc0947c..9ce5e67681a 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -47,7 +47,7 @@ pub struct LSPS1ServiceConfig { /// A token to be send with each channel request. pub token: Option, /// The options supported by the LSP. - pub supported_options: Option, + pub supported_options: LSPS1Options, } /// The main object allowing to send and receive bLIP-51 / LSPS1 messages. @@ -117,15 +117,7 @@ where let mut message_queue_notifier = self.pending_messages.notifier(); let response = LSPS1Response::GetInfo(LSPS1GetInfoResponse { - options: self - .config - .supported_options - .clone() - .ok_or(LightningError { - err: format!("Configuration for LSP server not set."), - action: ErrorAction::IgnoreAndLog(Level::Info), - }) - .unwrap(), + options: self.config.supported_options.clone(), }); let msg = LSPS1Message::Response(request_id, response).into(); @@ -140,14 +132,11 @@ where let mut message_queue_notifier = self.pending_messages.notifier(); let event_queue_notifier = self.pending_events.notifier(); - if !is_valid(¶ms.order, &self.config.supported_options.as_ref().unwrap()) { + if !is_valid(¶ms.order, &self.config.supported_options) { let response = LSPS1Response::CreateOrderError(LSPSResponseError { code: LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE, message: format!("Order does not match options supported by LSP server"), - data: Some(format!( - "Supported options are {:?}", - &self.config.supported_options.as_ref().unwrap() - )), + data: Some(format!("Supported options are {:?}", &self.config.supported_options)), }); let msg = LSPS1Message::Response(request_id, response).into(); message_queue_notifier.enqueue(counterparty_node_id, msg); diff --git a/lightning-liquidity/tests/lsps0_integration_tests.rs b/lightning-liquidity/tests/lsps0_integration_tests.rs index 423d49785f2..7f0e01bde92 100644 --- a/lightning-liquidity/tests/lsps0_integration_tests.rs +++ b/lightning-liquidity/tests/lsps0_integration_tests.rs @@ -9,6 +9,8 @@ use lightning_liquidity::lsps0::event::LSPS0ClientEvent; #[cfg(lsps1_service)] use lightning_liquidity::lsps1::client::LSPS1ClientConfig; #[cfg(lsps1_service)] +use lightning_liquidity::lsps1::msgs::LSPS1Options; +#[cfg(lsps1_service)] use lightning_liquidity::lsps1::service::LSPS1ServiceConfig; use lightning_liquidity::lsps2::client::LSPS2ClientConfig; use lightning_liquidity::lsps2::service::LSPS2ServiceConfig; @@ -34,7 +36,21 @@ fn list_protocols_integration_test() { let promise_secret = [42; 32]; let lsps2_service_config = LSPS2ServiceConfig { promise_secret }; #[cfg(lsps1_service)] - let lsps1_service_config = LSPS1ServiceConfig { supported_options: None, token: None }; + let lsps1_service_config = { + let supported_options = LSPS1Options { + min_required_channel_confirmations: 0, + min_funding_confirms_within_blocks: 6, + supports_zero_channel_reserve: true, + max_channel_expiry_blocks: 144, + min_initial_client_balance_sat: 10_000_000, + max_initial_client_balance_sat: 100_000_000, + min_initial_lsp_balance_sat: 100_000, + max_initial_lsp_balance_sat: 100_000_000, + min_channel_balance_sat: 100_000, + max_channel_balance_sat: 100_000_000, + }; + LSPS1ServiceConfig { supported_options, token: None } + }; let lsps5_service_config = LSPS5ServiceConfig::default(); let service_config = LiquidityServiceConfig { #[cfg(lsps1_service)] diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index 0db96f591e9..e799cced976 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -30,8 +30,7 @@ use lightning::ln::functional_test_utils::{create_network, Node}; fn build_lsps1_configs( supported_options: LSPS1Options, ) -> (LiquidityServiceConfig, LiquidityClientConfig) { - let lsps1_service_config = - LSPS1ServiceConfig { token: None, supported_options: Some(supported_options) }; + let lsps1_service_config = LSPS1ServiceConfig { token: None, supported_options }; let service_config = LiquidityServiceConfig { lsps1_service_config: Some(lsps1_service_config), lsps2_service_config: None, From a7f388842b0f99d1b4485f059915d8e2bc03ddc0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Dec 2025 11:13:22 +0100 Subject: [PATCH 188/627] Respond to `GetOrder` requests from our saved state Previously, we'd use an event to have the user check the order status and then call back in. As we already track the order status, we here change that to a model where we respond immediately based on our state and have the user/LSP update that state whenever it detects a change (e.g., a received payment, reorg, etc.). In the next commmit we will add/modify the corresponding API methods to do so. --- lightning-liquidity/src/lsps1/event.rs | 20 ----- lightning-liquidity/src/lsps1/msgs.rs | 2 + lightning-liquidity/src/lsps1/peer_state.rs | 14 ++- lightning-liquidity/src/lsps1/service.rs | 85 ++++++++++--------- .../tests/lsps1_integration_tests.rs | 26 ------ 5 files changed, 58 insertions(+), 89 deletions(-) diff --git a/lightning-liquidity/src/lsps1/event.rs b/lightning-liquidity/src/lsps1/event.rs index fdf3fc57b0d..d966f8bdc2f 100644 --- a/lightning-liquidity/src/lsps1/event.rs +++ b/lightning-liquidity/src/lsps1/event.rs @@ -165,26 +165,6 @@ pub enum LSPS1ServiceEvent { /// The order requested by the client. order: LSPS1OrderParams, }, - /// A request from client to check the status of the payment. - /// - /// An event to poll for checking payment status either onchain or lightning. - /// - /// You must call [`LSPS1ServiceHandler::update_order_status`] to update the client - /// regarding the status of the payment and order. - /// - /// **Note: ** This event will *not* be persisted across restarts. - /// - /// [`LSPS1ServiceHandler::update_order_status`]: crate::lsps1::service::LSPS1ServiceHandler::update_order_status - CheckPaymentConfirmation { - /// An identifier that must be passed to [`LSPS1ServiceHandler::update_order_status`]. - /// - /// [`LSPS1ServiceHandler::update_order_status`]: crate::lsps1::service::LSPS1ServiceHandler::update_order_status - request_id: LSPSRequestId, - /// The node id of the client making the information request. - counterparty_node_id: PublicKey, - /// The order id of order with pending payment. - order_id: LSPS1OrderId, - }, /// If error is encountered, refund the amount if paid by the client. /// /// **Note: ** This event will *not* be persisted across restarts. diff --git a/lightning-liquidity/src/lsps1/msgs.rs b/lightning-liquidity/src/lsps1/msgs.rs index 8402827a4a6..4f79a13821a 100644 --- a/lightning-liquidity/src/lsps1/msgs.rs +++ b/lightning-liquidity/src/lsps1/msgs.rs @@ -32,6 +32,8 @@ pub(crate) const LSPS1_GET_ORDER_METHOD_NAME: &str = "lsps1.get_order"; pub(crate) const _LSPS1_CREATE_ORDER_REQUEST_INVALID_PARAMS_ERROR_CODE: i32 = -32602; #[cfg(lsps1_service)] pub(crate) const LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE: i32 = 100; +#[cfg(lsps1_service)] +pub(crate) const LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE: i32 = 101; /// The identifier of an order. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)] diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index a3d2000b62e..31ea41db3d3 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -43,17 +43,27 @@ impl PeerState { channel_order } + pub(super) fn get_order<'a>( + &'a self, order_id: &LSPS1OrderId, + ) -> Result<&'a ChannelOrder, PeerStateError> { + let order = self + .outbound_channels_by_order_id + .get(order_id) + .ok_or(PeerStateError::UnknownOrderId)?; + Ok(order) + } + pub(super) fn update_order<'a>( &'a mut self, order_id: &LSPS1OrderId, order_state: LSPS1OrderState, channel_details: Option, - ) -> Result<&'a ChannelOrder, PeerStateError> { + ) -> Result<(), PeerStateError> { let order = self .outbound_channels_by_order_id .get_mut(order_id) .ok_or(PeerStateError::UnknownOrderId)?; order.order_state = order_state; order.channel_details = channel_details; - Ok(order) + Ok(()) } pub(super) fn register_request( diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 9ce5e67681a..f4e1c1d273d 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -19,6 +19,7 @@ use super::msgs::{ LSPS1GetOrderRequest, LSPS1Message, LSPS1Options, LSPS1OrderId, LSPS1OrderParams, LSPS1OrderState, LSPS1PaymentInfo, LSPS1Request, LSPS1Response, LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE, + LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE, }; use super::peer_state::PeerState; use crate::message_queue::MessageQueue; @@ -245,71 +246,75 @@ where &self, request_id: LSPSRequestId, counterparty_node_id: &PublicKey, params: LSPS1GetOrderRequest, ) -> Result<(), LightningError> { - let event_queue_notifier = self.pending_events.notifier(); + let mut message_queue_notifier = self.pending_messages.notifier(); let outer_state_lock = self.per_peer_state.read().unwrap(); match outer_state_lock.get(counterparty_node_id) { Some(inner_state_lock) => { - let mut peer_state_lock = inner_state_lock.lock().unwrap(); - - let request = LSPS1Request::GetOrder(params.clone()); - peer_state_lock.register_request(request_id.clone(), request).map_err(|e| { + let peer_state_lock = inner_state_lock.lock().unwrap(); + + let order = peer_state_lock.get_order(¶ms.order_id).map_err(|e| { + let response = LSPS1Response::GetOrderError(LSPSResponseError { + code: LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE, + message: format!("Order with the requested order_id has not been found."), + data: None, + }); + let msg = LSPS1Message::Response(request_id.clone(), response).into(); + message_queue_notifier.enqueue(counterparty_node_id, msg); let err = format!("Failed to handle request due to: {}", e); let action = ErrorAction::IgnoreAndLog(Level::Error); LightningError { err, action } })?; - event_queue_notifier.enqueue(LSPS1ServiceEvent::CheckPaymentConfirmation { - request_id, - counterparty_node_id: *counterparty_node_id, + let response = LSPS1Response::GetOrder(LSPS1CreateOrderResponse { order_id: params.order_id, + order: order.order_params.clone(), + order_state: order.order_state.clone(), + created_at: order.created_at.clone(), + payment: order.payment_details.clone(), + channel: order.channel_details.clone(), }); + let msg = LSPS1Message::Response(request_id, response).into(); + message_queue_notifier.enqueue(&counterparty_node_id, msg); + Ok(()) }, None => { - return Err(LightningError { - err: format!("Received error response for a create order request from an unknown counterparty ({:?})", counterparty_node_id), - action: ErrorAction::IgnoreAndLog(Level::Info), + let response = LSPS1Response::GetOrderError(LSPSResponseError { + code: LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE, + message: format!("Order with the requested order_id has not been found."), + data: None, }); + let msg = LSPS1Message::Response(request_id, response).into(); + message_queue_notifier.enqueue(counterparty_node_id, msg); + Err(LightningError { + err: format!( + "Received get_order request from an unknown counterparty ({:?})", + counterparty_node_id + ), + action: ErrorAction::IgnoreAndLog(Level::Info), + }) }, } - - Ok(()) } /// Used by LSP to give details to client regarding the status of channel opening. - /// Called to respond to client's GetOrder request. - /// The LSP continously polls for checking payment confirmation on-chain or lighting - /// and then responds to client request. - /// - /// Should be called in response to receiving a [`LSPS1ServiceEvent::CheckPaymentConfirmation`] event. /// - /// [`LSPS1ServiceEvent::CheckPaymentConfirmation`]: crate::lsps1::event::LSPS1ServiceEvent::CheckPaymentConfirmation + /// The LSP continously polls for checking payment confirmation on-chain or Lightning + /// and then responds to client request. pub fn update_order_status( - &self, request_id: LSPSRequestId, counterparty_node_id: PublicKey, order_id: LSPS1OrderId, + &self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId, order_state: LSPS1OrderState, channel_details: Option, ) -> Result<(), APIError> { - let mut message_queue_notifier = self.pending_messages.notifier(); - let outer_state_lock = self.per_peer_state.read().unwrap(); match outer_state_lock.get(&counterparty_node_id) { Some(inner_state_lock) => { let mut peer_state_lock = inner_state_lock.lock().unwrap(); - let order = peer_state_lock - .update_order(&order_id, order_state, channel_details) - .map_err(|e| APIError::APIMisuseError { - err: format!("Failed to update order: {:?}", e), - })?; + peer_state_lock.update_order(&order_id, order_state, channel_details).map_err( + |e| APIError::APIMisuseError { + err: format!("Failed to update order: {:?}", e), + }, + )?; - let response = LSPS1Response::GetOrder(LSPS1CreateOrderResponse { - order_id, - order: order.order_params.clone(), - order_state: order.order_state.clone(), - created_at: order.created_at.clone(), - payment: order.payment_details.clone(), - channel: order.channel_details.clone(), - }); - let msg = LSPS1Message::Response(request_id, response).into(); - message_queue_notifier.enqueue(&counterparty_node_id, msg); Ok(()) }, None => Err(APIError::APIMisuseError { @@ -364,7 +369,7 @@ fn check_range(min: u64, max: u64, value: u64) -> bool { } fn is_valid(order: &LSPS1OrderParams, options: &LSPS1Options) -> bool { - let bool = check_range( + check_range( options.min_initial_client_balance_sat, options.max_initial_client_balance_sat, order.client_balance_sat, @@ -376,7 +381,5 @@ fn is_valid(order: &LSPS1OrderParams, options: &LSPS1Options) -> bool { 1, options.max_channel_expiry_blocks.into(), order.channel_expiry_blocks.into(), - ); - - bool + ) } diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index e799cced976..ef210a34a16 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -10,7 +10,6 @@ use lightning_liquidity::events::LiquidityEvent; use lightning_liquidity::lsps1::client::LSPS1ClientConfig; use lightning_liquidity::lsps1::event::LSPS1ClientEvent; use lightning_liquidity::lsps1::event::LSPS1ServiceEvent; -use lightning_liquidity::lsps1::msgs::LSPS1OrderState; use lightning_liquidity::lsps1::msgs::{ LSPS1OnchainPaymentInfo, LSPS1Options, LSPS1OrderParams, LSPS1PaymentInfo, }; @@ -214,31 +213,6 @@ fn lsps1_happy_path() { .handle_custom_message(check_order_status, client_node_id) .unwrap(); - let _check_payment_confirmation_event = service_node.liquidity_manager.next_event().unwrap(); - - if let LiquidityEvent::LSPS1Service(LSPS1ServiceEvent::CheckPaymentConfirmation { - request_id, - counterparty_node_id, - order_id, - }) = _check_payment_confirmation_event - { - assert_eq!(request_id, check_order_status_id); - assert_eq!(counterparty_node_id, client_node_id); - assert_eq!(order_id, expected_order_id.clone()); - } else { - panic!("Unexpected event"); - } - - let _ = service_handler - .update_order_status( - check_order_status_id.clone(), - client_node_id, - expected_order_id.clone(), - LSPS1OrderState::Created, - None, - ) - .unwrap(); - let order_status_response = get_lsps_message!(service_node, client_node_id); client_node From 38775f7cdea55ea91465de505fcf3e9477ffd0d6 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 11 Dec 2025 13:32:02 +0100 Subject: [PATCH 189/627] Add serialization logic for LSPS1 `PeerState` types We add the serializations for all types that will be persisted as part of the `PeerState`. --- lightning-liquidity/src/lsps1/msgs.rs | 81 ++++++++++++++++++++- lightning-liquidity/src/lsps1/peer_state.rs | 16 ++++ lightning/src/util/ser.rs | 37 ++++++++++ 3 files changed, 133 insertions(+), 1 deletion(-) diff --git a/lightning-liquidity/src/lsps1/msgs.rs b/lightning-liquidity/src/lsps1/msgs.rs index 4f79a13821a..5bf130400e1 100644 --- a/lightning-liquidity/src/lsps1/msgs.rs +++ b/lightning-liquidity/src/lsps1/msgs.rs @@ -19,8 +19,9 @@ use crate::lsps0::ser::{ }; use bitcoin::{Address, FeeRate, OutPoint}; - use lightning::offers::offer::Offer; +use lightning::util::ser::{Readable, Writeable}; +use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use lightning_invoice::Bolt11Invoice; use serde::{Deserialize, Serialize}; @@ -39,6 +40,23 @@ pub(crate) const LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE: i32 = 101; #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)] pub struct LSPS1OrderId(pub String); +impl Writeable for LSPS1OrderId { + fn write( + &self, writer: &mut W, + ) -> Result<(), lightning::io::Error> { + self.0.write(writer) + } +} + +impl Readable for LSPS1OrderId { + fn read( + reader: &mut R, + ) -> Result { + let inner = Readable::read(reader)?; + Ok(Self(inner)) + } +} + /// A request made to an LSP to retrieve the supported options. /// /// Please refer to the [bLIP-51 / LSPS1 @@ -128,6 +146,16 @@ pub struct LSPS1OrderParams { pub announce_channel: bool, } +impl_writeable_tlv_based!(LSPS1OrderParams, { + (0, lsp_balance_sat, required), + (2, client_balance_sat, required), + (4, required_channel_confirmations, required), + (6, funding_confirms_within_blocks, required), + (8, channel_expiry_blocks, required), + (10, token, option), + (12, announce_channel, required), +}); + /// A response to a [`LSPS1CreateOrderRequest`]. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] pub struct LSPS1CreateOrderResponse { @@ -158,6 +186,12 @@ pub enum LSPS1OrderState { Failed, } +impl_writeable_tlv_based_enum!(LSPS1OrderState, + (0, Created) => {}, + (2, Completed) => {}, + (4, Failed) => {} +); + /// Details regarding how to pay for an order. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] pub struct LSPS1PaymentInfo { @@ -169,6 +203,12 @@ pub struct LSPS1PaymentInfo { pub onchain: Option, } +impl_writeable_tlv_based!(LSPS1PaymentInfo, { + (0, bolt11, option), + (2, bolt12, option), + (4, onchain, option), +}); + /// A Lightning payment using BOLT 11. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] pub struct LSPS1Bolt11PaymentInfo { @@ -186,6 +226,14 @@ pub struct LSPS1Bolt11PaymentInfo { pub invoice: Bolt11Invoice, } +impl_writeable_tlv_based!(LSPS1Bolt11PaymentInfo, { + (0, state, required), + (2, expires_at, required), + (4, fee_total_sat, required), + (6, order_total_sat, required), + (8, invoice, required), +}); + /// A Lightning payment using BOLT 12. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] pub struct LSPS1Bolt12PaymentInfo { @@ -204,6 +252,14 @@ pub struct LSPS1Bolt12PaymentInfo { pub offer: Offer, } +impl_writeable_tlv_based!(LSPS1Bolt12PaymentInfo, { + (0, state, required), + (2, expires_at, required), + (4, fee_total_sat, required), + (6, order_total_sat, required), + (8, offer, required), +}); + /// An onchain payment. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] pub struct LSPS1OnchainPaymentInfo { @@ -235,6 +291,17 @@ pub struct LSPS1OnchainPaymentInfo { pub refund_onchain_address: Option
        , } +impl_writeable_tlv_based!(LSPS1OnchainPaymentInfo, { + (0, state, required), + (2, expires_at, required), + (4, fee_total_sat, required), + (6, order_total_sat, required), + (8, address, required), + (10, min_onchain_payment_confirmations, option), + (12, min_fee_for_0conf, required), + (14, refund_onchain_address, option), +}); + /// The state of a payment. /// /// *Note*: Previously, the spec also knew a `CANCELLED` state for BOLT11 payments, which has since @@ -251,6 +318,12 @@ pub enum LSPS1PaymentState { Refunded, } +impl_writeable_tlv_based_enum!(LSPS1PaymentState, + (0, ExpectPayment) => {}, + (2, Paid) => {}, + (4, Refunded) => {} +); + /// Details regarding a detected on-chain payment. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] pub struct LSPS1OnchainPayment { @@ -274,6 +347,12 @@ pub struct LSPS1ChannelInfo { pub expires_at: LSPSDateTime, } +impl_writeable_tlv_based!(LSPS1ChannelInfo, { + (0, funded_at, required), + (2, funding_outpoint, required), + (4, expires_at, required), +}); + /// A request made to an LSP to retrieve information about an previously made order. /// /// Please refer to the [bLIP-51 / LSPS1 diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 31ea41db3d3..5af7537dd79 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -17,6 +17,9 @@ use super::msgs::{ use crate::lsps0::ser::{LSPSDateTime, LSPSRequestId}; use crate::prelude::HashMap; +use lightning::impl_writeable_tlv_based; +use lightning::util::hash_tables::new_hash_map; + use core::fmt; #[derive(Default)] @@ -87,6 +90,11 @@ impl PeerState { } } +impl_writeable_tlv_based!(PeerState, { + (0, outbound_channels_by_order_id, required), + (_unused, pending_requests, (static_value, new_hash_map())), +}); + #[derive(Debug, Copy, Clone)] pub(super) enum PeerStateError { UnknownRequestId, @@ -112,3 +120,11 @@ pub(super) struct ChannelOrder { pub(super) payment_details: LSPS1PaymentInfo, pub(super) channel_details: Option, } + +impl_writeable_tlv_based!(ChannelOrder, { + (0, order_params, required), + (2, order_state, required), + (4, created_at, required), + (6, payment_details, required), + (8, channel_details, option), +}); diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs index 2eace55a4bf..50665152a96 100644 --- a/lightning/src/util/ser.rs +++ b/lightning/src/util/ser.rs @@ -22,10 +22,12 @@ use crate::sync::{Mutex, RwLock}; use core::cmp; use core::hash::Hash; use core::ops::Deref; +use core::str::FromStr; use alloc::collections::BTreeMap; use bitcoin::absolute::LockTime as AbsoluteLockTime; +use bitcoin::address::Address; use bitcoin::amount::{Amount, SignedAmount}; use bitcoin::consensus::Encodable; use bitcoin::constants::ChainHash; @@ -46,6 +48,8 @@ use bitcoin::{consensus, Sequence, TxIn, Weight, Witness}; use dnssec_prover::rr::Name; +use lightning_invoice::Bolt11Invoice; + use crate::chain::ClaimId; #[cfg(taproot)] use crate::ln::msgs::PartialSignatureWithNonce; @@ -1499,6 +1503,39 @@ impl Readable for OutPoint { } } +impl Writeable for Address { + fn write(&self, w: &mut W) -> Result<(), io::Error> { + self.to_string().write(w)?; + Ok(()) + } +} + +impl Readable for Address { + fn read(r: &mut R) -> Result { + let addr_string: String = Readable::read(r)?; + let addr = Address::from_str(&addr_string) + .map_err(|_| DecodeError::InvalidValue)? + .assume_checked(); + Ok(addr) + } +} + +impl Writeable for Bolt11Invoice { + fn write(&self, w: &mut W) -> Result<(), io::Error> { + self.to_string().write(w)?; + Ok(()) + } +} + +impl Readable for Bolt11Invoice { + fn read(r: &mut R) -> Result { + let invoice_string: String = Readable::read(r)?; + let invoice = + Bolt11Invoice::from_str(&invoice_string).map_err(|_| DecodeError::InvalidValue)?; + Ok(invoice) + } +} + macro_rules! impl_consensus_ser { ($bitcoin_type: ty) => { impl Writeable for $bitcoin_type { From d33a701f4f411b79c8349296ee15995f36909c49 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 11 Dec 2025 14:24:04 +0100 Subject: [PATCH 190/627] Implement `LSPS1ServiceHandler` persistence and state pruning We follow the model already employed in LSPS2/LSPS5 and implement state pruning and persistence for `LSPS1ServiceHandler` state. Signed-off-by: Elias Rohrer --- lightning-liquidity/src/lsps1/peer_state.rs | 55 ++++ lightning-liquidity/src/lsps1/service.rs | 309 ++++++++++++++++-- lightning-liquidity/src/manager.rs | 21 +- lightning-liquidity/src/persist.rs | 6 + .../tests/lsps1_integration_tests.rs | 2 +- 5 files changed, 365 insertions(+), 28 deletions(-) diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 5af7537dd79..a4c477fe2f2 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -26,6 +26,7 @@ use core::fmt; pub(super) struct PeerState { outbound_channels_by_order_id: HashMap, pending_requests: HashMap, + needs_persist: bool, } impl PeerState { @@ -43,6 +44,7 @@ impl PeerState { channel_details, }; self.outbound_channels_by_order_id.insert(order_id, channel_order.clone()); + self.needs_persist |= true; channel_order } @@ -66,6 +68,7 @@ impl PeerState { .ok_or(PeerStateError::UnknownOrderId)?; order.order_state = order_state; order.channel_details = channel_details; + self.needs_persist |= true; Ok(()) } @@ -88,11 +91,39 @@ impl PeerState { pub(super) fn has_active_orders(&self) -> bool { !self.outbound_channels_by_order_id.is_empty() } + + pub(super) fn needs_persist(&self) -> bool { + self.needs_persist + } + + pub(super) fn set_needs_persist(&mut self, needs_persist: bool) { + self.needs_persist = needs_persist; + } + + pub(super) fn is_prunable(&self) -> bool { + // Return whether the entire state is empty. + self.pending_requests.is_empty() && self.outbound_channels_by_order_id.is_empty() + } + + pub(super) fn prune_pending_requests(&mut self) { + self.pending_requests.clear() + } + + pub(super) fn prune_expired_request_state(&mut self) { + self.outbound_channels_by_order_id.retain(|_order_id, entry| { + if entry.is_prunable() { + self.needs_persist |= true; + return false; + } + true + }); + } } impl_writeable_tlv_based!(PeerState, { (0, outbound_channels_by_order_id, required), (_unused, pending_requests, (static_value, new_hash_map())), + (_unused, needs_persist, (static_value, false)), }); #[derive(Debug, Copy, Clone)] @@ -121,6 +152,30 @@ pub(super) struct ChannelOrder { pub(super) channel_details: Option, } +impl ChannelOrder { + fn is_prunable(&self) -> bool { + let all_payment_details_expired; + #[cfg(feature = "time")] + { + let details = &self.payment_details; + all_payment_details_expired = + details.bolt11.as_ref().map_or(true, |d| d.expires_at.is_past()) + && details.bolt12.as_ref().map_or(true, |d| d.expires_at.is_past()) + && details.onchain.as_ref().map_or(true, |d| d.expires_at.is_past()); + } + #[cfg(not(feature = "time"))] + { + // TODO: We need to find a way to check expiry times in no-std builds. + all_payment_details_expired = false; + } + + let created_or_failed = + matches!(self.order_state, LSPS1OrderState::Created | LSPS1OrderState::Failed); + + all_payment_details_expired && created_or_failed + } +} + impl_writeable_tlv_based!(ChannelOrder, { (0, order_params, required), (2, order_state, required), diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index f4e1c1d273d..71587ae079c 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -9,9 +9,14 @@ //! Contains the main bLIP-51 / LSPS1 server object, [`LSPS1ServiceHandler`]. -use alloc::string::String; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use core::future::Future as StdFuture; use core::ops::Deref; +use core::pin::pin; +use core::sync::atomic::{AtomicUsize, Ordering}; +use core::task; use super::event::LSPS1ServiceEvent; use super::msgs::{ @@ -28,9 +33,14 @@ use crate::events::EventQueue; use crate::lsps0::ser::{ LSPSDateTime, LSPSProtocolMessageHandler, LSPSRequestId, LSPSResponseError, }; +use crate::persist::{ + LIQUIDITY_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, LSPS1_SERVICE_PERSISTENCE_SECONDARY_NAMESPACE, +}; +use crate::prelude::hash_map::Entry; use crate::prelude::{new_hash_map, HashMap}; use crate::sync::{Arc, Mutex, RwLock}; use crate::utils; +use crate::utils::async_poll::dummy_waker; use crate::utils::time::TimeProvider; use lightning::ln::channelmanager::AChannelManager; @@ -39,6 +49,7 @@ use lightning::sign::EntropySource; use lightning::util::errors::APIError; use lightning::util::logger::Level; use lightning::util::persist::KVStore; +use lightning::util::ser::Writeable; use bitcoin::secp256k1::PublicKey; @@ -63,9 +74,11 @@ pub struct LSPS1ServiceHandler< { entropy_source: ES, _channel_manager: CM, + kv_store: K, pending_messages: Arc, pending_events: Arc>, per_peer_state: RwLock>>, + persistence_in_flight: AtomicUsize, time_provider: TP, config: LSPS1ServiceConfig, } @@ -79,15 +92,17 @@ where /// Constructs a `LSPS1ServiceHandler`. pub(crate) fn new( entropy_source: ES, pending_messages: Arc, - pending_events: Arc>, channel_manager: CM, time_provider: TP, + pending_events: Arc>, channel_manager: CM, kv_store: K, time_provider: TP, config: LSPS1ServiceConfig, ) -> Self { Self { entropy_source, _channel_manager: channel_manager, + kv_store, pending_messages, pending_events, per_peer_state: RwLock::new(new_hash_map()), + persistence_in_flight: AtomicUsize::new(0), time_provider, config, } @@ -106,12 +121,153 @@ where /// Pending requests that are still awaiting our response are deliberately NOT counted. pub(crate) fn has_active_orders(&self, counterparty_node_id: &PublicKey) -> bool { let outer_state_lock = self.per_peer_state.read().unwrap(); - outer_state_lock.get(counterparty_node_id).map_or(false, |inner| { + outer_state_lock.get(counterparty_node_id).is_some_and(|inner| { let peer_state = inner.lock().unwrap(); peer_state.has_active_orders() }) } + pub(crate) fn peer_disconnected(&self, counterparty_node_id: PublicKey) { + let outer_state_lock = self.per_peer_state.write().unwrap(); + if let Some(inner_state_lock) = outer_state_lock.get(&counterparty_node_id) { + let mut peer_state_lock = inner_state_lock.lock().unwrap(); + // We clean up the peer state, but leave removing the peer entry to the prune logic in + // `persist` which removes it from the store. + peer_state_lock.prune_pending_requests(); + peer_state_lock.prune_expired_request_state(); + } + } + + pub(crate) async fn persist(&self) -> Result { + // TODO: We should eventually persist in parallel, however, when we do, we probably want to + // introduce some batching to upper-bound the number of requests inflight at any given + // time. + let mut did_persist = false; + + if self.persistence_in_flight.fetch_add(1, Ordering::AcqRel) > 0 { + // If we're not the first event processor to get here, just return early, the increment + // we just did will be treated as "go around again" at the end. + return Ok(did_persist); + } + + loop { + let mut need_remove = Vec::new(); + let mut need_persist = Vec::new(); + + { + // First build a list of peers to persist and prune with the read lock. This allows + // us to avoid the write lock unless we actually need to remove a node. + let outer_state_lock = self.per_peer_state.read().unwrap(); + for (counterparty_node_id, inner_state_lock) in outer_state_lock.iter() { + let mut peer_state_lock = inner_state_lock.lock().unwrap(); + peer_state_lock.prune_expired_request_state(); + let is_prunable = peer_state_lock.is_prunable(); + if is_prunable { + need_remove.push(*counterparty_node_id); + } else if peer_state_lock.needs_persist() { + need_persist.push(*counterparty_node_id); + } + } + } + + for counterparty_node_id in need_persist.into_iter() { + debug_assert!(!need_remove.contains(&counterparty_node_id)); + self.persist_peer_state(counterparty_node_id).await?; + did_persist = true; + } + + for counterparty_node_id in need_remove { + let mut future_opt = None; + { + // We need to take the `per_peer_state` write lock to remove an entry, but also + // have to hold it until after the `remove` call returns (but not through + // future completion) to ensure that writes for the peer's state are + // well-ordered with other `persist_peer_state` calls even across the removal + // itself. + let mut per_peer_state = self.per_peer_state.write().unwrap(); + if let Entry::Occupied(mut entry) = per_peer_state.entry(counterparty_node_id) { + let state = entry.get_mut().get_mut().unwrap(); + if state.is_prunable() { + entry.remove(); + let key = counterparty_node_id.to_string(); + future_opt = Some(self.kv_store.remove( + LIQUIDITY_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS1_SERVICE_PERSISTENCE_SECONDARY_NAMESPACE, + &key, + true, + )); + } else { + // If the peer got new state, force a re-persist of the current state. + state.set_needs_persist(true); + } + } else { + // This should never happen, we can only have one `persist` call + // in-progress at once and map entries are only removed by it. + debug_assert!(false); + } + } + if let Some(future) = future_opt { + future.await?; + did_persist = true; + } else { + self.persist_peer_state(counterparty_node_id).await?; + } + } + + if self.persistence_in_flight.fetch_sub(1, Ordering::AcqRel) != 1 { + // If another thread incremented the state while we were running we should go + // around again, but only once. + self.persistence_in_flight.store(1, Ordering::Release); + continue; + } + break; + } + + Ok(did_persist) + } + + async fn persist_peer_state( + &self, counterparty_node_id: PublicKey, + ) -> Result<(), lightning::io::Error> { + let fut = { + let outer_state_lock = self.per_peer_state.read().unwrap(); + match outer_state_lock.get(&counterparty_node_id) { + None => { + // We dropped the peer state by now. + return Ok(()); + }, + Some(entry) => { + let mut peer_state_lock = entry.lock().unwrap(); + if !peer_state_lock.needs_persist() { + // We already have persisted otherwise by now. + return Ok(()); + } else { + peer_state_lock.set_needs_persist(false); + let key = counterparty_node_id.to_string(); + let encoded = peer_state_lock.encode(); + // Begin the write with the entry lock held. This avoids racing with + // potentially-in-flight `persist` calls writing state for the same peer. + self.kv_store.write( + LIQUIDITY_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS1_SERVICE_PERSISTENCE_SECONDARY_NAMESPACE, + &key, + encoded, + ) + } + }, + } + }; + + fut.await.map_err(|e| { + self.per_peer_state + .read() + .unwrap() + .get(&counterparty_node_id) + .map(|p| p.lock().unwrap().set_needs_persist(true)); + e + }) + } + fn handle_get_info_request( &self, request_id: LSPSRequestId, counterparty_node_id: &PublicKey, ) -> Result<(), LightningError> { @@ -180,18 +336,17 @@ where /// Should be called in response to receiving a [`LSPS1ServiceEvent::RequestForPaymentDetails`] event. /// /// [`LSPS1ServiceEvent::RequestForPaymentDetails`]: crate::lsps1::event::LSPS1ServiceEvent::RequestForPaymentDetails - pub fn send_payment_details( - &self, request_id: LSPSRequestId, counterparty_node_id: &PublicKey, + pub async fn send_payment_details( + &self, request_id: LSPSRequestId, counterparty_node_id: PublicKey, payment_details: LSPS1PaymentInfo, ) -> Result<(), APIError> { let mut message_queue_notifier = self.pending_messages.notifier(); + let mut should_persist = false; - let outer_state_lock = self.per_peer_state.read().unwrap(); - match outer_state_lock.get(counterparty_node_id) { + match self.per_peer_state.read().unwrap().get(&counterparty_node_id) { Some(inner_state_lock) => { let mut peer_state_lock = inner_state_lock.lock().unwrap(); let request = peer_state_lock.remove_request(&request_id).map_err(|e| { - debug_assert!(false, "Failed to send response due to: {}", e); let err = format!("Failed to send response due to: {}", e); APIError::APIMisuseError { err } })?; @@ -208,6 +363,7 @@ where created_at, payment_details, ); + should_persist |= peer_state_lock.needs_persist(); let response = LSPS1Response::CreateOrder(LSPS1CreateOrderResponse { order: order.order_params, @@ -219,8 +375,7 @@ where channel: order.channel_details, }); let msg = LSPS1Message::Response(request_id, response).into(); - message_queue_notifier.enqueue(counterparty_node_id, msg); - Ok(()) + message_queue_notifier.enqueue(&counterparty_node_id, msg); }, t => { debug_assert!( @@ -236,10 +391,25 @@ where }, } }, - None => Err(APIError::APIMisuseError { - err: format!("No state for the counterparty exists: {}", counterparty_node_id), - }), + None => { + return Err(APIError::APIMisuseError { + err: format!("No state for the counterparty exists: {}", counterparty_node_id), + }); + }, + } + + if should_persist { + self.persist_peer_state(counterparty_node_id).await.map_err(|e| { + APIError::APIMisuseError { + err: format!( + "Failed to persist peer state for {}: {}", + counterparty_node_id, e + ), + } + })?; } + + Ok(()) } fn handle_get_order_request( @@ -300,13 +470,12 @@ where /// /// The LSP continously polls for checking payment confirmation on-chain or Lightning /// and then responds to client request. - pub fn update_order_status( + pub async fn update_order_status( &self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId, order_state: LSPS1OrderState, channel_details: Option, ) -> Result<(), APIError> { - let outer_state_lock = self.per_peer_state.read().unwrap(); - - match outer_state_lock.get(&counterparty_node_id) { + let mut should_persist = false; + match self.per_peer_state.read().unwrap().get(&counterparty_node_id) { Some(inner_state_lock) => { let mut peer_state_lock = inner_state_lock.lock().unwrap(); peer_state_lock.update_order(&order_id, order_state, channel_details).map_err( @@ -314,13 +483,27 @@ where err: format!("Failed to update order: {:?}", e), }, )?; - - Ok(()) + should_persist |= peer_state_lock.needs_persist(); + }, + None => { + return Err(APIError::APIMisuseError { + err: format!("No existing state with counterparty {}", counterparty_node_id), + }); }, - None => Err(APIError::APIMisuseError { - err: format!("No existing state with counterparty {}", counterparty_node_id), - }), } + + if should_persist { + self.persist_peer_state(counterparty_node_id).await.map_err(|e| { + APIError::APIMisuseError { + err: format!( + "Failed to persist peer state for {}: {}", + counterparty_node_id, e + ), + } + })?; + } + + Ok(()) } fn generate_order_id(&self) -> LSPS1OrderId { @@ -364,6 +547,88 @@ where } } +/// A synchroneous wrapper around [`LSPS1ServiceHandler`] to be used in contexts where async is not +/// available. +pub struct LSPS1ServiceHandlerSync< + 'a, + ES: EntropySource, + CM: Deref + Clone, + K: KVStore + Clone, + TP: Deref + Clone, +> where + CM::Target: AChannelManager, + TP::Target: TimeProvider, +{ + inner: &'a LSPS1ServiceHandler, +} + +impl<'a, ES: EntropySource, CM: Deref + Clone, K: KVStore + Clone, TP: Deref + Clone> + LSPS1ServiceHandlerSync<'a, ES, CM, K, TP> +where + CM::Target: AChannelManager, + TP::Target: TimeProvider, +{ + pub(crate) fn from_inner(inner: &'a LSPS1ServiceHandler) -> Self { + Self { inner } + } + + /// Returns a reference to the used config. + /// + /// Wraps [`LSPS1ServiceHandler::config`]. + pub fn config(&self) -> &LSPS1ServiceConfig { + &self.inner.config + } + + /// Used by LSP to send response containing details regarding the channel fees and payment information. + /// + /// Wraps [`LSPS1ServiceHandler::send_payment_details`]. + pub fn send_payment_details( + &self, request_id: LSPSRequestId, counterparty_node_id: PublicKey, + payment_details: LSPS1PaymentInfo, + ) -> Result<(), APIError> { + let mut fut = pin!(self.inner.send_payment_details( + request_id, + counterparty_node_id, + payment_details + )); + + let mut waker = dummy_waker(); + let mut ctx = task::Context::from_waker(&mut waker); + match fut.as_mut().poll(&mut ctx) { + task::Poll::Ready(result) => result, + task::Poll::Pending => { + // In a sync context, we can't wait for the future to complete. + unreachable!("Should not be pending in a sync context"); + }, + } + } + + /// Used by LSP to give details to client regarding the status of channel opening. + /// + /// Wraps [`LSPS1ServiceHandler::update_order_status`]. + pub fn update_order_status( + &self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId, + order_state: LSPS1OrderState, channel_details: Option, + ) -> Result<(), APIError> { + let mut fut = pin!(self.inner.update_order_status( + counterparty_node_id, + order_id, + order_state, + channel_details + )); + + let mut waker = dummy_waker(); + let mut ctx = task::Context::from_waker(&mut waker); + match fut.as_mut().poll(&mut ctx) { + task::Poll::Ready(result) => result, + task::Poll::Pending => { + // In a sync context, we can't wait for the future to complete. + unreachable!("Should not be pending in a sync context"); + }, + } + } +} + fn check_range(min: u64, max: u64, value: u64) -> bool { (value >= min) && (value <= max) } diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index 85c8ba3ebe2..da87b4cac3e 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -30,7 +30,7 @@ use crate::persist::{ use crate::lsps1::client::{LSPS1ClientConfig, LSPS1ClientHandler}; use crate::lsps1::msgs::LSPS1Message; #[cfg(lsps1_service)] -use crate::lsps1::service::{LSPS1ServiceConfig, LSPS1ServiceHandler}; +use crate::lsps1::service::{LSPS1ServiceConfig, LSPS1ServiceHandler, LSPS1ServiceHandlerSync}; use crate::lsps2::client::{LSPS2ClientConfig, LSPS2ClientHandler}; use crate::lsps2::msgs::LSPS2Message; @@ -462,6 +462,7 @@ where Arc::clone(&pending_messages), Arc::clone(&pending_events), channel_manager.clone(), + kv_store.clone(), time_provider, config.clone(), ) @@ -623,6 +624,11 @@ where let mut did_persist = false; did_persist |= self.pending_events.persist().await?; + #[cfg(lsps1_service)] + if let Some(lsps1_service_handler) = self.lsps1_service_handler.as_ref() { + did_persist |= lsps1_service_handler.persist().await?; + } + if let Some(lsps2_service_handler) = self.lsps2_service_handler.as_ref() { did_persist |= lsps2_service_handler.persist().await?; } @@ -879,6 +885,11 @@ where // If the peer was misbehaving, drop it from the ignored list to cleanup the kept state. self.ignored_peers.write().unwrap().remove(&counterparty_node_id); + #[cfg(lsps1_service)] + if let Some(lsps1_service_handler) = self.lsps1_service_handler.as_ref() { + lsps1_service_handler.peer_disconnected(counterparty_node_id); + } + if let Some(lsps2_service_handler) = self.lsps2_service_handler.as_ref() { lsps2_service_handler.peer_disconnected(counterparty_node_id); } @@ -1031,10 +1042,10 @@ where /// /// Wraps [`LiquidityManager::lsps1_service_handler`]. #[cfg(lsps1_service)] - pub fn lsps1_service_handler( - &self, - ) -> Option<&LSPS1ServiceHandler, TP>> { - self.inner.lsps1_service_handler() + pub fn lsps1_service_handler<'a>( + &'a self, + ) -> Option, TP>> { + self.inner.lsps1_service_handler.as_ref().map(|r| LSPS1ServiceHandlerSync::from_inner(r)) } /// Returns a reference to the LSPS2 client-side handler. diff --git a/lightning-liquidity/src/persist.rs b/lightning-liquidity/src/persist.rs index d0199440514..9518b409cdf 100644 --- a/lightning-liquidity/src/persist.rs +++ b/lightning-liquidity/src/persist.rs @@ -39,6 +39,12 @@ pub const LIQUIDITY_MANAGER_EVENT_QUEUE_PERSISTENCE_SECONDARY_NAMESPACE: &str = /// [`LiquidityManager`]: crate::LiquidityManager pub const LIQUIDITY_MANAGER_EVENT_QUEUE_PERSISTENCE_KEY: &str = "event_queue"; +/// The secondary namespace under which the [`LSPS1ServiceHandler`] data will be persisted. +/// +/// [`LSPS1ServiceHandler`]: crate::lsps1::service::LSPS1ServiceHandler +#[cfg(lsps1_service)] +pub const LSPS1_SERVICE_PERSISTENCE_SECONDARY_NAMESPACE: &str = "lsps1_service"; + /// The secondary namespace under which the [`LSPS2ServiceHandler`] data will be persisted. /// /// [`LSPS2ServiceHandler`]: crate::lsps2::service::LSPS2ServiceHandler diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index ef210a34a16..0343116a650 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -174,7 +174,7 @@ fn lsps1_happy_path() { serde_json::from_str(json_str).expect("Failed to parse JSON"); let payment_info = LSPS1PaymentInfo { bolt11: None, bolt12: None, onchain: Some(onchain) }; service_handler - .send_payment_details(_create_order_id.clone(), &client_node_id, payment_info.clone()) + .send_payment_details(_create_order_id.clone(), client_node_id, payment_info.clone()) .unwrap(); let create_order_response = get_lsps_message!(service_node, client_node_id); From 58faa5e25c279881fac8835ca448318654d73058 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Dec 2025 16:17:31 +0100 Subject: [PATCH 191/627] Read persisted LSPS1ServiceHandler state on startup .. we read the persisted state in `LiquidityManager::new` Signed-off-by: Elias Rohrer --- lightning-liquidity/src/lsps1/mod.rs | 2 +- lightning-liquidity/src/lsps1/peer_state.rs | 2 +- lightning-liquidity/src/lsps1/service.rs | 10 ++--- lightning-liquidity/src/manager.rs | 34 ++++++++++------ lightning-liquidity/src/persist.rs | 44 +++++++++++++++++++++ 5 files changed, 73 insertions(+), 19 deletions(-) diff --git a/lightning-liquidity/src/lsps1/mod.rs b/lightning-liquidity/src/lsps1/mod.rs index bdfc4045f54..2270abe2fa3 100644 --- a/lightning-liquidity/src/lsps1/mod.rs +++ b/lightning-liquidity/src/lsps1/mod.rs @@ -13,6 +13,6 @@ pub mod client; pub mod event; pub mod msgs; #[cfg(lsps1_service)] -mod peer_state; +pub(crate) mod peer_state; #[cfg(lsps1_service)] pub mod service; diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index a4c477fe2f2..2b94f76665c 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -23,7 +23,7 @@ use lightning::util::hash_tables::new_hash_map; use core::fmt; #[derive(Default)] -pub(super) struct PeerState { +pub(crate) struct PeerState { outbound_channels_by_order_id: HashMap, pending_requests: HashMap, needs_persist: bool, diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 71587ae079c..459406e117d 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -37,7 +37,7 @@ use crate::persist::{ LIQUIDITY_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, LSPS1_SERVICE_PERSISTENCE_SECONDARY_NAMESPACE, }; use crate::prelude::hash_map::Entry; -use crate::prelude::{new_hash_map, HashMap}; +use crate::prelude::HashMap; use crate::sync::{Arc, Mutex, RwLock}; use crate::utils; use crate::utils::async_poll::dummy_waker; @@ -91,9 +91,9 @@ where { /// Constructs a `LSPS1ServiceHandler`. pub(crate) fn new( - entropy_source: ES, pending_messages: Arc, - pending_events: Arc>, channel_manager: CM, kv_store: K, time_provider: TP, - config: LSPS1ServiceConfig, + per_peer_state: HashMap>, entropy_source: ES, + pending_messages: Arc, pending_events: Arc>, + channel_manager: CM, kv_store: K, time_provider: TP, config: LSPS1ServiceConfig, ) -> Self { Self { entropy_source, @@ -101,7 +101,7 @@ where kv_store, pending_messages, pending_events, - per_peer_state: RwLock::new(new_hash_map()), + per_peer_state: RwLock::new(per_peer_state), persistence_in_flight: AtomicUsize::new(0), time_provider, config, diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index da87b4cac3e..99a0c8f0306 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -23,6 +23,8 @@ use crate::lsps5::client::{LSPS5ClientConfig, LSPS5ClientHandler}; use crate::lsps5::msgs::LSPS5Message; use crate::lsps5::service::{LSPS5ServiceConfig, LSPS5ServiceHandler}; use crate::message_queue::MessageQueue; +#[cfg(lsps1_service)] +use crate::persist::read_lsps1_service_peer_states; use crate::persist::{ read_event_queue, read_lsps2_service_peer_states, read_lsps5_service_peer_states, }; @@ -450,24 +452,32 @@ where }); #[cfg(lsps1_service)] - let lsps1_service_handler = service_config.as_ref().and_then(|config| { - if let Some(number) = - as LSPSProtocolMessageHandler>::PROTOCOL_NUMBER - { - supported_protocols.push(number); - } - config.lsps1_service_config.as_ref().map(|config| { - LSPS1ServiceHandler::new( + let lsps1_service_handler = if let Some(service_config) = service_config.as_ref() { + if let Some(lsps1_service_config) = service_config.lsps1_service_config.as_ref() { + if let Some(number) = + as LSPSProtocolMessageHandler>::PROTOCOL_NUMBER + { + supported_protocols.push(number); + } + + let peer_states = read_lsps1_service_peer_states(kv_store.clone()).await?; + + Some(LSPS1ServiceHandler::new( + peer_states, entropy_source.clone(), Arc::clone(&pending_messages), Arc::clone(&pending_events), channel_manager.clone(), kv_store.clone(), time_provider, - config.clone(), - ) - }) - }); + lsps1_service_config.clone(), + )) + } else { + None + } + } else { + None + }; let lsps0_client_handler = LSPS0ClientHandler::new( entropy_source.clone(), diff --git a/lightning-liquidity/src/persist.rs b/lightning-liquidity/src/persist.rs index 9518b409cdf..13afdabb61b 100644 --- a/lightning-liquidity/src/persist.rs +++ b/lightning-liquidity/src/persist.rs @@ -10,6 +10,8 @@ //! Types and utils for persistence. use crate::events::{EventQueueDeserWrapper, LiquidityEvent}; +#[cfg(lsps1_service)] +use crate::lsps1::peer_state::PeerState as LSPS1ServicePeerState; use crate::lsps2::service::PeerState as LSPS2ServicePeerState; use crate::lsps5::service::PeerState as LSPS5ServicePeerState; use crate::prelude::{new_hash_map, HashMap}; @@ -86,6 +88,48 @@ pub(crate) async fn read_event_queue( Ok(Some(queue.0)) } +#[cfg(lsps1_service)] +pub(crate) async fn read_lsps1_service_peer_states( + kv_store: K, +) -> Result>, lightning::io::Error> { + let mut res = new_hash_map(); + + for stored_key in kv_store + .list( + LIQUIDITY_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS1_SERVICE_PERSISTENCE_SECONDARY_NAMESPACE, + ) + .await? + { + let mut reader = Cursor::new( + kv_store + .read( + LIQUIDITY_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS1_SERVICE_PERSISTENCE_SECONDARY_NAMESPACE, + &stored_key, + ) + .await?, + ); + + let peer_state = LSPS1ServicePeerState::read(&mut reader).map_err(|_| { + lightning::io::Error::new( + lightning::io::ErrorKind::InvalidData, + "Failed to deserialize LSPS1 peer state", + ) + })?; + + let key = PublicKey::from_str(&stored_key).map_err(|_| { + lightning::io::Error::new( + lightning::io::ErrorKind::InvalidData, + "Failed to deserialize stored key entry", + ) + })?; + + res.insert(key, Mutex::new(peer_state)); + } + Ok(res) +} + pub(crate) async fn read_lsps2_service_peer_states( kv_store: K, ) -> Result>, lightning::io::Error> { From b7b2f7ef3957b452d0105517026ab48af424c1bc Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Dec 2025 16:01:30 +0100 Subject: [PATCH 192/627] Add test case asserting `LSPS1ServiceState` is persisted across restarts Co-authored by Claude AI --- .../tests/lsps1_integration_tests.rs | 259 +++++++++++++++++- 1 file changed, 257 insertions(+), 2 deletions(-) diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index 0343116a650..01c9a38b982 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -15,16 +15,22 @@ use lightning_liquidity::lsps1::msgs::{ }; use lightning_liquidity::lsps1::service::LSPS1ServiceConfig; use lightning_liquidity::utils::time::DefaultTimeProvider; -use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig}; +use lightning_liquidity::{LiquidityClientConfig, LiquidityManagerSync, LiquidityServiceConfig}; use lightning::ln::functional_test_utils::{ create_chanmon_cfgs, create_node_cfgs, create_node_chanmgrs, }; -use lightning::util::test_utils::TestStore; +use lightning::util::test_utils::{TestBroadcaster, TestStore}; +use bitcoin::secp256k1::PublicKey; +use bitcoin::{Address, Network}; + +use std::str::FromStr; use std::sync::Arc; use lightning::ln::functional_test_utils::{create_network, Node}; +use lightning_liquidity::lsps1::msgs::LSPS1OrderId; +use lightning_liquidity::utils::time::TimeProvider; fn build_lsps1_configs( supported_options: LSPS1Options, @@ -240,3 +246,252 @@ fn lsps1_happy_path() { panic!("Unexpected event"); } } + +#[test] +fn lsps1_service_handler_persistence_across_restarts() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + // Create shared KV store for service node that will persist across restarts + let service_kv_store = Arc::new(TestStore::new(false)); + let client_kv_store = Arc::new(TestStore::new(false)); + + let supported_options = LSPS1Options { + min_required_channel_confirmations: 0, + min_funding_confirms_within_blocks: 6, + supports_zero_channel_reserve: true, + max_channel_expiry_blocks: 144, + min_initial_client_balance_sat: 10_000_000, + max_initial_client_balance_sat: 100_000_000, + min_initial_lsp_balance_sat: 100_000, + max_initial_lsp_balance_sat: 100_000_000, + min_channel_balance_sat: 100_000, + max_channel_balance_sat: 100_000_000, + }; + + let service_config = LiquidityServiceConfig { + lsps1_service_config: Some(LSPS1ServiceConfig { + supported_options: supported_options.clone(), + token: None, + }), + lsps2_service_config: None, + lsps5_service_config: None, + advertise_service: true, + }; + let time_provider: Arc = Arc::new(DefaultTimeProvider); + + // Variables to carry state between scopes + let client_node_id: PublicKey; + let expected_order_id: LSPS1OrderId; + let order_params: LSPS1OrderParams; + let payment_info: LSPS1PaymentInfo; + + // First scope: Setup, persistence, and dropping of all node objects + { + let LSPSNodes { service_node, client_node } = setup_test_lsps1_nodes_with_kv_stores( + nodes, + Arc::clone(&service_kv_store), + client_kv_store, + supported_options.clone(), + ); + + let service_node_id = service_node.inner.node.get_our_node_id(); + client_node_id = client_node.inner.node.get_our_node_id(); + + let client_handler = client_node.liquidity_manager.lsps1_client_handler().unwrap(); + let service_handler = service_node.liquidity_manager.lsps1_service_handler().unwrap(); + + // Request supported options + let _request_supported_options_id = + client_handler.request_supported_options(service_node_id); + let request_supported_options = get_lsps_message!(client_node, service_node_id); + + service_node + .liquidity_manager + .handle_custom_message(request_supported_options, client_node_id) + .unwrap(); + + let get_info_message = get_lsps_message!(service_node, client_node_id); + client_node + .liquidity_manager + .handle_custom_message(get_info_message, service_node_id) + .unwrap(); + + let _get_info_event = client_node.liquidity_manager.next_event().unwrap(); + + // Create an order to establish persistent state + order_params = LSPS1OrderParams { + lsp_balance_sat: 100_000, + client_balance_sat: 10_000_000, + required_channel_confirmations: 0, + funding_confirms_within_blocks: 6, + channel_expiry_blocks: 144, + token: None, + announce_channel: true, + }; + + let refund_onchain_address = + Address::from_str("bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr") + .unwrap() + .assume_checked(); + let create_order_id = client_handler.create_order( + &service_node_id, + order_params.clone(), + Some(refund_onchain_address), + ); + let create_order = get_lsps_message!(client_node, service_node_id); + + service_node.liquidity_manager.handle_custom_message(create_order, client_node_id).unwrap(); + + let request_for_payment_event = service_node.liquidity_manager.next_event().unwrap(); + let request_id = + if let LiquidityEvent::LSPS1Service(LSPS1ServiceEvent::RequestForPaymentDetails { + request_id, + .. + }) = request_for_payment_event + { + request_id + } else { + panic!("Unexpected event"); + }; + + // Service sends payment details, creating persistent order state + let json_str = r#"{ + "state": "EXPECT_PAYMENT", + "expires_at": "2035-01-01T00:00:00Z", + "fee_total_sat": "9999", + "order_total_sat": "200999", + "address": "bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr", + "min_onchain_payment_confirmations": 1, + "min_fee_for_0conf": 253 + }"#; + + let onchain: LSPS1OnchainPaymentInfo = + serde_json::from_str(json_str).expect("Failed to parse JSON"); + payment_info = LSPS1PaymentInfo { bolt11: None, bolt12: None, onchain: Some(onchain) }; + service_handler + .send_payment_details(request_id.clone(), client_node_id, payment_info.clone()) + .unwrap(); + + let create_order_response = get_lsps_message!(service_node, client_node_id); + + client_node + .liquidity_manager + .handle_custom_message(create_order_response, service_node_id) + .unwrap(); + + let order_created_event = client_node.liquidity_manager.next_event().unwrap(); + expected_order_id = if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderCreated { + request_id, + order_id, + .. + }) = order_created_event + { + assert_eq!(request_id, create_order_id); + order_id + } else { + panic!("Unexpected event"); + }; + + // Trigger persistence by calling persist + service_node.liquidity_manager.persist().unwrap(); + + // All node objects are dropped at the end of this scope + } + + // Second scope: Recovery from persisted store and verification + { + // Create fresh node configurations for restart + let node_chanmgrs_restart = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes_restart = create_network(2, &node_cfgs, &node_chanmgrs_restart); + + // Create a new LiquidityManager with the same configuration and KV store to simulate restart + let service_transaction_broadcaster = Arc::new(TestBroadcaster::new(Network::Testnet)); + let client_transaction_broadcaster = Arc::new(TestBroadcaster::new(Network::Testnet)); + let client_kv_store_restart = Arc::new(TestStore::new(false)); + + let restarted_service_lm = LiquidityManagerSync::new_with_custom_time_provider( + nodes_restart[0].keys_manager, + nodes_restart[0].keys_manager, + nodes_restart[0].node, + service_kv_store, + service_transaction_broadcaster, + Some(service_config), + None, + Arc::clone(&time_provider), + ) + .unwrap(); + + // Create a fresh client to query the restarted service + let lsps1_client_config = LSPS1ClientConfig { max_channel_fees_msat: None }; + let client_config = LiquidityClientConfig { + lsps1_client_config: Some(lsps1_client_config), + lsps2_client_config: None, + lsps5_client_config: None, + }; + + let client_lm = LiquidityManagerSync::new_with_custom_time_provider( + nodes_restart[1].keys_manager, + nodes_restart[1].keys_manager, + nodes_restart[1].node, + client_kv_store_restart, + client_transaction_broadcaster, + None, + Some(client_config), + time_provider, + ) + .unwrap(); + + let service_node_id = nodes_restart[0].node.get_our_node_id(); + let client_node_id_restart = nodes_restart[1].node.get_our_node_id(); + + // Verify node IDs match (since we use same node_cfgs) + assert_eq!(client_node_id_restart, client_node_id); + + // Use the client to send a GetOrder request + let client_handler = client_lm.lsps1_client_handler().unwrap(); + let check_order_status_id = + client_handler.check_order_status(&service_node_id, expected_order_id.clone()); + + // Get the request message from client + let pending_client_msgs = client_lm.get_and_clear_pending_msg(); + assert_eq!(pending_client_msgs.len(), 1); + let (target_node_id, request_msg) = pending_client_msgs.into_iter().next().unwrap(); + assert_eq!(target_node_id, service_node_id); + + // Pass the request to the restarted service + restarted_service_lm.handle_custom_message(request_msg, client_node_id).unwrap(); + + // Get the response from the service + let pending_service_msgs = restarted_service_lm.get_and_clear_pending_msg(); + assert_eq!(pending_service_msgs.len(), 1); + let (target_node_id, response_msg) = pending_service_msgs.into_iter().next().unwrap(); + assert_eq!(target_node_id, client_node_id); + + // Pass the response to the client + client_lm.handle_custom_message(response_msg, service_node_id).unwrap(); + + // Verify the client receives the order status event with correct data + let order_status_event = client_lm.next_event().unwrap(); + if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { + request_id, + counterparty_node_id, + order_id, + order, + payment, + channel, + }) = order_status_event + { + assert_eq!(request_id, check_order_status_id); + assert_eq!(counterparty_node_id, service_node_id); + assert_eq!(order_id, expected_order_id); + assert_eq!(order, order_params); + assert_eq!(payment, payment_info); + assert!(channel.is_none()); + } else { + panic!("Expected OrderStatus event after restart, got: {:?}", order_status_event); + } + } +} From 8304ebefc0333de1d5b27923331ceb7a8aed75a0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Dec 2025 13:23:43 +0100 Subject: [PATCH 193/627] Add some checks on provided payment details As per spec, we check that the user provides at least one payment detail *and* that they don't provide onchain payment details if `refund_onchain_address` is unset. Signed-off-by: Elias Rohrer --- lightning-liquidity/src/lsps1/event.rs | 6 +++ lightning-liquidity/src/lsps1/peer_state.rs | 6 +++ lightning-liquidity/src/lsps1/service.rs | 52 ++++++++++++++++++- .../tests/lsps1_integration_tests.rs | 16 ++++-- 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/lightning-liquidity/src/lsps1/event.rs b/lightning-liquidity/src/lsps1/event.rs index d966f8bdc2f..cdd09955163 100644 --- a/lightning-liquidity/src/lsps1/event.rs +++ b/lightning-liquidity/src/lsps1/event.rs @@ -15,6 +15,7 @@ use super::msgs::{LSPS1ChannelInfo, LSPS1Options, LSPS1OrderParams, LSPS1Payment use crate::lsps0::ser::{LSPSRequestId, LSPSResponseError}; use bitcoin::secp256k1::PublicKey; +use bitcoin::Address; /// An event which an bLIP-51 / LSPS1 client should take some action in response to. #[derive(Clone, Debug, PartialEq, Eq)] @@ -164,6 +165,11 @@ pub enum LSPS1ServiceEvent { counterparty_node_id: PublicKey, /// The order requested by the client. order: LSPS1OrderParams, + /// The address we need to send onchain refunds to in case channel opening fails. + /// + /// Please note that you can't offer onchain payments if this was not provided by the + /// client. + refund_onchain_address: Option
        , }, /// If error is encountered, refund the amount if paid by the client. /// diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 2b94f76665c..1b51f64a583 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -82,6 +82,12 @@ impl PeerState { Ok(()) } + pub(super) fn get_request( + &self, request_id: &LSPSRequestId, + ) -> Result<&LSPS1Request, PeerStateError> { + self.pending_requests.get(request_id).ok_or(PeerStateError::UnknownRequestId) + } + pub(super) fn remove_request( &mut self, request_id: &LSPSRequestId, ) -> Result { diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 459406e117d..478fc294b60 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -22,7 +22,7 @@ use super::event::LSPS1ServiceEvent; use super::msgs::{ LSPS1ChannelInfo, LSPS1CreateOrderRequest, LSPS1CreateOrderResponse, LSPS1GetInfoResponse, LSPS1GetOrderRequest, LSPS1Message, LSPS1Options, LSPS1OrderId, LSPS1OrderParams, - LSPS1OrderState, LSPS1PaymentInfo, LSPS1Request, LSPS1Response, + LSPS1OrderState, LSPS1PaymentInfo, LSPS1PaymentState, LSPS1Request, LSPS1Response, LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE, LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE, }; @@ -326,6 +326,7 @@ where request_id, counterparty_node_id: *counterparty_node_id, order: params.order, + refund_onchain_address: params.refund_onchain_address, }); Ok(()) @@ -335,6 +336,9 @@ where /// /// Should be called in response to receiving a [`LSPS1ServiceEvent::RequestForPaymentDetails`] event. /// + /// Note that the provided `payment_details` can't include the onchain payment variant if the + /// user didn't provide a `refund_onchain_address`. + /// /// [`LSPS1ServiceEvent::RequestForPaymentDetails`]: crate::lsps1::event::LSPS1ServiceEvent::RequestForPaymentDetails pub async fn send_payment_details( &self, request_id: LSPSRequestId, counterparty_node_id: PublicKey, @@ -343,9 +347,54 @@ where let mut message_queue_notifier = self.pending_messages.notifier(); let mut should_persist = false; + if payment_details.bolt11.is_none() + && payment_details.bolt12.is_none() + && payment_details.onchain.is_none() + { + let err = "At least one payment option must be provided".to_string(); + return Err(APIError::APIMisuseError { err }); + } + + if payment_details + .bolt11 + .as_ref() + .is_some_and(|b| b.state != LSPS1PaymentState::ExpectPayment) + || payment_details + .bolt12 + .as_ref() + .is_some_and(|b| b.state != LSPS1PaymentState::ExpectPayment) + || payment_details + .onchain + .as_ref() + .is_some_and(|o| o.state != LSPS1PaymentState::ExpectPayment) + { + return Err(APIError::APIMisuseError { + err: "All payment methods must start in ExpectPayment state".to_string(), + }); + } + match self.per_peer_state.read().unwrap().get(&counterparty_node_id) { Some(inner_state_lock) => { let mut peer_state_lock = inner_state_lock.lock().unwrap(); + + // Validate payment_details against the pending request before removing it, + // so the LSP operator can retry on failure. + if payment_details.onchain.is_some() { + let request = peer_state_lock.get_request(&request_id).map_err(|e| { + let err = format!("Failed to send response due to: {}", e); + APIError::APIMisuseError { err } + })?; + let has_refund_addr = matches!( + request, + LSPS1Request::CreateOrder(p) if p.refund_onchain_address.is_some() + ); + if !has_refund_addr { + // bLIP-51: 'LSP MUST disable on-chain payments if the client omits this field.' + let err = "Onchain payments must be disabled if no refund_onchain_address is set.".to_string(); + return Err(APIError::APIMisuseError { err }); + } + } + let request = peer_state_lock.remove_request(&request_id).map_err(|e| { let err = format!("Failed to send response due to: {}", e); APIError::APIMisuseError { err } @@ -357,6 +406,7 @@ where let created_at = LSPSDateTime::new_from_duration_since_epoch( self.time_provider.duration_since_epoch(), ); + let order = peer_state_lock.new_order( order_id.clone(), params.order, diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index 01c9a38b982..0261a088244 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -145,8 +145,15 @@ fn lsps1_happy_path() { announce_channel: true, }; - let _create_order_id = - client_handler.create_order(&service_node_id, order_params.clone(), None); + let refund_onchain_address = + Address::from_str("bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr") + .unwrap() + .assume_checked(); + let _create_order_id = client_handler.create_order( + &service_node_id, + order_params.clone(), + Some(refund_onchain_address.clone()), + ); let create_order = get_lsps_message!(client_node, service_node_id); service_node.liquidity_manager.handle_custom_message(create_order, client_node_id).unwrap(); @@ -157,11 +164,14 @@ fn lsps1_happy_path() { request_id, counterparty_node_id, order, + refund_onchain_address: refund_addr, + .. }) = _request_for_payment_event { assert_eq!(request_id, _create_order_id.clone()); assert_eq!(counterparty_node_id, client_node_id); assert_eq!(order, order_params); + assert_eq!(refund_addr, Some(refund_onchain_address)); } else { panic!("Unexpected event"); } @@ -339,7 +349,7 @@ fn lsps1_service_handler_persistence_across_restarts() { let create_order_id = client_handler.create_order( &service_node_id, order_params.clone(), - Some(refund_onchain_address), + Some(refund_onchain_address.clone()), ); let create_order = get_lsps_message!(client_node, service_node_id); From d210b889a0e7219f1f2743e587844fc78f1f408a Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Dec 2025 14:37:41 +0100 Subject: [PATCH 194/627] Don't hold write lock in `LSPS{1,2}ServiceHandler::peer_disconnected` .. as there's no need to do so. --- lightning-liquidity/src/lsps1/service.rs | 2 +- lightning-liquidity/src/lsps2/service.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 478fc294b60..d02d0f369c3 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -128,7 +128,7 @@ where } pub(crate) fn peer_disconnected(&self, counterparty_node_id: PublicKey) { - let outer_state_lock = self.per_peer_state.write().unwrap(); + let outer_state_lock = self.per_peer_state.read().unwrap(); if let Some(inner_state_lock) = outer_state_lock.get(&counterparty_node_id) { let mut peer_state_lock = inner_state_lock.lock().unwrap(); // We clean up the peer state, but leave removing the peer entry to the prune logic in diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index 35942dcd624..665cda1df89 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -1871,7 +1871,7 @@ where } pub(crate) fn peer_disconnected(&self, counterparty_node_id: PublicKey) { - let outer_state_lock = self.per_peer_state.write().unwrap(); + let outer_state_lock = self.per_peer_state.read().unwrap(); if let Some(inner_state_lock) = outer_state_lock.get(&counterparty_node_id) { let mut peer_state_lock = inner_state_lock.lock().unwrap(); // We clean up the peer state, but leave removing the peer entry to the prune logic in From f043b2e113763f0b9a2b6d00f1b311fe02fdcf57 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Dec 2025 15:01:05 +0100 Subject: [PATCH 195/627] Add `invalid_token_provided` API method We add a method that allows the LSP to signal to the client the token they used was invalid. We use the `102` error code as proposed in https://github.com/lightning/blips/pull/68. Signed-off-by: Elias Rohrer --- lightning-liquidity/src/lsps1/event.rs | 4 ++ lightning-liquidity/src/lsps1/msgs.rs | 1 + lightning-liquidity/src/lsps1/service.rs | 49 +++++++++++++++++-- .../tests/lsps0_integration_tests.rs | 2 +- .../tests/lsps1_integration_tests.rs | 3 +- 5 files changed, 53 insertions(+), 6 deletions(-) diff --git a/lightning-liquidity/src/lsps1/event.rs b/lightning-liquidity/src/lsps1/event.rs index cdd09955163..c9a1844da85 100644 --- a/lightning-liquidity/src/lsps1/event.rs +++ b/lightning-liquidity/src/lsps1/event.rs @@ -153,9 +153,13 @@ pub enum LSPS1ServiceEvent { /// send order parameters including the details regarding the /// payment and order id for this order for the client. /// + /// You should call [`LSPS1ServiceHandler::invalid_token_provided`] if the token provided as + /// part of the order parameters is invalid. + /// /// **Note: ** This event will *not* be persisted across restarts. /// /// [`LSPS1ServiceHandler::send_payment_details`]: crate::lsps1::service::LSPS1ServiceHandler::send_payment_details + /// [`LSPS1ServiceHandler::invalid_token_provided`]: crate::lsps1::service::LSPS1ServiceHandler::invalid_token_provided RequestForPaymentDetails { /// An identifier that must be passed to [`LSPS1ServiceHandler::send_payment_details`]. /// diff --git a/lightning-liquidity/src/lsps1/msgs.rs b/lightning-liquidity/src/lsps1/msgs.rs index 5bf130400e1..a2382e0b71c 100644 --- a/lightning-liquidity/src/lsps1/msgs.rs +++ b/lightning-liquidity/src/lsps1/msgs.rs @@ -35,6 +35,7 @@ pub(crate) const _LSPS1_CREATE_ORDER_REQUEST_INVALID_PARAMS_ERROR_CODE: i32 = -3 pub(crate) const LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE: i32 = 100; #[cfg(lsps1_service)] pub(crate) const LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE: i32 = 101; +pub(crate) const LSPS1_CREATE_ORDER_REQUEST_UNRECOGNIZED_OR_STALE_TOKEN_ERROR_CODE: i32 = 102; /// The identifier of an order. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)] diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index d02d0f369c3..e81213cc523 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -9,7 +9,7 @@ //! Contains the main bLIP-51 / LSPS1 server object, [`LSPS1ServiceHandler`]. -use alloc::string::{String, ToString}; +use alloc::string::ToString; use alloc::vec::Vec; use core::future::Future as StdFuture; @@ -24,6 +24,7 @@ use super::msgs::{ LSPS1GetOrderRequest, LSPS1Message, LSPS1Options, LSPS1OrderId, LSPS1OrderParams, LSPS1OrderState, LSPS1PaymentInfo, LSPS1PaymentState, LSPS1Request, LSPS1Response, LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE, + LSPS1_CREATE_ORDER_REQUEST_UNRECOGNIZED_OR_STALE_TOKEN_ERROR_CODE, LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE, }; use super::peer_state::PeerState; @@ -56,8 +57,6 @@ use bitcoin::secp256k1::PublicKey; /// Server-side configuration options for bLIP-51 / LSPS1 channel requests. #[derive(Clone, Debug)] pub struct LSPS1ServiceConfig { - /// A token to be send with each channel request. - pub token: Option, /// The options supported by the LSP. pub supported_options: LSPS1Options, } @@ -462,6 +461,41 @@ where Ok(()) } + /// Used by LSP to inform a client that an order was rejected because the used token was invalid. + /// + /// Should be called in response to receiving a [`LSPS1ServiceEvent::RequestForPaymentDetails`] + /// event if the provided token is invalid. + /// + /// [`LSPS1ServiceEvent::RequestForPaymentDetails`]: crate::lsps1::event::LSPS1ServiceEvent::RequestForPaymentDetails + pub fn invalid_token_provided( + &self, counterparty_node_id: PublicKey, request_id: LSPSRequestId, + ) -> Result<(), APIError> { + let mut message_queue_notifier = self.pending_messages.notifier(); + + match self.per_peer_state.read().unwrap().get(&counterparty_node_id) { + Some(inner_state_lock) => { + let mut peer_state_lock = inner_state_lock.lock().unwrap(); + peer_state_lock.remove_request(&request_id).map_err(|e| { + let err = format!("Failed to send response due to: {}", e); + APIError::APIMisuseError { err } + })?; + + let response = LSPS1Response::CreateOrderError(LSPSResponseError { + code: LSPS1_CREATE_ORDER_REQUEST_UNRECOGNIZED_OR_STALE_TOKEN_ERROR_CODE, + message: "An unrecognized or stale token was provided".to_string(), + data: None, + }); + + let msg = LSPS1Message::Response(request_id, response).into(); + message_queue_notifier.enqueue(&counterparty_node_id, msg); + Ok(()) + }, + None => Err(APIError::APIMisuseError { + err: format!("No state for the counterparty exists: {}", counterparty_node_id), + }), + } + } + fn handle_get_order_request( &self, request_id: LSPSRequestId, counterparty_node_id: &PublicKey, params: LSPS1GetOrderRequest, @@ -653,6 +687,15 @@ where } } + /// Used by LSP to inform a client that an order was rejected because the used token was invalid. + /// + /// Wraps [`LSPS1ServiceHandler::invalid_token_provided`]. + pub fn invalid_token_provided( + &self, counterparty_node_id: PublicKey, request_id: LSPSRequestId, + ) -> Result<(), APIError> { + self.inner.invalid_token_provided(counterparty_node_id, request_id) + } + /// Used by LSP to give details to client regarding the status of channel opening. /// /// Wraps [`LSPS1ServiceHandler::update_order_status`]. diff --git a/lightning-liquidity/tests/lsps0_integration_tests.rs b/lightning-liquidity/tests/lsps0_integration_tests.rs index 7f0e01bde92..58d9e867398 100644 --- a/lightning-liquidity/tests/lsps0_integration_tests.rs +++ b/lightning-liquidity/tests/lsps0_integration_tests.rs @@ -49,7 +49,7 @@ fn list_protocols_integration_test() { min_channel_balance_sat: 100_000, max_channel_balance_sat: 100_000_000, }; - LSPS1ServiceConfig { supported_options, token: None } + LSPS1ServiceConfig { supported_options } }; let lsps5_service_config = LSPS5ServiceConfig::default(); let service_config = LiquidityServiceConfig { diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index 0261a088244..93a4bddf801 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -35,7 +35,7 @@ use lightning_liquidity::utils::time::TimeProvider; fn build_lsps1_configs( supported_options: LSPS1Options, ) -> (LiquidityServiceConfig, LiquidityClientConfig) { - let lsps1_service_config = LSPS1ServiceConfig { token: None, supported_options }; + let lsps1_service_config = LSPS1ServiceConfig { supported_options }; let service_config = LiquidityServiceConfig { lsps1_service_config: Some(lsps1_service_config), lsps2_service_config: None, @@ -284,7 +284,6 @@ fn lsps1_service_handler_persistence_across_restarts() { let service_config = LiquidityServiceConfig { lsps1_service_config: Some(LSPS1ServiceConfig { supported_options: supported_options.clone(), - token: None, }), lsps2_service_config: None, lsps5_service_config: None, From be5c2c1a9ee05868dd6121f4a610ac61380900d4 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Dec 2025 15:25:55 +0100 Subject: [PATCH 196/627] Add test case for `invalid_token_provided` flow We test the just-added API. Co-authored by Claude AI --- .../tests/lsps1_integration_tests.rs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index 93a4bddf801..8cd7f28ec86 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -504,3 +504,99 @@ fn lsps1_service_handler_persistence_across_restarts() { } } } + +#[test] +fn lsps1_invalid_token_error() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let supported_options = LSPS1Options { + min_required_channel_confirmations: 0, + min_funding_confirms_within_blocks: 6, + supports_zero_channel_reserve: true, + max_channel_expiry_blocks: 144, + min_initial_client_balance_sat: 10_000_000, + max_initial_client_balance_sat: 100_000_000, + min_initial_lsp_balance_sat: 100_000, + max_initial_lsp_balance_sat: 100_000_000, + min_channel_balance_sat: 100_000, + max_channel_balance_sat: 100_000_000, + }; + + let LSPSNodes { service_node, client_node } = + setup_test_lsps1_nodes(nodes, supported_options.clone()); + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + let client_handler = client_node.liquidity_manager.lsps1_client_handler().unwrap(); + let service_handler = service_node.liquidity_manager.lsps1_service_handler().unwrap(); + + // Create an order with an invalid token + let order_params = LSPS1OrderParams { + lsp_balance_sat: 100_000, + client_balance_sat: 10_000_000, + required_channel_confirmations: 0, + funding_confirms_within_blocks: 6, + channel_expiry_blocks: 144, + token: Some("invalid_token".to_string()), + announce_channel: true, + }; + + let refund_onchain_address = + Address::from_str("bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr") + .unwrap() + .assume_checked(); + let create_order_id = client_handler.create_order( + &service_node_id, + order_params.clone(), + Some(refund_onchain_address), + ); + let create_order = get_lsps_message!(client_node, service_node_id); + + // Service receives the create_order request + service_node.liquidity_manager.handle_custom_message(create_order, client_node_id).unwrap(); + + // Service emits RequestForPaymentDetails event + let request_for_payment_event = service_node.liquidity_manager.next_event().unwrap(); + let request_id = + if let LiquidityEvent::LSPS1Service(LSPS1ServiceEvent::RequestForPaymentDetails { + request_id, + counterparty_node_id, + order, + }) = request_for_payment_event + { + assert_eq!(counterparty_node_id, client_node_id); + assert_eq!(order, order_params); + request_id + } else { + panic!("Unexpected event: expected RequestForPaymentDetails"); + }; + + // Service rejects the order due to invalid token + service_handler.invalid_token_provided(client_node_id, request_id).unwrap(); + + // Get the error response message + let error_response = get_lsps_message!(service_node, client_node_id); + + // Client receives the error response + client_node + .liquidity_manager + .handle_custom_message(error_response, service_node_id) + .unwrap_err(); + + // Client receives OrderRequestFailed event with error code 102 + let error_event = client_node.liquidity_manager.next_event().unwrap(); + if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderRequestFailed { + request_id, + counterparty_node_id, + error, + }) = error_event + { + assert_eq!(request_id, create_order_id); + assert_eq!(counterparty_node_id, service_node_id); + assert_eq!(error.code, 102); // LSPS1_CREATE_ORDER_REQUEST_UNRECOGNIZED_OR_STALE_TOKEN_ERROR_CODE + } else { + panic!("Unexpected event: expected OrderRequestFailed"); + } +} From 23223851c5d369134c9511cc4f6a1e63ab063a42 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Dec 2025 10:45:39 +0100 Subject: [PATCH 197/627] Drop `lsps1_service` cfg flag Signed-off-by: Elias Rohrer --- ci/ci-tests-cfg-flags.sh | 2 -- lightning-liquidity/Cargo.toml | 1 - lightning-liquidity/src/events/mod.rs | 2 -- lightning-liquidity/src/lsps1/event.rs | 1 - lightning-liquidity/src/lsps1/mod.rs | 2 -- lightning-liquidity/src/lsps1/msgs.rs | 2 -- lightning-liquidity/src/manager.rs | 25 +++---------------- lightning-liquidity/src/persist.rs | 3 --- .../tests/lsps0_integration_tests.rs | 13 ---------- .../tests/lsps1_integration_tests.rs | 2 +- .../tests/lsps2_integration_tests.rs | 2 -- .../tests/lsps5_integration_tests.rs | 3 --- 12 files changed, 5 insertions(+), 53 deletions(-) diff --git a/ci/ci-tests-cfg-flags.sh b/ci/ci-tests-cfg-flags.sh index 5380c986f3f..2bdc94b0cfc 100755 --- a/ci/ci-tests-cfg-flags.sh +++ b/ci/ci-tests-cfg-flags.sh @@ -9,6 +9,4 @@ RUSTFLAGS="--cfg=taproot" cargo test --quiet --color always -p lightning [ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean RUSTFLAGS="--cfg=simple_close" cargo test --quiet --color always -p lightning [ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean -RUSTFLAGS="--cfg=lsps1_service" cargo test --quiet --color always -p lightning-liquidity -[ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean RUSTFLAGS="--cfg=peer_storage" cargo test --quiet --color always -p lightning diff --git a/lightning-liquidity/Cargo.toml b/lightning-liquidity/Cargo.toml index 61f41c15d38..cc7fb0c0f08 100644 --- a/lightning-liquidity/Cargo.toml +++ b/lightning-liquidity/Cargo.toml @@ -46,7 +46,6 @@ parking_lot = { version = "0.12", default-features = false } level = "forbid" # When adding a new cfg attribute, ensure that it is added to this list. check-cfg = [ - "cfg(lsps1_service)", "cfg(c_bindings)", "cfg(backtrace)", "cfg(ldk_bench)", diff --git a/lightning-liquidity/src/events/mod.rs b/lightning-liquidity/src/events/mod.rs index c39b8b9fd59..3d9587a058a 100644 --- a/lightning-liquidity/src/events/mod.rs +++ b/lightning-liquidity/src/events/mod.rs @@ -33,7 +33,6 @@ pub enum LiquidityEvent { /// An LSPS1 (Channel Request) client event. LSPS1Client(lsps1::event::LSPS1ClientEvent), /// An LSPS1 (Channel Request) server event. - #[cfg(lsps1_service)] LSPS1Service(lsps1::event::LSPS1ServiceEvent), /// An LSPS2 (JIT Channel) client event. LSPS2Client(lsps2::event::LSPS2ClientEvent), @@ -57,7 +56,6 @@ impl From for LiquidityEvent { } } -#[cfg(lsps1_service)] impl From for LiquidityEvent { fn from(event: lsps1::event::LSPS1ServiceEvent) -> Self { Self::LSPS1Service(event) diff --git a/lightning-liquidity/src/lsps1/event.rs b/lightning-liquidity/src/lsps1/event.rs index c9a1844da85..d78d6d975c2 100644 --- a/lightning-liquidity/src/lsps1/event.rs +++ b/lightning-liquidity/src/lsps1/event.rs @@ -143,7 +143,6 @@ pub enum LSPS1ClientEvent { } /// An event which an LSPS1 server should take some action in response to. -#[cfg(lsps1_service)] #[derive(Clone, Debug, PartialEq, Eq)] pub enum LSPS1ServiceEvent { /// A client has selected the parameters to use from the supported options of the LSP diff --git a/lightning-liquidity/src/lsps1/mod.rs b/lightning-liquidity/src/lsps1/mod.rs index 2270abe2fa3..5f7f554dfb0 100644 --- a/lightning-liquidity/src/lsps1/mod.rs +++ b/lightning-liquidity/src/lsps1/mod.rs @@ -12,7 +12,5 @@ pub mod client; pub mod event; pub mod msgs; -#[cfg(lsps1_service)] pub(crate) mod peer_state; -#[cfg(lsps1_service)] pub mod service; diff --git a/lightning-liquidity/src/lsps1/msgs.rs b/lightning-liquidity/src/lsps1/msgs.rs index a2382e0b71c..eae9568f589 100644 --- a/lightning-liquidity/src/lsps1/msgs.rs +++ b/lightning-liquidity/src/lsps1/msgs.rs @@ -31,9 +31,7 @@ pub(crate) const LSPS1_CREATE_ORDER_METHOD_NAME: &str = "lsps1.create_order"; pub(crate) const LSPS1_GET_ORDER_METHOD_NAME: &str = "lsps1.get_order"; pub(crate) const _LSPS1_CREATE_ORDER_REQUEST_INVALID_PARAMS_ERROR_CODE: i32 = -32602; -#[cfg(lsps1_service)] pub(crate) const LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE: i32 = 100; -#[cfg(lsps1_service)] pub(crate) const LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE: i32 = 101; pub(crate) const LSPS1_CREATE_ORDER_REQUEST_UNRECOGNIZED_OR_STALE_TOKEN_ERROR_CODE: i32 = 102; diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index 99a0c8f0306..f1b098dbfaa 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -23,15 +23,13 @@ use crate::lsps5::client::{LSPS5ClientConfig, LSPS5ClientHandler}; use crate::lsps5::msgs::LSPS5Message; use crate::lsps5::service::{LSPS5ServiceConfig, LSPS5ServiceHandler}; use crate::message_queue::MessageQueue; -#[cfg(lsps1_service)] -use crate::persist::read_lsps1_service_peer_states; use crate::persist::{ - read_event_queue, read_lsps2_service_peer_states, read_lsps5_service_peer_states, + read_event_queue, read_lsps1_service_peer_states, read_lsps2_service_peer_states, + read_lsps5_service_peer_states, }; use crate::lsps1::client::{LSPS1ClientConfig, LSPS1ClientHandler}; use crate::lsps1::msgs::LSPS1Message; -#[cfg(lsps1_service)] use crate::lsps1::service::{LSPS1ServiceConfig, LSPS1ServiceHandler, LSPS1ServiceHandlerSync}; use crate::lsps2::client::{LSPS2ClientConfig, LSPS2ClientHandler}; @@ -73,7 +71,6 @@ const LSPS_FEATURE_BIT: usize = 729; #[derive(Clone)] pub struct LiquidityServiceConfig { /// Optional server-side configuration for LSPS1 channel requests. - #[cfg(lsps1_service)] pub lsps1_service_config: Option, /// Optional server-side configuration for JIT channels /// should you want to support them. @@ -284,7 +281,6 @@ pub struct LiquidityManager< ignored_peers: RwLock>, lsps0_client_handler: LSPS0ClientHandler, lsps0_service_handler: Option, - #[cfg(lsps1_service)] lsps1_service_handler: Option>, lsps1_client_handler: Option>, lsps2_service_handler: Option>, @@ -451,7 +447,6 @@ where }) }); - #[cfg(lsps1_service)] let lsps1_service_handler = if let Some(service_config) = service_config.as_ref() { if let Some(lsps1_service_config) = service_config.lsps1_service_config.as_ref() { if let Some(number) = @@ -499,7 +494,6 @@ where lsps0_client_handler, lsps0_service_handler, lsps1_client_handler, - #[cfg(lsps1_service)] lsps1_service_handler, lsps2_client_handler, lsps2_service_handler, @@ -530,7 +524,6 @@ where } /// Returns a reference to the LSPS1 server-side handler. - #[cfg(lsps1_service)] pub fn lsps1_service_handler(&self) -> Option<&LSPS1ServiceHandler> { self.lsps1_service_handler.as_ref() } @@ -634,7 +627,6 @@ where let mut did_persist = false; did_persist |= self.pending_events.persist().await?; - #[cfg(lsps1_service)] if let Some(lsps1_service_handler) = self.lsps1_service_handler.as_ref() { did_persist |= lsps1_service_handler.persist().await?; } @@ -680,18 +672,15 @@ where }, } }, - LSPSMessage::LSPS1(_msg @ LSPS1Message::Request(..)) => { - #[cfg(lsps1_service)] + LSPSMessage::LSPS1(msg @ LSPS1Message::Request(..)) => { match &self.lsps1_service_handler { Some(lsps1_service_handler) => { - lsps1_service_handler.handle_message(_msg, sender_node_id)?; + lsps1_service_handler.handle_message(msg, sender_node_id)?; }, None => { return Err(LightningError { err: format!("Received LSPS1 request message without LSPS1 service handler configured. From node {}", sender_node_id), action: ErrorAction::IgnoreAndLog(Level::Debug)}); }, } - #[cfg(not(lsps1_service))] - return Err(LightningError { err: format!("Received LSPS1 request message without LSPS1 service handler configured. From node {}", sender_node_id), action: ErrorAction::IgnoreAndLog(Level::Debug)}); }, LSPSMessage::LSPS2(msg @ LSPS2Message::Response(..)) => { match &self.lsps2_client_handler { @@ -732,14 +721,10 @@ where .lsps2_service_handler .as_ref() .is_some_and(|h| h.has_active_requests(sender_node_id)); - #[cfg(lsps1_service)] let lsps1_has_active_orders = self .lsps1_service_handler .as_ref() .is_some_and(|h| h.has_active_orders(sender_node_id)); - #[cfg(not(lsps1_service))] - let lsps1_has_active_orders = false; - lsps5_service_handler.enforce_prior_activity_or_reject( sender_node_id, lsps2_has_active_requests, @@ -895,7 +880,6 @@ where // If the peer was misbehaving, drop it from the ignored list to cleanup the kept state. self.ignored_peers.write().unwrap().remove(&counterparty_node_id); - #[cfg(lsps1_service)] if let Some(lsps1_service_handler) = self.lsps1_service_handler.as_ref() { lsps1_service_handler.peer_disconnected(counterparty_node_id); } @@ -1051,7 +1035,6 @@ where /// Returns a reference to the LSPS1 server-side handler. /// /// Wraps [`LiquidityManager::lsps1_service_handler`]. - #[cfg(lsps1_service)] pub fn lsps1_service_handler<'a>( &'a self, ) -> Option, TP>> { diff --git a/lightning-liquidity/src/persist.rs b/lightning-liquidity/src/persist.rs index 13afdabb61b..30d78249796 100644 --- a/lightning-liquidity/src/persist.rs +++ b/lightning-liquidity/src/persist.rs @@ -10,7 +10,6 @@ //! Types and utils for persistence. use crate::events::{EventQueueDeserWrapper, LiquidityEvent}; -#[cfg(lsps1_service)] use crate::lsps1::peer_state::PeerState as LSPS1ServicePeerState; use crate::lsps2::service::PeerState as LSPS2ServicePeerState; use crate::lsps5::service::PeerState as LSPS5ServicePeerState; @@ -44,7 +43,6 @@ pub const LIQUIDITY_MANAGER_EVENT_QUEUE_PERSISTENCE_KEY: &str = "event_queue"; /// The secondary namespace under which the [`LSPS1ServiceHandler`] data will be persisted. /// /// [`LSPS1ServiceHandler`]: crate::lsps1::service::LSPS1ServiceHandler -#[cfg(lsps1_service)] pub const LSPS1_SERVICE_PERSISTENCE_SECONDARY_NAMESPACE: &str = "lsps1_service"; /// The secondary namespace under which the [`LSPS2ServiceHandler`] data will be persisted. @@ -88,7 +86,6 @@ pub(crate) async fn read_event_queue( Ok(Some(queue.0)) } -#[cfg(lsps1_service)] pub(crate) async fn read_lsps1_service_peer_states( kv_store: K, ) -> Result>, lightning::io::Error> { diff --git a/lightning-liquidity/tests/lsps0_integration_tests.rs b/lightning-liquidity/tests/lsps0_integration_tests.rs index 58d9e867398..c2e94e30661 100644 --- a/lightning-liquidity/tests/lsps0_integration_tests.rs +++ b/lightning-liquidity/tests/lsps0_integration_tests.rs @@ -6,11 +6,8 @@ use common::{create_service_and_client_nodes, get_lsps_message, LSPSNodes}; use lightning_liquidity::events::LiquidityEvent; use lightning_liquidity::lsps0::event::LSPS0ClientEvent; -#[cfg(lsps1_service)] use lightning_liquidity::lsps1::client::LSPS1ClientConfig; -#[cfg(lsps1_service)] use lightning_liquidity::lsps1::msgs::LSPS1Options; -#[cfg(lsps1_service)] use lightning_liquidity::lsps1::service::LSPS1ServiceConfig; use lightning_liquidity::lsps2::client::LSPS2ClientConfig; use lightning_liquidity::lsps2::service::LSPS2ServiceConfig; @@ -35,7 +32,6 @@ fn list_protocols_integration_test() { let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let promise_secret = [42; 32]; let lsps2_service_config = LSPS2ServiceConfig { promise_secret }; - #[cfg(lsps1_service)] let lsps1_service_config = { let supported_options = LSPS1Options { min_required_channel_confirmations: 0, @@ -53,7 +49,6 @@ fn list_protocols_integration_test() { }; let lsps5_service_config = LSPS5ServiceConfig::default(); let service_config = LiquidityServiceConfig { - #[cfg(lsps1_service)] lsps1_service_config: Some(lsps1_service_config), lsps2_service_config: Some(lsps2_service_config), lsps5_service_config: Some(lsps5_service_config), @@ -61,14 +56,10 @@ fn list_protocols_integration_test() { }; let lsps2_client_config = LSPS2ClientConfig::default(); - #[cfg(lsps1_service)] let lsps1_client_config: LSPS1ClientConfig = LSPS1ClientConfig { max_channel_fees_msat: None }; let lsps5_client_config = LSPS5ClientConfig::default(); let client_config = LiquidityClientConfig { - #[cfg(lsps1_service)] lsps1_client_config: Some(lsps1_client_config), - #[cfg(not(lsps1_service))] - lsps1_client_config: None, lsps2_client_config: Some(lsps2_client_config), lsps5_client_config: Some(lsps5_client_config), }; @@ -107,16 +98,12 @@ fn list_protocols_integration_test() { protocols, }) => { assert_eq!(counterparty_node_id, client_node_id); - #[cfg(lsps1_service)] { assert!(protocols.contains(&1)); assert!(protocols.contains(&2)); assert!(protocols.contains(&5)); assert_eq!(protocols.len(), 3); } - - #[cfg(not(lsps1_service))] - assert_eq!(protocols, vec![2, 5]); }, _ => panic!("Unexpected event"), } diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index 8cd7f28ec86..d2ca559e577 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -1,4 +1,4 @@ -#![cfg(all(test, feature = "time", lsps1_service))] +#![cfg(all(test, feature = "time"))] mod common; diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 1c37f164d32..47be70f80dc 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -60,7 +60,6 @@ fn build_lsps2_configs() -> ([u8; 32], LiquidityServiceConfig, LiquidityClientCo let promise_secret = [42; 32]; let lsps2_service_config = LSPS2ServiceConfig { promise_secret }; let service_config = LiquidityServiceConfig { - #[cfg(lsps1_service)] lsps1_service_config: None, lsps2_service_config: Some(lsps2_service_config), lsps5_service_config: None, @@ -941,7 +940,6 @@ fn lsps2_service_handler_persistence_across_restarts() { let promise_secret = [42; 32]; let service_config = LiquidityServiceConfig { - #[cfg(lsps1_service)] lsps1_service_config: None, lsps2_service_config: Some(LSPS2ServiceConfig { promise_secret }), lsps5_service_config: None, diff --git a/lightning-liquidity/tests/lsps5_integration_tests.rs b/lightning-liquidity/tests/lsps5_integration_tests.rs index 6af0c137be5..2b32b4dcbc6 100644 --- a/lightning-liquidity/tests/lsps5_integration_tests.rs +++ b/lightning-liquidity/tests/lsps5_integration_tests.rs @@ -52,7 +52,6 @@ pub(crate) fn lsps5_test_setup_with_kv_stores<'a, 'b, 'c>( ) -> (LSPSNodes<'a, 'b, 'c>, LSPS5Validator) { let lsps5_service_config = LSPS5ServiceConfig::default(); let service_config = LiquidityServiceConfig { - #[cfg(lsps1_service)] lsps1_service_config: None, lsps2_service_config: None, lsps5_service_config: Some(lsps5_service_config), @@ -236,7 +235,6 @@ pub(crate) fn lsps5_lsps2_test_setup<'a, 'b, 'c>( let lsps5_service_config = LSPS5ServiceConfig::default(); let lsps2_service_config = LSPS2ServiceConfig { promise_secret: [42; 32] }; let service_config = LiquidityServiceConfig { - #[cfg(lsps1_service)] lsps1_service_config: None, lsps2_service_config: Some(lsps2_service_config), lsps5_service_config: Some(lsps5_service_config), @@ -1512,7 +1510,6 @@ fn lsps5_service_handler_persistence_across_restarts() { let client_kv_store = Arc::new(TestStore::new(false)); let service_config = LiquidityServiceConfig { - #[cfg(lsps1_service)] lsps1_service_config: None, lsps2_service_config: None, lsps5_service_config: Some(LSPS5ServiceConfig::default()), From ed9a8672028385ffabb92898f9bfb9c09437d622 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Dec 2025 17:04:09 +0100 Subject: [PATCH 198/627] Fix clippy lints --- lightning-liquidity/src/lsps1/service.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index e81213cc523..c4a678dad8e 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -291,7 +291,7 @@ where if !is_valid(¶ms.order, &self.config.supported_options) { let response = LSPS1Response::CreateOrderError(LSPSResponseError { code: LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE, - message: format!("Order does not match options supported by LSP server"), + message: "Order does not match options supported by LSP server".to_string(), data: Some(format!("Supported options are {:?}", &self.config.supported_options)), }); let msg = LSPS1Message::Response(request_id, response).into(); @@ -509,7 +509,8 @@ where let order = peer_state_lock.get_order(¶ms.order_id).map_err(|e| { let response = LSPS1Response::GetOrderError(LSPSResponseError { code: LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE, - message: format!("Order with the requested order_id has not been found."), + message: "Order with the requested order_id has not been found." + .to_string(), data: None, }); let msg = LSPS1Message::Response(request_id.clone(), response).into(); @@ -534,7 +535,7 @@ where None => { let response = LSPS1Response::GetOrderError(LSPSResponseError { code: LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE, - message: format!("Order with the requested order_id has not been found."), + message: "Order with the requested order_id has not been found.".to_string(), data: None, }); let msg = LSPS1Message::Response(request_id, response).into(); From c7db17de48151c8270c1ca5c230815fa48cca42e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 5 Feb 2026 13:16:55 +0100 Subject: [PATCH 199/627] Refactor `ChannelOrder` to use `ChannelOrderState` state machine This refactors `ChannelOrder` to use an internal state machine enum `ChannelOrderState` that: - Encapsulates state-specific data in variants (e.g., `channel_info` only available in `CompletedAndChannelOpened`) - Provides type-safe state transitions - Replaces the generic `update_order_status` API with specific transition methods: `order_payment_received`, `order_channel_opened`, and `order_failed_and_refunded` The state machine has four states: - `ExpectingPayment`: Initial state, awaiting payment - `OrderPaid`: Payment received, awaiting channel open - `CompletedAndChannelOpened`: Terminal state with channel info - `FailedAndRefunded`: Terminal state for failed/refunded orders Co-Authored-By: HAL 9000 Signed-off-by: Elias Rohrer --- lightning-liquidity/src/lsps1/peer_state.rs | 585 +++++++++++++++++++- lightning-liquidity/src/lsps1/service.rs | 180 +++++- 2 files changed, 709 insertions(+), 56 deletions(-) diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 1b51f64a583..1d13d07d206 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -11,17 +11,240 @@ use super::msgs::{ LSPS1ChannelInfo, LSPS1OrderId, LSPS1OrderParams, LSPS1OrderState, LSPS1PaymentInfo, - LSPS1Request, + LSPS1PaymentState, LSPS1Request, }; use crate::lsps0::ser::{LSPSDateTime, LSPSRequestId}; use crate::prelude::HashMap; -use lightning::impl_writeable_tlv_based; use lightning::util::hash_tables::new_hash_map; +use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use core::fmt; +/// Indicates which payment method was used for the order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaymentMethod { + /// A Lightning payment using BOLT 11. + Bolt11, + /// A Lightning payment using BOLT 12. + Bolt12, + /// An onchain payment. + Onchain, +} + +/// Error type for invalid state transitions. +#[derive(Debug, Clone)] +pub(super) enum ChannelOrderStateError { + /// Attempted an invalid state transition. + InvalidStateTransition { + /// The state from which the transition was attempted. + from: LSPS1OrderState, + /// The action that was attempted. + action: &'static str, + }, + /// The specified payment method was not configured for this order. + PaymentMethodNotConfigured, +} + +impl fmt::Display for ChannelOrderStateError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidStateTransition { from, action } => { + write!(f, "invalid state transition: cannot {} from {:?}", action, from) + }, + Self::PaymentMethodNotConfigured => { + write!(f, "payment method not configured for this order") + }, + } + } +} + +/// Internal state machine for tracking channel order progress. +/// +/// This combines the wire `order_state` (CREATED/COMPLETED/FAILED) with internal +/// payment tracking to provide type-safe state transitions. +#[derive(Debug, Clone)] +pub(super) enum ChannelOrderState { + /// Initial state - awaiting payment from client. + /// Payment states within payment_details should be EXPECT_PAYMENT. + ExpectingPayment { + /// Details about how to pay for the order. + payment_details: LSPS1PaymentInfo, + }, + /// Payment received, awaiting channel open. + /// The paid method's state should be PAID. + OrderPaid { + /// Details about how to pay for the order (with paid method updated). + payment_details: LSPS1PaymentInfo, + }, + /// Channel successfully funded and opened (terminal). + /// Payment states should be PAID. + CompletedAndChannelOpened { + /// Details about how to pay for the order. + payment_details: LSPS1PaymentInfo, + /// Information about the opened channel. + channel_info: LSPS1ChannelInfo, + }, + /// Order failed, payment refunded (terminal). + /// Payment states should be REFUNDED. + FailedAndRefunded { + /// Details about how to pay for the order (with states set to REFUNDED). + payment_details: LSPS1PaymentInfo, + }, +} + +impl ChannelOrderState { + /// Creates a new state in the ExpectingPayment state. + pub(super) fn new(payment_details: LSPS1PaymentInfo) -> Self { + ChannelOrderState::ExpectingPayment { payment_details } + } + + /// Transition: ExpectingPayment -> OrderPaid + /// + /// Updates the specified payment method's state to PAID. + pub(super) fn payment_received( + &mut self, method: PaymentMethod, + ) -> Result<(), ChannelOrderStateError> { + match self { + ChannelOrderState::ExpectingPayment { payment_details } => { + // Update the payment state for the specified method + let method_exists = match method { + PaymentMethod::Bolt11 => { + if let Some(ref mut bolt11) = payment_details.bolt11 { + bolt11.state = LSPS1PaymentState::Paid; + true + } else { + false + } + }, + PaymentMethod::Bolt12 => { + if let Some(ref mut bolt12) = payment_details.bolt12 { + bolt12.state = LSPS1PaymentState::Paid; + true + } else { + false + } + }, + PaymentMethod::Onchain => { + if let Some(ref mut onchain) = payment_details.onchain { + onchain.state = LSPS1PaymentState::Paid; + true + } else { + false + } + }, + }; + + if !method_exists { + return Err(ChannelOrderStateError::PaymentMethodNotConfigured); + } + + // Move to OrderPaid state + *self = ChannelOrderState::OrderPaid { payment_details: payment_details.clone() }; + Ok(()) + }, + _ => Err(ChannelOrderStateError::InvalidStateTransition { + from: self.order_state(), + action: "payment_received", + }), + } + } + + /// Transition: OrderPaid -> CompletedAndChannelOpened + pub(super) fn channel_opened( + &mut self, channel_info: LSPS1ChannelInfo, + ) -> Result<(), ChannelOrderStateError> { + match self { + ChannelOrderState::OrderPaid { payment_details } => { + *self = ChannelOrderState::CompletedAndChannelOpened { + payment_details: payment_details.clone(), + channel_info, + }; + Ok(()) + }, + _ => Err(ChannelOrderStateError::InvalidStateTransition { + from: self.order_state(), + action: "channel_opened", + }), + } + } + + /// Transition: ExpectingPayment|OrderPaid -> FailedAndRefunded + /// + /// Updates all payment states to REFUNDED. + pub(super) fn mark_failed_and_refunded(&mut self) -> Result<(), ChannelOrderStateError> { + match self { + ChannelOrderState::ExpectingPayment { payment_details } + | ChannelOrderState::OrderPaid { payment_details } => { + // Mark all payment methods as refunded + let mut refunded_details = payment_details.clone(); + if let Some(ref mut bolt11) = refunded_details.bolt11 { + bolt11.state = LSPS1PaymentState::Refunded; + } + if let Some(ref mut bolt12) = refunded_details.bolt12 { + bolt12.state = LSPS1PaymentState::Refunded; + } + if let Some(ref mut onchain) = refunded_details.onchain { + onchain.state = LSPS1PaymentState::Refunded; + } + + *self = ChannelOrderState::FailedAndRefunded { payment_details: refunded_details }; + Ok(()) + }, + _ => Err(ChannelOrderStateError::InvalidStateTransition { + from: self.order_state(), + action: "mark_failed_and_refunded", + }), + } + } + + /// Get payment_details (available in all states). + pub(super) fn payment_details(&self) -> &LSPS1PaymentInfo { + match self { + ChannelOrderState::ExpectingPayment { payment_details } + | ChannelOrderState::OrderPaid { payment_details } + | ChannelOrderState::CompletedAndChannelOpened { payment_details, .. } + | ChannelOrderState::FailedAndRefunded { payment_details } => payment_details, + } + } + + /// Get channel_info if in CompletedAndChannelOpened state. + pub(super) fn channel_info(&self) -> Option<&LSPS1ChannelInfo> { + match self { + ChannelOrderState::CompletedAndChannelOpened { channel_info, .. } => Some(channel_info), + _ => None, + } + } + + /// Convert to wire format LSPS1OrderState. + pub(super) fn order_state(&self) -> LSPS1OrderState { + match self { + ChannelOrderState::ExpectingPayment { .. } | ChannelOrderState::OrderPaid { .. } => { + LSPS1OrderState::Created + }, + ChannelOrderState::CompletedAndChannelOpened { .. } => LSPS1OrderState::Completed, + ChannelOrderState::FailedAndRefunded { .. } => LSPS1OrderState::Failed, + } + } +} + +impl_writeable_tlv_based_enum!(ChannelOrderState, + (0, ExpectingPayment) => { + (0, payment_details, required), + }, + (2, OrderPaid) => { + (0, payment_details, required), + }, + (4, CompletedAndChannelOpened) => { + (0, payment_details, required), + (2, channel_info, required), + }, + (6, FailedAndRefunded) => { + (0, payment_details, required), + } +); + #[derive(Default)] pub(crate) struct PeerState { outbound_channels_by_order_id: HashMap, @@ -34,15 +257,8 @@ impl PeerState { &mut self, order_id: LSPS1OrderId, order_params: LSPS1OrderParams, created_at: LSPSDateTime, payment_details: LSPS1PaymentInfo, ) -> ChannelOrder { - let order_state = LSPS1OrderState::Created; - let channel_details = None; - let channel_order = ChannelOrder { - order_params, - order_state, - created_at, - payment_details, - channel_details, - }; + let state = ChannelOrderState::new(payment_details); + let channel_order = ChannelOrder { order_params, state, created_at }; self.outbound_channels_by_order_id.insert(order_id, channel_order.clone()); self.needs_persist |= true; channel_order @@ -58,16 +274,45 @@ impl PeerState { Ok(order) } - pub(super) fn update_order<'a>( - &'a mut self, order_id: &LSPS1OrderId, order_state: LSPS1OrderState, - channel_details: Option, + /// Transition: ExpectingPayment -> OrderPaid + /// + /// Updates the specified payment method's state to PAID. + pub(super) fn order_payment_received( + &mut self, order_id: &LSPS1OrderId, method: PaymentMethod, + ) -> Result<(), PeerStateError> { + let order = self + .outbound_channels_by_order_id + .get_mut(order_id) + .ok_or(PeerStateError::UnknownOrderId)?; + order.state.payment_received(method).map_err(PeerStateError::InvalidStateTransition)?; + self.needs_persist |= true; + Ok(()) + } + + /// Transition: OrderPaid -> CompletedAndChannelOpened + pub(super) fn order_channel_opened( + &mut self, order_id: &LSPS1OrderId, channel_info: LSPS1ChannelInfo, ) -> Result<(), PeerStateError> { let order = self .outbound_channels_by_order_id .get_mut(order_id) .ok_or(PeerStateError::UnknownOrderId)?; - order.order_state = order_state; - order.channel_details = channel_details; + order.state.channel_opened(channel_info).map_err(PeerStateError::InvalidStateTransition)?; + self.needs_persist |= true; + Ok(()) + } + + /// Transition: ExpectingPayment|OrderPaid -> FailedAndRefunded + /// + /// Updates all payment states to REFUNDED. + pub(super) fn order_failed_and_refunded( + &mut self, order_id: &LSPS1OrderId, + ) -> Result<(), PeerStateError> { + let order = self + .outbound_channels_by_order_id + .get_mut(order_id) + .ok_or(PeerStateError::UnknownOrderId)?; + order.state.mark_failed_and_refunded().map_err(PeerStateError::InvalidStateTransition)?; self.needs_persist |= true; Ok(()) } @@ -132,11 +377,12 @@ impl_writeable_tlv_based!(PeerState, { (_unused, needs_persist, (static_value, false)), }); -#[derive(Debug, Copy, Clone)] +#[derive(Debug, Clone)] pub(super) enum PeerStateError { UnknownRequestId, DuplicateRequestId, UnknownOrderId, + InvalidStateTransition(ChannelOrderStateError), } impl fmt::Display for PeerStateError { @@ -145,6 +391,7 @@ impl fmt::Display for PeerStateError { Self::UnknownRequestId => write!(f, "unknown request id"), Self::DuplicateRequestId => write!(f, "duplicate request id"), Self::UnknownOrderId => write!(f, "unknown order id"), + Self::InvalidStateTransition(e) => write!(f, "{}", e), } } } @@ -152,18 +399,31 @@ impl fmt::Display for PeerStateError { #[derive(Debug, Clone)] pub(super) struct ChannelOrder { pub(super) order_params: LSPS1OrderParams, - pub(super) order_state: LSPS1OrderState, + pub(super) state: ChannelOrderState, pub(super) created_at: LSPSDateTime, - pub(super) payment_details: LSPS1PaymentInfo, - pub(super) channel_details: Option, } impl ChannelOrder { + /// Returns the order state. + pub(super) fn order_state(&self) -> LSPS1OrderState { + self.state.order_state() + } + + /// Returns the payment details. + pub(super) fn payment_details(&self) -> &LSPS1PaymentInfo { + self.state.payment_details() + } + + /// Returns the channel details if the channel has been opened. + pub(super) fn channel_details(&self) -> Option<&LSPS1ChannelInfo> { + self.state.channel_info() + } + fn is_prunable(&self) -> bool { let all_payment_details_expired; #[cfg(feature = "time")] { - let details = &self.payment_details; + let details = self.state.payment_details(); all_payment_details_expired = details.bolt11.as_ref().map_or(true, |d| d.expires_at.is_past()) && details.bolt12.as_ref().map_or(true, |d| d.expires_at.is_past()) @@ -175,8 +435,11 @@ impl ChannelOrder { all_payment_details_expired = false; } - let created_or_failed = - matches!(self.order_state, LSPS1OrderState::Created | LSPS1OrderState::Failed); + let created_or_failed = matches!( + self.state, + ChannelOrderState::ExpectingPayment { .. } + | ChannelOrderState::FailedAndRefunded { .. } + ); all_payment_details_expired && created_or_failed } @@ -184,8 +447,278 @@ impl ChannelOrder { impl_writeable_tlv_based!(ChannelOrder, { (0, order_params, required), - (2, order_state, required), + (2, state, required), (4, created_at, required), - (6, payment_details, required), - (8, channel_details, option), }); + +#[cfg(test)] +mod tests { + use super::*; + use crate::lsps0::ser::LSPSDateTime; + use crate::lsps1::msgs::{LSPS1Bolt11PaymentInfo, LSPS1OnchainPaymentInfo, LSPS1PaymentState}; + + use bitcoin::{Address, FeeRate, OutPoint}; + use lightning_invoice::Bolt11Invoice; + + use core::str::FromStr; + + fn create_test_bolt11_payment_info() -> LSPS1Bolt11PaymentInfo { + let invoice_str = "lnbc252u1p3aht9ysp580g4633gd2x9lc5al0wd8wx0mpn9748jeyz46kqjrpxn52uhfpjqpp5qgf67tcqmuqehzgjm8mzya90h73deafvr4m5705l5u5l4r05l8cqdpud3h8ymm4w3jhytnpwpczqmt0de6xsmre2pkxzm3qydmkzdjrdev9s7zhgfaqxqyjw5qcqpjrzjqt6xptnd85lpqnu2lefq4cx070v5cdwzh2xlvmdgnu7gqp4zvkus5zapryqqx9qqqyqqqqqqqqqqqcsq9q9qyysgqen77vu8xqjelum24hgjpgfdgfgx4q0nehhalcmuggt32japhjuksq9jv6eksjfnppm4hrzsgyxt8y8xacxut9qv3fpyetz8t7tsymygq8yzn05"; + LSPS1Bolt11PaymentInfo { + state: LSPS1PaymentState::ExpectPayment, + expires_at: LSPSDateTime::from_str("2035-01-01T00:00:00Z").unwrap(), + fee_total_sat: 9999, + order_total_sat: 200999, + invoice: Bolt11Invoice::from_str(invoice_str).unwrap(), + } + } + + fn create_test_onchain_payment_info() -> LSPS1OnchainPaymentInfo { + LSPS1OnchainPaymentInfo { + state: LSPS1PaymentState::ExpectPayment, + expires_at: LSPSDateTime::from_str("2035-01-01T00:00:00Z").unwrap(), + fee_total_sat: 9999, + order_total_sat: 200999, + address: Address::from_str( + "bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr", + ) + .unwrap() + .assume_checked(), + min_onchain_payment_confirmations: Some(1), + min_fee_for_0conf: FeeRate::from_sat_per_vb(253).unwrap(), + refund_onchain_address: None, + } + } + + fn create_test_payment_info_bolt11_only() -> LSPS1PaymentInfo { + LSPS1PaymentInfo { + bolt11: Some(create_test_bolt11_payment_info()), + bolt12: None, + onchain: None, + } + } + + fn create_test_payment_info_onchain_only() -> LSPS1PaymentInfo { + LSPS1PaymentInfo { + bolt11: None, + bolt12: None, + onchain: Some(create_test_onchain_payment_info()), + } + } + + fn create_test_channel_info() -> LSPS1ChannelInfo { + LSPS1ChannelInfo { + funded_at: LSPSDateTime::from_str("2035-01-01T00:00:00Z").unwrap(), + funding_outpoint: OutPoint::from_str( + "0301e0480b374b32851a9462db29dc19fe830a7f7d7a88b81612b9d42099c0ae:0", + ) + .unwrap(), + expires_at: LSPSDateTime::from_str("2036-01-01T00:00:00Z").unwrap(), + } + } + + // Test valid transition: ExpectingPayment -> OrderPaid via payment_received (Bolt11) + #[test] + fn test_payment_received_bolt11() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + + assert!(matches!(state, ChannelOrderState::ExpectingPayment { .. })); + assert_eq!(state.order_state(), LSPS1OrderState::Created); + + state.payment_received(PaymentMethod::Bolt11).unwrap(); + + assert!(matches!(state, ChannelOrderState::OrderPaid { .. })); + assert_eq!(state.order_state(), LSPS1OrderState::Created); + assert_eq!(state.payment_details().bolt11.as_ref().unwrap().state, LSPS1PaymentState::Paid); + } + + // Test valid transition: ExpectingPayment -> OrderPaid via payment_received (Onchain) + #[test] + fn test_payment_received_onchain() { + let payment_info = create_test_payment_info_onchain_only(); + let mut state = ChannelOrderState::new(payment_info); + + state.payment_received(PaymentMethod::Onchain).unwrap(); + + assert!(matches!(state, ChannelOrderState::OrderPaid { .. })); + assert_eq!( + state.payment_details().onchain.as_ref().unwrap().state, + LSPS1PaymentState::Paid + ); + } + + // Test valid transition: OrderPaid -> CompletedAndChannelOpened via channel_opened + #[test] + fn test_channel_opened() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + state.payment_received(PaymentMethod::Bolt11).unwrap(); + + let channel_info = create_test_channel_info(); + state.channel_opened(channel_info.clone()).unwrap(); + + assert!(matches!(state, ChannelOrderState::CompletedAndChannelOpened { .. })); + assert_eq!(state.order_state(), LSPS1OrderState::Completed); + assert_eq!(state.channel_info(), Some(&channel_info)); + } + + // Test valid transition: ExpectingPayment -> FailedAndRefunded + #[test] + fn test_mark_failed_from_expecting_payment() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + + state.mark_failed_and_refunded().unwrap(); + + assert!(matches!(state, ChannelOrderState::FailedAndRefunded { .. })); + assert_eq!(state.order_state(), LSPS1OrderState::Failed); + assert_eq!( + state.payment_details().bolt11.as_ref().unwrap().state, + LSPS1PaymentState::Refunded + ); + } + + // Test valid transition: OrderPaid -> FailedAndRefunded + #[test] + fn test_mark_failed_from_order_paid() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + state.payment_received(PaymentMethod::Bolt11).unwrap(); + + state.mark_failed_and_refunded().unwrap(); + + assert!(matches!(state, ChannelOrderState::FailedAndRefunded { .. })); + assert_eq!(state.order_state(), LSPS1OrderState::Failed); + assert_eq!( + state.payment_details().bolt11.as_ref().unwrap().state, + LSPS1PaymentState::Refunded + ); + } + + // Test invalid transition: payment_received from OrderPaid + #[test] + fn test_payment_received_from_order_paid_fails() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + state.payment_received(PaymentMethod::Bolt11).unwrap(); + + let result = state.payment_received(PaymentMethod::Bolt11); + assert!(matches!(result, Err(ChannelOrderStateError::InvalidStateTransition { .. }))); + } + + // Test invalid transition: payment_received from CompletedAndChannelOpened + #[test] + fn test_payment_received_from_completed_fails() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + state.payment_received(PaymentMethod::Bolt11).unwrap(); + state.channel_opened(create_test_channel_info()).unwrap(); + + let result = state.payment_received(PaymentMethod::Bolt11); + assert!(matches!(result, Err(ChannelOrderStateError::InvalidStateTransition { .. }))); + } + + // Test invalid transition: payment_received from FailedAndRefunded + #[test] + fn test_payment_received_from_failed_fails() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + state.mark_failed_and_refunded().unwrap(); + + let result = state.payment_received(PaymentMethod::Bolt11); + assert!(matches!(result, Err(ChannelOrderStateError::InvalidStateTransition { .. }))); + } + + // Test invalid transition: channel_opened from ExpectingPayment + #[test] + fn test_channel_opened_from_expecting_payment_fails() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + + let result = state.channel_opened(create_test_channel_info()); + assert!(matches!(result, Err(ChannelOrderStateError::InvalidStateTransition { .. }))); + } + + // Test invalid transition: channel_opened from CompletedAndChannelOpened + #[test] + fn test_channel_opened_from_completed_fails() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + state.payment_received(PaymentMethod::Bolt11).unwrap(); + state.channel_opened(create_test_channel_info()).unwrap(); + + let result = state.channel_opened(create_test_channel_info()); + assert!(matches!(result, Err(ChannelOrderStateError::InvalidStateTransition { .. }))); + } + + // Test invalid transition: channel_opened from FailedAndRefunded + #[test] + fn test_channel_opened_from_failed_fails() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + state.mark_failed_and_refunded().unwrap(); + + let result = state.channel_opened(create_test_channel_info()); + assert!(matches!(result, Err(ChannelOrderStateError::InvalidStateTransition { .. }))); + } + + // Test invalid transition: mark_failed_and_refunded from CompletedAndChannelOpened + #[test] + fn test_mark_failed_from_completed_fails() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + state.payment_received(PaymentMethod::Bolt11).unwrap(); + state.channel_opened(create_test_channel_info()).unwrap(); + + let result = state.mark_failed_and_refunded(); + assert!(matches!(result, Err(ChannelOrderStateError::InvalidStateTransition { .. }))); + } + + // Test invalid transition: mark_failed_and_refunded from FailedAndRefunded + #[test] + fn test_mark_failed_from_failed_fails() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + state.mark_failed_and_refunded().unwrap(); + + let result = state.mark_failed_and_refunded(); + assert!(matches!(result, Err(ChannelOrderStateError::InvalidStateTransition { .. }))); + } + + // Test error: payment_received with unconfigured payment method + #[test] + fn test_payment_received_unconfigured_method_fails() { + // Create payment info with only onchain configured + let payment_info = create_test_payment_info_onchain_only(); + let mut state = ChannelOrderState::new(payment_info); + + // Try to mark bolt11 as paid, which is not configured + let result = state.payment_received(PaymentMethod::Bolt11); + assert!(matches!(result, Err(ChannelOrderStateError::PaymentMethodNotConfigured))); + + // State should remain unchanged + assert!(matches!(state, ChannelOrderState::ExpectingPayment { .. })); + } + + // Test that channel_info is only available in CompletedAndChannelOpened state + #[test] + fn test_channel_info_availability() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + + // Not available in ExpectingPayment + assert!(state.channel_info().is_none()); + + state.payment_received(PaymentMethod::Bolt11).unwrap(); + + // Not available in OrderPaid + assert!(state.channel_info().is_none()); + + let channel_info = create_test_channel_info(); + state.channel_opened(channel_info.clone()).unwrap(); + + // Available in CompletedAndChannelOpened + assert_eq!(state.channel_info(), Some(&channel_info)); + } +} diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index c4a678dad8e..bc10116b14e 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -22,11 +22,12 @@ use super::event::LSPS1ServiceEvent; use super::msgs::{ LSPS1ChannelInfo, LSPS1CreateOrderRequest, LSPS1CreateOrderResponse, LSPS1GetInfoResponse, LSPS1GetOrderRequest, LSPS1Message, LSPS1Options, LSPS1OrderId, LSPS1OrderParams, - LSPS1OrderState, LSPS1PaymentInfo, LSPS1PaymentState, LSPS1Request, LSPS1Response, + LSPS1PaymentInfo, LSPS1PaymentState, LSPS1Request, LSPS1Response, LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE, LSPS1_CREATE_ORDER_REQUEST_UNRECOGNIZED_OR_STALE_TOKEN_ERROR_CODE, LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE, }; +pub use super::peer_state::PaymentMethod; use super::peer_state::PeerState; use crate::message_queue::MessageQueue; @@ -415,13 +416,12 @@ where should_persist |= peer_state_lock.needs_persist(); let response = LSPS1Response::CreateOrder(LSPS1CreateOrderResponse { - order: order.order_params, order_id, - - order_state: order.order_state, - created_at: order.created_at, - payment: order.payment_details, - channel: order.channel_details, + order_state: order.order_state(), + created_at: order.created_at.clone(), + payment: order.payment_details().clone(), + channel: order.channel_details().cloned(), + order: order.order_params, }); let msg = LSPS1Message::Response(request_id, response).into(); message_queue_notifier.enqueue(&counterparty_node_id, msg); @@ -523,10 +523,10 @@ where let response = LSPS1Response::GetOrder(LSPS1CreateOrderResponse { order_id: params.order_id, order: order.order_params.clone(), - order_state: order.order_state.clone(), + order_state: order.order_state(), created_at: order.created_at.clone(), - payment: order.payment_details.clone(), - channel: order.channel_details.clone(), + payment: order.payment_details().clone(), + channel: order.channel_details().cloned(), }); let msg = LSPS1Message::Response(request_id, response).into(); message_queue_notifier.enqueue(&counterparty_node_id, msg); @@ -551,23 +551,108 @@ where } } - /// Used by LSP to give details to client regarding the status of channel opening. + /// Marks an order as paid after payment has been received. + /// + /// This should be called when the LSP detects that a Lightning payment has arrived or an + /// on-chain payment has been confirmed. + /// + /// This should be called before opening the channel and the channel should not be opened if + /// this returns an error. + /// + /// Note that in the case of a lightning payment, we expect the payment to have been received + /// (i.e. LDK's [`Event::PaymentClaimable`]) but not claimed (i.e. calling LDK's + /// [`ChannelManager::claim_funds`]), allowing the payment to be returned to the sender if + /// channel opening fails. + /// + /// [`Event::PaymentClaimable`]: lightning::events::Event::PaymentClaimable + /// [`ChannelManager::claim_funds`]: lightning::ln::channelmanager::ChannelManager::claim_funds + pub async fn order_payment_received( + &self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId, method: PaymentMethod, + ) -> Result<(), APIError> { + let mut should_persist = false; + match self.per_peer_state.read().unwrap().get(&counterparty_node_id) { + Some(inner_state_lock) => { + let mut peer_state_lock = inner_state_lock.lock().unwrap(); + peer_state_lock.order_payment_received(&order_id, method).map_err(|e| { + APIError::APIMisuseError { err: format!("Failed to update order: {}", e) } + })?; + should_persist |= peer_state_lock.needs_persist(); + }, + None => { + return Err(APIError::APIMisuseError { + err: format!("No existing state with counterparty {}", counterparty_node_id), + }); + }, + } + + if should_persist { + self.persist_peer_state(counterparty_node_id).await.map_err(|e| { + APIError::APIMisuseError { + err: format!( + "Failed to persist peer state for {}: {}", + counterparty_node_id, e + ), + } + })?; + } + + Ok(()) + } + + /// Marks an order as completed after the channel has been opened. /// - /// The LSP continously polls for checking payment confirmation on-chain or Lightning - /// and then responds to client request. - pub async fn update_order_status( + /// This should be called when the LSP has successfully published the funding + /// transaction for the channel. + pub async fn order_channel_opened( &self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId, - order_state: LSPS1OrderState, channel_details: Option, + channel_info: LSPS1ChannelInfo, ) -> Result<(), APIError> { let mut should_persist = false; match self.per_peer_state.read().unwrap().get(&counterparty_node_id) { Some(inner_state_lock) => { let mut peer_state_lock = inner_state_lock.lock().unwrap(); - peer_state_lock.update_order(&order_id, order_state, channel_details).map_err( - |e| APIError::APIMisuseError { - err: format!("Failed to update order: {:?}", e), - }, - )?; + peer_state_lock.order_channel_opened(&order_id, channel_info).map_err(|e| { + APIError::APIMisuseError { err: format!("Failed to update order: {}", e) } + })?; + should_persist |= peer_state_lock.needs_persist(); + }, + None => { + return Err(APIError::APIMisuseError { + err: format!("No existing state with counterparty {}", counterparty_node_id), + }); + }, + } + + if should_persist { + self.persist_peer_state(counterparty_node_id).await.map_err(|e| { + APIError::APIMisuseError { + err: format!( + "Failed to persist peer state for {}: {}", + counterparty_node_id, e + ), + } + })?; + } + + Ok(()) + } + + /// Marks an order as failed and refunded. + /// + /// This should be called when: + /// - We require onchain payment and the client didn't provide a `refund_onchain_address`. + /// - The order expires without payment + /// - The channel open fails after payment and the LSP must refund + pub async fn order_failed_and_refunded( + &self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId, + ) -> Result<(), APIError> { + let mut should_persist = false; + match self.per_peer_state.read().unwrap().get(&counterparty_node_id) { + Some(inner_state_lock) => { + let mut peer_state_lock = inner_state_lock.lock().unwrap(); + peer_state_lock.order_failed_and_refunded(&order_id).map_err(|e| { + APIError::APIMisuseError { err: format!("Failed to update order: {}", e) } + })?; should_persist |= peer_state_lock.needs_persist(); }, None => { @@ -697,19 +782,54 @@ where self.inner.invalid_token_provided(counterparty_node_id, request_id) } - /// Used by LSP to give details to client regarding the status of channel opening. + /// Marks an order as paid after payment has been received. + /// + /// Wraps [`LSPS1ServiceHandler::order_payment_received`]. + pub fn order_payment_received( + &self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId, method: PaymentMethod, + ) -> Result<(), APIError> { + let mut fut = + pin!(self.inner.order_payment_received(counterparty_node_id, order_id, method)); + + let mut waker = dummy_waker(); + let mut ctx = task::Context::from_waker(&mut waker); + match fut.as_mut().poll(&mut ctx) { + task::Poll::Ready(result) => result, + task::Poll::Pending => { + // In a sync context, we can't wait for the future to complete. + unreachable!("Should not be pending in a sync context"); + }, + } + } + + /// Marks an order as completed after the channel has been opened. /// - /// Wraps [`LSPS1ServiceHandler::update_order_status`]. - pub fn update_order_status( + /// Wraps [`LSPS1ServiceHandler::order_channel_opened`]. + pub fn order_channel_opened( &self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId, - order_state: LSPS1OrderState, channel_details: Option, + channel_info: LSPS1ChannelInfo, ) -> Result<(), APIError> { - let mut fut = pin!(self.inner.update_order_status( - counterparty_node_id, - order_id, - order_state, - channel_details - )); + let mut fut = + pin!(self.inner.order_channel_opened(counterparty_node_id, order_id, channel_info)); + + let mut waker = dummy_waker(); + let mut ctx = task::Context::from_waker(&mut waker); + match fut.as_mut().poll(&mut ctx) { + task::Poll::Ready(result) => result, + task::Poll::Pending => { + // In a sync context, we can't wait for the future to complete. + unreachable!("Should not be pending in a sync context"); + }, + } + } + + /// Marks an order as failed and refunded. + /// + /// Wraps [`LSPS1ServiceHandler::order_failed_and_refunded`]. + pub fn order_failed_and_refunded( + &self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId, + ) -> Result<(), APIError> { + let mut fut = pin!(self.inner.order_failed_and_refunded(counterparty_node_id, order_id)); let mut waker = dummy_waker(); let mut ctx = task::Context::from_waker(&mut waker); From c5139d0f329ed97b9e9928848d8c430ed445d66e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 5 Feb 2026 13:28:16 +0100 Subject: [PATCH 200/627] Add integration tests for LSPS1 order state transition API Add two new integration tests to cover the new public API methods: - `lsps1_order_state_transitions`: Tests the full flow of `order_payment_received` followed by `order_channel_opened`, verifying that payment states are updated correctly and channel info is returned after the channel is opened. - `lsps1_order_failed_and_refunded`: Tests the `order_failed_and_refunded` method, verifying that payment states are set to Refunded. Co-Authored-By: HAL 9000 --- .../tests/lsps1_integration_tests.rs | 294 +++++++++++++++++- 1 file changed, 290 insertions(+), 4 deletions(-) diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index d2ca559e577..91825f9540b 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -7,13 +7,15 @@ use common::{get_lsps_message, LSPSNodes}; use lightning::ln::peer_handler::CustomMessageHandler; use lightning_liquidity::events::LiquidityEvent; +use lightning_liquidity::lsps0::ser::LSPSDateTime; use lightning_liquidity::lsps1::client::LSPS1ClientConfig; use lightning_liquidity::lsps1::event::LSPS1ClientEvent; use lightning_liquidity::lsps1::event::LSPS1ServiceEvent; use lightning_liquidity::lsps1::msgs::{ - LSPS1OnchainPaymentInfo, LSPS1Options, LSPS1OrderParams, LSPS1PaymentInfo, + LSPS1ChannelInfo, LSPS1OnchainPaymentInfo, LSPS1Options, LSPS1OrderParams, LSPS1PaymentInfo, + LSPS1PaymentState, }; -use lightning_liquidity::lsps1::service::LSPS1ServiceConfig; +use lightning_liquidity::lsps1::service::{LSPS1ServiceConfig, PaymentMethod}; use lightning_liquidity::utils::time::DefaultTimeProvider; use lightning_liquidity::{LiquidityClientConfig, LiquidityManagerSync, LiquidityServiceConfig}; @@ -23,7 +25,7 @@ use lightning::ln::functional_test_utils::{ use lightning::util::test_utils::{TestBroadcaster, TestStore}; use bitcoin::secp256k1::PublicKey; -use bitcoin::{Address, Network}; +use bitcoin::{Address, Network, OutPoint}; use std::str::FromStr; use std::sync::Arc; @@ -550,7 +552,7 @@ fn lsps1_invalid_token_error() { let create_order_id = client_handler.create_order( &service_node_id, order_params.clone(), - Some(refund_onchain_address), + Some(refund_onchain_address.clone()), ); let create_order = get_lsps_message!(client_node, service_node_id); @@ -564,10 +566,13 @@ fn lsps1_invalid_token_error() { request_id, counterparty_node_id, order, + refund_onchain_address: refund_addr, + .. }) = request_for_payment_event { assert_eq!(counterparty_node_id, client_node_id); assert_eq!(order, order_params); + assert_eq!(refund_addr, Some(refund_onchain_address)); request_id } else { panic!("Unexpected event: expected RequestForPaymentDetails"); @@ -600,3 +605,284 @@ fn lsps1_invalid_token_error() { panic!("Unexpected event: expected OrderRequestFailed"); } } + +#[test] +fn lsps1_order_state_transitions() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let supported_options = LSPS1Options { + min_required_channel_confirmations: 0, + min_funding_confirms_within_blocks: 6, + supports_zero_channel_reserve: true, + max_channel_expiry_blocks: 144, + min_initial_client_balance_sat: 10_000_000, + max_initial_client_balance_sat: 100_000_000, + min_initial_lsp_balance_sat: 100_000, + max_initial_lsp_balance_sat: 100_000_000, + min_channel_balance_sat: 100_000, + max_channel_balance_sat: 100_000_000, + }; + + let LSPSNodes { service_node, client_node } = + setup_test_lsps1_nodes(nodes, supported_options.clone()); + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + let client_handler = client_node.liquidity_manager.lsps1_client_handler().unwrap(); + let service_handler = service_node.liquidity_manager.lsps1_service_handler().unwrap(); + + // Create an order + let order_params = LSPS1OrderParams { + lsp_balance_sat: 100_000, + client_balance_sat: 10_000_000, + required_channel_confirmations: 0, + funding_confirms_within_blocks: 6, + channel_expiry_blocks: 144, + token: None, + announce_channel: true, + }; + + let refund_onchain_address = + Address::from_str("bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr") + .unwrap() + .assume_checked(); + let create_order_id = client_handler.create_order( + &service_node_id, + order_params.clone(), + Some(refund_onchain_address), + ); + let create_order = get_lsps_message!(client_node, service_node_id); + + service_node.liquidity_manager.handle_custom_message(create_order, client_node_id).unwrap(); + + let request_for_payment_event = service_node.liquidity_manager.next_event().unwrap(); + let request_id = + if let LiquidityEvent::LSPS1Service(LSPS1ServiceEvent::RequestForPaymentDetails { + request_id, + .. + }) = request_for_payment_event + { + request_id + } else { + panic!("Unexpected event"); + }; + + // Send payment details with onchain payment option + let json_str = r#"{ + "state": "EXPECT_PAYMENT", + "expires_at": "2035-01-01T00:00:00Z", + "fee_total_sat": "9999", + "order_total_sat": "200999", + "address": "bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr", + "min_onchain_payment_confirmations": 1, + "min_fee_for_0conf": 253 + }"#; + + let onchain: LSPS1OnchainPaymentInfo = + serde_json::from_str(json_str).expect("Failed to parse JSON"); + let payment_info = LSPS1PaymentInfo { bolt11: None, bolt12: None, onchain: Some(onchain) }; + service_handler + .send_payment_details(request_id.clone(), client_node_id, payment_info.clone()) + .unwrap(); + + let create_order_response = get_lsps_message!(service_node, client_node_id); + client_node + .liquidity_manager + .handle_custom_message(create_order_response, service_node_id) + .unwrap(); + + let order_created_event = client_node.liquidity_manager.next_event().unwrap(); + let order_id = if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderCreated { + request_id, + order_id, + payment, + .. + }) = order_created_event + { + assert_eq!(request_id, create_order_id); + // Initially, payment state should be ExpectPayment + assert_eq!(payment.onchain.as_ref().unwrap().state, LSPS1PaymentState::ExpectPayment); + order_id + } else { + panic!("Unexpected event"); + }; + + // Test order_payment_received: mark the order as paid + service_handler + .order_payment_received(client_node_id, order_id.clone(), PaymentMethod::Onchain) + .unwrap(); + + // Client checks order status - should see payment state as Paid + let _check_order_id = client_handler.check_order_status(&service_node_id, order_id.clone()); + let check_order = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(check_order, client_node_id).unwrap(); + let order_response = get_lsps_message!(service_node, client_node_id); + client_node.liquidity_manager.handle_custom_message(order_response, service_node_id).unwrap(); + + let order_status_event = client_node.liquidity_manager.next_event().unwrap(); + if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { payment, channel, .. }) = + order_status_event + { + // Payment state should be Paid + assert_eq!(payment.onchain.as_ref().unwrap().state, LSPS1PaymentState::Paid); + // No channel info yet (order state is still Created internally) + assert!(channel.is_none()); + } else { + panic!("Unexpected event"); + } + + // Test order_channel_opened: mark the channel as opened + let channel_info = LSPS1ChannelInfo { + funded_at: LSPSDateTime::from_str("2035-01-01T00:00:00Z").unwrap(), + funding_outpoint: OutPoint::from_str( + "0301e0480b374b32851a9462db29dc19fe830a7f7d7a88b81612b9d42099c0ae:0", + ) + .unwrap(), + expires_at: LSPSDateTime::from_str("2036-01-01T00:00:00Z").unwrap(), + }; + service_handler + .order_channel_opened(client_node_id, order_id.clone(), channel_info.clone()) + .unwrap(); + + // Client checks order status - should see Completed state with channel info + let _check_order_id = client_handler.check_order_status(&service_node_id, order_id.clone()); + let check_order = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(check_order, client_node_id).unwrap(); + let order_response = get_lsps_message!(service_node, client_node_id); + client_node.liquidity_manager.handle_custom_message(order_response, service_node_id).unwrap(); + + let order_status_event = client_node.liquidity_manager.next_event().unwrap(); + if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { channel, .. }) = + order_status_event + { + // Channel info should be present (indicates Completed state) + assert_eq!(channel, Some(channel_info)); + } else { + panic!("Unexpected event"); + } +} + +#[test] +fn lsps1_order_failed_and_refunded() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let supported_options = LSPS1Options { + min_required_channel_confirmations: 0, + min_funding_confirms_within_blocks: 6, + supports_zero_channel_reserve: true, + max_channel_expiry_blocks: 144, + min_initial_client_balance_sat: 10_000_000, + max_initial_client_balance_sat: 100_000_000, + min_initial_lsp_balance_sat: 100_000, + max_initial_lsp_balance_sat: 100_000_000, + min_channel_balance_sat: 100_000, + max_channel_balance_sat: 100_000_000, + }; + + let LSPSNodes { service_node, client_node } = + setup_test_lsps1_nodes(nodes, supported_options.clone()); + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + let client_handler = client_node.liquidity_manager.lsps1_client_handler().unwrap(); + let service_handler = service_node.liquidity_manager.lsps1_service_handler().unwrap(); + + // Create an order + let order_params = LSPS1OrderParams { + lsp_balance_sat: 100_000, + client_balance_sat: 10_000_000, + required_channel_confirmations: 0, + funding_confirms_within_blocks: 6, + channel_expiry_blocks: 144, + token: None, + announce_channel: true, + }; + + let refund_onchain_address = + Address::from_str("bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr") + .unwrap() + .assume_checked(); + let create_order_id = client_handler.create_order( + &service_node_id, + order_params.clone(), + Some(refund_onchain_address), + ); + let create_order = get_lsps_message!(client_node, service_node_id); + + service_node.liquidity_manager.handle_custom_message(create_order, client_node_id).unwrap(); + + let request_for_payment_event = service_node.liquidity_manager.next_event().unwrap(); + let request_id = + if let LiquidityEvent::LSPS1Service(LSPS1ServiceEvent::RequestForPaymentDetails { + request_id, + .. + }) = request_for_payment_event + { + request_id + } else { + panic!("Unexpected event"); + }; + + // Send payment details + let json_str = r#"{ + "state": "EXPECT_PAYMENT", + "expires_at": "2035-01-01T00:00:00Z", + "fee_total_sat": "9999", + "order_total_sat": "200999", + "address": "bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr", + "min_onchain_payment_confirmations": 1, + "min_fee_for_0conf": 253 + }"#; + + let onchain: LSPS1OnchainPaymentInfo = + serde_json::from_str(json_str).expect("Failed to parse JSON"); + let payment_info = LSPS1PaymentInfo { bolt11: None, bolt12: None, onchain: Some(onchain) }; + service_handler + .send_payment_details(request_id.clone(), client_node_id, payment_info.clone()) + .unwrap(); + + let create_order_response = get_lsps_message!(service_node, client_node_id); + client_node + .liquidity_manager + .handle_custom_message(create_order_response, service_node_id) + .unwrap(); + + let order_created_event = client_node.liquidity_manager.next_event().unwrap(); + let order_id = if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderCreated { + request_id, + order_id, + .. + }) = order_created_event + { + assert_eq!(request_id, create_order_id); + order_id + } else { + panic!("Unexpected event"); + }; + + // Test order_failed_and_refunded: mark the order as failed + service_handler.order_failed_and_refunded(client_node_id, order_id.clone()).unwrap(); + + // Client checks order status - should see Failed state with Refunded payment + let _check_order_id = client_handler.check_order_status(&service_node_id, order_id.clone()); + let check_order = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(check_order, client_node_id).unwrap(); + let order_response = get_lsps_message!(service_node, client_node_id); + client_node.liquidity_manager.handle_custom_message(order_response, service_node_id).unwrap(); + + let order_status_event = client_node.liquidity_manager.next_event().unwrap(); + if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { payment, channel, .. }) = + order_status_event + { + // Payment state should be Refunded (indicates Failed state) + assert_eq!(payment.onchain.as_ref().unwrap().state, LSPS1PaymentState::Refunded); + // No channel info + assert!(channel.is_none()); + } else { + panic!("Unexpected event"); + } +} From 248487381a9042085b64b0441c87c7d669d5c8ef Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 5 Feb 2026 13:32:02 +0100 Subject: [PATCH 201/627] Add integration test for expired order pruning Add `lsps1_expired_orders_are_pruned_and_not_persisted` test that verifies: - Orders with expired payment details (expires_at in the past) are accessible before persist() is called - After persist() is called, expired orders in ExpectingPayment state are pruned and no longer accessible - Pruned orders are not recovered after restart, confirming that the pruning also removes the persisted state Co-Authored-By: HAL 9000 --- .../tests/lsps1_integration_tests.rs | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index 91825f9540b..92ad06abfdc 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -886,3 +886,254 @@ fn lsps1_order_failed_and_refunded() { panic!("Unexpected event"); } } + +#[test] +fn lsps1_expired_orders_are_pruned_and_not_persisted() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + // Create shared KV store for service node that will persist across restarts + let service_kv_store = Arc::new(TestStore::new(false)); + let client_kv_store = Arc::new(TestStore::new(false)); + + let supported_options = LSPS1Options { + min_required_channel_confirmations: 0, + min_funding_confirms_within_blocks: 6, + supports_zero_channel_reserve: true, + max_channel_expiry_blocks: 144, + min_initial_client_balance_sat: 10_000_000, + max_initial_client_balance_sat: 100_000_000, + min_initial_lsp_balance_sat: 100_000, + max_initial_lsp_balance_sat: 100_000_000, + min_channel_balance_sat: 100_000, + max_channel_balance_sat: 100_000_000, + }; + + let service_config = LiquidityServiceConfig { + lsps1_service_config: Some(LSPS1ServiceConfig { + supported_options: supported_options.clone(), + }), + lsps2_service_config: None, + lsps5_service_config: None, + advertise_service: true, + }; + let time_provider: Arc = Arc::new(DefaultTimeProvider); + + // Variables to carry state between scopes + let client_node_id: PublicKey; + let expected_order_id: LSPS1OrderId; + + // First scope: Create an order with EXPIRED payment details + { + let LSPSNodes { service_node, client_node } = setup_test_lsps1_nodes_with_kv_stores( + nodes, + Arc::clone(&service_kv_store), + Arc::clone(&client_kv_store), + supported_options.clone(), + ); + + let service_node_id = service_node.inner.node.get_our_node_id(); + client_node_id = client_node.inner.node.get_our_node_id(); + + let client_handler = client_node.liquidity_manager.lsps1_client_handler().unwrap(); + let service_handler = service_node.liquidity_manager.lsps1_service_handler().unwrap(); + + // Create an order + let order_params = LSPS1OrderParams { + lsp_balance_sat: 100_000, + client_balance_sat: 10_000_000, + required_channel_confirmations: 0, + funding_confirms_within_blocks: 6, + channel_expiry_blocks: 144, + token: None, + announce_channel: true, + }; + + let refund_onchain_address = + Address::from_str("bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr") + .unwrap() + .assume_checked(); + let create_order_id = client_handler.create_order( + &service_node_id, + order_params.clone(), + Some(refund_onchain_address), + ); + let create_order = get_lsps_message!(client_node, service_node_id); + + service_node.liquidity_manager.handle_custom_message(create_order, client_node_id).unwrap(); + + let request_for_payment_event = service_node.liquidity_manager.next_event().unwrap(); + let request_id = + if let LiquidityEvent::LSPS1Service(LSPS1ServiceEvent::RequestForPaymentDetails { + request_id, + .. + }) = request_for_payment_event + { + request_id + } else { + panic!("Unexpected event"); + }; + + // Send payment details with EXPIRED expiry time (in the past) + let json_str = r#"{ + "state": "EXPECT_PAYMENT", + "expires_at": "2020-01-01T00:00:00Z", + "fee_total_sat": "9999", + "order_total_sat": "200999", + "address": "bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr", + "min_onchain_payment_confirmations": 1, + "min_fee_for_0conf": 253 + }"#; + + let onchain: LSPS1OnchainPaymentInfo = + serde_json::from_str(json_str).expect("Failed to parse JSON"); + let payment_info = LSPS1PaymentInfo { bolt11: None, bolt12: None, onchain: Some(onchain) }; + service_handler + .send_payment_details(request_id.clone(), client_node_id, payment_info.clone()) + .unwrap(); + + let create_order_response = get_lsps_message!(service_node, client_node_id); + client_node + .liquidity_manager + .handle_custom_message(create_order_response, service_node_id) + .unwrap(); + + let order_created_event = client_node.liquidity_manager.next_event().unwrap(); + expected_order_id = if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderCreated { + request_id, + order_id, + .. + }) = order_created_event + { + assert_eq!(request_id, create_order_id); + order_id + } else { + panic!("Unexpected event"); + }; + + // Verify the order exists by querying it (before persist is called) + let _check_order_id = + client_handler.check_order_status(&service_node_id, expected_order_id.clone()); + let check_order = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(check_order, client_node_id).unwrap(); + let order_response = get_lsps_message!(service_node, client_node_id); + client_node + .liquidity_manager + .handle_custom_message(order_response, service_node_id) + .unwrap(); + + // Should get the order status (order exists before pruning) + let order_status_event = client_node.liquidity_manager.next_event().unwrap(); + assert!(matches!( + order_status_event, + LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { .. }) + )); + + // Now call persist - this should prune the expired order since expires_at is in the past + // (prune_expired_request_state is called during persist) + service_node.liquidity_manager.persist().unwrap(); + + // Try to query the order again - it should fail (order not found) + let _check_order_id = + client_handler.check_order_status(&service_node_id, expected_order_id.clone()); + let check_order = get_lsps_message!(client_node, service_node_id); + + // This should return an error response since the order was pruned + service_node + .liquidity_manager + .handle_custom_message(check_order, client_node_id) + .unwrap_err(); + + let error_response = get_lsps_message!(service_node, client_node_id); + client_node + .liquidity_manager + .handle_custom_message(error_response, service_node_id) + .unwrap_err(); + + // Should get an error event (order not found) + let error_event = client_node.liquidity_manager.next_event().unwrap(); + if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderRequestFailed { error, .. }) = + error_event + { + // Error code 101 is LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE + assert_eq!(error.code, 101); + } else { + panic!("Expected OrderRequestFailed event"); + } + + // All node objects are dropped at the end of this scope + } + + // Second scope: Restart and verify pruned order is NOT recovered + { + let node_chanmgrs_restart = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes_restart = create_network(2, &node_cfgs, &node_chanmgrs_restart); + + let service_transaction_broadcaster = Arc::new(TestBroadcaster::new(Network::Testnet)); + let client_transaction_broadcaster = Arc::new(TestBroadcaster::new(Network::Testnet)); + + let restarted_service_lm = LiquidityManagerSync::new_with_custom_time_provider( + nodes_restart[0].keys_manager, + nodes_restart[0].keys_manager, + nodes_restart[0].node, + Arc::clone(&service_kv_store), + service_transaction_broadcaster, + Some(service_config), + None, + Arc::clone(&time_provider), + ) + .unwrap(); + + let lsps1_client_config = LSPS1ClientConfig { max_channel_fees_msat: None }; + let client_config = LiquidityClientConfig { + lsps1_client_config: Some(lsps1_client_config), + lsps2_client_config: None, + lsps5_client_config: None, + }; + + let client_lm = LiquidityManagerSync::new_with_custom_time_provider( + nodes_restart[1].keys_manager, + nodes_restart[1].keys_manager, + nodes_restart[1].node, + Arc::clone(&client_kv_store), + client_transaction_broadcaster, + None, + Some(client_config), + time_provider, + ) + .unwrap(); + + let service_node_id = nodes_restart[0].node.get_our_node_id(); + + // Try to query the previously pruned order - it should NOT be recovered + let client_handler = client_lm.lsps1_client_handler().unwrap(); + let _check_order_id = + client_handler.check_order_status(&service_node_id, expected_order_id.clone()); + + let pending_client_msgs = client_lm.get_and_clear_pending_msg(); + assert_eq!(pending_client_msgs.len(), 1); + let (_, request_msg) = pending_client_msgs.into_iter().next().unwrap(); + + // This should return an error since the order was pruned and not persisted + restarted_service_lm.handle_custom_message(request_msg, client_node_id).unwrap_err(); + + let pending_service_msgs = restarted_service_lm.get_and_clear_pending_msg(); + assert_eq!(pending_service_msgs.len(), 1); + let (_, response_msg) = pending_service_msgs.into_iter().next().unwrap(); + + client_lm.handle_custom_message(response_msg, service_node_id).unwrap_err(); + + // Should get an error event (order not found after restart) + let error_event = client_lm.next_event().unwrap(); + if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderRequestFailed { error, .. }) = + error_event + { + // Error code 101 is LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE + assert_eq!(error.code, 101); + } else { + panic!("Expected OrderRequestFailed event after restart, got: {:?}", error_event); + } + } +} From 029ad804ea89605108ca62929570d6eb3f1d8d05 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 5 Feb 2026 13:44:17 +0100 Subject: [PATCH 202/627] Drop unused `LSPS1OnchainPayment` type --- lightning-liquidity/src/lsps1/msgs.rs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/lightning-liquidity/src/lsps1/msgs.rs b/lightning-liquidity/src/lsps1/msgs.rs index eae9568f589..6021b650cfa 100644 --- a/lightning-liquidity/src/lsps1/msgs.rs +++ b/lightning-liquidity/src/lsps1/msgs.rs @@ -323,18 +323,6 @@ impl_writeable_tlv_based_enum!(LSPS1PaymentState, (4, Refunded) => {} ); -/// Details regarding a detected on-chain payment. -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] -pub struct LSPS1OnchainPayment { - /// The outpoint of the payment. - pub outpoint: String, - /// The amount of satoshi paid. - #[serde(with = "string_amount")] - pub sat: u64, - /// Indicates if the LSP regards the transaction as sufficiently confirmed. - pub confirmed: bool, -} - /// Details regarding the state of an ordered channel. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] pub struct LSPS1ChannelInfo { From 36e11982c4d2d8b71c834a8fdccdb4ff21b35c5b Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 5 Feb 2026 13:51:28 +0100 Subject: [PATCH 203/627] Add `Hold` payment state per bLIP-51 spec The bLIP-51 specification defines a `HOLD` intermediate payment state: - `EXPECT_PAYMENT` -> `HOLD` -> `PAID` (success path) - `EXPECT_PAYMENT` -> `REFUNDED` (failure before payment) - `HOLD` -> `REFUNDED` (failure after payment received) This commit adds the `Hold` variant to `LSPS1PaymentState` and updates the state machine transitions: - `payment_received()` now sets payment state to `Hold` (not `Paid`) - `channel_opened()` transitions payment state from `Hold` to `Paid` - Tests updated to verify the correct state at each transition This allows LSPs to properly communicate when a payment has been received but the channel has not yet been opened (e.g., Lightning HTLC held, or on-chain tx detected but channel funding not published). Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps1/msgs.rs | 12 +++-- lightning-liquidity/src/lsps1/peer_state.rs | 49 +++++++++++++++---- .../tests/lsps1_integration_tests.rs | 8 +-- 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/lightning-liquidity/src/lsps1/msgs.rs b/lightning-liquidity/src/lsps1/msgs.rs index 6021b650cfa..9eff06e7d90 100644 --- a/lightning-liquidity/src/lsps1/msgs.rs +++ b/lightning-liquidity/src/lsps1/msgs.rs @@ -310,7 +310,12 @@ impl_writeable_tlv_based!(LSPS1OnchainPaymentInfo, { pub enum LSPS1PaymentState { /// A payment is expected. ExpectPayment, - /// A sufficient payment has been received. + /// A payment has been received but the channel has not yet been opened. + /// + /// This indicates the LSP has received the payment (e.g., Lightning HTLC held, + /// or on-chain transaction detected) but has not yet published the funding transaction. + Hold, + /// A sufficient payment has been received and the channel has been opened. Paid, /// The payment has been refunded. #[serde(alias = "CANCELLED")] @@ -319,8 +324,9 @@ pub enum LSPS1PaymentState { impl_writeable_tlv_based_enum!(LSPS1PaymentState, (0, ExpectPayment) => {}, - (2, Paid) => {}, - (4, Refunded) => {} + (2, Hold) => {}, + (4, Paid) => {}, + (6, Refunded) => {} ); /// Details regarding the state of an ordered channel. diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 1d13d07d206..d2b806c6dbd 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -102,17 +102,17 @@ impl ChannelOrderState { /// Transition: ExpectingPayment -> OrderPaid /// - /// Updates the specified payment method's state to PAID. + /// Updates the specified payment method's state to HOLD. pub(super) fn payment_received( &mut self, method: PaymentMethod, ) -> Result<(), ChannelOrderStateError> { match self { ChannelOrderState::ExpectingPayment { payment_details } => { - // Update the payment state for the specified method + // Update the payment state for the specified method to HOLD let method_exists = match method { PaymentMethod::Bolt11 => { if let Some(ref mut bolt11) = payment_details.bolt11 { - bolt11.state = LSPS1PaymentState::Paid; + bolt11.state = LSPS1PaymentState::Hold; true } else { false @@ -120,7 +120,7 @@ impl ChannelOrderState { }, PaymentMethod::Bolt12 => { if let Some(ref mut bolt12) = payment_details.bolt12 { - bolt12.state = LSPS1PaymentState::Paid; + bolt12.state = LSPS1PaymentState::Hold; true } else { false @@ -128,7 +128,7 @@ impl ChannelOrderState { }, PaymentMethod::Onchain => { if let Some(ref mut onchain) = payment_details.onchain { - onchain.state = LSPS1PaymentState::Paid; + onchain.state = LSPS1PaymentState::Hold; true } else { false @@ -152,13 +152,33 @@ impl ChannelOrderState { } /// Transition: OrderPaid -> CompletedAndChannelOpened + /// + /// Updates payment states from HOLD to PAID. pub(super) fn channel_opened( &mut self, channel_info: LSPS1ChannelInfo, ) -> Result<(), ChannelOrderStateError> { match self { ChannelOrderState::OrderPaid { payment_details } => { + // Update payment states from HOLD to PAID + let mut paid_details = payment_details.clone(); + if let Some(ref mut bolt11) = paid_details.bolt11 { + if bolt11.state == LSPS1PaymentState::Hold { + bolt11.state = LSPS1PaymentState::Paid; + } + } + if let Some(ref mut bolt12) = paid_details.bolt12 { + if bolt12.state == LSPS1PaymentState::Hold { + bolt12.state = LSPS1PaymentState::Paid; + } + } + if let Some(ref mut onchain) = paid_details.onchain { + if onchain.state == LSPS1PaymentState::Hold { + onchain.state = LSPS1PaymentState::Paid; + } + } + *self = ChannelOrderState::CompletedAndChannelOpened { - payment_details: payment_details.clone(), + payment_details: paid_details, channel_info, }; Ok(()) @@ -276,7 +296,7 @@ impl PeerState { /// Transition: ExpectingPayment -> OrderPaid /// - /// Updates the specified payment method's state to PAID. + /// Updates the specified payment method's state to HOLD. pub(super) fn order_payment_received( &mut self, order_id: &LSPS1OrderId, method: PaymentMethod, ) -> Result<(), PeerStateError> { @@ -530,7 +550,8 @@ mod tests { assert!(matches!(state, ChannelOrderState::OrderPaid { .. })); assert_eq!(state.order_state(), LSPS1OrderState::Created); - assert_eq!(state.payment_details().bolt11.as_ref().unwrap().state, LSPS1PaymentState::Paid); + // Payment state should be HOLD (not PAID) until channel is opened + assert_eq!(state.payment_details().bolt11.as_ref().unwrap().state, LSPS1PaymentState::Hold); } // Test valid transition: ExpectingPayment -> OrderPaid via payment_received (Onchain) @@ -542,9 +563,10 @@ mod tests { state.payment_received(PaymentMethod::Onchain).unwrap(); assert!(matches!(state, ChannelOrderState::OrderPaid { .. })); + // Payment state should be HOLD (not PAID) until channel is opened assert_eq!( state.payment_details().onchain.as_ref().unwrap().state, - LSPS1PaymentState::Paid + LSPS1PaymentState::Hold ); } @@ -555,12 +577,17 @@ mod tests { let mut state = ChannelOrderState::new(payment_info); state.payment_received(PaymentMethod::Bolt11).unwrap(); + // Verify payment state is HOLD before channel opens + assert_eq!(state.payment_details().bolt11.as_ref().unwrap().state, LSPS1PaymentState::Hold); + let channel_info = create_test_channel_info(); state.channel_opened(channel_info.clone()).unwrap(); assert!(matches!(state, ChannelOrderState::CompletedAndChannelOpened { .. })); assert_eq!(state.order_state(), LSPS1OrderState::Completed); assert_eq!(state.channel_info(), Some(&channel_info)); + // Payment state should now be PAID after channel is opened + assert_eq!(state.payment_details().bolt11.as_ref().unwrap().state, LSPS1PaymentState::Paid); } // Test valid transition: ExpectingPayment -> FailedAndRefunded @@ -586,10 +613,14 @@ mod tests { let mut state = ChannelOrderState::new(payment_info); state.payment_received(PaymentMethod::Bolt11).unwrap(); + // Verify payment state is HOLD before failure + assert_eq!(state.payment_details().bolt11.as_ref().unwrap().state, LSPS1PaymentState::Hold); + state.mark_failed_and_refunded().unwrap(); assert!(matches!(state, ChannelOrderState::FailedAndRefunded { .. })); assert_eq!(state.order_state(), LSPS1OrderState::Failed); + // Payment state should now be REFUNDED assert_eq!( state.payment_details().bolt11.as_ref().unwrap().state, LSPS1PaymentState::Refunded diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index 92ad06abfdc..63185669bbf 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -725,8 +725,8 @@ fn lsps1_order_state_transitions() { if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { payment, channel, .. }) = order_status_event { - // Payment state should be Paid - assert_eq!(payment.onchain.as_ref().unwrap().state, LSPS1PaymentState::Paid); + // Payment state should be Hold (payment received but channel not yet opened) + assert_eq!(payment.onchain.as_ref().unwrap().state, LSPS1PaymentState::Hold); // No channel info yet (order state is still Created internally) assert!(channel.is_none()); } else { @@ -754,9 +754,11 @@ fn lsps1_order_state_transitions() { client_node.liquidity_manager.handle_custom_message(order_response, service_node_id).unwrap(); let order_status_event = client_node.liquidity_manager.next_event().unwrap(); - if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { channel, .. }) = + if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { payment, channel, .. }) = order_status_event { + // Payment state should now be Paid (channel has been opened) + assert_eq!(payment.onchain.as_ref().unwrap().state, LSPS1PaymentState::Paid); // Channel info should be present (indicates Completed state) assert_eq!(channel, Some(channel_info)); } else { From c0cef54b21904559de7718fb212138ebf969320a Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 11 Feb 2026 10:52:25 +0100 Subject: [PATCH 204/627] Drop unused `LSPS1ServiceEvent::Refund` event Turns out this was another variant we didn't actually use anywhere. So we're dropping it. --- lightning-liquidity/src/lsps1/event.rs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/lightning-liquidity/src/lsps1/event.rs b/lightning-liquidity/src/lsps1/event.rs index d78d6d975c2..1d188421e9d 100644 --- a/lightning-liquidity/src/lsps1/event.rs +++ b/lightning-liquidity/src/lsps1/event.rs @@ -174,15 +174,4 @@ pub enum LSPS1ServiceEvent { /// client. refund_onchain_address: Option
        , }, - /// If error is encountered, refund the amount if paid by the client. - /// - /// **Note: ** This event will *not* be persisted across restarts. - Refund { - /// An identifier. - request_id: LSPSRequestId, - /// The node id of the client making the information request. - counterparty_node_id: PublicKey, - /// The order id of the refunded order. - order_id: LSPS1OrderId, - }, } From 98f71f5df5b51b4c1956db0abb3f187129adb235 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 11 Feb 2026 11:13:37 +0100 Subject: [PATCH 205/627] Add `onchain_payment_required` method We previously had no way to reject requests in case the LSP requires onchain payment while the client not providing `refund_onchain_address`. Here we add a method allowing to do so. --- lightning-liquidity/src/lsps1/event.rs | 6 ++- lightning-liquidity/src/lsps1/msgs.rs | 2 +- lightning-liquidity/src/lsps1/service.rs | 57 ++++++++++++++++++++++-- 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/lightning-liquidity/src/lsps1/event.rs b/lightning-liquidity/src/lsps1/event.rs index 1d188421e9d..8868790da31 100644 --- a/lightning-liquidity/src/lsps1/event.rs +++ b/lightning-liquidity/src/lsps1/event.rs @@ -170,8 +170,10 @@ pub enum LSPS1ServiceEvent { order: LSPS1OrderParams, /// The address we need to send onchain refunds to in case channel opening fails. /// - /// Please note that you can't offer onchain payments if this was not provided by the - /// client. + /// If this is `None` and you *require* onchain payment, you should call + /// [`LSPS1ServiceHandler::onchain_payments_required`] to reject the request. + /// + /// [`LSPS1ServiceHandler::onchain_payments_required`]: crate::lsps1::service::LSPS1ServiceHandler::onchain_payments_required refund_onchain_address: Option
        , }, } diff --git a/lightning-liquidity/src/lsps1/msgs.rs b/lightning-liquidity/src/lsps1/msgs.rs index 9eff06e7d90..b754f0438aa 100644 --- a/lightning-liquidity/src/lsps1/msgs.rs +++ b/lightning-liquidity/src/lsps1/msgs.rs @@ -31,7 +31,7 @@ pub(crate) const LSPS1_CREATE_ORDER_METHOD_NAME: &str = "lsps1.create_order"; pub(crate) const LSPS1_GET_ORDER_METHOD_NAME: &str = "lsps1.get_order"; pub(crate) const _LSPS1_CREATE_ORDER_REQUEST_INVALID_PARAMS_ERROR_CODE: i32 = -32602; -pub(crate) const LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE: i32 = 100; +pub(crate) const LSPS1_CREATE_ORDER_REQUEST_OPTION_MISMATCH_ERROR_CODE: i32 = 100; pub(crate) const LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE: i32 = 101; pub(crate) const LSPS1_CREATE_ORDER_REQUEST_UNRECOGNIZED_OR_STALE_TOKEN_ERROR_CODE: i32 = 102; diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index bc10116b14e..9d58ea07862 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -23,7 +23,7 @@ use super::msgs::{ LSPS1ChannelInfo, LSPS1CreateOrderRequest, LSPS1CreateOrderResponse, LSPS1GetInfoResponse, LSPS1GetOrderRequest, LSPS1Message, LSPS1Options, LSPS1OrderId, LSPS1OrderParams, LSPS1PaymentInfo, LSPS1PaymentState, LSPS1Request, LSPS1Response, - LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE, + LSPS1_CREATE_ORDER_REQUEST_OPTION_MISMATCH_ERROR_CODE, LSPS1_CREATE_ORDER_REQUEST_UNRECOGNIZED_OR_STALE_TOKEN_ERROR_CODE, LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE, }; @@ -291,7 +291,7 @@ where if !is_valid(¶ms.order, &self.config.supported_options) { let response = LSPS1Response::CreateOrderError(LSPSResponseError { - code: LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE, + code: LSPS1_CREATE_ORDER_REQUEST_OPTION_MISMATCH_ERROR_CODE, message: "Order does not match options supported by LSP server".to_string(), data: Some(format!("Supported options are {:?}", &self.config.supported_options)), }); @@ -337,7 +337,8 @@ where /// Should be called in response to receiving a [`LSPS1ServiceEvent::RequestForPaymentDetails`] event. /// /// Note that the provided `payment_details` can't include the onchain payment variant if the - /// user didn't provide a `refund_onchain_address`. + /// user didn't provide a `refund_onchain_address`. If you *require* onchain payments, you need + /// to call [`Self::onchain_payments_required`] to reject the request. /// /// [`LSPS1ServiceEvent::RequestForPaymentDetails`]: crate::lsps1::event::LSPS1ServiceEvent::RequestForPaymentDetails pub async fn send_payment_details( @@ -496,6 +497,45 @@ where } } + /// Used by LSP to inform a client that an order was rejected because they require onchain + /// payments and the client didn't provide a `refund_onchain_address`. + /// + /// Should be called in response to receiving a [`LSPS1ServiceEvent::RequestForPaymentDetails`] + /// event if the LSP requires onchain payments and `refund_onchain_address` is `None`. + /// + /// [`LSPS1ServiceEvent::RequestForPaymentDetails`]: crate::lsps1::event::LSPS1ServiceEvent::RequestForPaymentDetails + pub fn onchain_payments_required( + &self, counterparty_node_id: PublicKey, request_id: LSPSRequestId, + ) -> Result<(), APIError> { + let mut message_queue_notifier = self.pending_messages.notifier(); + + match self.per_peer_state.read().unwrap().get(&counterparty_node_id) { + Some(inner_state_lock) => { + let mut peer_state_lock = inner_state_lock.lock().unwrap(); + peer_state_lock.remove_request(&request_id).map_err(|e| { + debug_assert!(false, "Failed to send response due to: {}", e); + let err = format!("Failed to send response due to: {}", e); + APIError::APIMisuseError { err } + })?; + + let response = LSPS1Response::CreateOrderError(LSPSResponseError { + code: LSPS1_CREATE_ORDER_REQUEST_OPTION_MISMATCH_ERROR_CODE, + message: + "We require onchain payment but no `refund_onchain_address` was provided" + .to_string(), + data: None, + }); + + let msg = LSPS1Message::Response(request_id, response).into(); + message_queue_notifier.enqueue(&counterparty_node_id, msg); + Ok(()) + }, + None => Err(APIError::APIMisuseError { + err: format!("No state for the counterparty exists: {}", counterparty_node_id), + }), + } + } + fn handle_get_order_request( &self, request_id: LSPSRequestId, counterparty_node_id: &PublicKey, params: LSPS1GetOrderRequest, @@ -640,7 +680,6 @@ where /// Marks an order as failed and refunded. /// /// This should be called when: - /// - We require onchain payment and the client didn't provide a `refund_onchain_address`. /// - The order expires without payment /// - The channel open fails after payment and the LSP must refund pub async fn order_failed_and_refunded( @@ -782,6 +821,16 @@ where self.inner.invalid_token_provided(counterparty_node_id, request_id) } + /// Used by LSP to inform a client that an order was rejected because they require onchain + /// payments and the client didn't provide a `refund_onchain_address`. + /// + /// Wraps [`LSPS1ServiceHandler::onchain_payments_required`]. + pub fn onchain_payments_required( + &self, counterparty_node_id: PublicKey, request_id: LSPSRequestId, + ) -> Result<(), APIError> { + self.inner.onchain_payments_required(counterparty_node_id, request_id) + } + /// Marks an order as paid after payment has been received. /// /// Wraps [`LSPS1ServiceHandler::order_payment_received`]. From b272235708bd9b280dd47c8914a784cde77572f0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 11 Feb 2026 13:09:43 +0100 Subject: [PATCH 206/627] Limit pending requests and peers in LSPS1 service Add per-peer and global rate limiting to `LSPS1ServiceHandler` to prevent resource exhaustion, mirroring the existing LSPS2 pattern. Introduce `MAX_PENDING_REQUESTS_PER_PEER` (10), `MAX_TOTAL_PENDING_REQUESTS` (1000), and `MAX_TOTAL_PEERS` (100000) constants and enforce them in `handle_create_order_request`. Rejected requests receive a `CreateOrderError` with `LSPS0_CLIENT_REJECTED_ERROR_CODE`. A `total_pending_requests` atomic counter tracks the global count, and a `verify_pending_request_counter` debug assertion ensures it stays in sync. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps1/peer_state.rs | 30 +++++- lightning-liquidity/src/lsps1/service.rs | 53 ++++++++--- .../tests/lsps1_integration_tests.rs | 91 +++++++++++++++++++ 3 files changed, 158 insertions(+), 16 deletions(-) diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index d2b806c6dbd..6e1889749ae 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -22,6 +22,8 @@ use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use core::fmt; +const MAX_PENDING_REQUESTS_PER_PEER: usize = 10; + /// Indicates which payment method was used for the order. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PaymentMethod { @@ -340,6 +342,9 @@ impl PeerState { pub(super) fn register_request( &mut self, request_id: LSPSRequestId, request: LSPS1Request, ) -> Result<(), PeerStateError> { + if self.pending_requests_and_unpaid_orders() >= MAX_PENDING_REQUESTS_PER_PEER { + return Err(PeerStateError::TooManyPendingRequests); + } if self.pending_requests.contains_key(&request_id) { return Err(PeerStateError::DuplicateRequestId); } @@ -376,8 +381,10 @@ impl PeerState { self.pending_requests.is_empty() && self.outbound_channels_by_order_id.is_empty() } - pub(super) fn prune_pending_requests(&mut self) { - self.pending_requests.clear() + pub(super) fn prune_pending_requests(&mut self) -> usize { + let num_pruned = self.pending_requests.len(); + self.pending_requests.clear(); + num_pruned } pub(super) fn prune_expired_request_state(&mut self) { @@ -389,6 +396,23 @@ impl PeerState { true }); } + + fn pending_requests_and_unpaid_orders(&self) -> usize { + let pending_requests = self.pending_requests.len(); + // We exclude paid and completed orders. + let unpaid_orders = self + .outbound_channels_by_order_id + .iter() + .filter(|(_, v)| { + !matches!( + v.state, + ChannelOrderState::OrderPaid { .. } + | ChannelOrderState::CompletedAndChannelOpened { .. } + ) + }) + .count(); + pending_requests + unpaid_orders + } } impl_writeable_tlv_based!(PeerState, { @@ -403,6 +427,7 @@ pub(super) enum PeerStateError { DuplicateRequestId, UnknownOrderId, InvalidStateTransition(ChannelOrderStateError), + TooManyPendingRequests, } impl fmt::Display for PeerStateError { @@ -412,6 +437,7 @@ impl fmt::Display for PeerStateError { Self::DuplicateRequestId => write!(f, "duplicate request id"), Self::UnknownOrderId => write!(f, "unknown order id"), Self::InvalidStateTransition(e) => write!(f, "{}", e), + Self::TooManyPendingRequests => write!(f, "too many pending requests"), } } } diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 9d58ea07862..7cf0412f14e 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -34,6 +34,7 @@ use crate::message_queue::MessageQueue; use crate::events::EventQueue; use crate::lsps0::ser::{ LSPSDateTime, LSPSProtocolMessageHandler, LSPSRequestId, LSPSResponseError, + LSPS0_CLIENT_REJECTED_ERROR_CODE, }; use crate::persist::{ LIQUIDITY_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, LSPS1_SERVICE_PERSISTENCE_SECONDARY_NAMESPACE, @@ -62,6 +63,8 @@ pub struct LSPS1ServiceConfig { pub supported_options: LSPS1Options, } +const MAX_TOTAL_PEERS: usize = 100000; + /// The main object allowing to send and receive bLIP-51 / LSPS1 messages. pub struct LSPS1ServiceHandler< ES: EntropySource, @@ -308,11 +311,30 @@ where { let mut outer_state_lock = self.per_peer_state.write().unwrap(); + let num_peers = outer_state_lock.len(); - let inner_state_lock = outer_state_lock - .entry(*counterparty_node_id) - .or_insert(Mutex::new(PeerState::default())); - let mut peer_state_lock = inner_state_lock.lock().unwrap(); + let inner_state_entry = outer_state_lock.entry(*counterparty_node_id); + + if matches!(inner_state_entry, Entry::Vacant(_)) && num_peers >= MAX_TOTAL_PEERS { + let response = LSPS1Response::CreateOrderError(LSPSResponseError { + code: LSPS0_CLIENT_REJECTED_ERROR_CODE, + message: "Reached maximum number of pending requests. Please try again later." + .to_string(), + data: None, + }); + let msg = LSPS1Message::Response(request_id, response).into(); + message_queue_notifier.enqueue(counterparty_node_id, msg); + return Err(LightningError { + err: format!( + "Dropping request from peer {} due to reaching maximally allowed number of total peers: {}", + counterparty_node_id, MAX_TOTAL_PEERS + ), + action: ErrorAction::IgnoreAndLog(Level::Debug), + }); + } + + let mut peer_state_lock = + inner_state_entry.or_insert(Mutex::new(PeerState::default())).lock().unwrap(); let request = LSPS1Request::CreateOrder(params.clone()); peer_state_lock.register_request(request_id.clone(), request).map_err(|e| { @@ -734,16 +756,19 @@ where &self, message: Self::ProtocolMessage, counterparty_node_id: &PublicKey, ) -> Result<(), LightningError> { match message { - LSPS1Message::Request(request_id, request) => match request { - LSPS1Request::GetInfo(_) => { - self.handle_get_info_request(request_id, counterparty_node_id) - }, - LSPS1Request::CreateOrder(params) => { - self.handle_create_order_request(request_id, counterparty_node_id, params) - }, - LSPS1Request::GetOrder(params) => { - self.handle_get_order_request(request_id, counterparty_node_id, params) - }, + LSPS1Message::Request(request_id, request) => { + let res = match request { + LSPS1Request::GetInfo(_) => { + self.handle_get_info_request(request_id, counterparty_node_id) + }, + LSPS1Request::CreateOrder(params) => { + self.handle_create_order_request(request_id, counterparty_node_id, params) + }, + LSPS1Request::GetOrder(params) => { + self.handle_get_order_request(request_id, counterparty_node_id, params) + }, + }; + res }, _ => { debug_assert!( diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index 63185669bbf..a177b338ad7 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -34,6 +34,8 @@ use lightning::ln::functional_test_utils::{create_network, Node}; use lightning_liquidity::lsps1::msgs::LSPS1OrderId; use lightning_liquidity::utils::time::TimeProvider; +const MAX_PENDING_REQUESTS_PER_PEER: usize = 10; + fn build_lsps1_configs( supported_options: LSPS1Options, ) -> (LiquidityServiceConfig, LiquidityClientConfig) { @@ -1139,3 +1141,92 @@ fn lsps1_expired_orders_are_pruned_and_not_persisted() { } } } + +#[test] +fn max_pending_requests_per_peer_rejected() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let supported_options = LSPS1Options { + min_required_channel_confirmations: 0, + min_funding_confirms_within_blocks: 6, + supports_zero_channel_reserve: true, + max_channel_expiry_blocks: 144, + min_initial_client_balance_sat: 10_000_000, + max_initial_client_balance_sat: 100_000_000, + min_initial_lsp_balance_sat: 100_000, + max_initial_lsp_balance_sat: 100_000_000, + min_channel_balance_sat: 100_000, + max_channel_balance_sat: 100_000_000, + }; + + let LSPSNodes { service_node, client_node } = + setup_test_lsps1_nodes(nodes, supported_options.clone()); + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + let client_handler = client_node.liquidity_manager.lsps1_client_handler().unwrap(); + + let order_params = LSPS1OrderParams { + lsp_balance_sat: 100_000, + client_balance_sat: 10_000_000, + required_channel_confirmations: 0, + funding_confirms_within_blocks: 6, + channel_expiry_blocks: 144, + token: None, + announce_channel: true, + }; + + let refund_onchain_address = + Address::from_str("bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr") + .unwrap() + .assume_checked(); + + // Send MAX_PENDING_REQUESTS_PER_PEER create_order requests, all should succeed. + for _ in 0..MAX_PENDING_REQUESTS_PER_PEER { + let _ = client_handler.create_order( + &service_node_id, + order_params.clone(), + Some(refund_onchain_address.clone()), + ); + let req_msg = get_lsps_message!(client_node, service_node_id); + let result = service_node.liquidity_manager.handle_custom_message(req_msg, client_node_id); + assert!(result.is_ok()); + let event = service_node.liquidity_manager.next_event().unwrap(); + assert!(matches!( + event, + LiquidityEvent::LSPS1Service(LSPS1ServiceEvent::RequestForPaymentDetails { .. }) + )); + } + + // The next request should be rejected due to per-peer limit. + let rejected_req_id = client_handler.create_order( + &service_node_id, + order_params.clone(), + Some(refund_onchain_address), + ); + let rejected_req_msg = get_lsps_message!(client_node, service_node_id); + let result = + service_node.liquidity_manager.handle_custom_message(rejected_req_msg, client_node_id); + assert!(result.is_err(), "We should have hit the per-peer limit"); + + let error_response = get_lsps_message!(service_node, client_node_id); + let result = + client_node.liquidity_manager.handle_custom_message(error_response, service_node_id); + assert!(result.is_err()); + + let event = client_node.liquidity_manager.next_event().unwrap(); + if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderRequestFailed { + request_id, + counterparty_node_id, + error, + }) = event + { + assert_eq!(request_id, rejected_req_id); + assert_eq!(counterparty_node_id, service_node_id); + assert_eq!(error.code, 1); // LSPS0_CLIENT_REJECTED_ERROR_CODE + } else { + panic!("Expected LSPS1ClientEvent::OrderRequestFailed event"); + } +} From 15dbb21790fb9f38a345d7ec50a481ddd483baf0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 11 Feb 2026 13:48:19 +0100 Subject: [PATCH 207/627] Reject clients if request registration failed (e.g., duplicative Id) Signed-off-by: Elias Rohrer --- lightning-liquidity/src/lsps1/service.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 7cf0412f14e..e776ae262d1 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -339,6 +339,13 @@ where let request = LSPS1Request::CreateOrder(params.clone()); peer_state_lock.register_request(request_id.clone(), request).map_err(|e| { let err = format!("Failed to handle request due to: {}", e); + let response = LSPS1Response::CreateOrderError(LSPSResponseError { + code: LSPS0_CLIENT_REJECTED_ERROR_CODE, + message: err.clone(), + data: None, + }); + let msg = LSPS1Message::Response(request_id.clone(), response).into(); + message_queue_notifier.enqueue(counterparty_node_id, msg); let action = ErrorAction::IgnoreAndLog(Level::Error); LightningError { err, action } })?; From 4bec6db53b5be5ee2e3218eb1d380a1aaf7c0dec Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 17 Mar 2026 12:24:10 +0100 Subject: [PATCH 208/627] Validate all common fields in LSPS1 `is_valid` order check Add missing cross-validation of `LSPS1OrderParams` against `LSPS1Options` as required by bLIP-51: - Check `required_channel_confirmations` >= `min_required_channel_confirmations` - Check `funding_confirms_within_blocks` >= `min_funding_confirms_within_blocks` - Check total channel balance (`lsp_balance_sat` + `client_balance_sat`) is within [`min_channel_balance_sat`, `max_channel_balance_sat`], using `checked_add` to guard against overflow Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps1/service.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index e776ae262d1..0ac24203353 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -929,6 +929,11 @@ fn check_range(min: u64, max: u64, value: u64) -> bool { } fn is_valid(order: &LSPS1OrderParams, options: &LSPS1Options) -> bool { + let channel_balance_sat = match order.lsp_balance_sat.checked_add(order.client_balance_sat) { + Some(sum) => sum, + None => return false, + }; + check_range( options.min_initial_client_balance_sat, options.max_initial_client_balance_sat, @@ -941,5 +946,10 @@ fn is_valid(order: &LSPS1OrderParams, options: &LSPS1Options) -> bool { 1, options.max_channel_expiry_blocks.into(), order.channel_expiry_blocks.into(), - ) + ) && check_range( + options.min_channel_balance_sat, + options.max_channel_balance_sat, + channel_balance_sat, + ) && order.required_channel_confirmations >= options.min_required_channel_confirmations + && order.funding_confirms_within_blocks >= options.min_funding_confirms_within_blocks } From 47e5c04f64492ade647652463dd6e3435f1aa815 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 17 Mar 2026 14:31:45 +0100 Subject: [PATCH 209/627] Reset `persistence_in_flight` counter on error in LSPS1/LSPS2 Previously, if any `.await?` in the persist loop returned an error, the `?` would propagate out of `persist()` before reaching the `fetch_sub` at the end of the loop. This left the counter permanently > 0, causing all subsequent `persist()` calls to early-return and effectively disabling persistence for the lifetime of the handler. Fix this by extracting the loop into `do_persist()` and unconditionally resetting the counter via `store(0, Release)` in the outer `persist()` after `do_persist()` returns, regardless of success or failure. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps1/service.rs | 12 ++++++++++-- lightning-liquidity/src/lsps2/service.rs | 12 ++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 0ac24203353..0e139907589 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -145,14 +145,22 @@ where // TODO: We should eventually persist in parallel, however, when we do, we probably want to // introduce some batching to upper-bound the number of requests inflight at any given // time. - let mut did_persist = false; if self.persistence_in_flight.fetch_add(1, Ordering::AcqRel) > 0 { // If we're not the first event processor to get here, just return early, the increment // we just did will be treated as "go around again" at the end. - return Ok(did_persist); + return Ok(false); } + let res = self.do_persist().await; + debug_assert!(res.is_err() || self.persistence_in_flight.load(Ordering::Acquire) == 0); + self.persistence_in_flight.store(0, Ordering::Release); + res + } + + async fn do_persist(&self) -> Result { + let mut did_persist = false; + loop { let mut need_remove = Vec::new(); let mut need_persist = Vec::new(); diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index 665cda1df89..b7f6f2fc64d 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -1786,14 +1786,22 @@ where // TODO: We should eventually persist in parallel, however, when we do, we probably want to // introduce some batching to upper-bound the number of requests inflight at any given // time. - let mut did_persist = false; if self.persistence_in_flight.fetch_add(1, Ordering::AcqRel) > 0 { // If we're not the first event processor to get here, just return early, the increment // we just did will be treated as "go around again" at the end. - return Ok(did_persist); + return Ok(false); } + let res = self.do_persist().await; + debug_assert!(res.is_err() || self.persistence_in_flight.load(Ordering::Acquire) == 0); + self.persistence_in_flight.store(0, Ordering::Release); + res + } + + async fn do_persist(&self) -> Result { + let mut did_persist = false; + loop { let mut need_remove = Vec::new(); let mut need_persist = Vec::new(); From da7c3da232fbb6cdae42716eb25a5293ce7fa0e7 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Thu, 26 Feb 2026 09:03:17 +0100 Subject: [PATCH 210/627] Clarify that each pending monitor update ID must be marked complete The previous wording implied that persisting a full ChannelMonitor would automatically resolve all pending updates. Reword to make clear that each update ID still needs to be individually marked complete via channel_monitor_updated, even after a full monitor persistence. Co-Authored-By: Claude Opus 4.6 --- lightning/src/chain/chainmonitor.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lightning/src/chain/chainmonitor.rs b/lightning/src/chain/chainmonitor.rs index 7db1b697c2b..74e5e03d07f 100644 --- a/lightning/src/chain/chainmonitor.rs +++ b/lightning/src/chain/chainmonitor.rs @@ -83,8 +83,10 @@ use core::sync::atomic::{AtomicUsize, Ordering}; /// the background with [`ChainMonitor::list_pending_monitor_updates`] and /// [`ChainMonitor::get_monitor`]. /// -/// Once a full [`ChannelMonitor`] has been persisted, all pending updates for that channel can -/// be marked as complete via [`ChainMonitor::channel_monitor_updated`]. +/// Each pending update must be individually marked as complete by calling +/// [`ChainMonitor::channel_monitor_updated`] with the corresponding update ID. Note that +/// persisting a full [`ChannelMonitor`] covers all prior updates, but each update ID still +/// needs to be marked complete separately. /// /// If at some point no further progress can be made towards persisting the pending updates, the /// node should simply shut down. From 876449fe29239bab242753d373a04814a4cbd006 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Wed, 25 Feb 2026 09:01:13 +0100 Subject: [PATCH 211/627] Extract shared dummy_monitor helper in channelmonitor.rs Extract the ChannelMonitor construction boilerplate that was duplicated across channelmonitor test functions into a reusable #[cfg(test)] pub(super) dummy_monitor helper, generic over the signer type. AI tools were used in preparing this commit. --- lightning/src/chain/channelmonitor.rs | 162 +++++++++++--------------- 1 file changed, 70 insertions(+), 92 deletions(-) diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs index 636c6cb28be..4b8fdd6b230 100644 --- a/lightning/src/chain/channelmonitor.rs +++ b/lightning/src/chain/channelmonitor.rs @@ -6749,6 +6749,71 @@ impl<'a, 'b, ES: EntropySource, SP: SignerProvider> ReadableArgs<(&'a ES, &'b SP } } +#[cfg(test)] +pub(super) fn dummy_monitor( + channel_id: ChannelId, wrap_signer: impl FnOnce(crate::sign::InMemorySigner) -> S, +) -> ChannelMonitor { + use crate::ln::chan_utils::{ChannelPublicKeys, CounterpartyChannelTransactionParameters}; + use crate::sign::{ChannelSigner, InMemorySigner}; + use bitcoin::network::Network; + + let secp_ctx = Secp256k1::new(); + let dummy_key = + PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap()); + let keys = InMemorySigner::new( + SecretKey::from_slice(&[41; 32]).unwrap(), + SecretKey::from_slice(&[41; 32]).unwrap(), + SecretKey::from_slice(&[41; 32]).unwrap(), + SecretKey::from_slice(&[41; 32]).unwrap(), + true, + SecretKey::from_slice(&[41; 32]).unwrap(), + SecretKey::from_slice(&[41; 32]).unwrap(), + [41; 32], + [0; 32], + [0; 32], + ); + let counterparty_pubkeys = ChannelPublicKeys { + funding_pubkey: dummy_key, + revocation_basepoint: RevocationBasepoint::from(dummy_key), + payment_point: dummy_key, + delayed_payment_basepoint: DelayedPaymentBasepoint::from(dummy_key), + htlc_basepoint: HtlcBasepoint::from(dummy_key), + }; + let funding_outpoint = + crate::chain::transaction::OutPoint { txid: Txid::all_zeros(), index: u16::MAX }; + let channel_parameters = ChannelTransactionParameters { + holder_pubkeys: keys.pubkeys(&secp_ctx), + holder_selected_contest_delay: 66, + is_outbound_from_holder: true, + counterparty_parameters: Some(CounterpartyChannelTransactionParameters { + pubkeys: counterparty_pubkeys, + selected_contest_delay: 67, + }), + funding_outpoint: Some(funding_outpoint), + splice_parent_funding_txid: None, + channel_type_features: ChannelTypeFeatures::only_static_remote_key(), + channel_value_satoshis: 0, + }; + let shutdown_script = crate::ln::script::ShutdownScript::new_p2wpkh_from_pubkey(dummy_key); + let best_block = BestBlock::from_network(Network::Testnet); + let signer = wrap_signer(keys); + ChannelMonitor::new( + secp_ctx, + signer, + Some(shutdown_script.into_inner()), + 0, + &ScriptBuf::new(), + &channel_parameters, + true, + 0, + HolderCommitmentTransaction::dummy(0, funding_outpoint, Vec::new()), + best_block, + dummy_key, + channel_id, + false, + ) +} + #[cfg(test)] mod tests { use bitcoin::amount::Amount; @@ -6778,23 +6843,16 @@ mod tests { weight_revoked_received_htlc, WEIGHT_REVOKED_OUTPUT, }; use crate::chain::transaction::OutPoint; - use crate::chain::{BestBlock, Confirm}; + use crate::chain::Confirm; use crate::io; - use crate::ln::chan_utils::{ - self, ChannelPublicKeys, ChannelTransactionParameters, - CounterpartyChannelTransactionParameters, HTLCOutputInCommitment, - HolderCommitmentTransaction, - }; + use crate::ln::chan_utils::{self, HTLCOutputInCommitment, HolderCommitmentTransaction}; use crate::ln::channel_keys::{ - DelayedPaymentBasepoint, DelayedPaymentKey, HtlcBasepoint, RevocationBasepoint, - RevocationKey, + DelayedPaymentBasepoint, DelayedPaymentKey, RevocationBasepoint, RevocationKey, }; use crate::ln::channelmanager::{HTLCSource, PaymentId}; use crate::ln::functional_test_utils::*; use crate::ln::outbound_payment::RecipientOnionFields; - use crate::ln::script::ShutdownScript; use crate::ln::types::ChannelId; - use crate::sign::{ChannelSigner, InMemorySigner}; use crate::sync::Arc; use crate::types::features::ChannelTypeFeatures; use crate::types::payment::{PaymentHash, PaymentPreimage}; @@ -6964,51 +7022,11 @@ mod tests { } } - let keys = InMemorySigner::new( - SecretKey::from_slice(&[41; 32]).unwrap(), - SecretKey::from_slice(&[41; 32]).unwrap(), - SecretKey::from_slice(&[41; 32]).unwrap(), - SecretKey::from_slice(&[41; 32]).unwrap(), - true, - SecretKey::from_slice(&[41; 32]).unwrap(), - SecretKey::from_slice(&[41; 32]).unwrap(), - [41; 32], - [0; 32], - [0; 32], - ); - - let counterparty_pubkeys = ChannelPublicKeys { - funding_pubkey: PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[44; 32]).unwrap()), - revocation_basepoint: RevocationBasepoint::from(PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[45; 32]).unwrap())), - payment_point: PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[46; 32]).unwrap()), - delayed_payment_basepoint: DelayedPaymentBasepoint::from(PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[47; 32]).unwrap())), - htlc_basepoint: HtlcBasepoint::from(PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[48; 32]).unwrap())) - }; let funding_outpoint = OutPoint { txid: Txid::all_zeros(), index: u16::MAX }; let channel_id = ChannelId::v1_from_funding_outpoint(funding_outpoint); - let channel_parameters = ChannelTransactionParameters { - holder_pubkeys: keys.pubkeys(&secp_ctx), - holder_selected_contest_delay: 66, - is_outbound_from_holder: true, - counterparty_parameters: Some(CounterpartyChannelTransactionParameters { - pubkeys: counterparty_pubkeys, - selected_contest_delay: 67, - }), - funding_outpoint: Some(funding_outpoint), - splice_parent_funding_txid: None, - channel_type_features: ChannelTypeFeatures::only_static_remote_key(), - channel_value_satoshis: 0, - }; // Prune with one old state and a holder commitment tx holding a few overlaps with the // old state. - let shutdown_pubkey = PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap()); - let shutdown_script = ShutdownScript::new_p2wpkh_from_pubkey(shutdown_pubkey); - let best_block = BestBlock::from_network(Network::Testnet); - let monitor = ChannelMonitor::new( - Secp256k1::new(), keys, Some(shutdown_script.into_inner()), 0, &ScriptBuf::new(), - &channel_parameters, true, 0, HolderCommitmentTransaction::dummy(0, funding_outpoint, Vec::new()), - best_block, dummy_key, channel_id, false, - ); + let monitor = super::dummy_monitor(channel_id, |keys| keys); let nondust_htlcs = preimages_slice_to_htlcs!(preimages[0..10]); let dummy_commitment_tx = HolderCommitmentTransaction::dummy(0, funding_outpoint, nondust_htlcs); @@ -7227,49 +7245,9 @@ mod tests { let dummy_key = PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap()); - let keys = InMemorySigner::new( - SecretKey::from_slice(&[41; 32]).unwrap(), - SecretKey::from_slice(&[41; 32]).unwrap(), - SecretKey::from_slice(&[41; 32]).unwrap(), - SecretKey::from_slice(&[41; 32]).unwrap(), - true, - SecretKey::from_slice(&[41; 32]).unwrap(), - SecretKey::from_slice(&[41; 32]).unwrap(), - [41; 32], - [0; 32], - [0; 32], - ); - - let counterparty_pubkeys = ChannelPublicKeys { - funding_pubkey: PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[44; 32]).unwrap()), - revocation_basepoint: RevocationBasepoint::from(PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[45; 32]).unwrap())), - payment_point: PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[46; 32]).unwrap()), - delayed_payment_basepoint: DelayedPaymentBasepoint::from(PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[47; 32]).unwrap())), - htlc_basepoint: HtlcBasepoint::from(PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[48; 32]).unwrap())), - }; let funding_outpoint = OutPoint { txid: Txid::all_zeros(), index: u16::MAX }; let channel_id = ChannelId::v1_from_funding_outpoint(funding_outpoint); - let channel_parameters = ChannelTransactionParameters { - holder_pubkeys: keys.pubkeys(&secp_ctx), - holder_selected_contest_delay: 66, - is_outbound_from_holder: true, - counterparty_parameters: Some(CounterpartyChannelTransactionParameters { - pubkeys: counterparty_pubkeys, - selected_contest_delay: 67, - }), - funding_outpoint: Some(funding_outpoint), - splice_parent_funding_txid: None, - channel_type_features: ChannelTypeFeatures::only_static_remote_key(), - channel_value_satoshis: 0, - }; - let shutdown_pubkey = PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap()); - let shutdown_script = ShutdownScript::new_p2wpkh_from_pubkey(shutdown_pubkey); - let best_block = BestBlock::from_network(Network::Testnet); - let monitor = ChannelMonitor::new( - Secp256k1::new(), keys, Some(shutdown_script.into_inner()), 0, &ScriptBuf::new(), - &channel_parameters, true, 0, HolderCommitmentTransaction::dummy(0, funding_outpoint, Vec::new()), - best_block, dummy_key, channel_id, false, - ); + let monitor = super::dummy_monitor(channel_id, |keys| keys); let chan_id = monitor.inner.lock().unwrap().channel_id(); let payment_hash = PaymentHash([1; 32]); From dbc1365fb4a621588cb12b4088395e58ecd10e2f Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Mon, 9 Feb 2026 13:55:24 +0100 Subject: [PATCH 212/627] Extract watch_channel_internal/update_channel_internal from Watch impl Pure refactor: move the bodies of Watch::watch_channel and Watch::update_channel into methods on ChainMonitor, and have the Watch trait methods delegate to them. This prepares for adding deferred mode where the Watch methods will conditionally queue operations instead of executing them immediately. Co-Authored-By: Claude Opus 4.6 --- lightning/src/chain/chainmonitor.rs | 300 +++++++++++++++------------- 1 file changed, 156 insertions(+), 144 deletions(-) diff --git a/lightning/src/chain/chainmonitor.rs b/lightning/src/chain/chainmonitor.rs index 74e5e03d07f..17f79528b07 100644 --- a/lightning/src/chain/chainmonitor.rs +++ b/lightning/src/chain/chainmonitor.rs @@ -1060,6 +1060,160 @@ where Ok(ChannelMonitorUpdateStatus::Completed) } + + fn watch_channel_internal( + &self, channel_id: ChannelId, monitor: ChannelMonitor, + ) -> Result { + let logger = WithChannelMonitor::from(&self.logger, &monitor, None); + let mut monitors = self.monitors.write().unwrap(); + let entry = match monitors.entry(channel_id) { + hash_map::Entry::Occupied(_) => { + log_error!(logger, "Failed to add new channel data: channel monitor for given channel ID is already present"); + return Err(()); + }, + hash_map::Entry::Vacant(e) => e, + }; + log_trace!(logger, "Got new ChannelMonitor"); + let update_id = monitor.get_latest_update_id(); + let mut pending_monitor_updates = Vec::new(); + let persist_res = self.persister.persist_new_channel(monitor.persistence_key(), &monitor); + match persist_res { + ChannelMonitorUpdateStatus::InProgress => { + log_info!(logger, "Persistence of new ChannelMonitor in progress",); + pending_monitor_updates.push(update_id); + }, + ChannelMonitorUpdateStatus::Completed => { + log_info!(logger, "Persistence of new ChannelMonitor completed",); + }, + ChannelMonitorUpdateStatus::UnrecoverableError => { + let err_str = "ChannelMonitor[Update] persistence failed unrecoverably. This indicates we cannot continue normal operation and must shut down."; + log_error!(logger, "{}", err_str); + panic!("{}", err_str); + }, + } + if let Some(ref chain_source) = self.chain_source { + monitor.load_outputs_to_watch(chain_source, &self.logger); + } + entry.insert(MonitorHolder { + monitor, + pending_monitor_updates: Mutex::new(pending_monitor_updates), + }); + Ok(persist_res) + } + + fn update_channel_internal( + &self, channel_id: ChannelId, update: &ChannelMonitorUpdate, + ) -> ChannelMonitorUpdateStatus { + // `ChannelMonitorUpdate`'s `channel_id` is `None` prior to 0.0.121 and all channels in those + // versions are V1-established. For 0.0.121+ the `channel_id` fields is always `Some`. + debug_assert_eq!(update.channel_id.unwrap(), channel_id); + // Update the monitor that watches the channel referred to by the given outpoint. + let monitors = self.monitors.read().unwrap(); + match monitors.get(&channel_id) { + None => { + let logger = WithContext::from(&self.logger, None, Some(channel_id), None); + log_error!(logger, "Failed to update channel monitor: no such monitor registered"); + + // We should never ever trigger this from within ChannelManager. Technically a + // user could use this object with some proxying in between which makes this + // possible, but in tests and fuzzing, this should be a panic. + #[cfg(debug_assertions)] + panic!("ChannelManager generated a channel update for a channel that was not yet registered!"); + #[cfg(not(debug_assertions))] + ChannelMonitorUpdateStatus::InProgress + }, + Some(monitor_state) => { + let monitor = &monitor_state.monitor; + let logger = WithChannelMonitor::from(&self.logger, &monitor, None); + log_trace!(logger, "Updating ChannelMonitor to id {}", update.update_id,); + + // We hold a `pending_monitor_updates` lock through `update_monitor` to ensure we + // have well-ordered updates from the users' point of view. See the + // `pending_monitor_updates` docs for more. + let mut pending_monitor_updates = + monitor_state.pending_monitor_updates.lock().unwrap(); + let update_res = monitor.update_monitor( + update, + &self.broadcaster, + &self.fee_estimator, + &self.logger, + ); + + let update_id = update.update_id; + let persist_res = if update_res.is_err() { + // Even if updating the monitor returns an error, the monitor's state will + // still be changed. Therefore, we should persist the updated monitor despite the error. + // We don't want to persist a `monitor_update` which results in a failure to apply later + // while reading `channel_monitor` with updates from storage. Instead, we should persist + // the entire `channel_monitor` here. + log_warn!(logger, "Failed to update ChannelMonitor. Going ahead and persisting the entire ChannelMonitor"); + self.persister.update_persisted_channel( + monitor.persistence_key(), + None, + monitor, + ) + } else { + self.persister.update_persisted_channel( + monitor.persistence_key(), + Some(update), + monitor, + ) + }; + match persist_res { + ChannelMonitorUpdateStatus::InProgress => { + pending_monitor_updates.push(update_id); + log_debug!( + logger, + "Persistence of ChannelMonitorUpdate id {:?} in progress", + update_id, + ); + }, + ChannelMonitorUpdateStatus::Completed => { + log_debug!( + logger, + "Persistence of ChannelMonitorUpdate id {:?} completed", + update_id, + ); + }, + ChannelMonitorUpdateStatus::UnrecoverableError => { + // Take the monitors lock for writing so that we poison it and any future + // operations going forward fail immediately. + core::mem::drop(pending_monitor_updates); + core::mem::drop(monitors); + let _poison = self.monitors.write().unwrap(); + let err_str = "ChannelMonitor[Update] persistence failed unrecoverably. This indicates we cannot continue normal operation and must shut down."; + log_error!(logger, "{}", err_str); + panic!("{}", err_str); + }, + } + + // We may need to start monitoring for any alternative funding transactions. + if let Some(ref chain_source) = self.chain_source { + for (funding_outpoint, funding_script) in + update.internal_renegotiated_funding_data() + { + log_trace!( + logger, + "Registering renegotiated funding outpoint {} with the filter to monitor confirmations and spends", + funding_outpoint + ); + chain_source.register_tx(&funding_outpoint.txid, &funding_script); + chain_source.register_output(WatchedOutput { + block_hash: None, + outpoint: funding_outpoint, + script_pubkey: funding_script, + }); + } + } + + if update_res.is_err() { + ChannelMonitorUpdateStatus::InProgress + } else { + persist_res + } + }, + } + } } impl< @@ -1274,155 +1428,13 @@ where fn watch_channel( &self, channel_id: ChannelId, monitor: ChannelMonitor, ) -> Result { - let logger = WithChannelMonitor::from(&self.logger, &monitor, None); - let mut monitors = self.monitors.write().unwrap(); - let entry = match monitors.entry(channel_id) { - hash_map::Entry::Occupied(_) => { - log_error!(logger, "Failed to add new channel data: channel monitor for given channel ID is already present"); - return Err(()); - }, - hash_map::Entry::Vacant(e) => e, - }; - log_trace!(logger, "Got new ChannelMonitor"); - let update_id = monitor.get_latest_update_id(); - let mut pending_monitor_updates = Vec::new(); - let persist_res = self.persister.persist_new_channel(monitor.persistence_key(), &monitor); - match persist_res { - ChannelMonitorUpdateStatus::InProgress => { - log_info!(logger, "Persistence of new ChannelMonitor in progress",); - pending_monitor_updates.push(update_id); - }, - ChannelMonitorUpdateStatus::Completed => { - log_info!(logger, "Persistence of new ChannelMonitor completed",); - }, - ChannelMonitorUpdateStatus::UnrecoverableError => { - let err_str = "ChannelMonitor[Update] persistence failed unrecoverably. This indicates we cannot continue normal operation and must shut down."; - log_error!(logger, "{}", err_str); - panic!("{}", err_str); - }, - } - if let Some(ref chain_source) = self.chain_source { - monitor.load_outputs_to_watch(chain_source, &self.logger); - } - entry.insert(MonitorHolder { - monitor, - pending_monitor_updates: Mutex::new(pending_monitor_updates), - }); - Ok(persist_res) + self.watch_channel_internal(channel_id, monitor) } fn update_channel( &self, channel_id: ChannelId, update: &ChannelMonitorUpdate, ) -> ChannelMonitorUpdateStatus { - // `ChannelMonitorUpdate`'s `channel_id` is `None` prior to 0.0.121 and all channels in those - // versions are V1-established. For 0.0.121+ the `channel_id` fields is always `Some`. - debug_assert_eq!(update.channel_id.unwrap(), channel_id); - // Update the monitor that watches the channel referred to by the given outpoint. - let monitors = self.monitors.read().unwrap(); - match monitors.get(&channel_id) { - None => { - let logger = WithContext::from(&self.logger, None, Some(channel_id), None); - log_error!(logger, "Failed to update channel monitor: no such monitor registered"); - - // We should never ever trigger this from within ChannelManager. Technically a - // user could use this object with some proxying in between which makes this - // possible, but in tests and fuzzing, this should be a panic. - #[cfg(debug_assertions)] - panic!("ChannelManager generated a channel update for a channel that was not yet registered!"); - #[cfg(not(debug_assertions))] - ChannelMonitorUpdateStatus::InProgress - }, - Some(monitor_state) => { - let monitor = &monitor_state.monitor; - let logger = WithChannelMonitor::from(&self.logger, &monitor, None); - log_trace!(logger, "Updating ChannelMonitor to id {}", update.update_id,); - - // We hold a `pending_monitor_updates` lock through `update_monitor` to ensure we - // have well-ordered updates from the users' point of view. See the - // `pending_monitor_updates` docs for more. - let mut pending_monitor_updates = - monitor_state.pending_monitor_updates.lock().unwrap(); - let update_res = monitor.update_monitor( - update, - &self.broadcaster, - &self.fee_estimator, - &self.logger, - ); - - let update_id = update.update_id; - let persist_res = if update_res.is_err() { - // Even if updating the monitor returns an error, the monitor's state will - // still be changed. Therefore, we should persist the updated monitor despite the error. - // We don't want to persist a `monitor_update` which results in a failure to apply later - // while reading `channel_monitor` with updates from storage. Instead, we should persist - // the entire `channel_monitor` here. - log_warn!(logger, "Failed to update ChannelMonitor. Going ahead and persisting the entire ChannelMonitor"); - self.persister.update_persisted_channel( - monitor.persistence_key(), - None, - monitor, - ) - } else { - self.persister.update_persisted_channel( - monitor.persistence_key(), - Some(update), - monitor, - ) - }; - match persist_res { - ChannelMonitorUpdateStatus::InProgress => { - pending_monitor_updates.push(update_id); - log_debug!( - logger, - "Persistence of ChannelMonitorUpdate id {:?} in progress", - update_id, - ); - }, - ChannelMonitorUpdateStatus::Completed => { - log_debug!( - logger, - "Persistence of ChannelMonitorUpdate id {:?} completed", - update_id, - ); - }, - ChannelMonitorUpdateStatus::UnrecoverableError => { - // Take the monitors lock for writing so that we poison it and any future - // operations going forward fail immediately. - core::mem::drop(pending_monitor_updates); - core::mem::drop(monitors); - let _poison = self.monitors.write().unwrap(); - let err_str = "ChannelMonitor[Update] persistence failed unrecoverably. This indicates we cannot continue normal operation and must shut down."; - log_error!(logger, "{}", err_str); - panic!("{}", err_str); - }, - } - - // We may need to start monitoring for any alternative funding transactions. - if let Some(ref chain_source) = self.chain_source { - for (funding_outpoint, funding_script) in - update.internal_renegotiated_funding_data() - { - log_trace!( - logger, - "Registering renegotiated funding outpoint {} with the filter to monitor confirmations and spends", - funding_outpoint - ); - chain_source.register_tx(&funding_outpoint.txid, &funding_script); - chain_source.register_output(WatchedOutput { - block_hash: None, - outpoint: funding_outpoint, - script_pubkey: funding_script, - }); - } - } - - if update_res.is_err() { - ChannelMonitorUpdateStatus::InProgress - } else { - persist_res - } - }, - } + self.update_channel_internal(channel_id, update) } fn release_pending_monitor_events( From 9b015a67f82a0c7fbaf09a43f5ed70e0405879cb Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Mon, 9 Feb 2026 14:46:35 +0100 Subject: [PATCH 213/627] Add deferred bool to ChainMonitor Add a `deferred` parameter to `ChainMonitor::new` and `ChainMonitor::new_async_beta`. When set to true, the Watch trait methods (watch_channel and update_channel) will unimplemented!() for now. All existing callers pass false to preserve current behavior. Co-Authored-By: Claude Opus 4.6 --- fuzz/src/chanmon_consistency.rs | 1 + fuzz/src/full_stack.rs | 1 + fuzz/src/lsps_message.rs | 1 + lightning/src/chain/chainmonitor.rs | 21 +++++++++++++++---- lightning/src/ln/chanmon_update_fail_tests.rs | 1 + lightning/src/ln/channelmanager.rs | 4 ++-- lightning/src/util/test_utils.rs | 1 + 7 files changed, 24 insertions(+), 6 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 53591adfe6e..45e9a68cb63 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -282,6 +282,7 @@ impl TestChainMonitor { Arc::clone(&persister), Arc::clone(&keys), keys.get_peer_storage_key(), + false, )), logger, keys, diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index 5dfa51079d8..47aebf41ac9 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -603,6 +603,7 @@ pub fn do_test(mut data: &[u8], logger: &Arc Arc::new(TestPersister { update_ret: Mutex::new(ChannelMonitorUpdateStatus::Completed) }), Arc::clone(&keys_manager), keys_manager.get_peer_storage_key(), + false, )); let network = Network::Bitcoin; diff --git a/fuzz/src/lsps_message.rs b/fuzz/src/lsps_message.rs index 42feed48cc1..8ff85d0fc24 100644 --- a/fuzz/src/lsps_message.rs +++ b/fuzz/src/lsps_message.rs @@ -59,6 +59,7 @@ pub fn do_test(data: &[u8]) { Arc::clone(&kv_store), Arc::clone(&keys_manager), keys_manager.get_peer_storage_key(), + false, )); let best_block = BestBlock::from_network(network); let params = ChainParameters { network, best_block }; diff --git a/lightning/src/chain/chainmonitor.rs b/lightning/src/chain/chainmonitor.rs index 17f79528b07..99f792fc531 100644 --- a/lightning/src/chain/chainmonitor.rs +++ b/lightning/src/chain/chainmonitor.rs @@ -373,6 +373,9 @@ pub struct ChainMonitor< #[cfg(peer_storage)] our_peerstorage_encryption_key: PeerStorageKey, + + /// When `true`, [`chain::Watch`] operations are queued rather than executed immediately. + deferred: bool, } impl< @@ -399,7 +402,7 @@ where pub fn new_async_beta( chain_source: Option, broadcaster: T, logger: L, feeest: F, persister: MonitorUpdatingPersisterAsync, _entropy_source: ES, - _our_peerstorage_encryption_key: PeerStorageKey, + _our_peerstorage_encryption_key: PeerStorageKey, deferred: bool, ) -> Self { let event_notifier = Arc::new(Notifier::new()); Self { @@ -416,6 +419,7 @@ where pending_send_only_events: Mutex::new(Vec::new()), #[cfg(peer_storage)] our_peerstorage_encryption_key: _our_peerstorage_encryption_key, + deferred, } } } @@ -605,7 +609,7 @@ where /// [`ChannelManager`]: crate::ln::channelmanager::ChannelManager pub fn new( chain_source: Option, broadcaster: T, logger: L, feeest: F, persister: P, - _entropy_source: ES, _our_peerstorage_encryption_key: PeerStorageKey, + _entropy_source: ES, _our_peerstorage_encryption_key: PeerStorageKey, deferred: bool, ) -> Self { Self { monitors: RwLock::new(new_hash_map()), @@ -621,6 +625,7 @@ where pending_send_only_events: Mutex::new(Vec::new()), #[cfg(peer_storage)] our_peerstorage_encryption_key: _our_peerstorage_encryption_key, + deferred, } } @@ -1428,13 +1433,21 @@ where fn watch_channel( &self, channel_id: ChannelId, monitor: ChannelMonitor, ) -> Result { - self.watch_channel_internal(channel_id, monitor) + if !self.deferred { + return self.watch_channel_internal(channel_id, monitor); + } + + unimplemented!(); } fn update_channel( &self, channel_id: ChannelId, update: &ChannelMonitorUpdate, ) -> ChannelMonitorUpdateStatus { - self.update_channel_internal(channel_id, update) + if !self.deferred { + return self.update_channel_internal(channel_id, update); + } + + unimplemented!(); } fn release_pending_monitor_events( diff --git a/lightning/src/ln/chanmon_update_fail_tests.rs b/lightning/src/ln/chanmon_update_fail_tests.rs index 36428256d67..a92af3ebc6e 100644 --- a/lightning/src/ln/chanmon_update_fail_tests.rs +++ b/lightning/src/ln/chanmon_update_fail_tests.rs @@ -4927,6 +4927,7 @@ fn native_async_persist() { native_async_persister, Arc::clone(&keys_manager), keys_manager.get_peer_storage_key(), + false, ); // Write the initial ChannelMonitor async, testing primarily that the `MonitorEvent::Completed` diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index b823864a6cc..70617b20894 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -21747,7 +21747,7 @@ pub mod bench { let seed_a = [1u8; 32]; let keys_manager_a = KeysManager::new(&seed_a, 42, 42, true); - let chain_monitor_a = ChainMonitor::new(None, &tx_broadcaster, &logger_a, &fee_estimator, &persister_a, &keys_manager_a, keys_manager_a.get_peer_storage_key()); + let chain_monitor_a = ChainMonitor::new(None, &tx_broadcaster, &logger_a, &fee_estimator, &persister_a, &keys_manager_a, keys_manager_a.get_peer_storage_key(), false); let node_a = ChannelManager::new(&fee_estimator, &chain_monitor_a, &tx_broadcaster, &router, &message_router, &logger_a, &keys_manager_a, &keys_manager_a, &keys_manager_a, config.clone(), ChainParameters { network, best_block: BestBlock::from_network(network), @@ -21757,7 +21757,7 @@ pub mod bench { let logger_b = test_utils::TestLogger::with_id("node a".to_owned()); let seed_b = [2u8; 32]; let keys_manager_b = KeysManager::new(&seed_b, 42, 42, true); - let chain_monitor_b = ChainMonitor::new(None, &tx_broadcaster, &logger_a, &fee_estimator, &persister_b, &keys_manager_b, keys_manager_b.get_peer_storage_key()); + let chain_monitor_b = ChainMonitor::new(None, &tx_broadcaster, &logger_a, &fee_estimator, &persister_b, &keys_manager_b, keys_manager_b.get_peer_storage_key(), false); let node_b = ChannelManager::new(&fee_estimator, &chain_monitor_b, &tx_broadcaster, &router, &message_router, &logger_b, &keys_manager_b, &keys_manager_b, &keys_manager_b, config.clone(), ChainParameters { network, best_block: BestBlock::from_network(network), diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 6c19af55f60..1009d2ad3c4 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -536,6 +536,7 @@ impl<'a> TestChainMonitor<'a> { persister, keys_manager, keys_manager.get_peer_storage_key(), + false, ), keys_manager, expect_channel_force_closed: Mutex::new(None), From 3f1345bed96049985fe8de16d06bf5533f00f8ad Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Mon, 9 Feb 2026 14:48:31 +0100 Subject: [PATCH 214/627] Implement deferred monitor write queueing and flushing Replace the unimplemented!() stubs with a full deferred write implementation. When ChainMonitor has deferred=true, Watch trait operations queue PendingMonitorOp entries instead of executing immediately. A new flush() method drains the queue and forwards operations to the internal watch/update methods, calling channel_monitor_updated on Completed status. The BackgroundProcessor is updated to capture pending_operation_count before persisting the ChannelManager, then flush that many writes afterward - ensuring monitor writes happen in the correct order relative to manager persistence. Key changes: - Add PendingMonitorOp enum and pending_ops queue to ChainMonitor - Implement flush() and pending_operation_count() public methods - Integrate flush calls in BackgroundProcessor (both sync and async) - Add TestChainMonitor::new_deferred, flush helpers, and auto-flush in release_pending_monitor_events for test compatibility - Add create_node_cfgs_deferred for deferred-mode test networks - Add unit tests for queue/flush mechanics and full payment flow Co-Authored-By: Claude Opus 4.6 --- lightning-background-processor/src/lib.rs | 94 +++++- lightning/src/chain/chainmonitor.rs | 337 +++++++++++++++++++++- lightning/src/ln/functional_test_utils.rs | 44 ++- lightning/src/util/test_utils.rs | 59 +++- 4 files changed, 517 insertions(+), 17 deletions(-) diff --git a/lightning-background-processor/src/lib.rs b/lightning-background-processor/src/lib.rs index fc58eda8eee..594681d9782 100644 --- a/lightning-background-processor/src/lib.rs +++ b/lightning-background-processor/src/lib.rs @@ -773,6 +773,17 @@ use futures_util::{dummy_waker, Joiner, OptionalSelector, Selector, SelectorOutp /// The `fetch_time` parameter should return the current wall clock time, if one is available. If /// no time is available, some features may be disabled, however the node will still operate fine. /// +/// Note that when deferred monitor writes are enabled on [`ChainMonitor`], this function flushes +/// pending writes after persisting the [`ChannelManager`]. If the [`Persist`] implementation +/// performs blocking I/O and returns [`Completed`] synchronously rather than returning +/// [`InProgress`], this will block the async executor. +/// +/// [`ChainMonitor`]: lightning::chain::chainmonitor::ChainMonitor +/// [`Persist`]: lightning::chain::chainmonitor::Persist +/// [`ChannelManager`]: lightning::ln::channelmanager::ChannelManager +/// [`Completed`]: lightning::chain::ChannelMonitorUpdateStatus::Completed +/// [`InProgress`]: lightning::chain::ChannelMonitorUpdateStatus::InProgress +/// /// For example, in order to process background events in a [Tokio](https://tokio.rs/) task, you /// could setup `process_events_async` like this: /// ``` @@ -1116,9 +1127,18 @@ where None => {}, } + // We capture pending_operation_count inside the persistence branch to + // avoid a race: ChannelManager handlers queue deferred monitor ops + // before the persistence flag is set. Capturing outside would let us + // observe pending ops while the flag is still unset, causing us to + // flush monitor writes without persisting the ChannelManager. + // Declared before futures so it outlives the Joiner (drop order). + let pending_monitor_writes; + let mut futures = Joiner::new(); if channel_manager.get_cm().get_and_clear_needs_persistence() { + pending_monitor_writes = chain_monitor.get_cm().pending_operation_count(); log_trace!(logger, "Persisting ChannelManager..."); let fut = async { @@ -1129,7 +1149,12 @@ where CHANNEL_MANAGER_PERSISTENCE_KEY, channel_manager.get_cm().encode(), ) - .await + .await?; + + // Flush monitor operations that were pending before we persisted. New updates + // that arrived after are left for the next iteration. + chain_monitor.get_cm().flush(pending_monitor_writes, &logger); + Ok(()) }; // TODO: Once our MSRV is 1.68 we should be able to drop the Box let mut fut = Box::pin(fut); @@ -1371,6 +1396,7 @@ where // After we exit, ensure we persist the ChannelManager one final time - this avoids // some races where users quit while channel updates were in-flight, with // ChannelMonitor update(s) persisted without a corresponding ChannelManager update. + let pending_monitor_writes = chain_monitor.get_cm().pending_operation_count(); kv_store .write( CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, @@ -1379,6 +1405,10 @@ where channel_manager.get_cm().encode(), ) .await?; + + // Flush monitor operations that were pending before final persistence. + chain_monitor.get_cm().flush(pending_monitor_writes, &logger); + if let Some(ref scorer) = scorer { kv_store .write( @@ -1682,7 +1712,15 @@ impl BackgroundProcessor { channel_manager.get_cm().timer_tick_occurred(); last_freshness_call = Instant::now(); } + if channel_manager.get_cm().get_and_clear_needs_persistence() { + // We capture pending_operation_count inside the persistence + // branch to avoid a race: ChannelManager handlers queue + // deferred monitor ops before the persistence flag is set. + // Capturing outside would let us observe pending ops while + // the flag is still unset, causing us to flush monitor + // writes without persisting the ChannelManager. + let pending_monitor_writes = chain_monitor.get_cm().pending_operation_count(); log_trace!(logger, "Persisting ChannelManager..."); (kv_store.write( CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, @@ -1691,6 +1729,10 @@ impl BackgroundProcessor { channel_manager.get_cm().encode(), ))?; log_trace!(logger, "Done persisting ChannelManager."); + + // Flush monitor operations that were pending before we persisted. + // New updates that arrived after are left for the next iteration. + chain_monitor.get_cm().flush(pending_monitor_writes, &logger); } if let Some(liquidity_manager) = liquidity_manager.as_ref() { @@ -1807,12 +1849,17 @@ impl BackgroundProcessor { // After we exit, ensure we persist the ChannelManager one final time - this avoids // some races where users quit while channel updates were in-flight, with // ChannelMonitor update(s) persisted without a corresponding ChannelManager update. + let pending_monitor_writes = chain_monitor.get_cm().pending_operation_count(); kv_store.write( CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, CHANNEL_MANAGER_PERSISTENCE_KEY, channel_manager.get_cm().encode(), )?; + + // Flush monitor operations that were pending before final persistence. + chain_monitor.get_cm().flush(pending_monitor_writes, &logger); + if let Some(ref scorer) = scorer { kv_store.write( SCORER_PERSISTENCE_PRIMARY_NAMESPACE, @@ -1894,9 +1941,10 @@ mod tests { use bitcoin::transaction::{Transaction, TxOut}; use bitcoin::{Amount, ScriptBuf, Txid}; use core::sync::atomic::{AtomicBool, Ordering}; + use lightning::chain::chainmonitor; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; use lightning::chain::transaction::OutPoint; - use lightning::chain::{chainmonitor, BestBlock, Confirm}; + use lightning::chain::{BestBlock, Confirm}; use lightning::events::{Event, PathFailure, ReplayEvent}; use lightning::ln::channelmanager; use lightning::ln::channelmanager::{ @@ -2441,6 +2489,7 @@ mod tests { Arc::clone(&kv_store), Arc::clone(&keys_manager), keys_manager.get_peer_storage_key(), + true, )); let best_block = BestBlock::from_network(network); let params = ChainParameters { network, best_block }; @@ -2562,6 +2611,8 @@ mod tests { (persist_dir, nodes) } + /// Opens a channel between two nodes without a running `BackgroundProcessor`, + /// so deferred monitor operations are flushed manually at each step. macro_rules! open_channel { ($node_a: expr, $node_b: expr, $channel_value: expr) => {{ begin_open_channel!($node_a, $node_b, $channel_value); @@ -2577,12 +2628,19 @@ mod tests { tx.clone(), ) .unwrap(); + // funding_transaction_generated does not call watch_channel, so no + // deferred op is queued and FundingCreated is available immediately. let msg_a = get_event_msg!( $node_a, MessageSendEvent::SendFundingCreated, $node_b.node.get_our_node_id() ); $node_b.node.handle_funding_created($node_a.node.get_our_node_id(), &msg_a); + // Flush node_b's new monitor (watch_channel) so it releases the + // FundingSigned message. + $node_b + .chain_monitor + .flush($node_b.chain_monitor.pending_operation_count(), &$node_b.logger); get_event!($node_b, Event::ChannelPending); let msg_b = get_event_msg!( $node_b, @@ -2590,6 +2648,11 @@ mod tests { $node_a.node.get_our_node_id() ); $node_a.node.handle_funding_signed($node_b.node.get_our_node_id(), &msg_b); + // Flush node_a's new monitor (watch_channel) queued by + // handle_funding_signed. + $node_a + .chain_monitor + .flush($node_a.chain_monitor.pending_operation_count(), &$node_a.logger); get_event!($node_a, Event::ChannelPending); tx }}; @@ -2715,6 +2778,20 @@ mod tests { confirm_transaction_depth(node, tx, ANTI_REORG_DELAY); } + /// Waits until the background processor has flushed all pending deferred monitor + /// operations for the given node. Panics if the pending count does not reach zero + /// within `EVENT_DEADLINE`. + fn wait_for_flushed(chain_monitor: &ChainMonitor) { + let start = std::time::Instant::now(); + while chain_monitor.pending_operation_count() > 0 { + assert!( + start.elapsed() < EVENT_DEADLINE, + "Pending monitor operations were not flushed within deadline" + ); + std::thread::sleep(Duration::from_millis(10)); + } + } + #[test] fn test_background_processor() { // Test that when a new channel is created, the ChannelManager needs to be re-persisted with @@ -3055,11 +3132,21 @@ mod tests { .node .funding_transaction_generated(temporary_channel_id, node_1_id, funding_tx.clone()) .unwrap(); + // funding_transaction_generated does not call watch_channel, so no deferred op is + // queued and the FundingCreated message is available immediately. let msg_0 = get_event_msg!(nodes[0], MessageSendEvent::SendFundingCreated, node_1_id); nodes[1].node.handle_funding_created(node_0_id, &msg_0); + // Node 1 has no bg processor, flush its new monitor (watch_channel) manually so + // events and FundingSigned are released. + nodes[1] + .chain_monitor + .flush(nodes[1].chain_monitor.pending_operation_count(), &nodes[1].logger); get_event!(nodes[1], Event::ChannelPending); let msg_1 = get_event_msg!(nodes[1], MessageSendEvent::SendFundingSigned, node_0_id); nodes[0].node.handle_funding_signed(node_1_id, &msg_1); + // Wait for the bg processor to flush the new monitor (watch_channel) queued by + // handle_funding_signed. + wait_for_flushed(&nodes[0].chain_monitor); channel_pending_recv .recv_timeout(EVENT_DEADLINE) .expect("ChannelPending not handled within deadline"); @@ -3120,6 +3207,9 @@ mod tests { error_message.to_string(), ) .unwrap(); + // Wait for the bg processor to flush the monitor update triggered by force close + // so the commitment tx is broadcast. + wait_for_flushed(&nodes[0].chain_monitor); let commitment_tx = nodes[0].tx_broadcaster.txn_broadcasted.lock().unwrap().pop().unwrap(); confirm_transaction_depth(&mut nodes[0], &commitment_tx, BREAKDOWN_TIMEOUT as u32); diff --git a/lightning/src/chain/chainmonitor.rs b/lightning/src/chain/chainmonitor.rs index 99f792fc531..215b48e4960 100644 --- a/lightning/src/chain/chainmonitor.rs +++ b/lightning/src/chain/chainmonitor.rs @@ -60,12 +60,21 @@ use crate::util::persist::{KVStore, MonitorName, MonitorUpdatingPersisterAsync}; use crate::util::ser::{VecWriter, Writeable}; use crate::util::wakers::{Future, Notifier}; +use alloc::collections::VecDeque; use alloc::sync::Arc; #[cfg(peer_storage)] use core::iter::Cycle; use core::ops::Deref; use core::sync::atomic::{AtomicUsize, Ordering}; +/// A pending operation queued for later execution when `ChainMonitor` is in deferred mode. +enum PendingMonitorOp { + /// A new monitor to insert and persist. + NewMonitor { channel_id: ChannelId, monitor: ChannelMonitor }, + /// An update to apply and persist. + Update { channel_id: ChannelId, update: ChannelMonitorUpdate }, +} + /// `Persist` defines behavior for persisting channel monitors: this could mean /// writing once to disk, and/or uploading to one or more backup services. /// @@ -376,6 +385,10 @@ pub struct ChainMonitor< /// When `true`, [`chain::Watch`] operations are queued rather than executed immediately. deferred: bool, + /// Queued monitor operations awaiting flush. Unused when `deferred` is `false`. + pending_ops: Mutex>>, + /// Guards [`Self::flush`] so that concurrent calls are serialized. + flush_lock: Mutex<()>, } impl< @@ -398,6 +411,18 @@ where /// /// Note that async monitor updating is considered beta, and bugs may be triggered by its use. /// + /// When `deferred` is `true`, [`chain::Watch::watch_channel`] and + /// [`chain::Watch::update_channel`] calls are not executed immediately. Instead, they are + /// queued internally and must be flushed by the caller via [`Self::flush`]. Use + /// [`Self::pending_operation_count`] to check how many operations are queued, then call + /// [`Self::flush`] to process them. This allows the caller to ensure that the + /// [`ChannelManager`] is persisted before its associated monitors, avoiding the risk of + /// force closures from a crash between monitor and channel manager persistence. + /// + /// When `deferred` is `false`, monitor operations are executed inline as usual. + /// + /// [`ChannelManager`]: crate::ln::channelmanager::ChannelManager + /// /// This is not exported to bindings users as async is not supported outside of Rust. pub fn new_async_beta( chain_source: Option, broadcaster: T, logger: L, feeest: F, @@ -420,6 +445,8 @@ where #[cfg(peer_storage)] our_peerstorage_encryption_key: _our_peerstorage_encryption_key, deferred, + pending_ops: Mutex::new(VecDeque::new()), + flush_lock: Mutex::new(()), } } } @@ -604,6 +631,16 @@ where /// is obtained by the [`ChannelManager`] through [`NodeSigner`] to decrypt peer backups. /// Using an inconsistent or incorrect key will result in the inability to decrypt previously encrypted backups. /// + /// When `deferred` is `true`, [`chain::Watch::watch_channel`] and + /// [`chain::Watch::update_channel`] calls are not executed immediately. Instead, they are + /// queued internally and must be flushed by the caller via [`Self::flush`]. Use + /// [`Self::pending_operation_count`] to check how many operations are queued, then call + /// [`Self::flush`] to process them. This allows the caller to ensure that the + /// [`ChannelManager`] is persisted before its associated monitors, avoiding the risk of + /// force closures from a crash between monitor and channel manager persistence. + /// + /// When `deferred` is `false`, monitor operations are executed inline as usual. + /// /// [`NodeSigner`]: crate::sign::NodeSigner /// [`NodeSigner::get_peer_storage_key`]: crate::sign::NodeSigner::get_peer_storage_key /// [`ChannelManager`]: crate::ln::channelmanager::ChannelManager @@ -626,6 +663,8 @@ where #[cfg(peer_storage)] our_peerstorage_encryption_key: _our_peerstorage_encryption_key, deferred, + pending_ops: Mutex::new(VecDeque::new()), + flush_lock: Mutex::new(()), } } @@ -1045,7 +1084,7 @@ where &self, channel_id: ChannelId, monitor: ChannelMonitor, ) -> Result { if !monitor.written_by_0_1_or_later() { - return chain::Watch::watch_channel(self, channel_id, monitor); + return self.watch_channel_internal(channel_id, monitor); } let logger = WithChannelMonitor::from(&self.logger, &monitor, None); @@ -1219,6 +1258,90 @@ where }, } } + + /// Returns the number of pending monitor operations queued for later execution. + /// + /// When the `ChainMonitor` is constructed with `deferred` set to `true`, + /// [`chain::Watch::watch_channel`] and [`chain::Watch::update_channel`] calls are queued + /// instead of being executed immediately. Call this method to determine how many operations + /// are waiting, then pass the result to [`Self::flush`] to process them. + pub fn pending_operation_count(&self) -> usize { + self.pending_ops.lock().unwrap().len() + } + + /// Flushes the first `count` pending monitor operations that were queued while the + /// `ChainMonitor` operates in deferred mode. `count` must not exceed the number of + /// pending operations returned by [`Self::pending_operation_count`]. + /// + /// A typical usage pattern is to call [`Self::pending_operation_count`], persist the + /// [`ChannelManager`], then pass the count to this method to flush the queued operations. + /// + /// [`ChannelManager`]: crate::ln::channelmanager::ChannelManager + pub fn flush(&self, count: usize, logger: &L) { + let _guard = self.flush_lock.lock().unwrap(); + if count > 0 { + log_info!(logger, "Flushing up to {} monitor operations", count); + } + for _ in 0..count { + let mut queue = self.pending_ops.lock().unwrap(); + let op = match queue.pop_front() { + Some(op) => op, + None => { + debug_assert!(false, "flush count exceeded queue length"); + return; + }, + }; + + let (channel_id, update_id, status) = match op { + PendingMonitorOp::NewMonitor { channel_id, monitor } => { + let logger = WithChannelMonitor::from(logger, &monitor, None); + let update_id = monitor.get_latest_update_id(); + log_trace!(logger, "Flushing new monitor"); + // Hold `pending_ops` across the internal call so that + // `watch_channel` (which checks `monitors` + `pending_ops` + // atomically) cannot race with this insertion. + match self.watch_channel_internal(channel_id, monitor) { + Ok(status) => { + drop(queue); + (channel_id, update_id, status) + }, + Err(()) => { + // `watch_channel` checks both `pending_ops` and `monitors` + // for duplicates before queueing, so this is unreachable. + unreachable!(); + }, + } + }, + PendingMonitorOp::Update { channel_id, update } => { + let logger = WithContext::from(logger, None, Some(channel_id), None); + log_trace!(logger, "Flushing monitor update {}", update.update_id); + // Release `pending_ops` before the internal call so that + // concurrent `update_channel` queuing is not blocked. + drop(queue); + let update_id = update.update_id; + let status = self.update_channel_internal(channel_id, &update); + (channel_id, update_id, status) + }, + }; + + match status { + ChannelMonitorUpdateStatus::Completed => { + let logger = WithContext::from(logger, None, Some(channel_id), None); + if let Err(e) = self.channel_monitor_updated(channel_id, update_id) { + debug_assert!(false, "channel_monitor_updated failed: {:?}", e); + log_error!(logger, "channel_monitor_updated failed: {:?}", e); + } + }, + ChannelMonitorUpdateStatus::InProgress => {}, + ChannelMonitorUpdateStatus::UnrecoverableError => { + // Neither watch_channel_internal nor update_channel_internal + // return UnrecoverableError; they panic on that variant + // before it can be returned. + unreachable!(); + }, + } + } + } } impl< @@ -1437,7 +1560,22 @@ where return self.watch_channel_internal(channel_id, monitor); } - unimplemented!(); + // Atomically check for duplicates in both the pending queue and the + // flushed monitor set. + let mut pending_ops = self.pending_ops.lock().unwrap(); + let monitors = self.monitors.read().unwrap(); + if monitors.contains_key(&channel_id) { + return Err(()); + } + let already_pending = pending_ops.iter().any(|op| match op { + PendingMonitorOp::NewMonitor { channel_id: id, .. } => *id == channel_id, + _ => false, + }); + if already_pending { + return Err(()); + } + pending_ops.push_back(PendingMonitorOp::NewMonitor { channel_id, monitor }); + Ok(ChannelMonitorUpdateStatus::InProgress) } fn update_channel( @@ -1447,7 +1585,21 @@ where return self.update_channel_internal(channel_id, update); } - unimplemented!(); + let mut pending_ops = self.pending_ops.lock().unwrap(); + debug_assert!( + { + let monitors = self.monitors.read().unwrap(); + let in_monitors = monitors.contains_key(&channel_id); + let in_pending = pending_ops.iter().any(|op| match op { + PendingMonitorOp::NewMonitor { channel_id: id, .. } => *id == channel_id, + _ => false, + }); + in_monitors || in_pending + }, + "ChannelManager generated a channel update for a channel that was not yet registered!" + ); + pending_ops.push_back(PendingMonitorOp::Update { channel_id, update: update.clone() }); + ChannelMonitorUpdateStatus::InProgress } fn release_pending_monitor_events( @@ -1577,12 +1729,22 @@ where #[cfg(test)] mod tests { - use crate::chain::channelmonitor::ANTI_REORG_DELAY; + use super::ChainMonitor; + use crate::chain::channelmonitor::{ChannelMonitorUpdate, ANTI_REORG_DELAY}; use crate::chain::{ChannelMonitorUpdateStatus, Watch}; use crate::events::{ClosureReason, Event}; use crate::ln::functional_test_utils::*; use crate::ln::msgs::{BaseMessageHandler, ChannelMessageHandler, MessageSendEvent}; + use crate::ln::types::ChannelId; + use crate::sign::NodeSigner; + use crate::util::dyn_signer::DynSigner; + use crate::util::test_channel_signer::TestChannelSigner; + use crate::util::test_utils::{ + TestBroadcaster, TestChainSource, TestFeeEstimator, TestKeysInterface, TestLogger, + TestPersister, + }; use crate::{expect_payment_path_successful, get_event_msg}; + use bitcoin::Network; const CHAINSYNC_MONITOR_PARTITION_FACTOR: u32 = 5; @@ -1840,4 +2002,171 @@ mod tests { }) .is_err()); } + + /// Concrete `ChainMonitor` type wired to the standard test utilities in deferred mode. + type TestDeferredChainMonitor<'a> = ChainMonitor< + TestChannelSigner, + &'a TestChainSource, + &'a TestBroadcaster, + &'a TestFeeEstimator, + &'a TestLogger, + &'a TestPersister, + &'a TestKeysInterface, + >; + + /// Creates a minimal `ChannelMonitorUpdate` with no actual update steps. + fn dummy_update(update_id: u64, channel_id: ChannelId) -> ChannelMonitorUpdate { + ChannelMonitorUpdate { updates: vec![], update_id, channel_id: Some(channel_id) } + } + + fn create_deferred_chain_monitor<'a>( + chain_source: &'a TestChainSource, broadcaster: &'a TestBroadcaster, + logger: &'a TestLogger, fee_est: &'a TestFeeEstimator, persister: &'a TestPersister, + keys: &'a TestKeysInterface, + ) -> TestDeferredChainMonitor<'a> { + ChainMonitor::new( + Some(chain_source), + broadcaster, + logger, + fee_est, + persister, + keys, + keys.get_peer_storage_key(), + true, + ) + } + + /// Tests queueing and flushing of both `watch_channel` and `update_channel` operations + /// when `ChainMonitor` is in deferred mode, verifying that operations flow through to + /// `Persist` and that `channel_monitor_updated` is called on `Completed` status. + #[test] + fn test_queue_and_flush() { + let broadcaster = TestBroadcaster::new(Network::Testnet); + let fee_est = TestFeeEstimator::new(253); + let logger = TestLogger::new(); + let persister = TestPersister::new(); + let chain_source = TestChainSource::new(Network::Testnet); + let keys = TestKeysInterface::new(&[0; 32], Network::Testnet); + let deferred = create_deferred_chain_monitor( + &chain_source, + &broadcaster, + &logger, + &fee_est, + &persister, + &keys, + ); + + // Queue starts empty. + assert_eq!(deferred.pending_operation_count(), 0); + + // Queue a watch_channel, verifying InProgress status. + let chan = ChannelId::from_bytes([1u8; 32]); + let monitor = crate::chain::channelmonitor::dummy_monitor(chan, |keys| { + TestChannelSigner::new(DynSigner::new(keys)) + }); + let status = Watch::watch_channel(&deferred, chan, monitor); + assert_eq!(status, Ok(ChannelMonitorUpdateStatus::InProgress)); + assert_eq!(deferred.pending_operation_count(), 1); + + // Nothing persisted yet — operations are only queued. + assert!(persister.new_channel_persistences.lock().unwrap().is_empty()); + + // Queue two updates after the watch. Update IDs must be sequential (starting + // from 1 since the initial monitor has update_id 0). + assert_eq!( + Watch::update_channel(&deferred, chan, &dummy_update(1, chan)), + ChannelMonitorUpdateStatus::InProgress + ); + assert_eq!( + Watch::update_channel(&deferred, chan, &dummy_update(2, chan)), + ChannelMonitorUpdateStatus::InProgress + ); + assert_eq!(deferred.pending_operation_count(), 3); + + // Flush 2 of 3: persist_new_channel returns Completed (triggers + // channel_monitor_updated), update_persisted_channel returns InProgress (does not). + persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); + persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress); + deferred.flush(2, &&logger); + + assert_eq!(deferred.pending_operation_count(), 1); + + // persist_new_channel was called for the watch. + assert_eq!(persister.new_channel_persistences.lock().unwrap().len(), 1); + + // Because persist_new_channel returned Completed, channel_monitor_updated was called, + // so update_id 0 should no longer be pending. + let pending = deferred.list_pending_monitor_updates(); + #[cfg(not(c_bindings))] + let pending_for_chan = pending.get(&chan).unwrap(); + #[cfg(c_bindings)] + let pending_for_chan = &pending.iter().find(|(chan_id, _)| *chan_id == chan).unwrap().1; + assert!(!pending_for_chan.contains(&0)); + + // update_persisted_channel was called for update_id 1, and because it returned + // InProgress, update_id 1 remains pending. + let monitor_name = deferred.get_monitor(chan).unwrap().persistence_key(); + assert!(persister + .offchain_monitor_updates + .lock() + .unwrap() + .get(&monitor_name) + .unwrap() + .contains(&1)); + assert!(pending_for_chan.contains(&1)); + + // Flush remaining: update_persisted_channel returns Completed (default), triggers + // channel_monitor_updated. + deferred.flush(1, &&logger); + assert_eq!(deferred.pending_operation_count(), 0); + + // update_persisted_channel was called for update_id 2. + assert!(persister + .offchain_monitor_updates + .lock() + .unwrap() + .get(&monitor_name) + .unwrap() + .contains(&2)); + + // update_id 1 is still pending from the InProgress earlier, but update_id 2 was + // completed in this flush so it is no longer pending. + let pending = deferred.list_pending_monitor_updates(); + #[cfg(not(c_bindings))] + let pending_for_chan = pending.get(&chan).unwrap(); + #[cfg(c_bindings)] + let pending_for_chan = &pending.iter().find(|(chan_id, _)| *chan_id == chan).unwrap().1; + assert!(pending_for_chan.contains(&1)); + assert!(!pending_for_chan.contains(&2)); + + // Flushing an empty queue is a no-op. + let persist_count_before = persister.new_channel_persistences.lock().unwrap().len(); + deferred.flush(0, &&logger); + assert_eq!(persister.new_channel_persistences.lock().unwrap().len(), persist_count_before); + } + + /// Tests that `ChainMonitor` in deferred mode properly defers `watch_channel` and + /// `update_channel` operations, verifying correctness through a complete channel open + /// and payment flow. Operations are auto-flushed via the `TestChainMonitor` + /// `release_pending_monitor_events` helper. + #[test] + fn test_deferred_monitor_payment() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs_deferred(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let chain_monitor_a = &nodes[0].chain_monitor.chain_monitor; + let chain_monitor_b = &nodes[1].chain_monitor.chain_monitor; + + create_announced_chan_between_nodes(&nodes, 0, 1); + + let (preimage, _hash, ..) = route_payment(&nodes[0], &[&nodes[1]], 10_000); + claim_payment(&nodes[0], &[&nodes[1]], preimage); + + assert_eq!(chain_monitor_a.list_monitors().len(), 1); + assert_eq!(chain_monitor_b.list_monitors().len(), 1); + assert_eq!(chain_monitor_a.pending_operation_count(), 0); + assert_eq!(chain_monitor_b.pending_operation_count(), 0); + } } diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 641842ddaff..596b2420ca2 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -4566,6 +4566,7 @@ pub fn create_chanmon_cfgs_internal( fn create_node_cfgs_internal<'a, F>( node_count: usize, chanmon_cfgs: &'a Vec, persisters: Vec<&'a impl test_utils::SyncPersist>, message_router_constructor: F, + deferred: bool, ) -> Vec> where F: Fn( @@ -4578,14 +4579,25 @@ where for i in 0..node_count { let cfg = &chanmon_cfgs[i]; let network_graph = Arc::new(NetworkGraph::new(Network::Testnet, &cfg.logger)); - let chain_monitor = test_utils::TestChainMonitor::new( - Some(&cfg.chain_source), - &cfg.tx_broadcaster, - &cfg.logger, - &cfg.fee_estimator, - persisters[i], - &cfg.keys_manager, - ); + let chain_monitor = if deferred { + test_utils::TestChainMonitor::new_deferred( + Some(&cfg.chain_source), + &cfg.tx_broadcaster, + &cfg.logger, + &cfg.fee_estimator, + persisters[i], + &cfg.keys_manager, + ) + } else { + test_utils::TestChainMonitor::new( + Some(&cfg.chain_source), + &cfg.tx_broadcaster, + &cfg.logger, + &cfg.fee_estimator, + persisters[i], + &cfg.keys_manager, + ) + }; let seed = [i as u8; 32]; nodes.push(NodeCfg { @@ -4622,6 +4634,20 @@ pub fn create_node_cfgs<'a>( chanmon_cfgs, persisters, test_utils::TestMessageRouter::new_default, + false, + ) +} + +pub fn create_node_cfgs_deferred<'a>( + node_count: usize, chanmon_cfgs: &'a Vec, +) -> Vec> { + let persisters = chanmon_cfgs.iter().map(|c| &c.persister).collect(); + create_node_cfgs_internal( + node_count, + chanmon_cfgs, + persisters, + test_utils::TestMessageRouter::new_default, + true, ) } @@ -4634,6 +4660,7 @@ pub fn create_node_cfgs_with_persisters<'a>( chanmon_cfgs, persisters, test_utils::TestMessageRouter::new_default, + false, ) } @@ -4646,6 +4673,7 @@ pub fn create_node_cfgs_with_node_id_message_router<'a>( chanmon_cfgs, persisters, test_utils::TestMessageRouter::new_node_id_router, + false, ) } diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 1009d2ad3c4..3541c823d08 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -508,6 +508,7 @@ pub struct TestChainMonitor<'a> { &'a TestKeysInterface, >, pub keys_manager: &'a TestKeysInterface, + pub logger: &'a TestLogger, /// If this is set to Some(), the next update_channel call (not watch_channel) must be a /// ChannelForceClosed event for the given channel_id with should_broadcast set to the given /// boolean. @@ -523,6 +524,38 @@ impl<'a> TestChainMonitor<'a> { chain_source: Option<&'a TestChainSource>, broadcaster: &'a dyn SyncBroadcaster, logger: &'a TestLogger, fee_estimator: &'a TestFeeEstimator, persister: &'a dyn SyncPersist, keys_manager: &'a TestKeysInterface, + ) -> Self { + Self::with_deferred( + chain_source, + broadcaster, + logger, + fee_estimator, + persister, + keys_manager, + false, + ) + } + + pub fn new_deferred( + chain_source: Option<&'a TestChainSource>, broadcaster: &'a dyn SyncBroadcaster, + logger: &'a TestLogger, fee_estimator: &'a TestFeeEstimator, + persister: &'a dyn SyncPersist, keys_manager: &'a TestKeysInterface, + ) -> Self { + Self::with_deferred( + chain_source, + broadcaster, + logger, + fee_estimator, + persister, + keys_manager, + true, + ) + } + + fn with_deferred( + chain_source: Option<&'a TestChainSource>, broadcaster: &'a dyn SyncBroadcaster, + logger: &'a TestLogger, fee_estimator: &'a TestFeeEstimator, + persister: &'a dyn SyncPersist, keys_manager: &'a TestKeysInterface, deferred: bool, ) -> Self { Self { added_monitors: Mutex::new(Vec::new()), @@ -536,9 +569,10 @@ impl<'a> TestChainMonitor<'a> { persister, keys_manager, keys_manager.get_peer_storage_key(), - false, + deferred, ), keys_manager, + logger, expect_channel_force_closed: Mutex::new(None), expect_monitor_round_trip_fail: Mutex::new(None), #[cfg(feature = "std")] @@ -546,6 +580,10 @@ impl<'a> TestChainMonitor<'a> { } } + pub fn pending_operation_count(&self) -> usize { + self.chain_monitor.pending_operation_count() + } + pub fn complete_sole_pending_chan_update(&self, channel_id: &ChannelId) { let (_, latest_update) = self.latest_monitor_update_id.lock().unwrap().get(channel_id).unwrap().clone(); @@ -676,6 +714,12 @@ impl<'a> chain::Watch for TestChainMonitor<'a> { fn release_pending_monitor_events( &self, ) -> Vec<(OutPoint, ChannelId, Vec, PublicKey)> { + // Auto-flush pending operations so that the ChannelManager can pick up monitor + // completion events. When not in deferred mode the queue is empty so this only + // costs a lock acquisition. It ensures standard test helpers (route_payment, etc.) + // work with deferred chain monitors. + let count = self.chain_monitor.pending_operation_count(); + self.chain_monitor.flush(count, &self.logger); return self.chain_monitor.release_pending_monitor_events(); } } @@ -835,6 +879,8 @@ pub struct TestPersister { /// The queue of update statuses we'll return. If none are queued, ::Completed will always be /// returned. pub update_rets: Mutex>, + /// When we get a persist_new_channel call, we push the monitor name here. + pub new_channel_persistences: Mutex>, /// When we get an update_persisted_channel call *with* a ChannelMonitorUpdate, we insert the /// [`ChannelMonitor::get_latest_update_id`] here. pub offchain_monitor_updates: Mutex>>, @@ -845,9 +891,15 @@ pub struct TestPersister { impl TestPersister { pub fn new() -> Self { let update_rets = Mutex::new(VecDeque::new()); + let new_channel_persistences = Mutex::new(Vec::new()); let offchain_monitor_updates = Mutex::new(new_hash_map()); let chain_sync_monitor_persistences = Mutex::new(VecDeque::new()); - Self { update_rets, offchain_monitor_updates, chain_sync_monitor_persistences } + Self { + update_rets, + new_channel_persistences, + offchain_monitor_updates, + chain_sync_monitor_persistences, + } } /// Queue an update status to return. @@ -857,8 +909,9 @@ impl TestPersister { } impl Persist for TestPersister { fn persist_new_channel( - &self, _monitor_name: MonitorName, _data: &ChannelMonitor, + &self, monitor_name: MonitorName, _data: &ChannelMonitor, ) -> chain::ChannelMonitorUpdateStatus { + self.new_channel_persistences.lock().unwrap().push(monitor_name); if let Some(update_ret) = self.update_rets.lock().unwrap().pop_front() { return update_ret; } From 3e1a18c80e47a10fc941b064fd50df445cd1bdf4 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Wed, 4 Mar 2026 12:36:31 +0100 Subject: [PATCH 215/627] Fail HTLCs from late counterparty commitment updates after funding spend When a ChannelMonitorUpdate containing a new counterparty commitment is dispatched (e.g. via deferred writes) before a channel force-closes but only applied to the in-memory monitor after the commitment transaction has already confirmed on-chain, the outbound HTLCs in that update must be failed back. Add fail_htlcs_from_update_after_funding_spend to ChannelMonitorImpl which detects this race condition during update_monitor. When a LatestCounterpartyCommitmentTXInfo or LatestCounterpartyCommitment update is applied and the funding output has already been spent, the function iterates all outbound HTLCs from the update and creates OnchainEvent::HTLCUpdate entries for those that need to be failed back. These entries mature after ANTI_REORG_DELAY blocks, giving time for the peer to potentially broadcast the newer commitment. HTLCs that appear as non-dust outputs in the confirmed commitment (whether counterparty or holder) are skipped, as they will be resolved on-chain via the normal HTLC timeout/success path. HTLCs already fulfilled by the counterparty (tracked in counterparty_fulfilled_htlcs) are also skipped. Duplicate failures from previously-known counterparty commitments are handled gracefully by the ChannelManager. AI tools were used in preparing this commit. --- lightning/src/chain/chainmonitor.rs | 10 +- lightning/src/chain/channelmonitor.rs | 187 +++++++++++++- lightning/src/ln/chanmon_update_fail_tests.rs | 244 ++++++++++++++++++ lightning/src/util/test_utils.rs | 17 +- 4 files changed, 449 insertions(+), 9 deletions(-) diff --git a/lightning/src/chain/chainmonitor.rs b/lightning/src/chain/chainmonitor.rs index 215b48e4960..396ee277067 100644 --- a/lightning/src/chain/chainmonitor.rs +++ b/lightning/src/chain/chainmonitor.rs @@ -1279,15 +1279,17 @@ where /// [`ChannelManager`]: crate::ln::channelmanager::ChannelManager pub fn flush(&self, count: usize, logger: &L) { let _guard = self.flush_lock.lock().unwrap(); - if count > 0 { - log_info!(logger, "Flushing up to {} monitor operations", count); + if count == 0 { + return; } + log_info!(logger, "Flushing up to {} monitor operations", count); for _ in 0..count { let mut queue = self.pending_ops.lock().unwrap(); let op = match queue.pop_front() { Some(op) => op, None => { debug_assert!(false, "flush count exceeded queue length"); + log_error!(logger, "flush count exceeded queue length"); return; }, }; @@ -1341,6 +1343,10 @@ where }, } } + + // A flushed monitor update may have generated new events, so assume we have + // some and wake the event processor. + self.event_notifier.notify(); } } diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs index 4b8fdd6b230..8e7b6035523 100644 --- a/lightning/src/chain/channelmonitor.rs +++ b/lightning/src/chain/channelmonitor.rs @@ -1378,8 +1378,8 @@ pub(crate) struct ChannelMonitorImpl { /// In-memory only HTLC ids used to track upstream HTLCs that have been failed backwards due to /// a downstream channel force-close remaining unconfirmed by the time the upstream timeout /// expires. This is used to tell us we already generated an event to fail this HTLC back - /// during a previous block scan. - failed_back_htlc_ids: HashSet, + /// during a previous block scan. Not serialized. + pub(crate) failed_back_htlc_ids: HashSet, // The auxiliary HTLC data associated with a holder commitment transaction. This includes // non-dust HTLC sources, along with dust HTLCs and their sources. Note that this assumes any @@ -4299,6 +4299,55 @@ impl ChannelMonitorImpl { self.latest_update_id = updates.update_id; + // If a counterparty commitment update was applied while the funding output has already + // been spent on-chain, fail back the outbound HTLCs from the update. This handles the + // race where a monitor update is dispatched before the channel force-closes but only + // applied after the commitment transaction confirms. + for update in updates.updates.iter() { + match update { + ChannelMonitorUpdateStep::LatestCounterpartyCommitmentTXInfo { + htlc_outputs, .. + } => { + // Only outbound HTLCs have a source; inbound ones are `None` + // and skipped by the `filter_map`. + self.fail_htlcs_from_update_after_funding_spend( + htlc_outputs.iter().filter_map(|(htlc, source)| { + source.as_ref().map(|s| (&**s, htlc.payment_hash, htlc.amount_msat)) + }), + logger, + ); + }, + ChannelMonitorUpdateStep::LatestCounterpartyCommitment { + commitment_txs, htlc_data, + } => { + // On a counterparty commitment, `offered=false` means offered by + // us (outbound). `nondust_htlc_sources` contains sources only for + // these outbound nondust HTLCs, matching the filter order. + debug_assert_eq!( + commitment_txs[0].nondust_htlcs().iter() + .filter(|htlc| !htlc.offered).count(), + htlc_data.nondust_htlc_sources.len(), + ); + let nondust = commitment_txs[0] + .nondust_htlcs() + .iter() + .filter(|htlc| !htlc.offered) + .zip(htlc_data.nondust_htlc_sources.iter()) + .map(|(htlc, source)| (source, htlc.payment_hash, htlc.amount_msat)); + // Only outbound dust HTLCs have a source; inbound ones are `None` + // and skipped by the `filter_map`. + let dust = htlc_data.dust_htlcs.iter().filter_map(|(htlc, source)| { + source.as_ref().map(|s| (s, htlc.payment_hash, htlc.amount_msat)) + }); + self.fail_htlcs_from_update_after_funding_spend( + nondust.chain(dust), + logger, + ); + }, + _ => {}, + } + } + // Refuse updates after we've detected a spend onchain (or if the channel was otherwise // closed), but only if the update isn't the kind of update we expect to see after channel // closure. @@ -4345,6 +4394,121 @@ impl ChannelMonitorImpl { self.funding_spend_seen || self.lockdown_from_offchain || self.holder_tx_signed } + /// Given outbound HTLCs from a counterparty commitment update, checks if the funding output + /// has been spent on-chain. If so, creates `OnchainEvent::HTLCUpdate` entries to fail back + /// HTLCs that weren't already known to the monitor. + /// + /// This handles the race where a `ChannelMonitorUpdate` with a new counterparty commitment + /// is dispatched (e.g., via deferred writes) before the channel force-closes, but only + /// applied to the in-memory monitor after the commitment transaction has already confirmed. + /// + /// Only truly new HTLCs (not present in any previously-known commitment) need to be failed + /// here. HTLCs that were already tracked by the monitor will be handled by the existing + /// `fail_unbroadcast_htlcs` logic when the spending transaction confirms. + fn fail_htlcs_from_update_after_funding_spend<'a, L: Logger>( + &mut self, htlcs: impl Iterator, + logger: &WithContext, + ) { + let pending_spend_entry = self + .onchain_events_awaiting_threshold_conf + .iter() + .find(|event| matches!(event.event, OnchainEvent::FundingSpendConfirmation { .. })) + .map(|entry| (entry.txid, entry.transaction.clone(), entry.height, entry.block_hash)); + if self.funding_spend_confirmed.is_none() && pending_spend_entry.is_none() { + return; + } + + // Check HTLC sources against all previously-known commitments to find truly new + // ones. After the update has been applied, `prev_counterparty_commitment_txid` holds + // what was `current` before this update, so it represents the already-known + // counterparty state. HTLCs already present in any of these will be handled by + // `fail_unbroadcast_htlcs` when the spending transaction confirms. + let is_source_known = |source: &HTLCSource| { + if let Some(ref txid) = self.funding.prev_counterparty_commitment_txid { + if let Some(htlc_list) = self.funding.counterparty_claimable_outpoints.get(txid) { + if htlc_list.iter().any(|(_, s)| s.as_ref().map(|s| s.as_ref()) == Some(source)) + { + return true; + } + } + } + // Note that we don't care about the case where a counterparty sent us a fresh local commitment transaction + // post-closure (with the `ChannelManager` still operating the channel). First of all we only care about + // resolving outbound HTLCs, which fundamentally have to be initiated by us. However we also don't mind + // looking at the current holder commitment transaction's HTLCs as any fresh outbound HTLCs will have to + // first come in a locally-initiated update to the counterparty's commitment transaction which we can, by + // refusing to apply the update, prevent the counterparty from ever seeing (as no messages can be sent until + // the monitor is updated). Thus, the HTLCs we care about can never appear in the holder commitment + // transaction. + if holder_commitment_htlcs!(self, CURRENT_WITH_SOURCES).any(|(_, s)| s == Some(source)) + { + return true; + } + if let Some(mut iter) = holder_commitment_htlcs!(self, PREV_WITH_SOURCES) { + if iter.any(|(_, s)| s == Some(source)) { + return true; + } + } + false + }; + for (source, payment_hash, amount_msat) in htlcs { + if is_source_known(source) { + continue; + } + if self.counterparty_fulfilled_htlcs.get(&SentHTLCId::from_source(source)).is_some() { + continue; + } + let htlc_value_satoshis = Some(amount_msat / 1000); + let logger = WithContext::from(logger, None, None, Some(payment_hash)); + // Defensively mark the HTLC as failed back so the expiry-based failure + // path in `block_connected` doesn't generate a duplicate `HTLCUpdate` + // event for the same source. + self.failed_back_htlc_ids.insert(SentHTLCId::from_source(source)); + if let Some(confirmed_txid) = self.funding_spend_confirmed { + // Funding spend already confirmed past ANTI_REORG_DELAY: resolve immediately. + log_trace!( + logger, + "Failing HTLC from late counterparty commitment update immediately \ + (funding spend already confirmed)" + ); + self.pending_monitor_events.push(MonitorEvent::HTLCEvent(HTLCUpdate { + payment_hash, + payment_preimage: None, + source: source.clone(), + htlc_value_satoshis, + })); + self.htlcs_resolved_on_chain.push(IrrevocablyResolvedHTLC { + commitment_tx_output_idx: None, + resolving_txid: Some(confirmed_txid), + resolving_tx: None, + payment_preimage: None, + }); + } else { + // Funding spend still awaiting ANTI_REORG_DELAY: queue the failure. + let (txid, transaction, height, block_hash) = pending_spend_entry.clone().unwrap(); + let entry = OnchainEventEntry { + txid, + transaction, + height, + block_hash, + event: OnchainEvent::HTLCUpdate { + source: source.clone(), + payment_hash, + htlc_value_satoshis, + commitment_tx_output_idx: None, + }, + }; + log_trace!( + logger, + "Failing HTLC from late counterparty commitment update, \ + waiting for confirmation (at height {})", + entry.confirmation_threshold() + ); + self.onchain_events_awaiting_threshold_conf.push(entry); + } + } + } + fn get_latest_update_id(&self) -> u64 { self.latest_update_id } @@ -6834,7 +6998,7 @@ mod tests { use bitcoin::{Sequence, Witness}; use crate::chain::chaininterface::LowerBoundedFeeEstimator; - use crate::events::ClosureReason; + use crate::events::{ClosureReason, Event}; use super::ChannelMonitorUpdateStep; use crate::chain::channelmonitor::{ChannelMonitor, WithChannelMonitor}; @@ -6957,8 +7121,21 @@ mod tests { check_spends!(htlc_txn[1], broadcast_tx); check_closed_broadcast(&nodes[1], 1, true); - check_closed_event(&nodes[1], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[0].node.get_our_node_id()], 100000); - check_added_monitors(&nodes[1], 1); + if !use_local_txn { + // When the counterparty commitment confirms, FundingSpendConfirmation matures + // immediately (no CSV delay), so funding_spend_confirmed is set. The new payment's + // commitment update then triggers immediate HTLC failure, generating payment events + // alongside the channel close event. + let events = nodes[1].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 3); + assert!(events.iter().any(|e| matches!(e, Event::PaymentPathFailed { .. }))); + assert!(events.iter().any(|e| matches!(e, Event::PaymentFailed { .. }))); + assert!(events.iter().any(|e| matches!(e, Event::ChannelClosed { .. }))); + check_added_monitors(&nodes[1], 2); + } else { + check_closed_event(&nodes[1], 1, ClosureReason::CommitmentTxConfirmed, &[nodes[0].node.get_our_node_id()], 100000); + check_added_monitors(&nodes[1], 1); + } } #[test] diff --git a/lightning/src/ln/chanmon_update_fail_tests.rs b/lightning/src/ln/chanmon_update_fail_tests.rs index a92af3ebc6e..623d028560f 100644 --- a/lightning/src/ln/chanmon_update_fail_tests.rs +++ b/lightning/src/ln/chanmon_update_fail_tests.rs @@ -48,6 +48,7 @@ use crate::util::test_utils; use crate::prelude::*; use crate::sync::{Arc, Mutex}; use bitcoin::hashes::Hash; +use core::sync::atomic::Ordering; #[test] fn test_monitor_and_persister_update_fail() { @@ -5171,3 +5172,246 @@ fn test_mpp_claim_to_holding_cell() { expect_payment_claimable!(nodes[3], paymnt_hash_2, payment_secret_2, 400_000); claim_payment(&nodes[2], &[&nodes[3]], preimage_2); } + +fn do_test_late_counterparty_commitment_update_after_funding_spend(fully_confirmed: bool) { + // Tests that when a ChannelMonitorUpdate containing a new counterparty commitment (with an + // outbound HTLC) is applied to a monitor that has already seen the funding output spent + // on-chain, the HTLC is properly failed back. + // + // This exercises the race condition where: + // 1. A sends an HTLC to B, creating a monitor update with LatestCounterpartyCommitmentTXInfo + // 2. In deferred-write mode, this update is queued but not applied to the in-memory monitor + // 3. B's commitment transaction (without the HTLC) is broadcast and confirmed + // 4. The queued update is flushed, applying the counterparty commitment to the monitor + // 5. The monitor detects the funding spend and fails the HTLC + // + // When `fully_confirmed` is true, ANTI_REORG_DELAY has fully passed before the flush, so + // funding_spend_confirmed is set. Otherwise, the FundingSpendConfirmation entry is still + // pending in onchain_events_awaiting_threshold_conf. + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs_deferred(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_b_id = nodes[1].node.get_our_node_id(); + + let chan_id = create_announced_chan_between_nodes(&nodes, 0, 1).2; + + // Get B's commitment transaction before any HTLCs are added. This is the transaction that + // will be mined on-chain, simulating B broadcasting while A's monitor update is pending. + let bs_commitment_tx = get_local_commitment_txn!(nodes[1], chan_id); + assert_eq!(bs_commitment_tx.len(), 1); + + // Pause auto-flush on A so that the monitor update from send_payment is queued but NOT + // applied to the in-memory monitor. + nodes[0].chain_monitor.pause_flush.store(true, Ordering::Release); + + // Send a payment from A to B. The ChannelManager creates a LatestCounterpartyCommitmentTXInfo + // monitor update, but in deferred mode with pause_flush it remains queued. + let (route, payment_hash, _, payment_secret) = + get_route_and_payment_hash!(nodes[0], nodes[1], 1_000_000); + let payment_id = PaymentId(payment_hash.0); + nodes[0] + .node + .send_payment_with_route( + route, + payment_hash, + RecipientOnionFields::secret_only(payment_secret, 1_000_000), + payment_id, + ) + .unwrap(); + check_added_monitors(&nodes[0], 1); + + // Mine B's (old) commitment transaction on A and advance blocks. When fully_confirmed, + // advance past ANTI_REORG_DELAY so FundingSpendConfirmation is consumed and + // funding_spend_confirmed is set. Otherwise, stop one block short so the entry remains + // in onchain_events_awaiting_threshold_conf. + mine_transaction(&nodes[0], &bs_commitment_tx[0]); + let extra_blocks = if fully_confirmed { ANTI_REORG_DELAY - 1 } else { ANTI_REORG_DELAY - 2 }; + connect_blocks(&nodes[0], extra_blocks); + + if fully_confirmed { + // The channel close event, error message, and ChannelForceClosed monitor update were + // generated during block connection. Consume them before flushing. + check_closed_event( + &nodes[0], + 1, + ClosureReason::CommitmentTxConfirmed, + &[node_b_id], + 100000, + ); + check_closed_broadcast(&nodes[0], 1, true); + check_added_monitors(&nodes[0], 1); + } + + // Flush the queued monitor updates. This applies the LatestCounterpartyCommitmentTXInfo + // (and ChannelForceClosed) to the monitor, which triggers fail_htlcs_from_update_after_ + // funding_spend to create OnchainEvent::HTLCUpdate entries for the HTLC. + nodes[0].chain_monitor.pause_flush.store(false, Ordering::Release); + let pending_count = nodes[0].chain_monitor.chain_monitor.pending_operation_count(); + nodes[0].chain_monitor.chain_monitor.flush(pending_count, &nodes[0].logger); + + if !fully_confirmed { + // The channel close event, error message, and ChannelForceClosed monitor update were + // generated during block connection. + check_closed_event( + &nodes[0], + 1, + ClosureReason::CommitmentTxConfirmed, + &[node_b_id], + 100000, + ); + check_closed_broadcast(&nodes[0], 1, true); + check_added_monitors(&nodes[0], 1); + } + + // Advance ANTI_REORG_DELAY blocks so the OnchainEvent::HTLCUpdate entries (created at + // best_block.height during the flush) mature into MonitorEvent::HTLCEvent. + connect_blocks(&nodes[0], ANTI_REORG_DELAY); + + // The ChannelManager processes the MonitorEvent::HTLCEvent and fails the payment. + expect_payment_failed_conditions( + &nodes[0], + payment_hash, + false, + PaymentFailedConditions::new(), + ); + // The payment failure generates a ReleasePaymentComplete monitor update. + check_added_monitors(&nodes[0], 1); +} + +#[test] +fn test_late_counterparty_commitment_update_after_funding_spend() { + do_test_late_counterparty_commitment_update_after_funding_spend(false); +} + +#[test] +fn test_late_counterparty_commitment_update_after_funding_spend_fully_confirmed() { + do_test_late_counterparty_commitment_update_after_funding_spend(true); +} + +fn do_test_late_counterparty_commitment_update_after_holder_commitment_spend(dust: bool) { + // Tests that when the confirmed spending transaction is a holder commitment, HTLCs that + // have non-dust outputs in the holder commitment are NOT failed by + // fail_htlcs_from_update_after_funding_spend (they'll be resolved on-chain via + // HTLC-timeout), while HTLCs only present in the late counterparty commitment update ARE + // failed. + // + // When `dust` is true, HTLC Y is a dust amount, verifying that dust HTLCs in late + // counterparty commitment updates are also correctly failed. + // + // Setup: + // 1. Route HTLC X from A to B (fully committed in both holder and counterparty commitments) + // 2. Grab A's holder commitment (which contains HTLC X) + // 3. Pause flush, then send HTLC Y from A to B (counterparty commitment update is queued) + // 4. Mine A's holder commitment (contains X but not Y) + // 5. Flush the queued update (contains both X and Y) + // 6. Verify: X is not failed by our code (on-chain output), Y is failed + // 7. Drive HTLC X to resolution via the on-chain HTLC-timeout path + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs_deferred(2, &chanmon_cfgs); + // Use legacy (non-anchor) channels so that the HTLC-timeout transaction is broadcast + // directly by the monitor rather than going through the BumpTransaction event path. + let legacy_cfg = test_legacy_channel_config(); + let node_chanmgrs = + create_node_chanmgrs(2, &node_cfgs, &[Some(legacy_cfg.clone()), Some(legacy_cfg)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_b_id = nodes[1].node.get_our_node_id(); + + let chan_id = create_announced_chan_between_nodes(&nodes, 0, 1).2; + + // Route HTLC X fully (committed in both commitments). + let (_, payment_hash_x, ..) = route_payment(&nodes[0], &[&nodes[1]], 1_000_000); + + // Get A's holder commitment which now contains HTLC X. For legacy (non-anchor) channels, + // the HTLC-timeout transaction is also returned. + let as_txn = get_local_commitment_txn!(nodes[0], chan_id); + let as_commitment_tx = &as_txn[0]; + // Verify HTLC X is present as a non-dust output (commitment has HTLC-timeout tx too). + assert!(as_txn.len() >= 2, "Expected commitment + HTLC-timeout tx, got {}", as_txn.len()); + + // Pause flush so the next monitor update is queued. + nodes[0].chain_monitor.pause_flush.store(true, Ordering::Release); + + // Send HTLC Y. When `dust` is true, 1000 msat (1 sat) is well below the dust limit and + // will not appear as an output in any commitment transaction. When false, 2_000_000 msat + // is non-dust. Either way, the LatestCounterpartyCommitmentTXInfo update (containing both + // X and Y) is queued in deferred mode. + let htlc_y_amount = if dust { 1_000 } else { 2_000_000 }; + let (route_y, payment_hash_y, _, payment_secret_y) = + get_route_and_payment_hash!(nodes[0], nodes[1], htlc_y_amount); + let payment_id_y = PaymentId(payment_hash_y.0); + nodes[0] + .node + .send_payment_with_route( + route_y, + payment_hash_y, + RecipientOnionFields::secret_only(payment_secret_y, htlc_y_amount), + payment_id_y, + ) + .unwrap(); + check_added_monitors(&nodes[0], 1); + + // Mine A's holder commitment (contains X but not Y). + mine_transaction(&nodes[0], as_commitment_tx); + connect_blocks(&nodes[0], ANTI_REORG_DELAY - 2); + + // Flush the queued monitor updates. + nodes[0].chain_monitor.pause_flush.store(false, Ordering::Release); + let pending_count = nodes[0].chain_monitor.chain_monitor.pending_operation_count(); + nodes[0].chain_monitor.chain_monitor.flush(pending_count, &nodes[0].logger); + + check_closed_event(&nodes[0], 1, ClosureReason::CommitmentTxConfirmed, &[node_b_id], 100000); + check_closed_broadcast(&nodes[0], 1, true); + check_added_monitors(&nodes[0], 1); + + // Advance ANTI_REORG_DELAY blocks so OnchainEvent::HTLCUpdate entries mature. + connect_blocks(&nodes[0], ANTI_REORG_DELAY); + + // HTLC Y should be failed by our code. HTLC X has an on-chain output in the holder + // commitment and will be resolved via the HTLC-timeout path. + expect_payment_failed_conditions( + &nodes[0], + payment_hash_y, + false, + PaymentFailedConditions::new(), + ); + check_added_monitors(&nodes[0], 1); + + // Verify HTLC X was NOT failed (no payment failure event for it at this point). + assert!(nodes[0].node.get_and_clear_pending_events().is_empty()); + + // Drive HTLC X to resolution via the on-chain HTLC-timeout path. Connect blocks until we + // pass the CLTV expiry so the monitor broadcasts the HTLC-timeout transaction. + connect_blocks(&nodes[0], TEST_FINAL_CLTV); + let as_htlc_timeout_claim = + nodes[0].tx_broadcaster.txn_broadcasted.lock().unwrap().split_off(0); + assert_eq!(as_htlc_timeout_claim.len(), 1); + check_spends!(as_htlc_timeout_claim[0], as_commitment_tx); + + // Mine the HTLC-timeout transaction and wait for ANTI_REORG_DELAY. + mine_transaction(&nodes[0], &as_htlc_timeout_claim[0]); + connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1); + + // HTLC X should now be resolved on-chain. + expect_payment_failed_conditions( + &nodes[0], + payment_hash_x, + false, + PaymentFailedConditions::new(), + ); + check_added_monitors(&nodes[0], 1); +} + +#[test] +fn test_late_counterparty_commitment_update_after_holder_commitment_spend() { + do_test_late_counterparty_commitment_update_after_holder_commitment_spend(false); +} + +#[test] +fn test_late_counterparty_commitment_update_after_holder_commitment_spend_dust() { + do_test_late_counterparty_commitment_update_after_holder_commitment_spend(true); +} diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 3541c823d08..d31c16ccbf0 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -518,6 +518,10 @@ pub struct TestChainMonitor<'a> { pub expect_monitor_round_trip_fail: Mutex>, #[cfg(feature = "std")] pub write_blocker: Mutex>>, + /// When set to `true`, `release_pending_monitor_events` will not auto-flush pending + /// deferred operations. This allows tests to control exactly when queued monitor updates + /// are applied to the in-memory monitor. + pub pause_flush: AtomicBool, } impl<'a> TestChainMonitor<'a> { pub fn new( @@ -577,6 +581,7 @@ impl<'a> TestChainMonitor<'a> { expect_monitor_round_trip_fail: Mutex::new(None), #[cfg(feature = "std")] write_blocker: Mutex::new(None), + pause_flush: AtomicBool::new(false), } } @@ -701,12 +706,18 @@ impl<'a> chain::Watch for TestChainMonitor<'a> { ) .unwrap() .1; + // failed_back_htlc_ids is an in-memory-only dedup guard that is intentionally not + // serialized. Copy it to the deserialized monitor for the comparison, then clear + // it so it doesn't leak into the rest of the test. + let failed_back = monitor.inner.lock().unwrap().failed_back_htlc_ids.clone(); + new_monitor.inner.lock().unwrap().failed_back_htlc_ids = failed_back; if let Some(chan_id) = self.expect_monitor_round_trip_fail.lock().unwrap().take() { assert_eq!(chan_id, channel_id); assert!(new_monitor != *monitor); } else { assert!(new_monitor == *monitor); } + new_monitor.inner.lock().unwrap().failed_back_htlc_ids.clear(); self.added_monitors.lock().unwrap().push((channel_id, new_monitor)); update_res } @@ -718,8 +729,10 @@ impl<'a> chain::Watch for TestChainMonitor<'a> { // completion events. When not in deferred mode the queue is empty so this only // costs a lock acquisition. It ensures standard test helpers (route_payment, etc.) // work with deferred chain monitors. - let count = self.chain_monitor.pending_operation_count(); - self.chain_monitor.flush(count, &self.logger); + if !self.pause_flush.load(Ordering::Acquire) { + let count = self.chain_monitor.pending_operation_count(); + self.chain_monitor.flush(count, &self.logger); + } return self.chain_monitor.release_pending_monitor_events(); } } From 279f2c1c51bacfbf88b04785de410176269508f1 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Thu, 19 Mar 2026 10:50:53 +0100 Subject: [PATCH 216/627] fuzz: handle missing SendTx* message events in chanmon_consistency Add handlers for SendTxInitRbf, SendTxAckRbf, SendTxRemoveInput, and SendTxRemoveOutput in the chanmon_consistency fuzz target. These variants were reachable but not matched, causing panics on the wildcard arm ("Unhandled message event"). SendTxInitRbf became reachable after commit 5873660a0 added splicing support without updating the fuzz target's message delivery logic. AI tools were used in preparing this commit. --- fuzz/src/chanmon_consistency.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 45e9a68cb63..228439fbd34 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1515,6 +1515,14 @@ pub fn do_test( if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } *node_id == a_id }, + MessageSendEvent::SendTxRemoveInput { ref node_id, .. } => { + if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } + *node_id == a_id + }, + MessageSendEvent::SendTxRemoveOutput { ref node_id, .. } => { + if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } + *node_id == a_id + }, MessageSendEvent::SendTxComplete { ref node_id, .. } => { if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } *node_id == a_id @@ -1523,6 +1531,14 @@ pub fn do_test( if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } *node_id == a_id }, + MessageSendEvent::SendTxInitRbf { ref node_id, .. } => { + if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } + *node_id == a_id + }, + MessageSendEvent::SendTxAckRbf { ref node_id, .. } => { + if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } + *node_id == a_id + }, MessageSendEvent::SendTxSignatures { ref node_id, .. } => { if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } *node_id == a_id @@ -1715,6 +1731,22 @@ pub fn do_test( } } }, + MessageSendEvent::SendTxInitRbf { ref node_id, ref msg } => { + for (idx, dest) in nodes.iter().enumerate() { + if dest.get_our_node_id() == *node_id { + out.locked_write(format!("Delivering tx_init_rbf from node {} to node {}.\n", $node, idx).as_bytes()); + dest.handle_tx_init_rbf(nodes[$node].get_our_node_id(), msg); + } + } + }, + MessageSendEvent::SendTxAckRbf { ref node_id, ref msg } => { + for (idx, dest) in nodes.iter().enumerate() { + if dest.get_our_node_id() == *node_id { + out.locked_write(format!("Delivering tx_ack_rbf from node {} to node {}.\n", $node, idx).as_bytes()); + dest.handle_tx_ack_rbf(nodes[$node].get_our_node_id(), msg); + } + } + }, MessageSendEvent::SendTxSignatures { ref node_id, ref msg } => { for (idx, dest) in nodes.iter().enumerate() { if dest.get_our_node_id() == *node_id { From 43066b787f26a1b54aa9b69b0776a0cf93987f3f Mon Sep 17 00:00:00 2001 From: Atishyy27 Date: Sat, 14 Mar 2026 16:42:00 +0530 Subject: [PATCH 217/627] ci: fix OOM in artifact upload and update actions to v4 --- .github/workflows/audit.yml | 2 +- .github/workflows/build.yml | 1 + .github/workflows/ldk-node-integration.yml | 4 ++-- .github/workflows/semver.yml | 2 ++ 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index e617573a381..790fd0f26e9 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -11,7 +11,7 @@ jobs: issues: write checks: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: rustsec/audit-check@v1.4.1 with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7d0a81ee6fc..6d512791420 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -250,6 +250,7 @@ jobs: with: name: hfuzz-corpus path: fuzz/hfuzz_workspace + compression-level: 0 linting: runs-on: ubuntu-latest diff --git a/.github/workflows/ldk-node-integration.yml b/.github/workflows/ldk-node-integration.yml index 446abd40a07..8ca66b75664 100644 --- a/.github/workflows/ldk-node-integration.yml +++ b/.github/workflows/ldk-node-integration.yml @@ -12,11 +12,11 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: path: rust-lightning - name: Checkout LDK Node - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: lightningdevkit/ldk-node path: ldk-node diff --git a/.github/workflows/semver.yml b/.github/workflows/semver.yml index de10e562f98..0e196804517 100644 --- a/.github/workflows/semver.yml +++ b/.github/workflows/semver.yml @@ -13,6 +13,8 @@ jobs: steps: - name: Checkout source code uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Install Rust stable toolchain run: | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain stable From 970d4d9459aa159ae8b92ae4b09bb0a7923d9f81 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Thu, 26 Feb 2026 10:09:31 +0100 Subject: [PATCH 218/627] Replace dual-sync-async persistence panic with Watch contract Commit 0760f99 ("Disallow dual-sync-async persistence without restarting") added a panic in non-test builds when a Persist implementation returns both Completed and InProgress from the same ChannelManager instance. However, this check runs against the status that ChainMonitor returns to ChannelManager, not the raw Persist result. When ChannelMonitor::update_monitor fails (e.g. a counterparty commitment_signed arrives after a funding spend confirms), ChainMonitor persists the full monitor successfully but overrides the return value to InProgress. If the user's Persist impl only ever returns Completed, this override triggers a false mode-mismatch panic. This replaces the panic with a per-channel contract at the Watch trait level: a Watch implementation must not return Completed for a channel update while prior InProgress updates are still pending. Switching from Completed to InProgress is always allowed, but switching back is impractical because the Watch implementation cannot observe when ChannelManager has finished processing a MonitorEvent::Completed. The documentation on ChannelMonitorUpdateStatus is updated to describe these rules. The mode tracking and panic checks from 0760f99 are removed and replaced with a panic that validates the new contract directly on the in-flight update state. Legacy tests that switch the persister between modes mid-flight can opt out via Node::disable_monitor_completeness_assertion(). Co-Authored-By: Claude Opus 4.6 --- lightning/src/chain/channelmonitor.rs | 1 + lightning/src/chain/mod.rs | 12 ++---- lightning/src/ln/chanmon_update_fail_tests.rs | 6 +++ lightning/src/ln/channelmanager.rs | 41 +++++++++---------- lightning/src/ln/functional_test_utils.rs | 8 ++++ lightning/src/ln/monitor_tests.rs | 1 + lightning/src/ln/reload_tests.rs | 3 ++ 7 files changed, 43 insertions(+), 29 deletions(-) diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs index 8e7b6035523..1eb1484d07d 100644 --- a/lightning/src/chain/channelmonitor.rs +++ b/lightning/src/chain/channelmonitor.rs @@ -7048,6 +7048,7 @@ mod tests { let legacy_cfg = test_legacy_channel_config(); let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[Some(legacy_cfg.clone()), Some(legacy_cfg.clone()), Some(legacy_cfg)]); let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + nodes[1].disable_monitor_completeness_assertion(); let channel = create_announced_chan_between_nodes(&nodes, 0, 1); create_announced_chan_between_nodes(&nodes, 1, 2); diff --git a/lightning/src/chain/mod.rs b/lightning/src/chain/mod.rs index bc47f1b1db6..99e184d8fda 100644 --- a/lightning/src/chain/mod.rs +++ b/lightning/src/chain/mod.rs @@ -233,11 +233,10 @@ pub enum ChannelMonitorUpdateStatus { /// This includes performing any `fsync()` calls required to ensure the update is guaranteed to /// be available on restart even if the application crashes. /// - /// If you return this variant, you cannot later return [`InProgress`] from the same instance of - /// [`Persist`]/[`Watch`] without first restarting. + /// You cannot switch from [`InProgress`] to this variant for the same channel without first + /// restarting. However, switching from this variant to [`InProgress`] is always allowed. /// /// [`InProgress`]: ChannelMonitorUpdateStatus::InProgress - /// [`Persist`]: chainmonitor::Persist Completed, /// Indicates that the update will happen asynchronously in the background or that a transient /// failure occurred which is being retried in the background and will eventually complete. @@ -263,12 +262,7 @@ pub enum ChannelMonitorUpdateStatus { /// reliable, this feature is considered beta, and a handful of edge-cases remain. Until the /// remaining cases are fixed, in rare cases, *using this feature may lead to funds loss*. /// - /// If you return this variant, you cannot later return [`Completed`] from the same instance of - /// [`Persist`]/[`Watch`] without first restarting. - /// /// [`InProgress`]: ChannelMonitorUpdateStatus::InProgress - /// [`Completed`]: ChannelMonitorUpdateStatus::Completed - /// [`Persist`]: chainmonitor::Persist InProgress, /// Indicates that an update has failed and will not complete at any point in the future. /// @@ -328,6 +322,8 @@ pub trait Watch { /// cannot be retried, the node should shut down immediately after returning /// [`ChannelMonitorUpdateStatus::UnrecoverableError`], see its documentation for more info. /// + /// See [`ChannelMonitorUpdateStatus`] for requirements on when each variant may be returned. + /// /// [`ChannelManager`]: crate::ln::channelmanager::ChannelManager fn update_channel( &self, channel_id: ChannelId, update: &ChannelMonitorUpdate, diff --git a/lightning/src/ln/chanmon_update_fail_tests.rs b/lightning/src/ln/chanmon_update_fail_tests.rs index 623d028560f..11fc8ac0ea3 100644 --- a/lightning/src/ln/chanmon_update_fail_tests.rs +++ b/lightning/src/ln/chanmon_update_fail_tests.rs @@ -176,6 +176,7 @@ fn do_test_simple_monitor_temporary_update_fail(disconnect: bool) { let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + nodes[0].disable_monitor_completeness_assertion(); let node_a_id = nodes[0].node.get_our_node_id(); let node_b_id = nodes[1].node.get_our_node_id(); @@ -317,6 +318,7 @@ fn do_test_monitor_temporary_update_fail(disconnect_count: usize) { let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + nodes[0].disable_monitor_completeness_assertion(); let node_a_id = nodes[0].node.get_our_node_id(); let node_b_id = nodes[1].node.get_our_node_id(); @@ -970,6 +972,7 @@ fn do_test_monitor_update_fail_raa(test_ignore_second_cs: bool) { let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]); let mut nodes = create_network(3, &node_cfgs, &node_chanmgrs); + nodes[1].disable_monitor_completeness_assertion(); let node_a_id = nodes[0].node.get_our_node_id(); let node_b_id = nodes[1].node.get_our_node_id(); @@ -1501,6 +1504,7 @@ fn claim_while_disconnected_monitor_update_fail() { let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + nodes[1].disable_monitor_completeness_assertion(); let node_a_id = nodes[0].node.get_our_node_id(); let node_b_id = nodes[1].node.get_our_node_id(); @@ -1728,6 +1732,7 @@ fn first_message_on_recv_ordering() { let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + nodes[1].disable_monitor_completeness_assertion(); let node_a_id = nodes[0].node.get_our_node_id(); let node_b_id = nodes[1].node.get_our_node_id(); @@ -3850,6 +3855,7 @@ fn do_test_durable_preimages_on_closed_channel( // Now reload node B let manager_b = nodes[1].node.encode(); reload_node!(nodes[1], &manager_b, &[&mon_ab, &mon_bc], persister, chain_mon, node_b_reload); + nodes[1].disable_monitor_completeness_assertion(); nodes[0].node.peer_disconnected(node_b_id); nodes[2].node.peer_disconnected(node_b_id); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 70617b20894..3ec174c58e1 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -2870,12 +2870,12 @@ pub struct ChannelManager< #[cfg(any(test, feature = "_test_utils"))] pub(super) per_peer_state: FairRwLock>>>, - /// We only support using one of [`ChannelMonitorUpdateStatus::InProgress`] and - /// [`ChannelMonitorUpdateStatus::Completed`] without restarting. Because the API does not - /// otherwise directly enforce this, we enforce it in non-test builds here by storing which one - /// is in use. - #[cfg(not(any(test, feature = "_externalize_tests")))] - monitor_update_type: AtomicUsize, + /// When set, disables the panic when `Watch::update_channel` returns `Completed` while + /// prior updates are still `InProgress`. Some legacy tests switch the persister between + /// `InProgress` and `Completed` mid-flight, which violates this contract but is otherwise + /// harmless in a test context. + #[cfg(test)] + pub(crate) skip_monitor_update_assertion: AtomicBool, /// The set of events which we need to give to the user to handle. In some cases an event may /// require some further action after the user handles it (currently only blocking a monitor @@ -3618,8 +3618,8 @@ impl< per_peer_state: FairRwLock::new(new_hash_map()), - #[cfg(not(any(test, feature = "_externalize_tests")))] - monitor_update_type: AtomicUsize::new(0), + #[cfg(test)] + skip_monitor_update_assertion: AtomicBool::new(false), pending_events: Mutex::new(VecDeque::new()), pending_events_processor: AtomicBool::new(false), @@ -10380,6 +10380,15 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ if update_completed { let _ = in_flight_updates.remove(update_idx); } + // A Watch implementation must not return Completed while prior updates are + // still InProgress, as this would violate the async persistence contract. + #[cfg(test)] + let skip_check = self.skip_monitor_update_assertion.load(Ordering::Relaxed); + #[cfg(not(test))] + let skip_check = false; + if !skip_check && update_completed && !in_flight_updates.is_empty() { + panic!("Watch::update_channel returned Completed while prior updates are still InProgress"); + } (update_completed, update_completed && in_flight_updates.is_empty()) } else { // We blindly assume that the ChannelMonitorUpdate will be regenerated on startup if we @@ -10445,23 +10454,13 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ panic!("{}", err_str); }, ChannelMonitorUpdateStatus::InProgress => { - #[cfg(not(any(test, feature = "_externalize_tests")))] - if self.monitor_update_type.swap(1, Ordering::Relaxed) == 2 { - panic!("Cannot use both ChannelMonitorUpdateStatus modes InProgress and Completed without restart"); - } log_debug!( logger, "ChannelMonitor update in flight, holding messages until the update completes.", ); false }, - ChannelMonitorUpdateStatus::Completed => { - #[cfg(not(any(test, feature = "_externalize_tests")))] - if self.monitor_update_type.swap(2, Ordering::Relaxed) == 1 { - panic!("Cannot use both ChannelMonitorUpdateStatus modes InProgress and Completed without restart"); - } - true - }, + ChannelMonitorUpdateStatus::Completed => true, } } @@ -20112,8 +20111,8 @@ impl< per_peer_state: FairRwLock::new(per_peer_state), - #[cfg(not(any(test, feature = "_externalize_tests")))] - monitor_update_type: AtomicUsize::new(0), + #[cfg(test)] + skip_monitor_update_assertion: AtomicBool::new(false), pending_events: Mutex::new(pending_events_read), pending_events_processor: AtomicBool::new(false), diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 596b2420ca2..a16adf88b51 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -598,6 +598,14 @@ impl<'a, 'b, 'c> Node<'a, 'b, 'c> { self.node.init_features() | self.onion_messenger.provided_init_features(peer_node_id) }) } + + /// Disables the panic when `Watch::update_channel` returns `Completed` while prior updates + /// are still `InProgress`. Some legacy tests switch the persister between modes mid-flight, + /// which violates this contract but is otherwise harmless. + #[cfg(test)] + pub fn disable_monitor_completeness_assertion(&self) { + self.node.skip_monitor_update_assertion.store(true, core::sync::atomic::Ordering::Relaxed); + } } impl<'a, 'b, 'c> std::panic::UnwindSafe for Node<'a, 'b, 'c> {} diff --git a/lightning/src/ln/monitor_tests.rs b/lightning/src/ln/monitor_tests.rs index 2368776dd3f..efd2084a38e 100644 --- a/lightning/src/ln/monitor_tests.rs +++ b/lightning/src/ln/monitor_tests.rs @@ -3384,6 +3384,7 @@ fn test_claim_event_never_handled() { let chan_0_monitor_serialized = get_monitor!(nodes[1], chan.2).encode(); let mons = &[&chan_0_monitor_serialized[..]]; reload_node!(nodes[1], &init_node_ser, mons, persister, new_chain_mon, nodes_1_reload); + nodes[1].disable_monitor_completeness_assertion(); expect_payment_claimed!(nodes[1], payment_hash_a, 1_000_000); // The reload logic spuriously generates a redundant payment preimage-containing diff --git a/lightning/src/ln/reload_tests.rs b/lightning/src/ln/reload_tests.rs index bb730f8fba8..8d9eac5c001 100644 --- a/lightning/src/ln/reload_tests.rs +++ b/lightning/src/ln/reload_tests.rs @@ -823,12 +823,14 @@ fn do_test_partial_claim_before_restart(persist_both_monitors: bool, double_rest // Now restart nodes[3]. reload_node!(nodes[3], original_manager.clone(), &[&updated_monitor.0, &original_monitor.0], persist_d_1, chain_d_1, node_d_1); + nodes[3].disable_monitor_completeness_assertion(); if double_restart { // Previously, we had a bug where we'd fail to reload if we re-persist the `ChannelManager` // without updating any `ChannelMonitor`s as we'd fail to double-initiate the claim replay. // We test that here ensuring that we can reload again. reload_node!(nodes[3], node_d_1.encode(), &[&updated_monitor.0, &original_monitor.0], persist_d_2, chain_d_2, node_d_2); + nodes[3].disable_monitor_completeness_assertion(); } // Until the startup background events are processed (in `get_and_clear_pending_events`, @@ -2216,6 +2218,7 @@ fn test_reload_with_mpp_claims_on_same_channel() { nodes_1_deserialized, Some(true) ); + nodes[1].disable_monitor_completeness_assertion(); // When the claims are reconstructed during reload, PaymentForwarded events are regenerated. let events = nodes[1].node.get_and_clear_pending_events(); From 1d3704d53252bc6aa714df604d01bf74f8f2abf7 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Thu, 5 Mar 2026 13:49:57 +0100 Subject: [PATCH 219/627] Add test for monitor update after funding spend Add a regression test that reproduces the panic when a commitment_signed is processed after the counterparty commitment transaction has confirmed. The ChannelMonitor's no_further_updates_allowed() returns true, causing update_monitor to fail, which ChainMonitor overrides to InProgress. A subsequent preimage claim returning Completed then triggers the per-channel assertion that Completed must not follow InProgress. AI tools were used in preparing this commit. --- lightning/src/ln/chanmon_update_fail_tests.rs | 71 ++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/chanmon_update_fail_tests.rs b/lightning/src/ln/chanmon_update_fail_tests.rs index 11fc8ac0ea3..87856c950d1 100644 --- a/lightning/src/ln/chanmon_update_fail_tests.rs +++ b/lightning/src/ln/chanmon_update_fail_tests.rs @@ -16,7 +16,7 @@ use crate::chain::chaininterface::LowerBoundedFeeEstimator; use crate::chain::chainmonitor::ChainMonitor; use crate::chain::channelmonitor::{ChannelMonitor, MonitorEvent, ANTI_REORG_DELAY}; use crate::chain::transaction::OutPoint; -use crate::chain::{ChannelMonitorUpdateStatus, Listen, Watch}; +use crate::chain::{ChannelMonitorUpdateStatus, Confirm, Listen, Watch}; use crate::events::{ClosureReason, Event, HTLCHandlingFailureType, PaymentPurpose}; use crate::ln::channel::AnnouncementSigsState; use crate::ln::channelmanager::{PaymentId, RAACommitmentOrder}; @@ -5421,3 +5421,72 @@ fn test_late_counterparty_commitment_update_after_holder_commitment_spend() { fn test_late_counterparty_commitment_update_after_holder_commitment_spend_dust() { do_test_late_counterparty_commitment_update_after_holder_commitment_spend(true); } + +#[test] +#[should_panic( + expected = "Watch::update_channel returned Completed while prior updates are still InProgress" +)] +fn test_monitor_update_fail_after_funding_spend() { + // When a counterparty commitment transaction confirms (funding spend), the + // ChannelMonitor sets funding_spend_seen. If a commitment_signed from the + // counterparty is then processed (a race between chain events and message + // processing), update_monitor returns Err because no_further_updates_allowed() + // is true. ChainMonitor overrides the result to InProgress, permanently + // freezing the channel. A subsequent preimage claim returning Completed then + // triggers the per-channel assertion. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_a_id = nodes[0].node.get_our_node_id(); + + let (_, _, chan_id, _) = create_announced_chan_between_nodes(&nodes, 0, 1); + + // Route payment 1 fully so B can claim it later. + let (payment_preimage_1, _payment_hash_1, ..) = + route_payment(&nodes[0], &[&nodes[1]], 1_000_000); + + // Get A's commitment tx (this is the "counterparty" commitment from B's perspective). + let as_commitment_tx = get_local_commitment_txn!(nodes[0], chan_id); + assert_eq!(as_commitment_tx.len(), 1); + + // Confirm A's commitment tx on B's chain_monitor ONLY (not on B's ChannelManager). + // This sets funding_spend_seen in the monitor, making no_further_updates_allowed() true. + let (block_hash, height) = nodes[1].best_block_info(); + let block = create_dummy_block(block_hash, height + 1, vec![as_commitment_tx[0].clone()]); + let txdata: Vec<_> = block.txdata.iter().enumerate().collect(); + nodes[1].chain_monitor.chain_monitor.transactions_confirmed(&block.header, &txdata, height + 1); + + // Send payment 2 from A to B. + let (route, payment_hash_2, _, payment_secret_2) = + get_route_and_payment_hash!(&nodes[0], nodes[1], 1_000_000); + nodes[0] + .node + .send_payment_with_route( + route, + payment_hash_2, + RecipientOnionFields::secret_only(payment_secret_2, 1_000_000), + PaymentId(payment_hash_2.0), + ) + .unwrap(); + check_added_monitors(&nodes[0], 1); + + let mut events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + let payment_event = SendEvent::from_event(events.remove(0)); + + nodes[1].node.handle_update_add_htlc(node_a_id, &payment_event.msgs[0]); + + // B processes commitment_signed. The monitor's update_monitor succeeds on the + // update steps, but returns Err at the end because no_further_updates_allowed() + // is true (funding_spend_seen). ChainMonitor overrides the result to InProgress. + nodes[1].node.handle_commitment_signed(node_a_id, &payment_event.commitment_msg[0]); + check_added_monitors(&nodes[1], 1); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + + // B claims payment 1. The PaymentPreimage monitor update returns Completed + // (update_monitor succeeds for preimage, and persister returns Completed), + // but the prior InProgress from the commitment_signed is still pending. + nodes[1].node.claim_funds(payment_preimage_1); +} From ea204f60ace3ffff93aafb580f31d16b6e5b01d0 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Mon, 9 Mar 2026 08:48:04 -0700 Subject: [PATCH 220/627] Release tx_signatures after async monitor update completes In 83b2d3e, we reworked `ChannelManager::funding_transaction_signed` such that it would also for a user to cancel a splice up until they send `commitment_signed`. Previously, we would would only emit `Event::FundingTransactionReadyForSigning` when both nodes exchanged `commitment_signed` and the corresponding monitor update completed. With the event now being generated immediately after the nodes exchange `tx_complete`, we now need to handle the monitor update not having completed by the time we are ready to send `tx_signatures`. Unfortunately, we also did not have test coverage, allowing this to go unnoticed until being caught by the fuzzer due to a debug assertion. Doing so avoids a potential funds-loss scenario if the funding transaction confirms without the counterparty's signature for our commitment being durably persisted. --- lightning/src/ln/async_signer_tests.rs | 92 ++++++++++ lightning/src/ln/channel.rs | 235 +++++++++++++------------ lightning/src/ln/channelmanager.rs | 64 ++++++- lightning/src/ln/interactivetxs.rs | 22 ++- lightning/src/ln/splicing_tests.rs | 112 ++++++++++++ 5 files changed, 404 insertions(+), 121 deletions(-) diff --git a/lightning/src/ln/async_signer_tests.rs b/lightning/src/ln/async_signer_tests.rs index 451af3918bf..fd9c0ad7305 100644 --- a/lightning/src/ln/async_signer_tests.rs +++ b/lightning/src/ln/async_signer_tests.rs @@ -1648,3 +1648,95 @@ fn test_async_splice_initial_commit_sig() { let _ = get_event!(initiator, Event::SplicePending); let _ = get_event!(acceptor, Event::SplicePending); } + +#[test] +fn test_async_splice_initial_commit_sig_waits_for_monitor_before_tx_signatures() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let channel_id = create_announced_chan_between_nodes(&nodes, 0, 1).2; + + let (initiator, acceptor) = (&nodes[0], &nodes[1]); + let initiator_node_id = initiator.node.get_our_node_id(); + let acceptor_node_id = acceptor.node.get_our_node_id(); + + acceptor.disable_channel_signer_op( + &initiator_node_id, + &channel_id, + SignerOp::SignCounterpartyCommitment, + ); + + // Negotiate a splice up until the signature exchange. + let outputs = vec![TxOut { + value: Amount::from_sat(1_000), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]; + let contribution = initiate_splice_out(initiator, acceptor, channel_id, outputs).unwrap(); + negotiate_splice_tx(initiator, acceptor, channel_id, contribution); + + let event = get_event!(initiator, Event::FundingTransactionReadyForSigning); + if let Event::FundingTransactionReadyForSigning { unsigned_transaction, .. } = event { + let partially_signed_tx = initiator.wallet_source.sign_tx(unsigned_transaction).unwrap(); + initiator + .node + .funding_transaction_signed(&channel_id, &acceptor_node_id, partially_signed_tx) + .unwrap(); + } + + let initiator_commit_sig = get_htlc_update_msgs(initiator, &acceptor_node_id); + + // Keep the monitor update from processing the initiator's initial commitment signed pending on + // the acceptor. + chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress); + acceptor + .node + .handle_commitment_signed(initiator_node_id, &initiator_commit_sig.commitment_signed[0]); + check_added_monitors(acceptor, 1); + assert!(acceptor.node.get_and_clear_pending_msg_events().is_empty()); + + // Once the async signer is unblocked, we should send the initial commitment_signed, but still + // hold back tx_signatures until the monitor update is completed. + acceptor.enable_channel_signer_op( + &initiator_node_id, + &channel_id, + SignerOp::SignCounterpartyCommitment, + ); + acceptor.node.signer_unblocked(None); + + let msg_events = acceptor.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + if let MessageSendEvent::UpdateHTLCs { updates, .. } = &msg_events[0] { + initiator.node.handle_commitment_signed(acceptor_node_id, &updates.commitment_signed[0]); + check_added_monitors(initiator, 1); + } else { + panic!("Unexpected event"); + } + + assert!(initiator.node.get_and_clear_pending_msg_events().is_empty()); + assert!(acceptor.node.get_and_clear_pending_msg_events().is_empty()); + + // Reestablishing before the monitor update completes should still not release `tx_signatures`. + initiator.node.peer_disconnected(acceptor_node_id); + acceptor.node.peer_disconnected(initiator_node_id); + let mut reconnect_args = ReconnectArgs::new(initiator, acceptor); + reconnect_args.send_announcement_sigs = (true, true); + reconnect_nodes(reconnect_args); + assert!(initiator.node.get_and_clear_pending_msg_events().is_empty()); + assert!(acceptor.node.get_and_clear_pending_msg_events().is_empty()); + + acceptor.chain_monitor.complete_sole_pending_chan_update(&channel_id); + chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); + + let tx_signatures = + get_event_msg!(acceptor, MessageSendEvent::SendTxSignatures, initiator_node_id); + initiator.node.handle_tx_signatures(acceptor_node_id, &tx_signatures); + + let tx_signatures = + get_event_msg!(initiator, MessageSendEvent::SendTxSignatures, acceptor_node_id); + acceptor.node.handle_tx_signatures(initiator_node_id, &tx_signatures); + + let _ = get_event!(initiator, Event::SplicePending); + let _ = get_event!(acceptor, Event::SplicePending); +} diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 4241decfba9..23202d72c24 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -1194,7 +1194,7 @@ pub(super) struct MonitorRestoreUpdates { pub channel_ready: Option, pub channel_ready_order: ChannelReadyOrder, pub announcement_sigs: Option, - pub tx_signatures: Option, + pub funding_tx_signed: Option, /// The sources of outbound HTLCs that were forwarded and irrevocably committed on this channel /// (the outbound edge), along with their outbound amounts. Useful to store in the inbound HTLC /// to ensure it gets resolved. @@ -2172,9 +2172,6 @@ where }, }; - let channel_id = context.channel_id; - let counterparty_node_id = context.counterparty_node_id; - let signing_session = if let Some(signing_session) = context.interactive_tx_signing_session.as_mut() { @@ -2189,7 +2186,7 @@ where .unwrap_or(false)); } - if signing_session.holder_tx_signatures().is_some() { + if signing_session.has_holder_tx_signatures() { // Our `tx_signatures` either should've been the first time we processed them, // or we're waiting for our counterparty to send theirs first. return Ok(FundingTxSigned { @@ -2270,33 +2267,18 @@ where .unwrap_or(funding); let commitment_signed = context.get_initial_commitment_signed_v2(funding, &&logger); - // For zero conf channels, we don't expect the funding transaction to be ready for broadcast - // yet as, according to the spec, our counterparty shouldn't have sent their `tx_signatures` - // without us having sent our initial commitment signed to them first. However, in the event - // they do, we choose to handle it anyway. Note that because of this behavior not being - // spec-compliant, we're not able to test this without custom logic. - let (splice_negotiated, splice_locked) = if let Some(funding_tx) = funding_tx.clone() { - debug_assert!(tx_signatures.is_some()); - let funded_channel = self.as_funded_mut().expect( - "Funding transactions ready for broadcast can only exist for funded channels", - ); - funded_channel.on_tx_signatures_exchange(funding_tx, best_block_height, &logger) - } else { - (None, None) + let mut funding_tx_signed = FundingTxSigned { + commitment_signed, + counterparty_initial_commitment_signed_result: None, + tx_signatures, + funding_tx: None, + splice_negotiated: None, + splice_locked: None, }; - let funding_tx = funding_tx.map(|tx| { - let tx_type = if splice_negotiated.is_some() { - TransactionType::Splice { counterparty_node_id, channel_id } - } else { - TransactionType::Funding { channels: vec![(counterparty_node_id, channel_id)] } - }; - (tx, tx_type) - }); - // If we have a pending splice with a buffered initial commitment signed from our // counterparty, process it now that we have provided our signatures. - let counterparty_initial_commitment_signed_result = + funding_tx_signed.counterparty_initial_commitment_signed_result = self.as_funded_mut().and_then(|funded_channel| { funded_channel .pending_splice @@ -2322,14 +2304,25 @@ where }) }); - Ok(FundingTxSigned { - commitment_signed, - counterparty_initial_commitment_signed_result, - tx_signatures, - funding_tx, - splice_negotiated, - splice_locked, - }) + // For zero conf channels, we don't expect the funding transaction to be ready for broadcast + // yet as, according to the spec, our counterparty shouldn't have sent their `tx_signatures` + // without us having sent our initial commitment signed to them first. However, in the event + // they do, we choose to handle it anyway. Note that because of this behavior not being + // spec-compliant, we're not able to test this without custom logic. + if let Some(funding_tx) = funding_tx { + debug_assert!(funding_tx_signed.tx_signatures.is_some()); + let funded_channel = self.as_funded_mut().expect( + "Funding transactions ready for broadcast can only exist for funded channels", + ); + funded_channel.on_tx_signatures_exchange( + &mut funding_tx_signed, + funding_tx, + best_block_height, + &logger, + ) + }; + + Ok(funding_tx_signed) } pub fn force_shutdown(&mut self, closure_reason: ClosureReason) -> ShutdownResult { @@ -2393,7 +2386,7 @@ where .context .interactive_tx_signing_session .as_ref() - .map(|session| session.holder_tx_signatures().is_some()) + .map(|session| session.has_holder_tx_signatures()) .unwrap_or(false); // We delay processing this until the user manually approves the splice via @@ -2409,6 +2402,7 @@ where .expect("We have a pending splice negotiated"); let funding_negotiation = pending_splice.funding_negotiation.as_mut() .expect("We have a pending splice negotiated"); + log_debug!(logger, "Stashing counterparty initial commitment_signed to process after funding_transaction_signed"); if let FundingNegotiation::AwaitingSignatures { ref mut initial_commitment_signed_from_counterparty, .. } = funding_negotiation { @@ -4798,7 +4792,7 @@ impl ChannelContext { ChannelState::FundingNegotiated(_) => self .interactive_tx_signing_session .as_ref() - .map(|signing_session| signing_session.holder_tx_signatures().is_some()) + .map(|signing_session| signing_session.has_holder_tx_signatures()) .unwrap_or(false), ChannelState::AwaitingChannelReady(flags) => !flags.is_waiting_for_batch(), _ => true, @@ -6647,6 +6641,7 @@ pub(super) struct TxCompleteResult { } /// The result of signing a funding transaction negotiated using the interactive-tx protocol. +#[derive(Default)] pub(super) struct FundingTxSigned { /// The initial `commitment_signed` message to send to the counterparty, if necessary. pub commitment_signed: Option, @@ -7461,7 +7456,7 @@ where .interactive_tx_signing_session .as_ref() .map(|signing_session| { - signing_session.holder_tx_signatures().is_some() + signing_session.has_holder_tx_signatures() || signing_session.has_received_tx_signatures() }) .unwrap_or(false); @@ -8960,9 +8955,9 @@ where } fn on_tx_signatures_exchange<'a, L: Logger>( - &mut self, funding_tx: Transaction, best_block_height: u32, - logger: &WithChannelContext<'a, L>, - ) -> (Option, Option) { + &mut self, funding_tx_signed: &mut FundingTxSigned, funding_tx: Transaction, + best_block_height: u32, logger: &WithChannelContext<'a, L>, + ) { debug_assert!(!self.context.channel_state.is_monitor_update_in_progress()); debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke()); @@ -8974,7 +8969,7 @@ where .. }) = pending_splice.funding_negotiation.take() { - funding.funding_transaction = Some(funding_tx); + funding.funding_transaction = Some(funding_tx.clone()); pending_splice.last_funding_feerate_sat_per_1000_weight = Some(funding_feerate_sat_per_1000_weight); @@ -9006,16 +9001,24 @@ where ); } - (Some(splice_negotiated), splice_locked) + let tx_type = TransactionType::Splice { + counterparty_node_id: self.context.counterparty_node_id, + channel_id: self.context.channel_id, + }; + funding_tx_signed.funding_tx = Some((funding_tx, tx_type)); + funding_tx_signed.splice_negotiated = Some(splice_negotiated); + funding_tx_signed.splice_locked = splice_locked; } else { debug_assert!(false); - (None, None) } } else { - self.funding.funding_transaction = Some(funding_tx); + self.funding.funding_transaction = Some(funding_tx.clone()); self.context.channel_state = ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new()); - (None, None) + let tx_type = TransactionType::Funding { + channels: vec![(self.context.counterparty_node_id, self.context.channel_id)], + }; + funding_tx_signed.funding_tx = Some((funding_tx, tx_type)); } } @@ -9074,34 +9077,41 @@ where msg.tx_hash ); - let (splice_negotiated, splice_locked) = if let Some(funding_tx) = funding_tx.clone() { - self.on_tx_signatures_exchange(funding_tx, best_block_height, &logger) - } else { - (None, None) - }; - - let funding_tx = funding_tx.map(|tx| { - let tx_type = if splice_negotiated.is_some() { - TransactionType::Splice { - counterparty_node_id: self.context.counterparty_node_id, - channel_id: self.context.channel_id, - } - } else { - TransactionType::Funding { - channels: vec![(self.context.counterparty_node_id, self.context.channel_id)], - } - }; - (tx, tx_type) - }); - - Ok(FundingTxSigned { + let mut funding_tx_signed = FundingTxSigned { commitment_signed: None, counterparty_initial_commitment_signed_result: None, - tx_signatures: holder_tx_signatures, - funding_tx, - splice_negotiated, - splice_locked, - }) + tx_signatures: None, + funding_tx: None, + splice_negotiated: None, + splice_locked: None, + }; + if self.is_awaiting_monitor_update() { + // Although the user may have already provided our `tx_signatures`, we must not send + // them if we're waiting for the monitor to durably persist the counterparty's signature + // for our initial commitment post-splice. + debug_assert!(self.context.monitor_pending_tx_signatures); + log_debug!( + logger, + "Waiting for async monitor update to complete prior to releasing our tx_signatures" + ); + return Ok(funding_tx_signed); + } + + funding_tx_signed.tx_signatures = holder_tx_signatures; + if let Some(funding_tx) = funding_tx { + self.on_tx_signatures_exchange( + &mut funding_tx_signed, + funding_tx, + best_block_height, + &logger, + ); + } else { + debug_assert!( + false, + "Signed funding transaction should be available upon tx_signatures exchange" + ); + } + Ok(funding_tx_signed) } /// Queues up an outbound update fee by placing it in the holding cell. You should call @@ -9282,8 +9292,8 @@ where /// successfully and we should restore normal operation. Returns messages which should be sent /// to the remote side. #[rustfmt::skip] - pub fn monitor_updating_restored( - &mut self, logger: &L, node_signer: &NS, chain_hash: ChainHash, + pub fn monitor_updating_restored<'a, L: Logger, NS: NodeSigner, CBP>( + &mut self, logger: &WithChannelContext<'a, L>, node_signer: &NS, chain_hash: ChainHash, user_config: &UserConfig, best_block_height: u32, path_for_release_htlc: CBP ) -> MonitorRestoreUpdates where @@ -9293,27 +9303,42 @@ where self.context.channel_state.clear_monitor_update_in_progress(); assert_eq!(self.blocked_monitor_updates_pending(), 0); + // We want to clear that the monitor update for our `tx_signatures` has completed, but + // we may still need to hold back the message until it's ready to be sent. let mut tx_signatures = self .context .monitor_pending_tx_signatures .then(|| ()) .and_then(|_| self.context.interactive_tx_signing_session.as_ref()) - .and_then(|signing_session| signing_session.holder_tx_signatures().clone()); - if tx_signatures.is_some() { - // We want to clear that the monitor update for our `tx_signatures` has completed, but - // we may still need to hold back the message until it's ready to be sent. - self.context.monitor_pending_tx_signatures = false; - - if self.context.signer_pending_funding { - tx_signatures.take(); - } + .and_then(|signing_session| signing_session.holder_tx_signatures()); + self.context.monitor_pending_tx_signatures = false; + let mut funding_tx_signed = None; + if tx_signatures.is_some() { let signing_session = self.context.interactive_tx_signing_session.as_ref() .expect("We have a tx_signatures message so we must have a valid signing session"); - if !signing_session.holder_sends_tx_signatures_first() - && !signing_session.has_received_tx_signatures() - { + if self.context.signer_pending_funding { tx_signatures.take(); + } else { + debug_assert!(tx_signatures.is_some()); + funding_tx_signed = Some(FundingTxSigned { + commitment_signed: None, + counterparty_initial_commitment_signed_result: None, + tx_signatures, + funding_tx: None, + splice_negotiated: None, + splice_locked: None, + }); + if let Some(funding_tx) = signing_session.signed_tx() { + self.on_tx_signatures_exchange( + funding_tx_signed.as_mut().unwrap(), + funding_tx, + best_block_height, + logger, + ); + } else if signing_session.has_received_tx_signatures() { + debug_assert!(false, "Signed funding transaction should be available upon tx_signatures exchange"); + } } } @@ -9382,7 +9407,7 @@ where return MonitorRestoreUpdates { raa: None, commitment_update: None, commitment_order: RAACommitmentOrder::RevokeAndACKFirst, accepted_htlcs, failed_htlcs, finalized_claimed_htlcs, pending_update_adds, - funding_broadcastable, channel_ready, announcement_sigs, tx_signatures: None, + funding_broadcastable, channel_ready, announcement_sigs, funding_tx_signed, channel_ready_order, committed_outbound_htlc_sources }; } @@ -9413,7 +9438,7 @@ where match commitment_order { RAACommitmentOrder::CommitmentFirst => "commitment", RAACommitmentOrder::RevokeAndACKFirst => "RAA"}); MonitorRestoreUpdates { raa, commitment_update, commitment_order, accepted_htlcs, failed_htlcs, finalized_claimed_htlcs, - pending_update_adds, funding_broadcastable, channel_ready, announcement_sigs, tx_signatures, + pending_update_adds, funding_broadcastable, channel_ready, announcement_sigs, funding_tx_signed, channel_ready_order, committed_outbound_htlc_sources } } @@ -9530,11 +9555,7 @@ where let tx_signatures = if funding_commit_sig.is_some() { if let Some(signing_session) = self.context.interactive_tx_signing_session.as_ref() { - let should_send_tx_signatures = signing_session.holder_sends_tx_signatures_first() - || signing_session.has_received_tx_signatures(); - should_send_tx_signatures - .then(|| ()) - .and_then(|_| signing_session.holder_tx_signatures().clone()) + signing_session.holder_tx_signatures().filter(|_| !self.is_awaiting_monitor_update()) } else { debug_assert!(false); None @@ -9951,19 +9972,16 @@ where // // - if it has already received `tx_signatures` for that funding transaction: // - MUST send its `tx_signatures` for that funding transaction. - if (session.has_received_commitment_signed() && session.holder_sends_tx_signatures_first()) - || session.has_received_tx_signatures() - { - // If `holder_tx_signatures` is `None` here, the `tx_signatures` message will be sent - // when the holder provides their witnesses as this will queue a `tx_signatures` if the - // holder must send one. - if session.holder_tx_signatures().is_none() { - log_debug!(logger, "Waiting for funding transaction signatures to be provided"); - } else if self.context.channel_state.is_monitor_update_in_progress() { + if let Some(holder_tx_signatures) = session.holder_tx_signatures() { + if self.is_awaiting_monitor_update() { log_debug!(logger, "Waiting for monitor update before providing funding transaction signatures"); + } else if self.context.signer_pending_funding { + log_debug!(logger, "Waiting for signer to provide counterparty commitment_signed before releasing funding transaction signatures"); } else { - tx_signatures = session.holder_tx_signatures().clone(); + tx_signatures = Some(holder_tx_signatures); } + } else if !session.has_holder_tx_signatures() { + log_debug!(logger, "Waiting for funding transaction signatures to be provided"); } } else { // We'll just send a `tx_abort` here if we don't have a signing session for this channel @@ -10398,7 +10416,7 @@ where matches!(self.context.channel_state, ChannelState::NegotiatingFunding(_)); if matches!(self.context.channel_state, ChannelState::FundingNegotiated(_)) { if let Some(signing_session) = self.context.interactive_tx_signing_session.as_ref() { - if signing_session.holder_tx_signatures().is_none() { + if !signing_session.has_holder_tx_signatures() { // If we're a V1 channel or we haven't yet sent our `tx_signatures` for a dual // funded channel, the funding tx couldn't be broadcasted yet, so just short-circuit // the shutdown logic. @@ -16309,7 +16327,8 @@ mod tests { use crate::ln::channel::{ AwaitingChannelReadyFlags, ChannelState, FundedChannel, HTLCUpdateAwaitingACK, InboundHTLCOutput, InboundHTLCState, InboundUpdateAdd, InboundV1Channel, - OutboundHTLCOutput, OutboundHTLCState, OutboundV1Channel, MIN_THEIR_CHAN_RESERVE_SATOSHIS, + OutboundHTLCOutput, OutboundHTLCState, OutboundV1Channel, WithChannelContext, + MIN_THEIR_CHAN_RESERVE_SATOSHIS, }; use crate::ln::channel_keys::{RevocationBasepoint, RevocationKey}; use crate::ln::channelmanager::{self, HTLCSource, PaymentId}; @@ -18650,7 +18669,7 @@ mod tests { &&logger, ).map_err(|_| ()).unwrap(); let node_b_updates = node_b_chan.monitor_updating_restored( - &&logger, + &WithChannelContext::from(&logger, &node_b_chan.context, None), &&keys_provider, chain_hash, &config, @@ -18665,7 +18684,7 @@ mod tests { ); let (mut node_a_chan, _) = if let Ok(res) = res { res } else { panic!(); }; let node_a_updates = node_a_chan.monitor_updating_restored( - &&logger, + &WithChannelContext::from(&logger, &node_a_chan.context, None), &&keys_provider, chain_hash, &config, diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index b823864a6cc..3756c197ea5 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -10602,7 +10602,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } else { log_debug!(logger, "Channel is open and awaiting update, resuming it"); let updates = chan.monitor_updating_restored( - &&logger, + &logger, &self.node_signer, self.chain_hash, &*self.config.read().unwrap(), @@ -10640,7 +10640,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ updates.funding_broadcastable, updates.channel_ready, updates.announcement_sigs, - updates.tx_signatures, + updates.funding_tx_signed, None, updates.channel_ready_order, ); @@ -10792,19 +10792,20 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ pending_forwards: Vec<(PendingHTLCInfo, u64)>, pending_update_adds: Vec, funding_broadcastable: Option, channel_ready: Option, announcement_sigs: Option, - tx_signatures: Option, tx_abort: Option, + mut funding_tx_signed: Option, tx_abort: Option, channel_ready_order: ChannelReadyOrder, ) -> (Vec, Option<(u64, Vec)>) { let logger = WithChannelContext::from(&self.logger, &channel.context, None); - log_trace!(logger, "Handling channel resumption with {} RAA, {} commitment update, {} pending forwards, {} pending update_add_htlcs, {}broadcasting funding, {} channel ready, {} announcement, {} tx_signatures, {} tx_abort", + log_trace!(logger, "Handling channel resumption with {} RAA, {} commitment update, {} pending forwards, {} pending update_add_htlcs, {}broadcasting funding, {} channel ready, {} announcement, {} tx_signatures, {} tx_abort, {} splice_locked", if raa.is_some() { "an" } else { "no" }, if commitment_update.is_some() { "a" } else { "no" }, pending_forwards.len(), pending_update_adds.len(), if funding_broadcastable.is_some() { "" } else { "not " }, if channel_ready.is_some() { "sending" } else { "without" }, if announcement_sigs.is_some() { "sending" } else { "without" }, - if tx_signatures.is_some() { "sending" } else { "without" }, + if funding_tx_signed.as_ref().map(|v| v.tx_signatures.is_some()).unwrap_or(false) { "sending" } else { "without" }, if tx_abort.is_some() { "sending" } else { "without" }, + if funding_tx_signed.as_ref().map(|v| v.splice_locked.is_some()).unwrap_or(false) { "sending" } else { "without" }, ); let counterparty_node_id = channel.context.get_counterparty_node_id(); @@ -10871,7 +10872,13 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }, } - if let Some(msg) = tx_signatures { + if let Some(funding_tx_signed) = funding_tx_signed.as_ref() { + // These [`FundingTxSigned`] fields are only expected as a result of calling + // [`ChannelManager::funding_transaction_signed`]. + debug_assert!(funding_tx_signed.commitment_signed.is_none()); + debug_assert!(funding_tx_signed.counterparty_initial_commitment_signed_result.is_none()); + } + if let Some(msg) = funding_tx_signed.as_mut().and_then(|v| v.tx_signatures.take()) { pending_msg_events.push(MessageSendEvent::SendTxSignatures { node_id: counterparty_node_id, msg, @@ -10896,10 +10903,20 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }); } } + + if let Some(msg) = funding_tx_signed.as_mut().and_then(|v| v.splice_locked.take()) { + pending_msg_events.push(MessageSendEvent::SendSpliceLocked { + node_id: counterparty_node_id, + msg, + }); + } } else if let Some(msg) = channel_ready { self.send_channel_ready(pending_msg_events, channel, msg); } + // If we just finished a pending interactive funding negotiation and are ready to broadcast + // the transaction, `funding_broadcastable` will only contain the transaction for a + // dual-funded channel. Splice transactions need to be broadcast separately. if let Some(tx) = funding_broadcastable { if channel.context.is_manual_broadcast() { log_info!(logger, "Not broadcasting funding transaction with txid {} as it is manually managed", tx.compute_txid()); @@ -10914,18 +10931,45 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } }; } else { + if let Some((funding_tx, tx_type)) = funding_tx_signed.as_ref().and_then(|v| v.funding_tx.as_ref()) { + debug_assert_eq!(&tx, funding_tx); + debug_assert!(matches!(tx_type, TransactionType::Funding { .. })); + } log_info!(logger, "Broadcasting funding transaction with txid {}", tx.compute_txid()); self.tx_broadcaster.broadcast_transactions(&[( &tx, TransactionType::Funding { channels: vec![(counterparty_node_id, channel.context.channel_id())] }, )]); } + } else if let Some((splice_tx, tx_type)) = funding_tx_signed + .as_mut() + .and_then(|v| v.funding_tx.take()) + .filter(|(_, tx_type)| matches!(tx_type, TransactionType::Splice { .. })) + { + log_info!(logger, "Broadcasting signed splice transaction with txid {}", splice_tx.compute_txid()); + self.tx_broadcaster.broadcast_transactions(&[(&splice_tx, tx_type)]); } { let mut pending_events = self.pending_events.lock().unwrap(); emit_channel_pending_event!(pending_events, channel); emit_initial_channel_ready_event!(pending_events, channel); + if let Some(splice_negotiated) = funding_tx_signed + .as_mut() + .and_then(|v| v.splice_negotiated.take()) + { + pending_events.push_back(( + events::Event::SplicePending { + channel_id: channel.context.channel_id(), + counterparty_node_id, + user_channel_id: channel.context.get_user_id(), + new_funding_txo: splice_negotiated.funding_txo, + channel_type: splice_negotiated.channel_type, + new_funding_redeem_script: splice_negotiated.funding_redeem_script, + }, + None, + )); + } } (htlc_forwards, decode_update_add_htlcs) @@ -13072,10 +13116,14 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } } let need_lnd_workaround = chan.context.workaround_lnd_bug_4006.take(); + let funding_tx_signed = responses.tx_signatures.map(|tx_signatures| FundingTxSigned { + tx_signatures: Some(tx_signatures), + ..Default::default() + }); let (htlc_forwards, decode_update_add_htlcs) = self.handle_channel_resumption( &mut peer_state.pending_msg_events, chan, responses.raa, responses.commitment_update, responses.commitment_order, Vec::new(), Vec::new(), None, responses.channel_ready, responses.announcement_sigs, - responses.tx_signatures, responses.tx_abort, responses.channel_ready_order, + funding_tx_signed, responses.tx_abort, responses.channel_ready_order, ); debug_assert!(htlc_forwards.is_empty()); debug_assert!(decode_update_add_htlcs.is_none()); @@ -20046,7 +20094,7 @@ impl< if let Some(signing_session) = chan.context().interactive_tx_signing_session.as_ref() { - if signing_session.holder_tx_signatures().is_none() + if !signing_session.has_holder_tx_signatures() && signing_session.has_local_contribution() { let unsigned_transaction = signing_session.unsigned_tx().tx().clone(); diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index 36367611abb..9957205716c 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -608,8 +608,18 @@ impl InteractiveTxSigningSession { self.counterparty_tx_signatures.is_some() } - pub fn holder_tx_signatures(&self) -> &Option { - &self.holder_tx_signatures + pub fn has_holder_tx_signatures(&self) -> bool { + self.holder_tx_signatures.is_some() + } + + pub fn holder_tx_signatures(&self) -> Option { + self.holder_tx_signatures + .as_ref() + .filter(|_| { + (self.has_received_commitment_signed && self.holder_sends_tx_signatures_first) + || self.has_received_tx_signatures() + }) + .cloned() } pub fn received_commitment_signed(&mut self) { @@ -651,7 +661,7 @@ impl InteractiveTxSigningSession { None }; - let funding_tx_opt = self.maybe_finalize_funding_tx(); + let funding_tx_opt = self.signed_tx(); Ok((holder_tx_signatures, funding_tx_opt)) } @@ -680,7 +690,7 @@ impl InteractiveTxSigningSession { self.holder_tx_signatures = Some(tx_signatures); - let funding_tx_opt = self.maybe_finalize_funding_tx(); + let funding_tx_opt = self.signed_tx(); let holder_tx_signatures = (self.has_received_commitment_signed && (self.holder_sends_tx_signatures_first || self.has_received_tx_signatures())) .then(|| { @@ -737,7 +747,9 @@ impl InteractiveTxSigningSession { }) } - fn maybe_finalize_funding_tx(&mut self) -> Option { + /// Returns `Some` with the fully signed transaction if both holder and counterparty signatures + /// are available. + pub fn signed_tx(&self) -> Option { let holder_tx_signatures = self.holder_tx_signatures.as_ref()?; let counterparty_tx_signatures = self.counterparty_tx_signatures.as_ref()?; let shared_input_signature = self.shared_input_signature.as_ref(); diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index bdfe14635e0..43c8c68ed4e 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -3461,6 +3461,118 @@ fn test_splice_buffer_invalid_commitment_signed_closes_channel() { check_added_monitors(&nodes[0], 1); } +#[test] +fn test_splice_waits_for_initial_commitment_monitor_update_before_releasing_tx_signatures() { + do_splice_waits_for_initial_commitment_monitor_update_before_releasing_tx_signatures(false); + do_splice_waits_for_initial_commitment_monitor_update_before_releasing_tx_signatures(true); +} + +#[cfg(test)] +fn do_splice_waits_for_initial_commitment_monitor_update_before_releasing_tx_signatures( + complete_update_while_disconnected: bool, +) { + // Test that if processing the counterparty's initial `commitment_signed` returns + // `ChannelMonitorUpdateStatus::InProgress`, we do not release our `tx_signatures` when their + // `tx_signatures` is received. We should only release ours once the monitor update completes. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); + + let outputs = vec![TxOut { + value: Amount::from_sat(1_000), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]; + let initiator_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); + negotiate_splice_tx(&nodes[0], &nodes[1], channel_id, initiator_contribution); + + let signing_event = get_event!(nodes[0], Event::FundingTransactionReadyForSigning); + if let Event::FundingTransactionReadyForSigning { + channel_id: event_channel_id, + counterparty_node_id, + unsigned_transaction, + .. + } = signing_event + { + assert_eq!(event_channel_id, channel_id); + assert_eq!(counterparty_node_id, node_id_1); + + let partially_signed_tx = nodes[0].wallet_source.sign_tx(unsigned_transaction).unwrap(); + nodes[0] + .node + .funding_transaction_signed(&channel_id, &node_id_1, partially_signed_tx) + .unwrap(); + } else { + panic!("Expected FundingTransactionReadyForSigning event"); + } + + let initiator_commit_sig = get_htlc_update_msgs(&nodes[0], &node_id_1); + nodes[1].node.handle_commitment_signed(node_id_0, &initiator_commit_sig.commitment_signed[0]); + check_added_monitors(&nodes[1], 1); + + // Leave the monitor update for node 0's processing of the initial `commitment_signed` pending. + chanmon_cfgs[0].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + let counterparty_commit_sig = + if let MessageSendEvent::UpdateHTLCs { ref updates, .. } = &msg_events[0] { + updates.commitment_signed[0].clone() + } else { + panic!("Expected UpdateHTLCs message"); + }; + let counterparty_tx_signatures = + if let MessageSendEvent::SendTxSignatures { ref msg, .. } = &msg_events[1] { + msg.clone() + } else { + panic!("Expected SendTxSignatures message"); + }; + + nodes[0].node.handle_commitment_signed(node_id_1, &counterparty_commit_sig); + check_added_monitors(&nodes[0], 1); + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + + nodes[0].node.handle_tx_signatures(node_id_1, &counterparty_tx_signatures); + + // We should not send our `tx_signatures` while the monitor update is still in progress. + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + + // Reestablishing before the monitor update completes should still not release `tx_signatures`. + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); + reconnect_args.send_announcement_sigs = (true, true); + reconnect_nodes(reconnect_args); + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + + if complete_update_while_disconnected { + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + } + + nodes[0].chain_monitor.complete_sole_pending_chan_update(&channel_id); + chanmon_cfgs[0].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); + + if !complete_update_while_disconnected { + let initiator_tx_signatures = + get_event_msg!(nodes[0], MessageSendEvent::SendTxSignatures, node_id_1); + nodes[1].node.handle_tx_signatures(node_id_0, &initiator_tx_signatures); + } + + expect_splice_pending_event(&nodes[0], &node_id_1); + if !complete_update_while_disconnected { + expect_splice_pending_event(&nodes[1], &node_id_0); + } +} + #[test] fn test_splice_balance_falls_below_reserve() { // Test that we're able to proceed with a splice where the acceptor does not contribute From 39c8b0c88ee8ffca4b1db1fd70dbe3c0676d60e8 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Thu, 26 Feb 2026 21:59:24 +0100 Subject: [PATCH 221/627] fuzz: use process::exit panic hook in stdin_fuzz on macOS On macOS, panic=abort causes the process to call abort() which sends SIGABRT. The ReportCrash daemon then tries to generate a crash report, leaving the process stuck in an uninterruptible wait state that cannot be killed even with SIGKILL. This makes stdin_fuzz unusable for crash reproduction on macOS. Install a custom panic hook (gated behind #[cfg(target_os = "macos")]) that flushes stdout (preserving log output), prints the panic info with a full backtrace to stderr, then calls process::exit(1) to terminate cleanly before the abort machinery runs. The hook is only installed on macOS to avoid interfering with debuggers like GDB on Linux. AI tools were used in preparing this commit. --- fuzz/src/bin/base32_target.rs | 12 ++++++++++++ fuzz/src/bin/bech32_parse_target.rs | 12 ++++++++++++ fuzz/src/bin/bolt11_deser_target.rs | 12 ++++++++++++ fuzz/src/bin/chanmon_consistency_target.rs | 12 ++++++++++++ fuzz/src/bin/chanmon_deser_target.rs | 12 ++++++++++++ fuzz/src/bin/feature_flags_target.rs | 12 ++++++++++++ fuzz/src/bin/fromstr_to_netaddress_target.rs | 12 ++++++++++++ fuzz/src/bin/fs_store_target.rs | 12 ++++++++++++ fuzz/src/bin/full_stack_target.rs | 12 ++++++++++++ fuzz/src/bin/indexedmap_target.rs | 12 ++++++++++++ fuzz/src/bin/invoice_deser_target.rs | 12 ++++++++++++ fuzz/src/bin/invoice_request_deser_target.rs | 12 ++++++++++++ fuzz/src/bin/lsps_message_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_accept_channel_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_accept_channel_v2_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_announcement_signatures_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_blinded_message_path_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_channel_announcement_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_channel_details_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_channel_ready_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_channel_reestablish_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_channel_update_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_closing_complete_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_closing_sig_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_closing_signed_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_commitment_signed_target.rs | 12 ++++++++++++ .../src/bin/msg_decoded_onion_error_packet_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_error_message_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_funding_created_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_funding_signed_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_gossip_timestamp_filter_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_init_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_node_announcement_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_open_channel_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_open_channel_v2_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_ping_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_pong_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_query_channel_range_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_query_short_channel_ids_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_reply_channel_range_target.rs | 12 ++++++++++++ .../bin/msg_reply_short_channel_ids_end_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_revoke_and_ack_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_shutdown_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_splice_ack_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_splice_init_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_splice_locked_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_stfu_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_tx_abort_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_tx_ack_rbf_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_tx_add_input_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_tx_add_output_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_tx_complete_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_tx_init_rbf_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_tx_remove_input_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_tx_remove_output_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_tx_signatures_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_update_add_htlc_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_update_fail_htlc_target.rs | 12 ++++++++++++ .../src/bin/msg_update_fail_malformed_htlc_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_update_fee_target.rs | 12 ++++++++++++ fuzz/src/bin/msg_update_fulfill_htlc_target.rs | 12 ++++++++++++ fuzz/src/bin/offer_deser_target.rs | 12 ++++++++++++ fuzz/src/bin/onion_hop_data_target.rs | 12 ++++++++++++ fuzz/src/bin/onion_message_target.rs | 12 ++++++++++++ fuzz/src/bin/peer_crypt_target.rs | 12 ++++++++++++ fuzz/src/bin/process_network_graph_target.rs | 12 ++++++++++++ fuzz/src/bin/process_onion_failure_target.rs | 12 ++++++++++++ fuzz/src/bin/refund_deser_target.rs | 12 ++++++++++++ fuzz/src/bin/router_target.rs | 12 ++++++++++++ fuzz/src/bin/static_invoice_deser_target.rs | 12 ++++++++++++ fuzz/src/bin/target_template.txt | 12 ++++++++++++ fuzz/src/bin/zbase32_target.rs | 12 ++++++++++++ 72 files changed, 864 insertions(+) diff --git a/fuzz/src/bin/base32_target.rs b/fuzz/src/bin/base32_target.rs index 5f168fdcea3..e79e6db7380 100644 --- a/fuzz/src/bin/base32_target.rs +++ b/fuzz/src/bin/base32_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); base32_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/bech32_parse_target.rs b/fuzz/src/bin/bech32_parse_target.rs index ad2f6653843..f9493bb1bc1 100644 --- a/fuzz/src/bin/bech32_parse_target.rs +++ b/fuzz/src/bin/bech32_parse_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); bech32_parse_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/bolt11_deser_target.rs b/fuzz/src/bin/bolt11_deser_target.rs index 9e2f33d92cc..28b1e2db679 100644 --- a/fuzz/src/bin/bolt11_deser_target.rs +++ b/fuzz/src/bin/bolt11_deser_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); bolt11_deser_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/chanmon_consistency_target.rs b/fuzz/src/bin/chanmon_consistency_target.rs index a729e3df1d6..7649900bae5 100644 --- a/fuzz/src/bin/chanmon_consistency_target.rs +++ b/fuzz/src/bin/chanmon_consistency_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); chanmon_consistency_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/chanmon_deser_target.rs b/fuzz/src/bin/chanmon_deser_target.rs index a2f109f17c8..d3cf30b86e3 100644 --- a/fuzz/src/bin/chanmon_deser_target.rs +++ b/fuzz/src/bin/chanmon_deser_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); chanmon_deser_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/feature_flags_target.rs b/fuzz/src/bin/feature_flags_target.rs index 2d23f96b4e6..b1f35f8820f 100644 --- a/fuzz/src/bin/feature_flags_target.rs +++ b/fuzz/src/bin/feature_flags_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); feature_flags_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/fromstr_to_netaddress_target.rs b/fuzz/src/bin/fromstr_to_netaddress_target.rs index fd34e029722..8f3e5c3dc7f 100644 --- a/fuzz/src/bin/fromstr_to_netaddress_target.rs +++ b/fuzz/src/bin/fromstr_to_netaddress_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); fromstr_to_netaddress_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/fs_store_target.rs b/fuzz/src/bin/fs_store_target.rs index 8942ebea7f7..8d84aad7b6b 100644 --- a/fuzz/src/bin/fs_store_target.rs +++ b/fuzz/src/bin/fs_store_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); fs_store_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/full_stack_target.rs b/fuzz/src/bin/full_stack_target.rs index a0be19786b5..c1f20b10af4 100644 --- a/fuzz/src/bin/full_stack_target.rs +++ b/fuzz/src/bin/full_stack_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); full_stack_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/indexedmap_target.rs b/fuzz/src/bin/indexedmap_target.rs index 51d135b372e..3bc4390fee4 100644 --- a/fuzz/src/bin/indexedmap_target.rs +++ b/fuzz/src/bin/indexedmap_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); indexedmap_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/invoice_deser_target.rs b/fuzz/src/bin/invoice_deser_target.rs index bcdbecd0706..44bf1851a40 100644 --- a/fuzz/src/bin/invoice_deser_target.rs +++ b/fuzz/src/bin/invoice_deser_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); invoice_deser_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/invoice_request_deser_target.rs b/fuzz/src/bin/invoice_request_deser_target.rs index f6eee60f142..06d8f87fa55 100644 --- a/fuzz/src/bin/invoice_request_deser_target.rs +++ b/fuzz/src/bin/invoice_request_deser_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); invoice_request_deser_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/lsps_message_target.rs b/fuzz/src/bin/lsps_message_target.rs index 4c6a0f45655..37e6f103fb4 100644 --- a/fuzz/src/bin/lsps_message_target.rs +++ b/fuzz/src/bin/lsps_message_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); lsps_message_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_accept_channel_target.rs b/fuzz/src/bin/msg_accept_channel_target.rs index aa3b6768eba..ee08a5fc344 100644 --- a/fuzz/src/bin/msg_accept_channel_target.rs +++ b/fuzz/src/bin/msg_accept_channel_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_accept_channel_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_accept_channel_v2_target.rs b/fuzz/src/bin/msg_accept_channel_v2_target.rs index 469ae98a410..2903e111f56 100644 --- a/fuzz/src/bin/msg_accept_channel_v2_target.rs +++ b/fuzz/src/bin/msg_accept_channel_v2_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_accept_channel_v2_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_announcement_signatures_target.rs b/fuzz/src/bin/msg_announcement_signatures_target.rs index f53aae636d5..064880abc18 100644 --- a/fuzz/src/bin/msg_announcement_signatures_target.rs +++ b/fuzz/src/bin/msg_announcement_signatures_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_announcement_signatures_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_blinded_message_path_target.rs b/fuzz/src/bin/msg_blinded_message_path_target.rs index 4159e1c6499..277e04c9656 100644 --- a/fuzz/src/bin/msg_blinded_message_path_target.rs +++ b/fuzz/src/bin/msg_blinded_message_path_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_blinded_message_path_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_channel_announcement_target.rs b/fuzz/src/bin/msg_channel_announcement_target.rs index 31cb61165b0..42e72d54b72 100644 --- a/fuzz/src/bin/msg_channel_announcement_target.rs +++ b/fuzz/src/bin/msg_channel_announcement_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_channel_announcement_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_channel_details_target.rs b/fuzz/src/bin/msg_channel_details_target.rs index 618c5d6d297..a03a7a44920 100644 --- a/fuzz/src/bin/msg_channel_details_target.rs +++ b/fuzz/src/bin/msg_channel_details_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_channel_details_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_channel_ready_target.rs b/fuzz/src/bin/msg_channel_ready_target.rs index eacacf10193..a0457815036 100644 --- a/fuzz/src/bin/msg_channel_ready_target.rs +++ b/fuzz/src/bin/msg_channel_ready_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_channel_ready_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_channel_reestablish_target.rs b/fuzz/src/bin/msg_channel_reestablish_target.rs index 9ed4a5d1ad3..b5449a90e37 100644 --- a/fuzz/src/bin/msg_channel_reestablish_target.rs +++ b/fuzz/src/bin/msg_channel_reestablish_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_channel_reestablish_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_channel_update_target.rs b/fuzz/src/bin/msg_channel_update_target.rs index 56ffeff2c4d..9feb6e6c6b4 100644 --- a/fuzz/src/bin/msg_channel_update_target.rs +++ b/fuzz/src/bin/msg_channel_update_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_channel_update_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_closing_complete_target.rs b/fuzz/src/bin/msg_closing_complete_target.rs index 3d8b1375266..22dd97c79c9 100644 --- a/fuzz/src/bin/msg_closing_complete_target.rs +++ b/fuzz/src/bin/msg_closing_complete_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_closing_complete_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_closing_sig_target.rs b/fuzz/src/bin/msg_closing_sig_target.rs index 8bd8e30b50f..26058a5277d 100644 --- a/fuzz/src/bin/msg_closing_sig_target.rs +++ b/fuzz/src/bin/msg_closing_sig_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_closing_sig_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_closing_signed_target.rs b/fuzz/src/bin/msg_closing_signed_target.rs index 68ed7239693..94408bc2ba9 100644 --- a/fuzz/src/bin/msg_closing_signed_target.rs +++ b/fuzz/src/bin/msg_closing_signed_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_closing_signed_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_commitment_signed_target.rs b/fuzz/src/bin/msg_commitment_signed_target.rs index bac1912c616..e8987848417 100644 --- a/fuzz/src/bin/msg_commitment_signed_target.rs +++ b/fuzz/src/bin/msg_commitment_signed_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_commitment_signed_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_decoded_onion_error_packet_target.rs b/fuzz/src/bin/msg_decoded_onion_error_packet_target.rs index 546acafd089..47d8970b453 100644 --- a/fuzz/src/bin/msg_decoded_onion_error_packet_target.rs +++ b/fuzz/src/bin/msg_decoded_onion_error_packet_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_decoded_onion_error_packet_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_error_message_target.rs b/fuzz/src/bin/msg_error_message_target.rs index f020c4532b3..ee3904a724e 100644 --- a/fuzz/src/bin/msg_error_message_target.rs +++ b/fuzz/src/bin/msg_error_message_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_error_message_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_funding_created_target.rs b/fuzz/src/bin/msg_funding_created_target.rs index cfa74aca486..028aa17ad8a 100644 --- a/fuzz/src/bin/msg_funding_created_target.rs +++ b/fuzz/src/bin/msg_funding_created_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_funding_created_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_funding_signed_target.rs b/fuzz/src/bin/msg_funding_signed_target.rs index de5f3b22300..4894c66df0b 100644 --- a/fuzz/src/bin/msg_funding_signed_target.rs +++ b/fuzz/src/bin/msg_funding_signed_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_funding_signed_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_gossip_timestamp_filter_target.rs b/fuzz/src/bin/msg_gossip_timestamp_filter_target.rs index 4fd905b3edd..6da383b2e6f 100644 --- a/fuzz/src/bin/msg_gossip_timestamp_filter_target.rs +++ b/fuzz/src/bin/msg_gossip_timestamp_filter_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_gossip_timestamp_filter_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_init_target.rs b/fuzz/src/bin/msg_init_target.rs index 9d2bc346304..f1d17c99289 100644 --- a/fuzz/src/bin/msg_init_target.rs +++ b/fuzz/src/bin/msg_init_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_init_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_node_announcement_target.rs b/fuzz/src/bin/msg_node_announcement_target.rs index 820fea1adca..b0615f3c5e5 100644 --- a/fuzz/src/bin/msg_node_announcement_target.rs +++ b/fuzz/src/bin/msg_node_announcement_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_node_announcement_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_open_channel_target.rs b/fuzz/src/bin/msg_open_channel_target.rs index fbfd0938924..b3dbf388c08 100644 --- a/fuzz/src/bin/msg_open_channel_target.rs +++ b/fuzz/src/bin/msg_open_channel_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_open_channel_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_open_channel_v2_target.rs b/fuzz/src/bin/msg_open_channel_v2_target.rs index 8c46c4c09df..0df11adf32e 100644 --- a/fuzz/src/bin/msg_open_channel_v2_target.rs +++ b/fuzz/src/bin/msg_open_channel_v2_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_open_channel_v2_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_ping_target.rs b/fuzz/src/bin/msg_ping_target.rs index 52cd3d941ab..48f855985de 100644 --- a/fuzz/src/bin/msg_ping_target.rs +++ b/fuzz/src/bin/msg_ping_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_ping_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_pong_target.rs b/fuzz/src/bin/msg_pong_target.rs index da9e9cc2b89..434e9cfe310 100644 --- a/fuzz/src/bin/msg_pong_target.rs +++ b/fuzz/src/bin/msg_pong_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_pong_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_query_channel_range_target.rs b/fuzz/src/bin/msg_query_channel_range_target.rs index e177b23072f..cb87260e1ef 100644 --- a/fuzz/src/bin/msg_query_channel_range_target.rs +++ b/fuzz/src/bin/msg_query_channel_range_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_query_channel_range_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_query_short_channel_ids_target.rs b/fuzz/src/bin/msg_query_short_channel_ids_target.rs index 53ca822bb21..bc286a7e523 100644 --- a/fuzz/src/bin/msg_query_short_channel_ids_target.rs +++ b/fuzz/src/bin/msg_query_short_channel_ids_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_query_short_channel_ids_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_reply_channel_range_target.rs b/fuzz/src/bin/msg_reply_channel_range_target.rs index 2a776eaabf7..c7df076c6c6 100644 --- a/fuzz/src/bin/msg_reply_channel_range_target.rs +++ b/fuzz/src/bin/msg_reply_channel_range_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_reply_channel_range_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_reply_short_channel_ids_end_target.rs b/fuzz/src/bin/msg_reply_short_channel_ids_end_target.rs index 02ffd90ebcb..2c73d866bd9 100644 --- a/fuzz/src/bin/msg_reply_short_channel_ids_end_target.rs +++ b/fuzz/src/bin/msg_reply_short_channel_ids_end_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_reply_short_channel_ids_end_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_revoke_and_ack_target.rs b/fuzz/src/bin/msg_revoke_and_ack_target.rs index 0a20ea7586c..6379d39591f 100644 --- a/fuzz/src/bin/msg_revoke_and_ack_target.rs +++ b/fuzz/src/bin/msg_revoke_and_ack_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_revoke_and_ack_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_shutdown_target.rs b/fuzz/src/bin/msg_shutdown_target.rs index ed26a25949c..6bf0409b7b5 100644 --- a/fuzz/src/bin/msg_shutdown_target.rs +++ b/fuzz/src/bin/msg_shutdown_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_shutdown_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_splice_ack_target.rs b/fuzz/src/bin/msg_splice_ack_target.rs index 0a1f13b7e08..96f373d5a1c 100644 --- a/fuzz/src/bin/msg_splice_ack_target.rs +++ b/fuzz/src/bin/msg_splice_ack_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_splice_ack_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_splice_init_target.rs b/fuzz/src/bin/msg_splice_init_target.rs index 9a7bc60ebda..73d4319c44a 100644 --- a/fuzz/src/bin/msg_splice_init_target.rs +++ b/fuzz/src/bin/msg_splice_init_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_splice_init_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_splice_locked_target.rs b/fuzz/src/bin/msg_splice_locked_target.rs index 0f9b0a2ed60..9210113a0c8 100644 --- a/fuzz/src/bin/msg_splice_locked_target.rs +++ b/fuzz/src/bin/msg_splice_locked_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_splice_locked_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_stfu_target.rs b/fuzz/src/bin/msg_stfu_target.rs index d6b898ba11b..d00536c7bcd 100644 --- a/fuzz/src/bin/msg_stfu_target.rs +++ b/fuzz/src/bin/msg_stfu_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_stfu_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_tx_abort_target.rs b/fuzz/src/bin/msg_tx_abort_target.rs index 3b824095062..8f216b46e63 100644 --- a/fuzz/src/bin/msg_tx_abort_target.rs +++ b/fuzz/src/bin/msg_tx_abort_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_tx_abort_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_tx_ack_rbf_target.rs b/fuzz/src/bin/msg_tx_ack_rbf_target.rs index d4905a5ce14..90b34c7f93f 100644 --- a/fuzz/src/bin/msg_tx_ack_rbf_target.rs +++ b/fuzz/src/bin/msg_tx_ack_rbf_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_tx_ack_rbf_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_tx_add_input_target.rs b/fuzz/src/bin/msg_tx_add_input_target.rs index 627797fdc6f..ce9700bd344 100644 --- a/fuzz/src/bin/msg_tx_add_input_target.rs +++ b/fuzz/src/bin/msg_tx_add_input_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_tx_add_input_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_tx_add_output_target.rs b/fuzz/src/bin/msg_tx_add_output_target.rs index be301558f6f..02682194e13 100644 --- a/fuzz/src/bin/msg_tx_add_output_target.rs +++ b/fuzz/src/bin/msg_tx_add_output_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_tx_add_output_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_tx_complete_target.rs b/fuzz/src/bin/msg_tx_complete_target.rs index 12abb32e020..48864f053c8 100644 --- a/fuzz/src/bin/msg_tx_complete_target.rs +++ b/fuzz/src/bin/msg_tx_complete_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_tx_complete_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_tx_init_rbf_target.rs b/fuzz/src/bin/msg_tx_init_rbf_target.rs index 6ede611b2ae..a8b613cdfca 100644 --- a/fuzz/src/bin/msg_tx_init_rbf_target.rs +++ b/fuzz/src/bin/msg_tx_init_rbf_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_tx_init_rbf_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_tx_remove_input_target.rs b/fuzz/src/bin/msg_tx_remove_input_target.rs index a508497e151..1e46c547dbf 100644 --- a/fuzz/src/bin/msg_tx_remove_input_target.rs +++ b/fuzz/src/bin/msg_tx_remove_input_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_tx_remove_input_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_tx_remove_output_target.rs b/fuzz/src/bin/msg_tx_remove_output_target.rs index 993ddb044b2..3a9c178e75f 100644 --- a/fuzz/src/bin/msg_tx_remove_output_target.rs +++ b/fuzz/src/bin/msg_tx_remove_output_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_tx_remove_output_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_tx_signatures_target.rs b/fuzz/src/bin/msg_tx_signatures_target.rs index 8054d4241ee..77f34cc1f6a 100644 --- a/fuzz/src/bin/msg_tx_signatures_target.rs +++ b/fuzz/src/bin/msg_tx_signatures_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_tx_signatures_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_update_add_htlc_target.rs b/fuzz/src/bin/msg_update_add_htlc_target.rs index 258dd2445f2..3ff5cf83dbe 100644 --- a/fuzz/src/bin/msg_update_add_htlc_target.rs +++ b/fuzz/src/bin/msg_update_add_htlc_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_update_add_htlc_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_update_fail_htlc_target.rs b/fuzz/src/bin/msg_update_fail_htlc_target.rs index b4ae4e52e1e..5b8a7e55dcb 100644 --- a/fuzz/src/bin/msg_update_fail_htlc_target.rs +++ b/fuzz/src/bin/msg_update_fail_htlc_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_update_fail_htlc_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_update_fail_malformed_htlc_target.rs b/fuzz/src/bin/msg_update_fail_malformed_htlc_target.rs index fb5325d54f7..e3e8918e492 100644 --- a/fuzz/src/bin/msg_update_fail_malformed_htlc_target.rs +++ b/fuzz/src/bin/msg_update_fail_malformed_htlc_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_update_fail_malformed_htlc_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_update_fee_target.rs b/fuzz/src/bin/msg_update_fee_target.rs index d8e9a26dc08..98e51181c79 100644 --- a/fuzz/src/bin/msg_update_fee_target.rs +++ b/fuzz/src/bin/msg_update_fee_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_update_fee_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/msg_update_fulfill_htlc_target.rs b/fuzz/src/bin/msg_update_fulfill_htlc_target.rs index cec5ccfc1fe..cb156448e13 100644 --- a/fuzz/src/bin/msg_update_fulfill_htlc_target.rs +++ b/fuzz/src/bin/msg_update_fulfill_htlc_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); msg_update_fulfill_htlc_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/offer_deser_target.rs b/fuzz/src/bin/offer_deser_target.rs index d788a8b04c9..c4a03f628b3 100644 --- a/fuzz/src/bin/offer_deser_target.rs +++ b/fuzz/src/bin/offer_deser_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); offer_deser_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/onion_hop_data_target.rs b/fuzz/src/bin/onion_hop_data_target.rs index 1677d075ebd..3b9b55bbfa9 100644 --- a/fuzz/src/bin/onion_hop_data_target.rs +++ b/fuzz/src/bin/onion_hop_data_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); onion_hop_data_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/onion_message_target.rs b/fuzz/src/bin/onion_message_target.rs index ff5feec3fb4..bb343e9de83 100644 --- a/fuzz/src/bin/onion_message_target.rs +++ b/fuzz/src/bin/onion_message_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); onion_message_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/peer_crypt_target.rs b/fuzz/src/bin/peer_crypt_target.rs index 6b21d8e6e5a..c68111deb06 100644 --- a/fuzz/src/bin/peer_crypt_target.rs +++ b/fuzz/src/bin/peer_crypt_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); peer_crypt_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/process_network_graph_target.rs b/fuzz/src/bin/process_network_graph_target.rs index 26306648151..7da2aafe3c8 100644 --- a/fuzz/src/bin/process_network_graph_target.rs +++ b/fuzz/src/bin/process_network_graph_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); process_network_graph_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/process_onion_failure_target.rs b/fuzz/src/bin/process_onion_failure_target.rs index 4c613a055b1..9e1cc8aa6d0 100644 --- a/fuzz/src/bin/process_onion_failure_target.rs +++ b/fuzz/src/bin/process_onion_failure_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); process_onion_failure_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/refund_deser_target.rs b/fuzz/src/bin/refund_deser_target.rs index c61c4f7a5d9..13837d2be73 100644 --- a/fuzz/src/bin/refund_deser_target.rs +++ b/fuzz/src/bin/refund_deser_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); refund_deser_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/router_target.rs b/fuzz/src/bin/router_target.rs index 73d6d1b3f7b..52a8c3408ff 100644 --- a/fuzz/src/bin/router_target.rs +++ b/fuzz/src/bin/router_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); router_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/static_invoice_deser_target.rs b/fuzz/src/bin/static_invoice_deser_target.rs index 59b854486ac..477f7869e7f 100644 --- a/fuzz/src/bin/static_invoice_deser_target.rs +++ b/fuzz/src/bin/static_invoice_deser_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); static_invoice_deser_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/target_template.txt b/fuzz/src/bin/target_template.txt index b085ae7ad7b..9b0dff8eb8c 100644 --- a/fuzz/src/bin/target_template.txt +++ b/fuzz/src/bin/target_template.txt @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); TARGET_NAME_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); diff --git a/fuzz/src/bin/zbase32_target.rs b/fuzz/src/bin/zbase32_target.rs index c17ea0ae8b5..68c8cf3e19c 100644 --- a/fuzz/src/bin/zbase32_target.rs +++ b/fuzz/src/bin/zbase32_target.rs @@ -57,6 +57,18 @@ fuzz_target!(|data: &[u8]| { fn main() { use std::io::Read; + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); zbase32_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); From f8a955c50d5d5ac28a1c5bb94ae820b17d315c03 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Mon, 9 Mar 2026 06:27:30 -0400 Subject: [PATCH 222/627] Defer monitor update completions after funding spend When no_further_updates_allowed() is true and the persister returns Completed, ChainMonitor now overrides the return to InProgress and pushes a MonitorEvent::Completed directly into pending_monitor_events. In release_pending_monitor_events, these deferred completions are appended after per-monitor events, so ChannelManager sees the force-close MonitorEvents before the completion. This eliminates phantom InProgress entries that would never complete: previously, a rejected pre-close update (e.g. commitment_signed arriving after funding spend) returned InProgress with no completion path, blocking MonitorUpdateCompletionActions (PaymentClaimed, PaymentForwarded) indefinitely. A subsequent post-close update returning Completed would then violate the in-order completion invariant. AI tools were used in preparing this commit. --- lightning/src/chain/chainmonitor.rs | 45 +++++- lightning/src/ln/chanmon_update_fail_tests.rs | 144 +++++++++++++----- 2 files changed, 144 insertions(+), 45 deletions(-) diff --git a/lightning/src/chain/chainmonitor.rs b/lightning/src/chain/chainmonitor.rs index 396ee277067..07d835dc785 100644 --- a/lightning/src/chain/chainmonitor.rs +++ b/lightning/src/chain/chainmonitor.rs @@ -1250,7 +1250,41 @@ where } } - if update_res.is_err() { + debug_assert!( + update_res.is_ok() || monitor.no_further_updates_allowed(), + "update_monitor returned Err but channel is not post-close", + ); + + // We also check update_res.is_err() as a defensive measure: an + // error should only occur on a post-close monitor (validated by + // the debug_assert above), but we defer here regardless to avoid + // returning Completed for a failed update. + if (update_res.is_err() || monitor.no_further_updates_allowed()) + && persist_res == ChannelMonitorUpdateStatus::Completed + { + // The channel is post-close (funding spend seen, lockdown, or + // holder tx signed). Return InProgress so ChannelManager freezes + // the channel until the force-close MonitorEvents are processed. + // Push a Completed event into pending_monitor_events so it gets + // picked up after the per-monitor events in the next + // release_pending_monitor_events call. + let funding_txo = monitor.get_funding_txo(); + let channel_id = monitor.channel_id(); + self.pending_monitor_events.lock().unwrap().push(( + funding_txo, + channel_id, + vec![MonitorEvent::Completed { + funding_txo, + channel_id, + monitor_update_id: monitor.get_latest_update_id(), + }], + monitor.get_counterparty_node_id(), + )); + log_debug!( + logger, + "Deferring completion of ChannelMonitorUpdate id {:?} (channel is post-close)", + update_id, + ); ChannelMonitorUpdateStatus::InProgress } else { persist_res @@ -1614,8 +1648,9 @@ where for (channel_id, update_id) in self.persister.get_and_clear_completed_updates() { let _ = self.channel_monitor_updated(channel_id, update_id); } - let mut pending_monitor_events = self.pending_monitor_events.lock().unwrap().split_off(0); - for monitor_state in self.monitors.read().unwrap().values() { + let monitors = self.monitors.read().unwrap(); + let mut pending_monitor_events = Vec::new(); + for monitor_state in monitors.values() { let monitor_events = monitor_state.monitor.get_and_clear_pending_monitor_events(); if monitor_events.len() > 0 { let monitor_funding_txo = monitor_state.monitor.get_funding_txo(); @@ -1629,6 +1664,10 @@ where )); } } + // Drain pending_monitor_events (which includes deferred post-close + // completions) after per-monitor events so that force-close + // MonitorEvents are processed by ChannelManager first. + pending_monitor_events.extend(self.pending_monitor_events.lock().unwrap().split_off(0)); pending_monitor_events } } diff --git a/lightning/src/ln/chanmon_update_fail_tests.rs b/lightning/src/ln/chanmon_update_fail_tests.rs index 87856c950d1..0d8a4a020f0 100644 --- a/lightning/src/ln/chanmon_update_fail_tests.rs +++ b/lightning/src/ln/chanmon_update_fail_tests.rs @@ -3905,11 +3905,28 @@ fn do_test_durable_preimages_on_closed_channel( } if !close_chans_before_reload { check_closed_broadcast(&nodes[1], 1, false); - let reason = ClosureReason::CommitmentTxConfirmed; - check_closed_event(&nodes[1], 1, reason, &[node_a_id], 100000); + // When hold=false, get_and_clear_pending_events also triggers + // process_background_events (replaying the preimage and force-close updates) + // and resolves the deferred completions, firing PaymentForwarded alongside + // ChannelClosed. When hold=true, only ChannelClosed fires. + let evs = nodes[1].node.get_and_clear_pending_events(); + let expected = if hold_post_reload_mon_update { 1 } else { 2 }; + assert_eq!(evs.len(), expected, "{:?}", evs); + assert!(evs.iter().any(|e| matches!( + e, + Event::ChannelClosed { reason: ClosureReason::CommitmentTxConfirmed, .. } + ))); + if !hold_post_reload_mon_update { + assert!(evs.iter().any(|e| matches!(e, Event::PaymentForwarded { .. }))); + check_added_monitors(&nodes[1], mons_added); + } } nodes[1].node.timer_tick_occurred(); - check_added_monitors(&nodes[1], mons_added); + // For !close_chans_before_reload && !hold, background events were already replayed + // during get_and_clear_pending_events above, so timer_tick adds no monitors. + let expected_mons = + if !close_chans_before_reload && !hold_post_reload_mon_update { 0 } else { mons_added }; + check_added_monitors(&nodes[1], expected_mons); // Finally, check that B created a payment preimage transaction and close out the payment. let bs_txn = nodes[1].tx_broadcaster.txn_broadcasted.lock().unwrap().split_off(0); @@ -3924,44 +3941,61 @@ fn do_test_durable_preimages_on_closed_channel( check_closed_broadcast(&nodes[0], 1, false); expect_payment_sent(&nodes[0], payment_preimage, None, true, true); + if close_chans_before_reload && !hold_post_reload_mon_update { + // For close_chans_before_reload with hold=false, the deferred completions + // haven't been processed yet. Trigger process_pending_monitor_events now. + let _ = nodes[1].node.get_and_clear_pending_msg_events(); + check_added_monitors(&nodes[1], 0); + } + if !close_chans_before_reload || close_only_a { // Make sure the B<->C channel is still alive and well by sending a payment over it. let mut reconnect_args = ReconnectArgs::new(&nodes[1], &nodes[2]); reconnect_args.pending_responding_commitment_signed.1 = true; - // The B<->C `ChannelMonitorUpdate` shouldn't be allowed to complete, which is the - // equivalent to the responding `commitment_signed` being a duplicate for node B, thus we - // need to set the `pending_responding_commitment_signed_dup` flag. - reconnect_args.pending_responding_commitment_signed_dup_monitor.1 = true; + if hold_post_reload_mon_update { + // When the A-B update is still InProgress, B-C monitor updates are blocked, + // so the responding commitment_signed is a duplicate that generates no update. + reconnect_args.pending_responding_commitment_signed_dup_monitor.1 = true; + } reconnect_args.pending_raa.1 = true; reconnect_nodes(reconnect_args); } - // Once the blocked `ChannelMonitorUpdate` *finally* completes, the pending - // `PaymentForwarded` event will finally be released. - let (_, ab_update_id) = nodes[1].chain_monitor.get_latest_mon_update_id(chan_id_ab); - nodes[1].chain_monitor.chain_monitor.force_channel_monitor_updated(chan_id_ab, ab_update_id); + if hold_post_reload_mon_update { + // When the persister returned InProgress, we need to manually complete the + // A-B monitor update to unblock the PaymentForwarded completion action. + let (_, ab_update_id) = nodes[1].chain_monitor.get_latest_mon_update_id(chan_id_ab); + nodes[1] + .chain_monitor + .chain_monitor + .force_channel_monitor_updated(chan_id_ab, ab_update_id); + } // If the A<->B channel was closed before we reload, we'll replay the claim against it on // reload, causing the `PaymentForwarded` event to get replayed. let evs = nodes[1].node.get_and_clear_pending_events(); - assert_eq!(evs.len(), if close_chans_before_reload { 2 } else { 1 }); - for ev in evs { - if let Event::PaymentForwarded { claim_from_onchain_tx, next_htlcs, .. } = ev { - if !claim_from_onchain_tx { - // If the outbound channel is still open, the `next_user_channel_id` should be available. - // This was previously broken. - assert!(next_htlcs[0].user_channel_id.is_some()) + if !close_chans_before_reload && !hold_post_reload_mon_update { + // PaymentForwarded already fired during get_and_clear_pending_events above. + assert!(evs.is_empty(), "{:?}", evs); + } else { + assert_eq!(evs.len(), if close_chans_before_reload { 2 } else { 1 }, "{:?}", evs); + for ev in evs { + if let Event::PaymentForwarded { claim_from_onchain_tx, next_htlcs, .. } = ev { + if !claim_from_onchain_tx { + assert!(next_htlcs[0].user_channel_id.is_some()) + } + } else { + panic!("Unexpected event: {:?}", ev); } - } else { - panic!(); } } if !close_chans_before_reload || close_only_a { - // Once we call `process_pending_events` the final `ChannelMonitor` for the B<->C channel - // will fly, removing the payment preimage from it. - check_added_monitors(&nodes[1], 1); + if hold_post_reload_mon_update { + // The B-C monitor update from the completion action fires now. + check_added_monitors(&nodes[1], 1); + } assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); send_payment(&nodes[1], &[&nodes[2]], 100_000); } @@ -5423,17 +5457,16 @@ fn test_late_counterparty_commitment_update_after_holder_commitment_spend_dust() } #[test] -#[should_panic( - expected = "Watch::update_channel returned Completed while prior updates are still InProgress" -)] -fn test_monitor_update_fail_after_funding_spend() { - // When a counterparty commitment transaction confirms (funding spend), the - // ChannelMonitor sets funding_spend_seen. If a commitment_signed from the - // counterparty is then processed (a race between chain events and message - // processing), update_monitor returns Err because no_further_updates_allowed() - // is true. ChainMonitor overrides the result to InProgress, permanently - // freezing the channel. A subsequent preimage claim returning Completed then - // triggers the per-channel assertion. +fn test_monitor_update_after_funding_spend() { + // Test that monitor updates still work after a funding spend is detected by the + // ChainMonitor but before ChannelManager has processed the corresponding block. + // + // When the counterparty commitment transaction confirms (funding spend), the + // ChannelMonitor sets funding_spend_seen and no_further_updates_allowed() returns + // true. ChainMonitor overrides all subsequent update_channel results to InProgress + // to freeze the channel. These overridden updates complete via deferred completions + // in release_pending_monitor_events, so that MonitorUpdateCompletionActions (like + // PaymentClaimed) can still fire. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); @@ -5444,7 +5477,7 @@ fn test_monitor_update_fail_after_funding_spend() { let (_, _, chan_id, _) = create_announced_chan_between_nodes(&nodes, 0, 1); // Route payment 1 fully so B can claim it later. - let (payment_preimage_1, _payment_hash_1, ..) = + let (payment_preimage_1, payment_hash_1, ..) = route_payment(&nodes[0], &[&nodes[1]], 1_000_000); // Get A's commitment tx (this is the "counterparty" commitment from B's perspective). @@ -5453,10 +5486,14 @@ fn test_monitor_update_fail_after_funding_spend() { // Confirm A's commitment tx on B's chain_monitor ONLY (not on B's ChannelManager). // This sets funding_spend_seen in the monitor, making no_further_updates_allowed() true. + // We also update the best block on the chain_monitor so the broadcaster height is + // consistent when claiming HTLCs. let (block_hash, height) = nodes[1].best_block_info(); let block = create_dummy_block(block_hash, height + 1, vec![as_commitment_tx[0].clone()]); let txdata: Vec<_> = block.txdata.iter().enumerate().collect(); nodes[1].chain_monitor.chain_monitor.transactions_confirmed(&block.header, &txdata, height + 1); + nodes[1].chain_monitor.chain_monitor.best_block_updated(&block.header, height + 1); + nodes[1].blocks.lock().unwrap().push((block, height + 1)); // Send payment 2 from A to B. let (route, payment_hash_2, _, payment_secret_2) = @@ -5478,15 +5515,38 @@ fn test_monitor_update_fail_after_funding_spend() { nodes[1].node.handle_update_add_htlc(node_a_id, &payment_event.msgs[0]); - // B processes commitment_signed. The monitor's update_monitor succeeds on the - // update steps, but returns Err at the end because no_further_updates_allowed() - // is true (funding_spend_seen). ChainMonitor overrides the result to InProgress. + // B processes commitment_signed. The monitor applies the update but returns Err + // because no_further_updates_allowed() is true. ChainMonitor overrides to InProgress, + // freezing the channel. nodes[1].node.handle_commitment_signed(node_a_id, &payment_event.commitment_msg[0]); check_added_monitors(&nodes[1], 1); - assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); - // B claims payment 1. The PaymentPreimage monitor update returns Completed - // (update_monitor succeeds for preimage, and persister returns Completed), - // but the prior InProgress from the commitment_signed is still pending. + // B claims payment 1. The preimage monitor update also returns InProgress (deferred), + // so no Completed-while-InProgress assertion fires. nodes[1].node.claim_funds(payment_preimage_1); + check_added_monitors(&nodes[1], 1); + + // First event cycle: the force-close MonitorEvent (CommitmentTxConfirmed) fires first, + // then the deferred completions resolve. The force-close generates a ChannelForceClosed + // update (also deferred), which blocks completion actions. So we only get ChannelClosed. + let events = nodes[1].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match &events[0] { + Event::ChannelClosed { reason: ClosureReason::CommitmentTxConfirmed, .. } => {}, + _ => panic!("Unexpected event: {:?}", events[0]), + } + check_added_monitors(&nodes[1], 1); + nodes[1].node.get_and_clear_pending_msg_events(); + + // Second event cycle: the ChannelForceClosed deferred completion resolves, unblocking + // the PaymentClaimed completion action. + let events = nodes[1].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match &events[0] { + Event::PaymentClaimed { payment_hash, amount_msat, .. } => { + assert_eq!(payment_hash_1, *payment_hash); + assert_eq!(1_000_000, *amount_msat); + }, + _ => panic!("Unexpected event: {:?}", events[0]), + } } From 43cf3800434136b7e1f3ddbe3a84301bfaa87416 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Mon, 23 Mar 2026 09:48:46 +0100 Subject: [PATCH 223/627] Add .cargo/config.toml for fuzz cfg flags Set fuzzing, secp256k1_fuzz, and hashes_fuzz cfg flags in fuzz/.cargo/config.toml so they are automatically applied to plain cargo commands (cargo test, cargo run) run from the fuzz directory. Remove the now-redundant RUSTFLAGS from the README crash reproduction examples, the CI cargo test step, and generate_fuzz_coverage.sh. The honggfuzz and cargo-fuzz docs are unchanged because those tools build their own RUSTFLAGS env var (which overrides config.toml) and require the flags to be exported separately. AI tools were used in preparing this commit. --- .github/workflows/build.yml | 2 +- contrib/generate_fuzz_coverage.sh | 2 -- fuzz/.cargo/config.toml | 2 ++ fuzz/README.md | 3 +-- 4 files changed, 4 insertions(+), 5 deletions(-) create mode 100644 fuzz/.cargo/config.toml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6d512791420..b7bae9166f3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -241,7 +241,7 @@ jobs: - name: Sanity check fuzz targets on Rust ${{ env.TOOLCHAIN }} run: | cd fuzz - RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz" cargo test --verbose --color always --lib --bins -j8 + cargo test --verbose --color always --lib --bins -j8 cargo clean - name: Run fuzzers run: cd fuzz && ./ci-fuzz.sh && cd .. diff --git a/contrib/generate_fuzz_coverage.sh b/contrib/generate_fuzz_coverage.sh index 09d37656f47..22826ca0c38 100755 --- a/contrib/generate_fuzz_coverage.sh +++ b/contrib/generate_fuzz_coverage.sh @@ -55,8 +55,6 @@ fi # Create output directory if it doesn't exist mkdir -p "$OUTPUT_DIR" -export RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz" - # dont run this command when running in CI if [ "$OUTPUT_CODECOV_JSON" = "0" ]; then cargo llvm-cov --html --ignore-filename-regex "fuzz/" --output-dir "$OUTPUT_DIR" diff --git a/fuzz/.cargo/config.toml b/fuzz/.cargo/config.toml new file mode 100644 index 00000000000..86513788566 --- /dev/null +++ b/fuzz/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +rustflags = ["--cfg=fuzzing", "--cfg=secp256k1_fuzz", "--cfg=hashes_fuzz"] diff --git a/fuzz/README.md b/fuzz/README.md index 0516ca7d7ea..4af70390d7d 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -134,7 +134,6 @@ mkdir -p ./test_cases/$TARGET echo $HEX | xxd -r -p > ./test_cases/$TARGET/any_filename_works export RUST_BACKTRACE=1 -export RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz" cargo test ``` @@ -152,7 +151,7 @@ Alternatively, you can use the `stdin_fuzz` feature to pipe the crash input dire creating test case files on disk: ```shell -echo -ne '\x2d\x31\x36\x38\x37\x34\x09\x01...' | RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz" cargo run --features stdin_fuzz --bin full_stack_target +echo -ne '\x2d\x31\x36\x38\x37\x34\x09\x01...' | cargo run --features stdin_fuzz --bin full_stack_target ``` Panics will abort the process directly (the crate uses `panic = "abort"`), resulting in a From 88f99de0aaa85ad7196c0dff9978c8552f4fe62a Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Mon, 23 Mar 2026 12:00:50 +0100 Subject: [PATCH 224/627] Skip mixed-mode assertion for replayed monitor updates When a node restarts and switches from async to sync persistence, the in-flight monitor updates from the previous session are replayed as background events. These replayed updates are resubmitted to the Watch which now returns Completed, while earlier in-flight updates are still queued as background events. This triggered a false panic in the assertion that guards against out-of-order monitor update completion. Track whether an update is a replay (already present in in_flight_monitor_updates) and skip the assertion for replays, since the remaining in-flight updates will be submitted by subsequent background events. AI tools were used in preparing this commit. --- lightning/src/ln/channelmanager.rs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 30eb7f85d71..d042a69bf80 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -10365,11 +10365,12 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ // During startup, we push monitor updates as background events through to here in // order to replay updates that were in-flight when we shut down. Thus, we have to // filter for uniqueness here. - let update_idx = - in_flight_updates.iter().position(|upd| upd == &new_update).unwrap_or_else(|| { - in_flight_updates.push(new_update); - in_flight_updates.len() - 1 - }); + let existing_idx = in_flight_updates.iter().position(|upd| upd == &new_update); + let is_replay = existing_idx.is_some(); + let update_idx = existing_idx.unwrap_or_else(|| { + in_flight_updates.push(new_update); + in_flight_updates.len() - 1 + }); if self.background_events_processed_since_startup.load(Ordering::Acquire) { let update_res = @@ -10382,11 +10383,18 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } // A Watch implementation must not return Completed while prior updates are // still InProgress, as this would violate the async persistence contract. + // We skip this check for replayed updates (startup background events) + // because during startup replay, the remaining in-flight updates may not + // have been submitted to the Watch yet and will be processed by subsequent + // background events. This is specifically necessary when switching from + // async to sync persistence across a restart: the replayed update + // returns Completed from the now-sync Watch while earlier in-flight + // updates are still queued as background events. #[cfg(test)] let skip_check = self.skip_monitor_update_assertion.load(Ordering::Relaxed); #[cfg(not(test))] let skip_check = false; - if !skip_check && update_completed && !in_flight_updates.is_empty() { + if !skip_check && !is_replay && update_completed && !in_flight_updates.is_empty() { panic!("Watch::update_channel returned Completed while prior updates are still InProgress"); } (update_completed, update_completed && in_flight_updates.is_empty()) From b41fa33da53a590e05b66ee4200acdc6a909b54f Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Fri, 20 Mar 2026 08:42:11 +0100 Subject: [PATCH 225/627] ci: split fuzz sanity check into separate parallel job The sanity check (cargo test on fuzz targets) doesn't use the restored corpus and was blocking the actual fuzz run. Move it to a separate fuzz_sanity job so both run in parallel. AI tools were used in preparing this commit. --- .github/workflows/build.yml | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b7bae9166f3..f50c9b8d231 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -205,6 +205,21 @@ jobs: - name: Simulate docs.rs build run: ci/check-docsrs.sh + fuzz_sanity: + runs-on: self-hosted + env: + TOOLCHAIN: 1.75 + steps: + - name: Checkout source code + uses: actions/checkout@v4 + - name: Install Rust ${{ env.TOOLCHAIN }} toolchain + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ env.TOOLCHAIN }} + - name: Sanity check fuzz targets on Rust ${{ env.TOOLCHAIN }} + run: | + cd fuzz + cargo test --quiet --color always --lib --bins -j8 + fuzz: runs-on: self-hosted env: @@ -238,11 +253,6 @@ jobs: key: fuzz-corpus-refs/heads/main-${{ github.sha }} restore-keys: | fuzz-corpus-refs/heads/main- - - name: Sanity check fuzz targets on Rust ${{ env.TOOLCHAIN }} - run: | - cd fuzz - cargo test --verbose --color always --lib --bins -j8 - cargo clean - name: Run fuzzers run: cd fuzz && ./ci-fuzz.sh && cd .. - name: Upload honggfuzz corpus @@ -308,7 +318,7 @@ jobs: TOR_PROXY="127.0.0.1:9050" RUSTFLAGS="--cfg=tor" cargo test --verbose --color always -p lightning-net-tokio notify-failure: - needs: [build-workspace, build-features, build-bindings, build-nostd, build-cfg-flags, build-sync, fuzz, linting, rustfmt, check_release, check_docs, benchmark, ext-test, tor-connect, coverage] + needs: [build-workspace, build-features, build-bindings, build-nostd, build-cfg-flags, build-sync, fuzz_sanity, fuzz, linting, rustfmt, check_release, check_docs, benchmark, ext-test, tor-connect, coverage] if: failure() && github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: From 24bbb4a632efbf031d53c07a0c6d2ca596589c69 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Fri, 20 Mar 2026 15:38:12 +0100 Subject: [PATCH 226/627] fuzz: improve iteration scaling, add minimization and summary table Replace the fixed 30s run_time with iteration counts scaled to 8x corpus size (plus a 1000 baseline) with a 10-minute hard cap per target. This ensures the full corpus is replayed with room for mutations, while small targets finish quickly. On main (and on PRs with the fuzz-minimize label), run honggfuzz corpus minimization after each target to prune inputs that don't contribute unique coverage, keeping the cache size manageable. Print a summary table at the end with per-target stats: iterations, corpus sizes before/after fuzzing and minimization, and run times. Other changes: - Use -q (quiet) to suppress per-iteration status output - Set 3s per-input timeout (-t 3) for all targets - Pass FUZZ_MINIMIZE env var from PR label in workflow - Check for crashes after minimization, not just after fuzzing AI tools were used in preparing this commit. --- .github/workflows/build.yml | 2 + fuzz/ci-fuzz.sh | 76 +++++++++++++++++++++++++++++++++---- 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f50c9b8d231..b68d545ac3e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -255,6 +255,8 @@ jobs: fuzz-corpus-refs/heads/main- - name: Run fuzzers run: cd fuzz && ./ci-fuzz.sh && cd .. + env: + FUZZ_MINIMIZE: ${{ contains(github.event.pull_request.labels.*.name, 'fuzz-minimize') }} - name: Upload honggfuzz corpus uses: actions/upload-artifact@v4 with: diff --git a/fuzz/ci-fuzz.sh b/fuzz/ci-fuzz.sh index d57a5ad78fa..47bf41ba620 100755 --- a/fuzz/ci-fuzz.sh +++ b/fuzz/ci-fuzz.sh @@ -30,20 +30,82 @@ sed -i 's/lto = true//' Cargo.toml export HFUZZ_BUILD_ARGS="--features honggfuzz_fuzz" cargo --color always hfuzz build -j8 + +SUMMARY="" + +check_crash() { + local FILE=$1 + if [ -f "hfuzz_workspace/$FILE/HONGGFUZZ.REPORT.TXT" ]; then + cat "hfuzz_workspace/$FILE/HONGGFUZZ.REPORT.TXT" + for CASE in "hfuzz_workspace/$FILE"/SIG*; do + cat "$CASE" | xxd -p + done + exit 1 + fi +} + for TARGET in src/bin/*.rs; do FILENAME=$(basename $TARGET) FILE="${FILENAME%.*}" - HFUZZ_RUN_ARGS="--exit_upon_crash -v -n8 --run_time 30" + CORPUS_DIR="hfuzz_workspace/$FILE/input" + CORPUS_COUNT=$(find "$CORPUS_DIR" -type f 2>/dev/null | wc -l) + # Run 8x the corpus size plus a baseline, ensuring full corpus replay + # with room for new mutations. The 10-minute hard cap (--run_time 600) + # prevents slow-per-iteration targets from running too long. + ITERATIONS=$((CORPUS_COUNT * 8 + 1000)) + HFUZZ_RUN_ARGS="--exit_upon_crash -q -n8 -t 3 -N $ITERATIONS --run_time 600" if [ "$FILE" = "chanmon_consistency_target" -o "$FILE" = "fs_store_target" ]; then HFUZZ_RUN_ARGS="$HFUZZ_RUN_ARGS -F 64" fi export HFUZZ_RUN_ARGS + FUZZ_START=$(date +%s) cargo --color always hfuzz run $FILE - if [ -f hfuzz_workspace/$FILE/HONGGFUZZ.REPORT.TXT ]; then - cat hfuzz_workspace/$FILE/HONGGFUZZ.REPORT.TXT - for CASE in hfuzz_workspace/$FILE/SIG*; do - cat $CASE | xxd -p - done - exit 1 + FUZZ_END=$(date +%s) + FUZZ_TIME=$((FUZZ_END - FUZZ_START)) + FUZZ_CORPUS_COUNT=$(find "$CORPUS_DIR" -type f 2>/dev/null | wc -l) + check_crash "$FILE" + if [ "$GITHUB_REF" = "refs/heads/main" ] || [ "$FUZZ_MINIMIZE" = "true" ]; then + HFUZZ_RUN_ARGS="-M -q -n8 -t 3" + export HFUZZ_RUN_ARGS + MIN_START=$(date +%s) + cargo --color always hfuzz run $FILE + MIN_END=$(date +%s) + MIN_TIME=$((MIN_END - MIN_START)) + MIN_CORPUS_COUNT=$(find "$CORPUS_DIR" -type f 2>/dev/null | wc -l) + check_crash "$FILE" + SUMMARY="${SUMMARY}${FILE}|${ITERATIONS}|${CORPUS_COUNT}|${FUZZ_CORPUS_COUNT}|${FUZZ_TIME}|${MIN_CORPUS_COUNT}|${MIN_TIME}\n" + else + SUMMARY="${SUMMARY}${FILE}|${ITERATIONS}|${CORPUS_COUNT}|${FUZZ_CORPUS_COUNT}|${FUZZ_TIME}|-|-\n" + fi +done + +fmt_time() { + local secs=$1 + local m=$((secs / 60)) + local s=$((secs % 60)) + if [ "$m" -gt 0 ]; then + printf "%dm %ds" "$m" "$s" + else + printf "%ds" "$s" + fi +} + +# Print summary table +set +x +echo "" +echo "==== Fuzz Summary ====" +HDR="%-40s %7s %7s %-15s %9s %-15s %9s\n" +FMT="%-40s %7s %7s %6s %-9s %9s %6s %-9s %9s\n" +printf "$HDR" "Target" "Iters" "Corpus" " Fuzzed" "Fuzz time" " Minimized" "Min. time" +printf "$HDR" "------" "-----" "------" "---------------" "---------" "---------------" "---------" +echo -e "$SUMMARY" | while IFS='|' read -r name iters orig fuzzed ftime minimized mtime; do + [ -z "$name" ] && continue + fuzz_delta=$((fuzzed - orig)) + if [ "$minimized" = "-" ]; then + printf "$FMT" "$name" "$iters" "$orig" "$fuzzed" "(+$fuzz_delta)" "$(fmt_time "$ftime")" "-" "" "-" + else + min_delta=$((minimized - fuzzed)) + printf "$FMT" "$name" "$iters" "$orig" "$fuzzed" "(+$fuzz_delta)" "$(fmt_time "$ftime")" "$minimized" "($min_delta)" "$(fmt_time "$mtime")" fi done +echo "======================" From 6da89c229340a4739e2d5f2fe2052b67fbba3d8f Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 24 Mar 2026 00:53:30 +0000 Subject: [PATCH 227/627] Send BroadcastChannelAnnouncements via the broadcast queue In 47a3e5c694321dac1a1d0f53e6dcb357282a79be we started asserting that the per-peer message queue was empty when a peer connected to ensure we don't have stale messages sitting around in memory. This turned up an issue for `channel_announcement` messages generated by block connections while a peer was disconnected. Here we push those out through the broadcast message queue rather than the per-peer message queue as there's no reason to tie them to the individual peer anyway, fixing the assertions. This should fix #4437 Written by Claude --- lightning/src/ln/channelmanager.rs | 49 +++++++++++++---------- lightning/src/ln/functional_test_utils.rs | 4 -- lightning/src/ln/priv_short_conf_tests.rs | 4 +- 3 files changed, 29 insertions(+), 28 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index d042a69bf80..f9772bb120b 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -13004,12 +13004,16 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ msg, &self.config.read().unwrap(), ); - peer_state.pending_msg_events.push(MessageSendEvent::BroadcastChannelAnnouncement { - msg: try_channel_entry!(self, peer_state, res, chan_entry), - // Note that announcement_signatures fails if the channel cannot be announced, - // so get_channel_update_for_broadcast will never fail by the time we get here. - update_msg: Some(self.get_channel_update_for_broadcast(chan).unwrap().0), - }); + let announcement_msg = try_channel_entry!(self, peer_state, res, chan_entry); + // Note that announcement_signatures fails if the channel cannot be announced, + // so get_channel_update_for_broadcast will never fail by the time we get here. + let update_msg = self.get_channel_update_for_broadcast(chan).unwrap().0; + self.pending_broadcast_messages.lock().unwrap().push( + MessageSendEvent::BroadcastChannelAnnouncement { + msg: announcement_msg, + update_msg: Some(update_msg), + }, + ); } else { return try_channel_entry!(self, peer_state, Err(ChannelError::close( "Got an announcement_signatures message for an unfunded channel!".into())), chan_entry); @@ -15485,11 +15489,14 @@ impl< &MessageSendEvent::HandleError { .. } => false, // Gossip &MessageSendEvent::SendChannelAnnouncement { .. } => false, - &MessageSendEvent::BroadcastChannelAnnouncement { .. } => true, - // [`ChannelManager::pending_broadcast_events`] holds the [`BroadcastChannelUpdate`] - // This check here is to ensure exhaustivity. + // [`ChannelManager::pending_broadcast_messages`] holds broadcast events, + // not per-peer queues. + &MessageSendEvent::BroadcastChannelAnnouncement { .. } => { + debug_assert!(false, "BroadcastChannelAnnouncement should be in pending_broadcast_messages"); + false + }, &MessageSendEvent::BroadcastChannelUpdate { .. } => { - debug_assert!(false, "This event shouldn't have been here"); + debug_assert!(false, "BroadcastChannelUpdate should be in pending_broadcast_messages"); false }, &MessageSendEvent::BroadcastNodeAnnouncement { .. } => true, @@ -15687,10 +15694,6 @@ impl< /// the chunks of `MessageSendEvent`s for different peers is random. I.e. if the array contains /// `MessageSendEvent`s for both `node_a` and `node_b`, the `MessageSendEvent`s for `node_a` /// will randomly be placed first or last in the returned array. - /// - /// Note that even though `BroadcastChannelAnnouncement` and `BroadcastChannelUpdate` - /// `MessageSendEvent`s are intended to be broadcasted to all peers, they will be placed among - /// the `MessageSendEvent`s to the specific peer they were generated under. fn get_and_clear_pending_msg_events(&self) -> Vec { let events = RefCell::new(Vec::new()); PersistenceNotifierGuard::optionally_notify(self, || { @@ -16143,14 +16146,16 @@ impl< if let Some(announcement) = funded_channel.get_signed_channel_announcement( &self.node_signer, self.chain_hash, height, &self.config.read().unwrap(), ) { - pending_msg_events.push(MessageSendEvent::BroadcastChannelAnnouncement { - msg: announcement, - // Note that get_signed_channel_announcement fails - // if the channel cannot be announced, so - // get_channel_update_for_broadcast will never fail - // by the time we get here. - update_msg: Some(self.get_channel_update_for_broadcast(funded_channel).unwrap().0), - }); + self.pending_broadcast_messages.lock().unwrap().push( + MessageSendEvent::BroadcastChannelAnnouncement { + msg: announcement, + // Note that get_signed_channel_announcement + // fails if the channel cannot be announced, so + // get_channel_update_for_broadcast will never + // fail by the time we get here. + update_msg: Some(self.get_channel_update_for_broadcast(funded_channel).unwrap().0), + }, + ); } } } diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 80274d180b4..e8859494071 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -1121,10 +1121,6 @@ pub fn get_htlc_update_msgs(node: &Node, recipient: &PublicKey) -> msgs::Commitm /// Fetches the first `msg_event` to the passed `node_id` in the passed `msg_events` vec. /// Returns the `msg_event`. -/// -/// Note that even though `BroadcastChannelAnnouncement` and `BroadcastChannelUpdate` -/// `msg_events` are stored under specific peers, this function does not fetch such `msg_events` as -/// such messages are intended to all peers. pub fn remove_first_msg_event_to_node( msg_node_id: &PublicKey, msg_events: &mut Vec, ) -> MessageSendEvent { diff --git a/lightning/src/ln/priv_short_conf_tests.rs b/lightning/src/ln/priv_short_conf_tests.rs index ffe5ea6cbb1..70d58533228 100644 --- a/lightning/src/ln/priv_short_conf_tests.rs +++ b/lightning/src/ln/priv_short_conf_tests.rs @@ -255,7 +255,7 @@ fn do_test_1_conf_open(connect_style: ConnectStyle) { assert_eq!(bs_announce_events.len(), 2); let bs_announcement_sigs = if let MessageSendEvent::SendAnnouncementSignatures { ref node_id, ref msg } = - bs_announce_events[1] + bs_announce_events[0] { assert_eq!(*node_id, node_a_id); msg.clone() @@ -264,7 +264,7 @@ fn do_test_1_conf_open(connect_style: ConnectStyle) { }; let (bs_announcement, bs_update) = if let MessageSendEvent::BroadcastChannelAnnouncement { ref msg, ref update_msg } = - bs_announce_events[0] + bs_announce_events[1] { (msg.clone(), update_msg.clone().unwrap()) } else { From 452ec46516b2cad79c73622329acc62b56cbbd10 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Sun, 15 Mar 2026 14:22:03 -0500 Subject: [PATCH 228/627] Adjust contribution feerate to minimum RBF feerate in funding_contributed When splice_channel is called before a counterparty's splice exists, the user builds a contribution at their chosen feerate without a minimum RBF feerate. If the counterparty completes a splice before funding_contributed is called, the contribution's feerate may be below the 25/24 RBF requirement. Rather than always waiting for the pending splice to lock (which would proceed as a fresh splice), funding_contributed now attempts to adjust the contribution's feerate upward to the minimum RBF feerate when the budget allows, enabling an immediate RBF. When the adjustment isn't possible (max_feerate too low or insufficient fee buffer), the contribution is left unchanged and try_send_stfu delays until the pending splice locks, at which point the splice proceeds at the original feerate. Co-Authored-By: Claude Opus 4.6 (1M context) --- lightning/src/ln/channel.rs | 77 ++++++++++- lightning/src/ln/funding.rs | 124 ++++++++++++++--- lightning/src/ln/splicing_tests.rs | 206 ++++++++++++++++++++++++++++- 3 files changed, 377 insertions(+), 30 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 6f23aa7857f..32247108ece 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12077,6 +12077,47 @@ where Ok(min_rbf_feerate) } + /// Attempts to adjust the contribution's feerate to the minimum RBF feerate so the splice can + /// proceed as an RBF immediately rather than waiting for the pending splice to lock. + /// Returns the adjusted contribution on success, or the original on failure. + fn maybe_adjust_for_rbf( + &self, contribution: FundingContribution, min_rbf_feerate: FeeRate, logger: &L, + ) -> FundingContribution { + if contribution.feerate() >= min_rbf_feerate { + return contribution; + } + + let holder_balance = match self + .get_holder_counterparty_balances_floor_incl_fee(&self.funding) + .map(|(holder, _)| holder) + { + Ok(balance) => balance, + Err(_) => return contribution, + }; + + if let Err(e) = + contribution.net_value_for_initiator_at_feerate(min_rbf_feerate, holder_balance) + { + log_info!( + logger, + "Cannot adjust to minimum RBF feerate {}: {}; will proceed as fresh splice after lock", + min_rbf_feerate, + e, + ); + return contribution; + } + + log_info!( + logger, + "Adjusting contribution feerate from {} to minimum RBF feerate {}", + contribution.feerate(), + min_rbf_feerate, + ); + contribution + .for_initiator_at_feerate(min_rbf_feerate, holder_balance) + .expect("feerate compatibility already checked") + } + pub fn funding_contributed( &mut self, contribution: FundingContribution, locktime: LockTime, logger: &L, ) -> Result, QuiescentError> { @@ -12161,6 +12202,15 @@ where })); } + // If a pending splice exists with negotiated candidates, attempt to adjust the + // contribution's feerate to the minimum RBF feerate so it can proceed as an RBF immediately + // rather than waiting for the splice to lock. + let contribution = if let Ok(Some(min_rbf_feerate)) = self.can_initiate_rbf() { + self.maybe_adjust_for_rbf(contribution, min_rbf_feerate, logger) + } else { + contribution + }; + self.propose_quiescence(logger, QuiescentAction::Splice { contribution, locktime }) } @@ -13758,13 +13808,26 @@ where #[allow(irrefutable_let_patterns)] if let QuiescentAction::Splice { contribution, .. } = action { if self.pending_splice.is_some() { - if let Err(msg) = self.can_initiate_rbf() { - log_given_level!( - logger, - logger_level, - "Waiting on sending stfu for splice RBF: {msg}" - ); - return None; + match self.can_initiate_rbf() { + Err(msg) => { + log_given_level!( + logger, + logger_level, + "Waiting on sending stfu for splice RBF: {msg}" + ); + return None; + }, + Ok(Some(min_rbf_feerate)) if contribution.feerate() < min_rbf_feerate => { + log_given_level!( + logger, + logger_level, + "Waiting for splice to lock: feerate {} below minimum RBF feerate {}", + contribution.feerate(), + min_rbf_feerate, + ); + return None; + }, + _ => {}, } } } diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 52aabe5a12a..acad13c32ae 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -545,8 +545,12 @@ impl FundingContribution { Ok(()) } - /// Computes the adjusted fee and change output value for the acceptor at the initiator's - /// proposed feerate, which may differ from the feerate used during coin selection. + /// Computes the adjusted fee and change output value at the given target feerate, which may + /// differ from the feerate used during coin selection. + /// + /// The `is_initiator` parameter determines fee responsibility: the initiator pays for common + /// transaction fields, the shared input, and the shared output, while the acceptor only pays + /// for their own contributed inputs and outputs. /// /// On success, returns the new estimated fee and, if applicable, the new change output value: /// - `Some(change)` — the adjusted change output value @@ -554,7 +558,7 @@ impl FundingContribution { /// /// Returns `Err` if the contribution cannot accommodate the target feerate. fn compute_feerate_adjustment( - &self, target_feerate: FeeRate, holder_balance: Amount, + &self, target_feerate: FeeRate, holder_balance: Amount, is_initiator: bool, ) -> Result<(Amount, Option), FeeRateAdjustmentError> { if target_feerate < self.feerate { return Err(FeeRateAdjustmentError::FeeRateTooLow { @@ -564,14 +568,15 @@ impl FundingContribution { } // If the target fee rate exceeds our max fee rate, we may still add our contribution - // if we pay less in fees. This may happen because the acceptor doesn't pay for common - // fields and the shared input / output. + // if we pay less in fees at the target feerate than at the original feerate. This can + // happen when adjusting as acceptor, since the acceptor doesn't pay for common fields + // and the shared input / output. if target_feerate > self.max_feerate { let target_fee = estimate_transaction_fee( &self.inputs, &self.outputs, self.change_output.as_ref(), - false, + is_initiator, self.is_splice, target_feerate, ); @@ -595,7 +600,7 @@ impl FundingContribution { &self.inputs, &self.outputs, self.change_output.as_ref(), - false, + is_initiator, self.is_splice, target_feerate, ); @@ -615,7 +620,7 @@ impl FundingContribution { &self.inputs, &self.outputs, None, - false, + is_initiator, self.is_splice, target_feerate, ); @@ -636,7 +641,7 @@ impl FundingContribution { &self.inputs, &self.outputs, None, - false, + is_initiator, self.is_splice, target_feerate, ); @@ -666,7 +671,7 @@ impl FundingContribution { &[], &self.outputs, None, - false, + is_initiator, self.is_splice, target_feerate, ); @@ -688,17 +693,14 @@ impl FundingContribution { } } - /// Adjusts the contribution's change output for the initiator's feerate. - /// - /// When the acceptor has a pending contribution (from the quiescence tie-breaker scenario), - /// the initiator's proposed feerate may differ from the feerate used during coin selection. - /// This adjusts the change output so the acceptor pays their target fee at the target - /// feerate. - pub(super) fn for_acceptor_at_feerate( - mut self, feerate: FeeRate, holder_balance: Amount, + /// Adjusts the contribution for a different feerate, updating the change output, fee + /// estimate, and feerate. Returns the adjusted contribution, or an error if the feerate + /// can't be accommodated. + fn at_feerate( + mut self, feerate: FeeRate, holder_balance: Amount, is_initiator: bool, ) -> Result { let (new_estimated_fee, new_change) = - self.compute_feerate_adjustment(feerate, holder_balance)?; + self.compute_feerate_adjustment(feerate, holder_balance, is_initiator)?; let surplus = self.fee_buffer_surplus(new_estimated_fee, &new_change); match new_change { Some(value) => self.change_output.as_mut().unwrap().value = value, @@ -710,16 +712,39 @@ impl FundingContribution { Ok(self) } + /// Adjusts the contribution's change output for the initiator's feerate. + /// + /// When the acceptor has a pending contribution (from the quiescence tie-breaker scenario), + /// the initiator's proposed feerate may differ from the feerate used during coin selection. + /// This adjusts the change output so the acceptor pays their target fee at the target + /// feerate. + pub(super) fn for_acceptor_at_feerate( + self, feerate: FeeRate, holder_balance: Amount, + ) -> Result { + self.at_feerate(feerate, holder_balance, false) + } + + /// Adjusts the contribution's change output for the minimum RBF feerate. + /// + /// When a pending splice exists with negotiated candidates and the contribution's feerate + /// is below the minimum RBF feerate (25/24 of the previous feerate), this adjusts the change output + /// so the initiator pays fees at the minimum RBF feerate. + pub(super) fn for_initiator_at_feerate( + self, feerate: FeeRate, holder_balance: Amount, + ) -> Result { + self.at_feerate(feerate, holder_balance, true) + } + /// Returns the net value at the given target feerate without mutating `self`. /// /// This serves double duty: it checks feerate compatibility (returning `Err` if the feerate /// can't be accommodated) and computes the adjusted net value (returning `Ok` with the value /// accounting for the target feerate). - pub(super) fn net_value_for_acceptor_at_feerate( - &self, target_feerate: FeeRate, holder_balance: Amount, + fn net_value_at_feerate( + &self, target_feerate: FeeRate, holder_balance: Amount, is_initiator: bool, ) -> Result { let (new_estimated_fee, new_change) = - self.compute_feerate_adjustment(target_feerate, holder_balance)?; + self.compute_feerate_adjustment(target_feerate, holder_balance, is_initiator)?; let surplus = self .fee_buffer_surplus(new_estimated_fee, &new_change) .to_signed() @@ -731,6 +756,22 @@ impl FundingContribution { Ok(net_value) } + /// Returns the net value at the given target feerate without mutating `self`, + /// assuming acceptor fee responsibility. + pub(super) fn net_value_for_acceptor_at_feerate( + &self, target_feerate: FeeRate, holder_balance: Amount, + ) -> Result { + self.net_value_at_feerate(target_feerate, holder_balance, false) + } + + /// Returns the net value at the given target feerate without mutating `self`, + /// assuming initiator fee responsibility. + pub(super) fn net_value_for_initiator_at_feerate( + &self, target_feerate: FeeRate, holder_balance: Amount, + ) -> Result { + self.net_value_at_feerate(target_feerate, holder_balance, true) + } + /// Returns the fee buffer surplus when a change output is removed. /// /// The fee buffer is the actual amount available for fees from inputs: total input value @@ -1867,4 +1908,43 @@ mod tests { let result = contribution.net_value_for_acceptor_at_feerate(target_feerate, holder_balance); assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. }))); } + + #[test] + fn test_for_initiator_at_feerate_higher_fee_than_acceptor() { + // Verify that the initiator fee estimate is higher than the acceptor estimate at the + // same feerate, since the initiator pays for common fields + shared input/output. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(3000); + let inputs = vec![funding_input_sats(100_000)]; + let change = funding_output_sats(10_000); + + let estimated_fee = + estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::from_sat(50_000), + estimated_fee, + inputs, + outputs: vec![], + change_output: Some(change), + feerate: original_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + let acceptor = + contribution.clone().for_acceptor_at_feerate(target_feerate, Amount::MAX).unwrap(); + let initiator = contribution.for_initiator_at_feerate(target_feerate, Amount::MAX).unwrap(); + + // Initiator pays more in fees (common fields + shared input/output weight). + assert!(initiator.estimated_fee > acceptor.estimated_fee); + // Initiator has less change remaining. + assert!( + initiator.change_output.as_ref().unwrap().value + < acceptor.change_output.as_ref().unwrap().value + ); + // Both have the adjusted feerate. + assert_eq!(initiator.feerate, target_feerate); + assert_eq!(acceptor.feerate, target_feerate); + } } diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index fbc2a81969c..07f2abe7b58 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -746,8 +746,16 @@ pub fn lock_splice<'a, 'b, 'c, 'd>( check_added_monitors(node, 1); } + let mut node_a_stfu = None; if !is_0conf { let mut msg_events = node_a.node.get_and_clear_pending_msg_events(); + + // If node_a had a pending QuiescentAction, filter out the stfu message. + node_a_stfu = msg_events + .iter() + .position(|event| matches!(event, MessageSendEvent::SendStfu { .. })) + .map(|i| msg_events.remove(i)); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); if let MessageSendEvent::SendAnnouncementSignatures { msg, .. } = msg_events.remove(0) { node_b.node.handle_announcement_signatures(node_id_a, &msg); @@ -776,7 +784,7 @@ pub fn lock_splice<'a, 'b, 'c, 'd>( } } - node_b_stfu + node_a_stfu.or(node_b_stfu) } pub fn lock_rbf_splice_after_blocks<'a, 'b, 'c, 'd>( @@ -5655,3 +5663,199 @@ fn test_splice_channel_with_pending_splice_includes_rbf_floor() { .splice_in_sync(added_value, expected_floor, FeeRate::MAX, &wallet) .is_ok()); } + +#[test] +fn test_funding_contributed_adjusts_feerate_for_rbf() { + // Test that funding_contributed adjusts the contribution's feerate to the minimum RBF feerate when a + // pending splice appears between splice_channel and funding_contributed. + // + // Node 0 calls splice_channel (no pending splice → min_rbf_feerate = None) and builds a + // contribution at floor feerate. Node 1 then initiates and completes a splice. When node 0 + // calls funding_contributed, the contribution is adjusted to the minimum RBF feerate and STFU is sent + // immediately. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 4, added_value * 2); + + // Node 0 calls splice_channel before any pending splice exists. + let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + assert!(funding_template.min_rbf_feerate().is_none()); + + // Build contribution at floor feerate with high max_feerate to allow adjustment. + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let contribution = + funding_template.splice_in_sync(added_value, floor_feerate, FeeRate::MAX, &wallet).unwrap(); + + // Node 1 initiates and completes a splice, creating pending_splice with negotiated candidates. + let node_1_contribution = do_initiate_splice_in(&nodes[1], &nodes[0], channel_id, added_value); + let (_first_splice_tx, _new_funding_script) = + splice_channel(&nodes[1], &nodes[0], channel_id, node_1_contribution); + + // Node 0 calls funding_contributed. The contribution's feerate (floor) is below the RBF + // floor (25/24 of floor), but funding_contributed adjusts it upward. + nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution.clone(), None).unwrap(); + + // STFU should be sent immediately (the adjusted feerate satisfies the RBF check). + let stfu = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu); + let stfu_resp = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_resp); + + // Verify the RBF handshake proceeds. + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + let rbf_feerate = FeeRate::from_sat_per_kwu(tx_init_rbf.feerate_sat_per_1000_weight as u64); + let expected_floor = + FeeRate::from_sat_per_kwu((FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24)); + assert!(rbf_feerate >= expected_floor); +} + +#[test] +fn test_funding_contributed_rbf_adjustment_exceeds_max_feerate() { + // Test that when the minimum RBF feerate exceeds max_feerate, the adjustment in funding_contributed + // fails gracefully and the contribution keeps its original feerate. The splice still + // proceeds (STFU is sent) and the RBF negotiation handles the feerate mismatch. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 4, added_value * 2); + + // Node 0 calls splice_channel and builds contribution with max_feerate = floor_feerate. + // This means the minimum RBF feerate (25/24 of floor) will exceed max_feerate, preventing adjustment. + let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let contribution = funding_template + .splice_in_sync(added_value, floor_feerate, floor_feerate, &wallet) + .unwrap(); + + // Node 1 initiates and completes a splice. + let node_1_contribution = do_initiate_splice_in(&nodes[1], &nodes[0], channel_id, added_value); + let (_splice_tx, _) = splice_channel(&nodes[1], &nodes[0], channel_id, node_1_contribution); + + // Node 0 calls funding_contributed. The adjustment fails (minimum RBF feerate > max_feerate), but + // funding_contributed still succeeds — the contribution keeps its original feerate. + nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution, None).unwrap(); + + // STFU is NOT sent — the feerate is below the minimum RBF feerate so try_send_stfu delays. + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + + // Mine and lock the pending splice → pending_splice is cleared. + mine_transaction(&nodes[0], &_splice_tx); + mine_transaction(&nodes[1], &_splice_tx); + let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); + + // STFU is sent during lock — the splice proceeds as a fresh splice (not RBF). + let stfu = match stfu { + Some(MessageSendEvent::SendStfu { msg, .. }) => { + assert!(msg.initiator); + msg + }, + other => panic!("Expected SendStfu, got {:?}", other), + }; + + // Complete the fresh splice and verify it uses the original floor feerate. + nodes[1].node.handle_stfu(node_id_0, &stfu); + let stfu_resp = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_resp); + + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + assert_eq!(splice_init.funding_feerate_per_kw, FEERATE_FLOOR_SATS_PER_KW); +} + +#[test] +fn test_funding_contributed_rbf_adjustment_insufficient_budget() { + // Test that when the change output can't absorb the fee increase needed for the minimum RBF feerate + // (even though max_feerate allows it), the adjustment fails gracefully and the splice + // proceeds with the original feerate. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 4, added_value * 2); + + // Node 0 calls splice_channel before any pending splice exists. + let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + + // Build node 0's contribution at floor feerate with a tight budget. + let wallet = TightBudgetWallet { + utxo_value: added_value + Amount::from_sat(3000), + change_value: Amount::from_sat(300), + }; + let contribution = + funding_template.splice_in_sync(added_value, floor_feerate, FeeRate::MAX, &wallet).unwrap(); + + // Node 1 initiates a splice at a HIGH feerate (10,000 sat/kwu). The minimum RBF feerate will be + // 25/24 of 10,000 = 10,417 sat/kwu — far above what node 0's tight budget can handle. + let high_feerate = FeeRate::from_sat_per_kwu(10_000); + let node_1_template = nodes[1].node.splice_channel(&channel_id, &node_id_0).unwrap(); + let node_1_wallet = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let node_1_contribution = node_1_template + .splice_in_sync(added_value, high_feerate, FeeRate::MAX, &node_1_wallet) + .unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_id_0, node_1_contribution.clone(), None) + .unwrap(); + let (_splice_tx, _) = splice_channel(&nodes[1], &nodes[0], channel_id, node_1_contribution); + + // Node 0 calls funding_contributed. Adjustment fails (insufficient fee buffer), so the + // contribution keeps its original feerate. + nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution, None).unwrap(); + + // STFU is NOT sent — the feerate is below the minimum RBF feerate so try_send_stfu delays. + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + + // Mine and lock the pending splice → pending_splice is cleared. + mine_transaction(&nodes[0], &_splice_tx); + mine_transaction(&nodes[1], &_splice_tx); + let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); + + // STFU is sent during lock — the splice proceeds as a fresh splice (not RBF). + let stfu = match stfu { + Some(MessageSendEvent::SendStfu { msg, .. }) => { + assert!(msg.initiator); + msg + }, + other => panic!("Expected SendStfu, got {:?}", other), + }; + + // Complete the fresh splice and verify it uses the original floor feerate. + nodes[1].node.handle_stfu(node_id_0, &stfu); + let stfu_resp = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_resp); + + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + assert_eq!(splice_init.funding_feerate_per_kw, FEERATE_FLOOR_SATS_PER_KW); +} From a052afa9fa5f83d9fe68f0b065f96b401917a838 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Sun, 15 Mar 2026 21:50:54 -0500 Subject: [PATCH 229/627] Merge rbf_channel into splice_channel and expose prior contribution Users previously had to choose between splice_channel (fresh splice) and rbf_channel (fee bump) upfront. Since splice_channel already detects pending splices and computes the minimum RBF feerate, rbf_channel was redundant. Merging into a single API lets the user call one method and discover from the returned FundingTemplate whether an RBF is possible. The FundingTemplate now carries the user's prior contribution from the previous splice negotiation when one is available. This lets users reuse their existing contribution for an RBF without performing new coin selection. A PriorContribution enum distinguishes whether the contribution has been adjusted to the minimum RBF feerate (Adjusted) or could not be adjusted due to insufficient fee buffer or max_feerate constraints (Unadjusted). Co-Authored-By: Claude Opus 4.6 (1M context) --- fuzz/src/chanmon_consistency.rs | 69 ++-- fuzz/src/full_stack.rs | 15 +- lightning/src/ln/channel.rs | 206 ++++++------ lightning/src/ln/channelmanager.rs | 85 +---- lightning/src/ln/funding.rs | 486 +++++++++++++++++++++++++++-- lightning/src/ln/splicing_tests.rs | 377 ++++++++++++++++++++-- lightning/src/util/wallet_utils.rs | 2 +- 7 files changed, 966 insertions(+), 274 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 5d46cf26031..abbf4736b0a 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1388,30 +1388,31 @@ pub fn do_test( }}; } - let splice_channel = |node: &ChanMan, - counterparty_node_id: &PublicKey, - channel_id: &ChannelId, - f: &dyn Fn(FundingTemplate) -> Result| { - match node.splice_channel(channel_id, counterparty_node_id) { - Ok(funding_template) => { - if let Ok(contribution) = f(funding_template) { - let _ = node.funding_contributed( - channel_id, - counterparty_node_id, - contribution, - None, + let splice_channel = + |node: &ChanMan, + counterparty_node_id: &PublicKey, + channel_id: &ChannelId, + f: &dyn Fn(FundingTemplate) -> Result| { + match node.splice_channel(channel_id, counterparty_node_id) { + Ok(funding_template) => { + if let Ok(contribution) = f(funding_template) { + let _ = node.funding_contributed( + channel_id, + counterparty_node_id, + contribution, + None, + ); + } + }, + Err(e) => { + assert!( + matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), + "{:?}", + e ); - } - }, - Err(e) => { - assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), - "{:?}", - e - ); - }, - } - }; + }, + } + }; let splice_in = |node: &ChanMan, @@ -1419,10 +1420,21 @@ pub fn do_test( channel_id: &ChannelId, wallet: &WalletSync<&TestWalletSource, Arc>, funding_feerate_sat_per_kw: FeeRate| { - splice_channel(node, counterparty_node_id, channel_id, &move |funding_template: FundingTemplate| { - let feerate = funding_template.min_rbf_feerate().unwrap_or(funding_feerate_sat_per_kw); - funding_template.splice_in_sync(Amount::from_sat(10_000), feerate, FeeRate::MAX, wallet) - }); + splice_channel( + node, + counterparty_node_id, + channel_id, + &move |funding_template: FundingTemplate| { + let feerate = + funding_template.min_rbf_feerate().unwrap_or(funding_feerate_sat_per_kw); + funding_template.splice_in_sync( + Amount::from_sat(10_000), + feerate, + FeeRate::MAX, + wallet, + ) + }, + ); }; let splice_out = |node: &ChanMan, @@ -1444,8 +1456,7 @@ pub fn do_test( return; } splice_channel(node, counterparty_node_id, channel_id, &move |funding_template| { - let feerate = - funding_template.min_rbf_feerate().unwrap_or(funding_feerate_sat_per_kw); + let feerate = funding_template.min_rbf_feerate().unwrap_or(funding_feerate_sat_per_kw); let outputs = vec![TxOut { value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), script_pubkey: wallet.get_change_script().unwrap(), diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index 9700390f8ef..f8f70fdc378 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -1032,8 +1032,7 @@ pub fn do_test(mut data: &[u8], logger: &Arc } let chan_id = chan.channel_id; let counterparty = chan.counterparty.node_id; - if let Ok(funding_template) = - channelmanager.splice_channel(&chan_id, &counterparty) + if let Ok(funding_template) = channelmanager.splice_channel(&chan_id, &counterparty) { let feerate = funding_template .min_rbf_feerate() @@ -1076,8 +1075,7 @@ pub fn do_test(mut data: &[u8], logger: &Arc let splice_out_sats = splice_out_sats.min(max_splice_out).max(546); // At least dust limit let chan_id = chan.channel_id; let counterparty = chan.counterparty.node_id; - if let Ok(funding_template) = - channelmanager.splice_channel(&chan_id, &counterparty) + if let Ok(funding_template) = channelmanager.splice_channel(&chan_id, &counterparty) { let feerate = funding_template .min_rbf_feerate() @@ -1087,9 +1085,12 @@ pub fn do_test(mut data: &[u8], logger: &Arc script_pubkey: wallet.get_change_script().unwrap(), }]; let wallet_sync = WalletSync::new(&wallet, Arc::clone(&logger)); - if let Ok(contribution) = - funding_template.splice_out_sync(outputs, feerate, FeeRate::MAX, &wallet_sync) - { + if let Ok(contribution) = funding_template.splice_out_sync( + outputs, + feerate, + FeeRate::MAX, + &wallet_sync, + ) { let _ = channelmanager.funding_contributed( &chan_id, &counterparty, diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 32247108ece..57aa83a01ae 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -56,7 +56,7 @@ use crate::ln::channelmanager::{ MAX_LOCAL_BREAKDOWN_TIMEOUT, MIN_CLTV_EXPIRY_DELTA, }; use crate::ln::funding::{ - FeeRateAdjustmentError, FundingContribution, FundingTemplate, FundingTxInput, + FeeRateAdjustmentError, FundingContribution, FundingTemplate, FundingTxInput, PriorContribution, }; use crate::ln::interactivetxs::{ AbortReason, HandleTxCompleteValue, InteractiveTxConstructor, InteractiveTxConstructorArgs, @@ -6790,24 +6790,30 @@ where shutdown_result } + /// Builds a [`SpliceFundingFailed`] from a contribution, filtering out inputs/outputs + /// that are still committed to a prior splice round. + fn splice_funding_failed_for(&self, contribution: FundingContribution) -> SpliceFundingFailed { + let (mut inputs, mut outputs) = contribution.into_contributed_inputs_and_outputs(); + if let Some(ref pending_splice) = self.pending_splice { + for input in pending_splice.contributed_inputs() { + inputs.retain(|i| *i != input); + } + for output in pending_splice.contributed_outputs() { + outputs.retain(|o| o.script_pubkey != output.script_pubkey); + } + } + SpliceFundingFailed { + funding_txo: None, + channel_type: None, + contributed_inputs: inputs, + contributed_outputs: outputs, + } + } + fn quiescent_action_into_error(&self, action: QuiescentAction) -> QuiescentError { match action { QuiescentAction::Splice { contribution, .. } => { - let (mut inputs, mut outputs) = contribution.into_contributed_inputs_and_outputs(); - if let Some(ref pending_splice) = self.pending_splice { - for input in pending_splice.contributed_inputs() { - inputs.retain(|i| *i != input); - } - for output in pending_splice.contributed_outputs() { - outputs.retain(|o| o.script_pubkey != output.script_pubkey); - } - } - QuiescentError::FailSplice(SpliceFundingFailed { - funding_txo: None, - channel_type: None, - contributed_inputs: inputs, - contributed_outputs: outputs, - }) + QuiescentError::FailSplice(self.splice_funding_failed_for(contribution)) }, #[cfg(any(test, fuzzing, feature = "_test_utils"))] QuiescentAction::DoNothing => QuiescentError::DoNothing, @@ -11907,7 +11913,7 @@ where } } - /// Initiate splicing. + /// Builds a [`FundingTemplate`] for splicing or RBF, if the channel state allows it. pub fn splice_channel(&self) -> Result { if self.holder_commitment_point.current_point().is_none() { return Err(APIError::APIMisuseError { @@ -11950,19 +11956,45 @@ where }); } - // Compute the RBF feerate floor from either negotiated candidates (via - // can_initiate_rbf) or an in-progress funding negotiation (which will become a - // negotiated candidate once it completes). - let min_rbf_feerate = self.can_initiate_rbf().ok().flatten().or_else(|| { - self.pending_splice - .as_ref() - .and_then(|pending_splice| pending_splice.funding_negotiation.as_ref()) - .map(|negotiation| { - let prev_feerate = negotiation.funding_feerate_sat_per_1000_weight(); - let min_feerate_kwu = ((prev_feerate as u64) * 25).div_ceil(24); - FeeRate::from_sat_per_kwu(min_feerate_kwu) - }) - }); + let (min_rbf_feerate, prior_contribution) = if self.is_rbf_compatible().is_err() { + // Channel can never RBF (e.g., zero-conf). + (None, None) + } else if let Some(pending_splice) = self.pending_splice.as_ref() { + // A splice is pending — either a completed negotiation that hasn't locked yet + // or an in-progress negotiation. In either case, the user's splice will need + // to satisfy the minimum RBF feerate, derived from the most recent feerate: + // - last_funding_feerate: from a completed but unlocked negotiation + // - funding_negotiation feerate: from an in-progress negotiation + // + // If the in-progress negotiation later fails (e.g., tx_abort), the derived + // min_rbf_feerate becomes stale, causing a slightly higher feerate than + // necessary. Call splice_channel again after receiving SpliceFailed to get a + // fresh template without the stale RBF constraint. + let prev_feerate = + pending_splice.last_funding_feerate_sat_per_1000_weight.or_else(|| { + pending_splice + .funding_negotiation + .as_ref() + .map(|n| n.funding_feerate_sat_per_1000_weight()) + }); + debug_assert!( + prev_feerate.is_some(), + "pending_splice should have last_funding_feerate or funding_negotiation", + ); + let min_rbf_feerate = prev_feerate.map(|f| { + let min_feerate_kwu = ((f as u64) * 25).div_ceil(24); + FeeRate::from_sat_per_kwu(min_feerate_kwu) + }); + let prior = if pending_splice.last_funding_feerate_sat_per_1000_weight.is_some() { + self.build_prior_contribution() + } else { + None + }; + (min_rbf_feerate, prior) + } else { + // No pending splice — fresh splice with no RBF constraint. + (None, None) + }; let funding_txo = self.funding.get_funding_txo().expect("funding_txo should be set"); let previous_utxo = @@ -11973,63 +12005,38 @@ where satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + FUNDING_TRANSACTION_WITNESS_WEIGHT, }; - Ok(FundingTemplate::new(Some(shared_input), min_rbf_feerate)) + Ok(FundingTemplate::new(Some(shared_input), min_rbf_feerate, prior_contribution)) } - /// Initiate an RBF of a pending splice transaction. - pub fn rbf_channel(&self) -> Result { - if self.holder_commitment_point.current_point().is_none() { - return Err(APIError::APIMisuseError { - err: format!( - "Channel {} cannot RBF until a payment is routed", - self.context.channel_id(), - ), - }); - } - - if self.quiescent_action.is_some() { - return Err(APIError::APIMisuseError { - err: format!( - "Channel {} cannot RBF as one is waiting to be negotiated", - self.context.channel_id(), - ), - }); - } - - if !self.context.is_usable() { - return Err(APIError::APIMisuseError { - err: format!( - "Channel {} cannot RBF as it is either pending open/close", - self.context.channel_id() - ), - }); - } + /// Clones the prior contribution and fetches the holder balance for deferred feerate + /// adjustment. + fn build_prior_contribution(&self) -> Option { + debug_assert!( + self.pending_splice.is_some(), + "build_prior_contribution requires pending_splice" + ); + let prior = self.pending_splice.as_ref()?.contributions.last()?; + let holder_balance = self + .get_holder_counterparty_balances_floor_incl_fee(&self.funding) + .map(|(h, _)| h) + .ok(); + Some(PriorContribution::new(prior.clone(), holder_balance)) + } + /// Returns whether this channel can ever RBF, independent of splice state. + fn is_rbf_compatible(&self) -> Result<(), String> { if self.context.minimum_depth(&self.funding) == Some(0) { - return Err(APIError::APIMisuseError { - err: format!( - "Channel {} has option_zeroconf, cannot RBF splice", - self.context.channel_id(), - ), - }); + return Err(format!( + "Channel {} has option_zeroconf, cannot RBF", + self.context.channel_id(), + )); } - - let min_rbf_feerate = - self.can_initiate_rbf().map_err(|err| APIError::APIMisuseError { err })?; - - let funding_txo = self.funding.get_funding_txo().expect("funding_txo should be set"); - let previous_utxo = - self.funding.get_funding_output().expect("funding_output should be set"); - let shared_input = Input { - outpoint: funding_txo.into_bitcoin_outpoint(), - previous_utxo, - satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + FUNDING_TRANSACTION_WITNESS_WEIGHT, - }; - - Ok(FundingTemplate::new(Some(shared_input), min_rbf_feerate)) + Ok(()) } - fn can_initiate_rbf(&self) -> Result, String> { + fn can_initiate_rbf(&self) -> Result { + self.is_rbf_compatible()?; + let pending_splice = match &self.pending_splice { Some(pending_splice) => pending_splice, None => { @@ -12068,13 +12075,16 @@ where )); } - let min_rbf_feerate = - pending_splice.last_funding_feerate_sat_per_1000_weight.map(|prev_feerate| { + match pending_splice.last_funding_feerate_sat_per_1000_weight { + Some(prev_feerate) => { let min_feerate_kwu = ((prev_feerate as u64) * 25).div_ceil(24); - FeeRate::from_sat_per_kwu(min_feerate_kwu) - }); - - Ok(min_rbf_feerate) + Ok(FeeRate::from_sat_per_kwu(min_feerate_kwu)) + }, + None => Err(format!( + "Channel {} has no prior feerate to compute RBF minimum", + self.context.channel_id(), + )), + } } /// Attempts to adjust the contribution's feerate to the minimum RBF feerate so the splice can @@ -12104,6 +12114,9 @@ where min_rbf_feerate, e, ); + // Note: try_send_stfu prevents sending stfu until the contribution's + // feerate meets the minimum RBF feerate, effectively waiting for the + // prior splice to lock before proceeding. return contribution; } @@ -12191,21 +12204,13 @@ where }) { log_error!(logger, "Channel {} cannot be funded: {}", self.context.channel_id(), e); - let (contributed_inputs, contributed_outputs) = - contribution.into_contributed_inputs_and_outputs(); - - return Err(QuiescentError::FailSplice(SpliceFundingFailed { - funding_txo: None, - channel_type: None, - contributed_inputs, - contributed_outputs, - })); + return Err(QuiescentError::FailSplice(self.splice_funding_failed_for(contribution))); } // If a pending splice exists with negotiated candidates, attempt to adjust the // contribution's feerate to the minimum RBF feerate so it can proceed as an RBF immediately // rather than waiting for the splice to lock. - let contribution = if let Ok(Some(min_rbf_feerate)) = self.can_initiate_rbf() { + let contribution = if let Ok(min_rbf_feerate) = self.can_initiate_rbf() { self.maybe_adjust_for_rbf(contribution, min_rbf_feerate, logger) } else { contribution @@ -12605,12 +12610,7 @@ where return Err(ChannelError::WarnAndDisconnect("Quiescence needed for RBF".to_owned())); } - if self.context.minimum_depth(&self.funding) == Some(0) { - return Err(ChannelError::WarnAndDisconnect(format!( - "Channel {} has option_zeroconf, cannot RBF splice", - self.context.channel_id(), - ))); - } + self.is_rbf_compatible().map_err(|msg| ChannelError::WarnAndDisconnect(msg))?; let pending_splice = match &self.pending_splice { Some(pending_splice) => pending_splice, @@ -13817,7 +13817,7 @@ where ); return None; }, - Ok(Some(min_rbf_feerate)) if contribution.feerate() < min_rbf_feerate => { + Ok(min_rbf_feerate) if contribution.feerate() < min_rbf_feerate => { log_given_level!( logger, logger_level, diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 223d74ce780..a2df8bd0951 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -4701,8 +4701,7 @@ impl< } /// Initiate a splice in order to add value to (splice-in) or remove value from (splice-out) - /// the channel. This will spend the channel's funding transaction output, effectively replacing - /// it with a new one. + /// the channel, or to RBF a pending splice transaction. /// /// # Required Feature Flags /// @@ -4710,15 +4709,13 @@ impl< /// channel (no matter the type) can be spliced, as long as the counterparty is currently /// connected. /// - /// Returns a [`FundingTemplate`] which should be used to build a [`FundingContribution`] via - /// one of its splice methods (e.g., [`FundingTemplate::splice_in_sync`]). The `min_feerate` - /// and `max_feerate` parameters are provided when calling those splice methods. The resulting - /// contribution must then be passed to [`ChannelManager::funding_contributed`]. + /// # Return Value /// - /// When a pending splice exists with negotiated candidates (i.e., a splice that hasn't been - /// locked yet), [`FundingTemplate::min_rbf_feerate`] will return the minimum feerate required - /// for an RBF attempt (25/24 of the previous feerate). This can be used to choose an - /// appropriate `min_feerate` when calling the splice methods. + /// Returns a [`FundingTemplate`] which should be used to obtain a [`FundingContribution`] + /// to pass to [`ChannelManager::funding_contributed`]. If a splice has been negotiated but + /// not yet locked, it can be replaced with a higher feerate transaction to speed up + /// confirmation via Replace By Fee (RBF). See [`FundingTemplate`] for details on building + /// a fresh contribution or reusing a prior one for RBF. #[rustfmt::skip] pub fn splice_channel( &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, @@ -4765,67 +4762,6 @@ impl< } } - /// Initiate an RBF of a pending splice transaction for an existing channel. - /// - /// This is used after a splice has been negotiated but before it has been locked, in order - /// to bump the feerate of the funding transaction via replace-by-fee. - /// - /// # Required Feature Flags - /// - /// Initiating an RBF requires that the channel counterparty supports splicing. The - /// counterparty must be currently connected. - /// - /// Returns a [`FundingTemplate`] which should be used to build a [`FundingContribution`] via - /// one of its splice methods (e.g., [`FundingTemplate::splice_in_sync`]). The `min_feerate` - /// and `max_feerate` parameters are provided when calling those splice methods. - /// [`FundingTemplate::min_rbf_feerate`] returns the minimum feerate required for the RBF - /// (25/24 of the previous feerate). The resulting contribution must then be passed to - /// [`ChannelManager::funding_contributed`]. - pub fn rbf_channel( - &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, - ) -> Result { - let per_peer_state = self.per_peer_state.read().unwrap(); - - let peer_state_mutex = match per_peer_state - .get(counterparty_node_id) - .ok_or_else(|| APIError::no_such_peer(counterparty_node_id)) - { - Ok(p) => p, - Err(e) => return Err(e), - }; - - let mut peer_state = peer_state_mutex.lock().unwrap(); - if !peer_state.latest_features.supports_splicing() { - return Err(APIError::ChannelUnavailable { - err: "Peer does not support splicing".to_owned(), - }); - } - if !peer_state.latest_features.supports_quiescence() { - return Err(APIError::ChannelUnavailable { - err: "Peer does not support quiescence, a splicing prerequisite".to_owned(), - }); - } - - // Look for the channel - match peer_state.channel_by_id.entry(*channel_id) { - hash_map::Entry::Occupied(chan_phase_entry) => { - if let Some(chan) = chan_phase_entry.get().as_funded() { - chan.rbf_channel() - } else { - Err(APIError::ChannelUnavailable { - err: format!( - "Channel with id {} is not funded, cannot RBF splice", - channel_id - ), - }) - } - }, - hash_map::Entry::Vacant(_) => { - Err(APIError::no_such_channel_for_peer(channel_id, counterparty_node_id)) - }, - } - } - #[cfg(test)] pub(crate) fn abandon_splice( &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, @@ -6590,13 +6526,16 @@ impl< /// /// If any failures occur while negotiating the funding transaction, an [`Event::SpliceFailed`] /// will be emitted. Any contributed inputs no longer used will be included in an - /// [`Event::DiscardFunding`] and thus can be re-spent. + /// [`Event::DiscardFunding`] and thus can be re-spent. If a [`FundingTemplate`] was obtained + /// while a previous splice was still being negotiated, its + /// [`min_rbf_feerate`][FundingTemplate::min_rbf_feerate] may be stale after the failure. + /// Call [`ChannelManager::splice_channel`] again to get a fresh template. /// /// After initial signatures have been exchanged, [`Event::FundingTransactionReadyForSigning`] /// will be generated and [`ChannelManager::funding_transaction_signed`] should be called. /// /// Once the splice has been locked by both counterparties, an [`Event::ChannelReady`] will be - /// emitted with the new funding output. At this point, a new splice can be negotiated by + /// emitted with the new funding output. At this point, a new (non-RBF) splice can be negotiated by /// calling [`ChannelManager::splice_channel`] again on this channel. /// /// # Errors diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index acad13c32ae..52562fc2118 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -106,12 +106,58 @@ impl core::fmt::Display for FeeRateAdjustmentError { } } +/// The user's prior contribution from a previous splice negotiation on this channel. +/// +/// When a pending splice exists with negotiated candidates, the prior contribution is +/// available for reuse (e.g., to bump the feerate via RBF). Contains the raw contribution and +/// the holder's balance for deferred feerate adjustment in [`FundingTemplate::rbf_sync`] or +/// [`FundingTemplate::rbf`]. +/// +/// Use [`FundingTemplate::prior_contribution`] to inspect the prior contribution before +/// deciding whether to call [`FundingTemplate::rbf_sync`] or one of the splice methods +/// with different parameters. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct PriorContribution { + contribution: FundingContribution, + /// The holder's balance, used for feerate adjustment. `None` when the balance computation + /// fails, in which case adjustment is skipped and coin selection is re-run. + holder_balance: Option, +} + +impl PriorContribution { + pub(super) fn new(contribution: FundingContribution, holder_balance: Option) -> Self { + Self { contribution, holder_balance } + } +} + /// A template for contributing to a channel's splice funding transaction. /// /// This is returned from [`ChannelManager::splice_channel`] when a channel is ready to be -/// spliced. It must be converted to a [`FundingContribution`] using one of the splice methods -/// and passed to [`ChannelManager::funding_contributed`] in order to resume the splicing -/// process. +/// spliced. A [`FundingContribution`] must be obtained from it and passed to +/// [`ChannelManager::funding_contributed`] in order to resume the splicing process. +/// +/// # Building a Contribution +/// +/// For a fresh splice (no pending splice to replace), build a new contribution using one of +/// the splice methods: +/// - [`FundingTemplate::splice_in_sync`] to add funds to the channel +/// - [`FundingTemplate::splice_out_sync`] to remove funds from the channel +/// - [`FundingTemplate::splice_in_and_out_sync`] to do both +/// +/// These perform coin selection and require `min_feerate` and `max_feerate` parameters. +/// +/// # Replace By Fee (RBF) +/// +/// When a pending splice exists that hasn't been locked yet, use [`FundingTemplate::rbf_sync`] +/// (or [`FundingTemplate::rbf`] for async) to build an RBF contribution. This handles the +/// prior contribution logic internally — reusing an adjusted prior when possible, re-running +/// coin selection when needed, or creating a fee-bump-only contribution. +/// +/// Check [`FundingTemplate::min_rbf_feerate`] for the minimum feerate required (25/24 of +/// the previous feerate). Use [`FundingTemplate::prior_contribution`] to inspect the prior +/// contribution's parameters (e.g., [`FundingContribution::value_added`], +/// [`FundingContribution::outputs`]) before deciding whether to reuse it via the RBF methods +/// or build a fresh contribution with different parameters using the splice methods above. /// /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel /// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed @@ -124,12 +170,18 @@ pub struct FundingTemplate { /// The minimum RBF feerate (25/24 of the previous feerate), if this template is for an /// RBF attempt. `None` for fresh splices with no pending splice candidates. min_rbf_feerate: Option, + + /// The user's prior contribution from a previous splice negotiation, if available. + prior_contribution: Option, } impl FundingTemplate { /// Constructs a [`FundingTemplate`] for a splice using the provided shared input. - pub(super) fn new(shared_input: Option, min_rbf_feerate: Option) -> Self { - Self { shared_input, min_rbf_feerate } + pub(super) fn new( + shared_input: Option, min_rbf_feerate: Option, + prior_contribution: Option, + ) -> Self { + Self { shared_input, min_rbf_feerate, prior_contribution } } /// Returns the minimum RBF feerate, if this template is for an RBF attempt. @@ -139,16 +191,34 @@ impl FundingTemplate { pub fn min_rbf_feerate(&self) -> Option { self.min_rbf_feerate } + + /// Returns a reference to the prior contribution from a previous splice negotiation, if + /// available. + /// + /// Use this to inspect the prior contribution's parameters (e.g., + /// [`FundingContribution::value_added`], [`FundingContribution::outputs`]) before deciding + /// whether to reuse it via [`FundingTemplate::rbf_sync`] or build a fresh contribution + /// with different parameters using the splice methods. + /// + /// Note: the returned contribution may reflect a different feerate than originally provided, + /// as it may have been adjusted for RBF or for the counterparty's feerate when acting as + /// the acceptor. This can change other parameters too (e.g., + /// [`FundingContribution::value_added`] may be higher if the change output was removed to + /// cover a higher fee). + pub fn prior_contribution(&self) -> Option<&FundingContribution> { + self.prior_contribution.as_ref().map(|p| &p.contribution) + } } macro_rules! build_funding_contribution { - ($value_added:expr, $outputs:expr, $shared_input:expr, $min_rbf_feerate:expr, $feerate:expr, $max_feerate:expr, $wallet:ident, $($await:tt)*) => {{ + ($value_added:expr, $outputs:expr, $shared_input:expr, $min_rbf_feerate:expr, $feerate:expr, $max_feerate:expr, $force_coin_selection:expr, $wallet:ident, $($await:tt)*) => {{ let value_added: Amount = $value_added; let outputs: Vec = $outputs; let shared_input: Option = $shared_input; let min_rbf_feerate: Option = $min_rbf_feerate; let feerate: FeeRate = $feerate; let max_feerate: FeeRate = $max_feerate; + let force_coin_selection: bool = $force_coin_selection; if feerate > max_feerate { return Err(()); @@ -178,7 +248,7 @@ macro_rules! build_funding_contribution { let is_splice = shared_input.is_some(); - let coin_selection = if value_added == Amount::ZERO { + let coin_selection = if value_added == Amount::ZERO && !force_coin_selection { CoinSelection { confirmed_utxos: vec![], change_output: None } } else { // Used for creating a redeem script for the new funding txo, since the funding pubkeys @@ -237,25 +307,32 @@ macro_rules! build_funding_contribution { impl FundingTemplate { /// Creates a [`FundingContribution`] for adding funds to a channel using `wallet` to perform /// coin selection. + /// + /// `value_added` is the total amount to add to the channel for this contribution. When + /// replacing a prior contribution via RBF, use [`FundingTemplate::prior_contribution`] to + /// inspect the prior parameters. To add funds on top of the prior contribution's amount, + /// combine them: `prior.value_added() + additional_amount`. pub async fn splice_in( self, value_added: Amount, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, ) -> Result { if value_added == Amount::ZERO { return Err(()); } - let FundingTemplate { shared_input, min_rbf_feerate } = self; - build_funding_contribution!(value_added, vec![], shared_input, min_rbf_feerate, min_feerate, max_feerate, wallet, await) + let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; + build_funding_contribution!(value_added, vec![], shared_input, min_rbf_feerate, min_feerate, max_feerate, false, wallet, await) } /// Creates a [`FundingContribution`] for adding funds to a channel using `wallet` to perform /// coin selection. + /// + /// See [`FundingTemplate::splice_in`] for details. pub fn splice_in_sync( self, value_added: Amount, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, ) -> Result { if value_added == Amount::ZERO { return Err(()); } - let FundingTemplate { shared_input, min_rbf_feerate } = self; + let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; build_funding_contribution!( value_added, vec![], @@ -263,31 +340,39 @@ impl FundingTemplate { min_rbf_feerate, min_feerate, max_feerate, + false, wallet, ) } /// Creates a [`FundingContribution`] for removing funds from a channel using `wallet` to /// perform coin selection. + /// + /// `outputs` are the complete set of withdrawal outputs for this contribution. When + /// replacing a prior contribution via RBF, use [`FundingTemplate::prior_contribution`] to + /// inspect the prior parameters. To keep existing withdrawals and add new ones, include the + /// prior's outputs: combine [`FundingContribution::outputs`] with the new outputs. pub async fn splice_out( self, outputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, ) -> Result { if outputs.is_empty() { return Err(()); } - let FundingTemplate { shared_input, min_rbf_feerate } = self; - build_funding_contribution!(Amount::ZERO, outputs, shared_input, min_rbf_feerate, min_feerate, max_feerate, wallet, await) + let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; + build_funding_contribution!(Amount::ZERO, outputs, shared_input, min_rbf_feerate, min_feerate, max_feerate, false, wallet, await) } /// Creates a [`FundingContribution`] for removing funds from a channel using `wallet` to /// perform coin selection. + /// + /// See [`FundingTemplate::splice_out`] for details. pub fn splice_out_sync( self, outputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, ) -> Result { if outputs.is_empty() { return Err(()); } - let FundingTemplate { shared_input, min_rbf_feerate } = self; + let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; build_funding_contribution!( Amount::ZERO, outputs, @@ -295,12 +380,18 @@ impl FundingTemplate { min_rbf_feerate, min_feerate, max_feerate, + false, wallet, ) } /// Creates a [`FundingContribution`] for both adding and removing funds from a channel using /// `wallet` to perform coin selection. + /// + /// `value_added` and `outputs` are the complete parameters for this contribution, not + /// increments on top of a prior contribution. When replacing a prior contribution via RBF, + /// use [`FundingTemplate::prior_contribution`] to inspect the prior parameters and combine + /// them as needed. pub async fn splice_in_and_out( self, value_added: Amount, outputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, @@ -308,12 +399,14 @@ impl FundingTemplate { if value_added == Amount::ZERO && outputs.is_empty() { return Err(()); } - let FundingTemplate { shared_input, min_rbf_feerate } = self; - build_funding_contribution!(value_added, outputs, shared_input, min_rbf_feerate, min_feerate, max_feerate, wallet, await) + let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; + build_funding_contribution!(value_added, outputs, shared_input, min_rbf_feerate, min_feerate, max_feerate, false, wallet, await) } /// Creates a [`FundingContribution`] for both adding and removing funds from a channel using /// `wallet` to perform coin selection. + /// + /// See [`FundingTemplate::splice_in_and_out`] for details. pub fn splice_in_and_out_sync( self, value_added: Amount, outputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, @@ -321,7 +414,7 @@ impl FundingTemplate { if value_added == Amount::ZERO && outputs.is_empty() { return Err(()); } - let FundingTemplate { shared_input, min_rbf_feerate } = self; + let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; build_funding_contribution!( value_added, outputs, @@ -329,9 +422,122 @@ impl FundingTemplate { min_rbf_feerate, min_feerate, max_feerate, + false, wallet, ) } + + /// Creates a [`FundingContribution`] for an RBF (Replace-By-Fee) attempt on a pending splice. + /// + /// `max_feerate` is the maximum feerate the caller is willing to accept as acceptor. It is + /// used as the returned contribution's `max_feerate` and also constrains coin selection when + /// re-running it for prior contributions that cannot be adjusted or fee-bump-only + /// contributions. + /// + /// This handles the prior contribution logic internally: + /// - If the prior contribution's feerate can be adjusted to the minimum RBF feerate, the + /// adjusted contribution is returned directly. For splice-in, the change output absorbs + /// the fee difference. For splice-out (no wallet inputs), the holder's channel balance + /// covers the higher fees. + /// - If adjustment fails, coin selection is re-run using the prior contribution's + /// parameters and the caller's `max_feerate`. For splice-out contributions, this changes + /// the fee source: wallet inputs are selected to cover fees instead of deducting them + /// from the channel balance. + /// - If no prior contribution exists, coin selection is run for a fee-bump-only contribution + /// (`value_added = 0`), covering fees for the common fields and shared input/output via + /// a newly selected input. Check [`FundingTemplate::prior_contribution`] to see if this + /// is intended. + /// + /// Returns `Err(())` if this is not an RBF scenario ([`FundingTemplate::min_rbf_feerate`] + /// is `None`) or if `max_feerate` is below the minimum RBF feerate. + pub async fn rbf( + self, max_feerate: FeeRate, wallet: W, + ) -> Result { + let FundingTemplate { shared_input, min_rbf_feerate, prior_contribution } = self; + let rbf_feerate = min_rbf_feerate.ok_or(())?; + if rbf_feerate > max_feerate { + return Err(()); + } + + match prior_contribution { + Some(PriorContribution { contribution, holder_balance }) => { + // Try to adjust the prior contribution to the RBF feerate. This fails if + // the holder balance can't cover the adjustment (splice-out) or the fee + // buffer is insufficient (splice-in), or if the prior's feerate is already + // above rbf_feerate (e.g., from a counterparty-initiated RBF that locked + // at a higher feerate). In all cases, fall through to re-run coin selection. + if let Some(holder_balance) = holder_balance { + if contribution + .net_value_for_initiator_at_feerate(rbf_feerate, holder_balance) + .is_ok() + { + let mut adjusted = contribution + .for_initiator_at_feerate(rbf_feerate, holder_balance) + .expect("feerate compatibility already checked"); + adjusted.max_feerate = max_feerate; + return Ok(adjusted); + } + } + build_funding_contribution!(contribution.value_added, contribution.outputs, shared_input, min_rbf_feerate, rbf_feerate, max_feerate, true, wallet, await) + }, + None => { + build_funding_contribution!(Amount::ZERO, vec![], shared_input, min_rbf_feerate, rbf_feerate, max_feerate, true, wallet, await) + }, + } + } + + /// Creates a [`FundingContribution`] for an RBF (Replace-By-Fee) attempt on a pending splice. + /// + /// See [`FundingTemplate::rbf`] for details. + pub fn rbf_sync( + self, max_feerate: FeeRate, wallet: W, + ) -> Result { + let FundingTemplate { shared_input, min_rbf_feerate, prior_contribution } = self; + let rbf_feerate = min_rbf_feerate.ok_or(())?; + if rbf_feerate > max_feerate { + return Err(()); + } + + match prior_contribution { + Some(PriorContribution { contribution, holder_balance }) => { + // See comment in `rbf` for details on when this adjustment fails. + if let Some(holder_balance) = holder_balance { + if contribution + .net_value_for_initiator_at_feerate(rbf_feerate, holder_balance) + .is_ok() + { + let mut adjusted = contribution + .for_initiator_at_feerate(rbf_feerate, holder_balance) + .expect("feerate compatibility already checked"); + adjusted.max_feerate = max_feerate; + return Ok(adjusted); + } + } + build_funding_contribution!( + contribution.value_added, + contribution.outputs, + shared_input, + min_rbf_feerate, + rbf_feerate, + max_feerate, + true, + wallet, + ) + }, + None => { + build_funding_contribution!( + Amount::ZERO, + vec![], + shared_input, + min_rbf_feerate, + rbf_feerate, + max_feerate, + true, + wallet, + ) + }, + } + } } fn estimate_transaction_fee( @@ -385,7 +591,7 @@ fn estimate_transaction_fee( } /// The components of a funding transaction contributed by one party. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct FundingContribution { /// The amount to contribute to the channel. /// @@ -445,6 +651,18 @@ impl FundingContribution { self.outputs.iter().chain(self.change_output.iter()) } + /// Returns the amount added to the channel by this contribution. + pub fn value_added(&self) -> Amount { + self.value_added + } + + /// Returns the outputs (e.g., withdrawal destinations) included in this contribution. + /// + /// This does not include the change output; see [`FundingContribution::change_output`]. + pub fn outputs(&self) -> &[TxOut] { + &self.outputs + } + /// Returns the change output included in this contribution, if any. /// /// When coin selection provides more value than needed for the funding contribution and fees, @@ -727,8 +945,8 @@ impl FundingContribution { /// Adjusts the contribution's change output for the minimum RBF feerate. /// /// When a pending splice exists with negotiated candidates and the contribution's feerate - /// is below the minimum RBF feerate (25/24 of the previous feerate), this adjusts the change output - /// so the initiator pays fees at the minimum RBF feerate. + /// is below the minimum RBF feerate (25/24 of the previous feerate), this adjusts the + /// change output so the initiator pays fees at the minimum RBF feerate. pub(super) fn for_initiator_at_feerate( self, feerate: FeeRate, holder_balance: Amount, ) -> Result { @@ -833,7 +1051,7 @@ pub type FundingTxInput = crate::util::wallet_utils::ConfirmedUtxo; mod tests { use super::{ estimate_transaction_fee, FeeRateAdjustmentError, FundingContribution, FundingTemplate, - FundingTxInput, + FundingTxInput, PriorContribution, }; use crate::chain::ClaimId; use crate::util::wallet_utils::{CoinSelection, CoinSelectionSourceSync, Input}; @@ -1142,7 +1360,7 @@ mod tests { // splice_in_sync with value_added > MAX_MONEY { - let template = FundingTemplate::new(None, None); + let template = FundingTemplate::new(None, None, None); assert!(template .splice_in_sync(over_max, feerate, feerate, UnreachableWallet) .is_err()); @@ -1150,7 +1368,7 @@ mod tests { // splice_out_sync with single output value > MAX_MONEY { - let template = FundingTemplate::new(None, None); + let template = FundingTemplate::new(None, None, None); let outputs = vec![funding_output_sats(over_max.to_sat())]; assert!(template .splice_out_sync(outputs, feerate, feerate, UnreachableWallet) @@ -1159,7 +1377,7 @@ mod tests { // splice_out_sync with multiple outputs summing > MAX_MONEY { - let template = FundingTemplate::new(None, None); + let template = FundingTemplate::new(None, None, None); let half_over = Amount::MAX_MONEY / 2 + Amount::from_sat(1); let outputs = vec![ funding_output_sats(half_over.to_sat()), @@ -1172,7 +1390,7 @@ mod tests { // splice_in_and_out_sync with value_added > MAX_MONEY { - let template = FundingTemplate::new(None, None); + let template = FundingTemplate::new(None, None, None); let outputs = vec![funding_output_sats(1_000)]; assert!(template .splice_in_and_out_sync(over_max, outputs, feerate, feerate, UnreachableWallet) @@ -1181,7 +1399,7 @@ mod tests { // splice_in_and_out_sync with output sum > MAX_MONEY { - let template = FundingTemplate::new(None, None); + let template = FundingTemplate::new(None, None, None); let outputs = vec![funding_output_sats(over_max.to_sat())]; assert!(template .splice_in_and_out_sync( @@ -1202,7 +1420,7 @@ mod tests { // min_feerate > max_feerate is rejected { - let template = FundingTemplate::new(None, None); + let template = FundingTemplate::new(None, None, None); assert!(template .splice_in_sync(Amount::from_sat(10_000), high, low, UnreachableWallet) .is_err()); @@ -1210,7 +1428,7 @@ mod tests { // min_feerate < min_rbf_feerate is rejected { - let template = FundingTemplate::new(None, Some(high)); + let template = FundingTemplate::new(None, Some(high), None); assert!(template .splice_in_sync(Amount::from_sat(10_000), low, FeeRate::MAX, UnreachableWallet) .is_err()); @@ -1947,4 +2165,218 @@ mod tests { assert_eq!(initiator.feerate, target_feerate); assert_eq!(acceptor.feerate, target_feerate); } + + #[test] + fn test_rbf_sync_rejects_max_feerate_below_min_rbf_feerate() { + // When the caller's max_feerate is below the minimum RBF feerate, rbf_sync should + // return Err(()). + let prior_feerate = FeeRate::from_sat_per_kwu(2000); + let min_rbf_feerate = FeeRate::from_sat_per_kwu(5000); + let max_feerate = FeeRate::from_sat_per_kwu(3000); + + let prior = FundingContribution { + value_added: Amount::from_sat(50_000), + estimated_fee: Amount::from_sat(1_000), + inputs: vec![funding_input_sats(100_000)], + outputs: vec![], + change_output: None, + feerate: prior_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + // max_feerate (3000) < min_rbf_feerate (5000). + let template = FundingTemplate::new( + None, + Some(min_rbf_feerate), + Some(PriorContribution::new(prior, None)), + ); + assert!(template.rbf_sync(max_feerate, UnreachableWallet).is_err()); + } + + #[test] + fn test_rbf_sync_adjusts_prior_to_rbf_feerate() { + // When the prior contribution's feerate is below the minimum RBF feerate and holder + // balance is available, rbf_sync should adjust the prior to the RBF feerate. + let prior_feerate = FeeRate::from_sat_per_kwu(2000); + let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025); + let max_feerate = FeeRate::from_sat_per_kwu(5000); + + let inputs = vec![funding_input_sats(100_000)]; + let change = funding_output_sats(10_000); + let estimated_fee = + estimate_transaction_fee(&inputs, &[], Some(&change), true, true, prior_feerate); + + let prior = FundingContribution { + value_added: Amount::from_sat(50_000), + estimated_fee, + inputs, + outputs: vec![], + change_output: Some(change), + feerate: prior_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + let template = FundingTemplate::new( + None, + Some(min_rbf_feerate), + Some(PriorContribution::new(prior, Some(Amount::MAX))), + ); + let contribution = template.rbf_sync(max_feerate, UnreachableWallet).unwrap(); + assert_eq!(contribution.feerate, min_rbf_feerate); + assert_eq!(contribution.max_feerate, max_feerate); + } + + /// A mock wallet that returns a single UTXO for coin selection. + struct SingleUtxoWallet { + utxo: FundingTxInput, + change_output: Option, + } + + impl CoinSelectionSourceSync for SingleUtxoWallet { + fn select_confirmed_utxos( + &self, _claim_id: Option, _must_spend: Vec, _must_pay_to: &[TxOut], + _target_feerate_sat_per_1000_weight: u32, _max_tx_weight: u64, + ) -> Result { + Ok(CoinSelection { + confirmed_utxos: vec![self.utxo.clone()], + change_output: self.change_output.clone(), + }) + } + fn sign_psbt(&self, _psbt: Psbt) -> Result { + unreachable!("should not reach signing") + } + } + + fn shared_input(value_sats: u64) -> Input { + Input { + outpoint: bitcoin::OutPoint::null(), + previous_utxo: TxOut { + value: Amount::from_sat(value_sats), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()), + }, + satisfaction_weight: 107, + } + } + + #[test] + fn test_rbf_sync_unadjusted_splice_out_runs_coin_selection() { + // When the prior contribution's feerate is below the minimum RBF feerate and no + // holder balance is available, rbf_sync should run coin selection to add inputs that + // cover the higher RBF fee. + let min_rbf_feerate = FeeRate::from_sat_per_kwu(5000); + let prior_feerate = FeeRate::from_sat_per_kwu(2000); + let withdrawal = funding_output_sats(20_000); + + let prior = FundingContribution { + value_added: Amount::ZERO, + estimated_fee: Amount::from_sat(500), + inputs: vec![], + outputs: vec![withdrawal.clone()], + change_output: None, + feerate: prior_feerate, + max_feerate: prior_feerate, + is_splice: true, + }; + + let template = FundingTemplate::new( + Some(shared_input(100_000)), + Some(min_rbf_feerate), + Some(PriorContribution::new(prior, None)), + ); + + let wallet = SingleUtxoWallet { + utxo: funding_input_sats(50_000), + change_output: Some(funding_output_sats(40_000)), + }; + + // rbf_sync should succeed and the contribution should have inputs from coin selection. + let contribution = template.rbf_sync(FeeRate::MAX, &wallet).unwrap(); + assert_eq!(contribution.value_added, Amount::ZERO); + assert!(!contribution.inputs.is_empty(), "coin selection should have added inputs"); + assert_eq!(contribution.outputs, vec![withdrawal]); + assert_eq!(contribution.feerate, min_rbf_feerate); + } + + #[test] + fn test_rbf_sync_no_prior_fee_bump_only_runs_coin_selection() { + // When there is no prior contribution (e.g., acceptor), rbf_sync should run coin + // selection to add inputs for a fee-bump-only contribution. + let min_rbf_feerate = FeeRate::from_sat_per_kwu(5000); + + let template = + FundingTemplate::new(Some(shared_input(100_000)), Some(min_rbf_feerate), None); + + let wallet = SingleUtxoWallet { + utxo: funding_input_sats(50_000), + change_output: Some(funding_output_sats(45_000)), + }; + + let contribution = template.rbf_sync(FeeRate::MAX, &wallet).unwrap(); + assert_eq!(contribution.value_added, Amount::ZERO); + assert!(!contribution.inputs.is_empty(), "coin selection should have added inputs"); + assert!(contribution.outputs.is_empty()); + assert_eq!(contribution.feerate, min_rbf_feerate); + } + + #[test] + fn test_rbf_sync_unadjusted_uses_callers_max_feerate() { + // When the prior contribution's feerate is below the minimum RBF feerate and no + // holder balance is available, rbf_sync should use the caller's max_feerate (not the + // prior's) for the resulting contribution. + let min_rbf_feerate = FeeRate::from_sat_per_kwu(5000); + let prior_max_feerate = FeeRate::from_sat_per_kwu(50_000); + let callers_max_feerate = FeeRate::from_sat_per_kwu(10_000); + let withdrawal = funding_output_sats(20_000); + + let prior = FundingContribution { + value_added: Amount::ZERO, + estimated_fee: Amount::from_sat(500), + inputs: vec![], + outputs: vec![withdrawal.clone()], + change_output: None, + feerate: FeeRate::from_sat_per_kwu(2000), + max_feerate: prior_max_feerate, + is_splice: true, + }; + + let template = FundingTemplate::new( + Some(shared_input(100_000)), + Some(min_rbf_feerate), + Some(PriorContribution::new(prior, None)), + ); + + let wallet = SingleUtxoWallet { + utxo: funding_input_sats(50_000), + change_output: Some(funding_output_sats(40_000)), + }; + + let contribution = template.rbf_sync(callers_max_feerate, &wallet).unwrap(); + assert_eq!( + contribution.max_feerate, callers_max_feerate, + "should use caller's max_feerate, not prior's" + ); + } + + #[test] + fn test_splice_out_sync_skips_coin_selection_during_rbf() { + // When splice_out_sync is called on a template with min_rbf_feerate set (user + // choosing a fresh splice-out instead of rbf_sync), coin selection should NOT run. + // Fees come from the channel balance. + let min_rbf_feerate = FeeRate::from_sat_per_kwu(5000); + let feerate = FeeRate::from_sat_per_kwu(5000); + let withdrawal = funding_output_sats(20_000); + + let template = + FundingTemplate::new(Some(shared_input(100_000)), Some(min_rbf_feerate), None); + + // UnreachableWallet panics if coin selection runs — verifying it is skipped. + let contribution = template + .splice_out_sync(vec![withdrawal.clone()], feerate, FeeRate::MAX, UnreachableWallet) + .unwrap(); + assert_eq!(contribution.value_added, Amount::ZERO); + assert!(contribution.inputs.is_empty()); + assert_eq!(contribution.outputs, vec![withdrawal]); + } } diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 07f2abe7b58..6971e91f717 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -237,7 +237,7 @@ pub fn do_initiate_rbf_splice_in<'a, 'b, 'c, 'd>( value_added: Amount, feerate: FeeRate, ) -> FundingContribution { let node_id_counterparty = counterparty.node.get_our_node_id(); - let funding_template = node.node.rbf_channel(&channel_id, &node_id_counterparty).unwrap(); + let funding_template = node.node.splice_channel(&channel_id, &node_id_counterparty).unwrap(); let wallet = WalletSync::new(Arc::clone(&node.wallet_source), node.logger); let funding_contribution = funding_template.splice_in_sync(value_added, feerate, FeeRate::MAX, &wallet).unwrap(); @@ -252,7 +252,7 @@ pub fn do_initiate_rbf_splice_in_and_out<'a, 'b, 'c, 'd>( value_added: Amount, outputs: Vec, feerate: FeeRate, ) -> FundingContribution { let node_id_counterparty = counterparty.node.get_our_node_id(); - let funding_template = node.node.rbf_channel(&channel_id, &node_id_counterparty).unwrap(); + let funding_template = node.node.splice_channel(&channel_id, &node_id_counterparty).unwrap(); let wallet = WalletSync::new(Arc::clone(&node.wallet_source), node.logger); let funding_contribution = funding_template .splice_in_and_out_sync(value_added, outputs, feerate, FeeRate::MAX, &wallet) @@ -4294,7 +4294,7 @@ fn test_splice_acceptor_disconnect_emits_events() { #[test] fn test_splice_rbf_acceptor_basic() { // Test the full end-to-end flow for RBF of a pending splice transaction. - // Complete a splice-in, then use rbf_channel API to initiate an RBF attempt + // Complete a splice-in, then use splice_channel API to initiate an RBF attempt // with a higher feerate, going through the full tx_init_rbf → tx_ack_rbf → // interactive TX → signing → mining → splice_locked flow. let chanmon_cfgs = create_chanmon_cfgs(2); @@ -4321,7 +4321,7 @@ fn test_splice_rbf_acceptor_basic() { // Step 2: Provide more UTXO reserves for the RBF attempt. provide_utxo_reserves(&nodes, 2, added_value * 2); - // Step 3: Use rbf_channel API to initiate the RBF. + // Step 3: Use splice_channel API to initiate the RBF. // Original feerate was FEERATE_FLOOR_SATS_PER_KW (253). 253 * 25 / 24 = 263.54, so 264 works. let rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); @@ -4384,7 +4384,7 @@ fn test_splice_rbf_insufficient_feerate() { // Initiator-side: splice_in_sync rejects an insufficient feerate. // Original feerate was 253. Using exactly 253 should fail since 253 * 24 < 253 * 25. let same_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = nodes[0].node.rbf_channel(&channel_id, &node_id_1).unwrap(); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); // Verify that the template exposes the RBF floor. let min_rbf_feerate = funding_template.min_rbf_feerate().unwrap(); @@ -4398,7 +4398,7 @@ fn test_splice_rbf_insufficient_feerate() { .is_err()); // Verify that the floor feerate succeeds. - let funding_template = nodes[0].node.rbf_channel(&channel_id, &node_id_1).unwrap(); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); assert!(funding_template .splice_in_sync(added_value, min_rbf_feerate, FeeRate::MAX, &wallet) .is_ok()); @@ -4579,6 +4579,43 @@ fn test_splice_rbf_after_splice_locked() { } } +#[test] +fn test_splice_zeroconf_no_rbf_feerate() { + // Test that splice_channel returns a FundingTemplate with min_rbf_feerate = None for a + // zero-conf channel, even when a splice negotiation is in progress. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut config = test_default_channel_config(); + config.channel_handshake_limits.trust_own_funding_0conf = true; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (funding_tx, channel_id) = + open_zero_conf_channel_with_value(&nodes[0], &nodes[1], None, initial_channel_value_sat, 0); + mine_transaction(&nodes[0], &funding_tx); + mine_transaction(&nodes[1], &funding_tx); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 1, added_value * 2); + + // Initiate a splice (node 0) and complete the handshake so a funding negotiation is in + // progress. + let _funding_contribution = + do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let _new_funding_script = complete_splice_handshake(&nodes[0], &nodes[1]); + + // The acceptor (node 1) calling splice_channel should return no RBF feerate since + // zero-conf channels cannot RBF. + let funding_template = nodes[1].node.splice_channel(&channel_id, &node_id_0).unwrap(); + assert!(funding_template.min_rbf_feerate().is_none()); + + // Drain pending interactive tx messages from the splice handshake. + nodes[0].node.get_and_clear_pending_msg_events(); +} + #[test] fn test_splice_rbf_zeroconf_rejected() { // Test that tx_init_rbf is rejected when option_zeroconf is negotiated. @@ -4621,10 +4658,7 @@ fn test_splice_rbf_zeroconf_rejected() { msgs::ErrorAction::DisconnectPeerWithWarning { msg: msgs::WarningMessage { channel_id, - data: format!( - "Channel {} has option_zeroconf, cannot RBF splice", - channel_id, - ), + data: format!("Channel {} has option_zeroconf, cannot RBF", channel_id,), }, } ); @@ -4740,7 +4774,7 @@ fn test_splice_rbf_tiebreak_feerate_too_high() { /// Runs the tie-breaker test with the given per-node feerates and node 1's splice value. /// -/// Both nodes call `rbf_channel` + `funding_contributed`, both send STFU, and node 0 (the outbound +/// Both nodes call `splice_channel` + `funding_contributed`, both send STFU, and node 0 (the outbound /// channel funder) wins the quiescence tie-break. The loser (node 1) becomes the acceptor. Whether /// node 1 contributes to the RBF transaction depends on the feerate and budget constraints. /// @@ -4772,11 +4806,11 @@ pub fn do_test_splice_rbf_tiebreak( // Provide more UTXOs for both nodes' RBF attempts. provide_utxo_reserves(&nodes, 2, added_value * 2); - // Node 0 calls rbf_channel + funding_contributed. + // Node 0 calls splice_channel + funding_contributed. let node_0_funding_contribution = do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate_0); - // Node 1 calls rbf_channel + funding_contributed. + // Node 1 calls splice_channel + funding_contributed. let node_1_funding_contribution = do_initiate_rbf_splice_in( &nodes[1], &nodes[0], @@ -5034,7 +5068,7 @@ fn test_splice_rbf_tiebreak_feerate_too_high_rejected() { let min_rbf_feerate = FeeRate::from_sat_per_kwu(min_rbf_feerate_sat_per_kwu); let node_1_max_feerate = FeeRate::from_sat_per_kwu(3_000); - let funding_template_0 = nodes[0].node.rbf_channel(&channel_id, &node_id_1).unwrap(); + let funding_template_0 = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); let node_0_funding_contribution = funding_template_0 .splice_in_sync(added_value, high_feerate, FeeRate::MAX, &wallet_0) @@ -5044,7 +5078,7 @@ fn test_splice_rbf_tiebreak_feerate_too_high_rejected() { .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) .unwrap(); - let funding_template_1 = nodes[1].node.rbf_channel(&channel_id, &node_id_0).unwrap(); + let funding_template_1 = nodes[1].node.splice_channel(&channel_id, &node_id_0).unwrap(); let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); let node_1_funding_contribution = funding_template_1 .splice_in_sync(added_value, min_rbf_feerate, node_1_max_feerate, &wallet_1) @@ -5159,7 +5193,7 @@ fn test_splice_rbf_acceptor_recontributes() { // Step 4: Provide new UTXOs for node 0's RBF (node 1 does NOT initiate RBF). provide_utxo_reserves(&nodes, 2, added_value * 2); - // Step 5: Only node 0 calls rbf_channel + funding_contributed. + // Step 5: Only node 0 calls splice_channel + funding_contributed. let rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); let rbf_funding_contribution = @@ -5203,6 +5237,127 @@ fn test_splice_rbf_acceptor_recontributes() { ); } +#[test] +fn test_splice_rbf_after_counterparty_rbf_aborted() { + // When a counterparty-initiated RBF is aborted, the acceptor's prior contribution retains + // the adjusted feerate. Initiating our own RBF afterward must not panic even though the + // prior contribution's feerate may be >= the new rbf_feerate. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000)); + + // Step 1: Both nodes initiate a splice at floor feerate. + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + + let funding_template_0 = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let node_0_funding_contribution = + funding_template_0.splice_in_sync(added_value, feerate, FeeRate::MAX, &wallet_0).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) + .unwrap(); + + let funding_template_1 = nodes[1].node.splice_channel(&channel_id, &node_id_0).unwrap(); + let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let node_1_funding_contribution = + funding_template_1.splice_in_sync(added_value, feerate, FeeRate::MAX, &wallet_1).unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) + .unwrap(); + + // Step 2: Tiebreak — node 0 wins, both contribute to initial splice. + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); + + let new_funding_script = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + node_0_funding_contribution, + Some(node_1_funding_contribution), + splice_ack.funding_contribution_satoshis, + new_funding_script, + ); + + let (_first_splice_tx, splice_locked) = + sign_interactive_funding_tx_with_acceptor_contribution(&nodes[0], &nodes[1], false, true); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Step 3: Node 0 initiates RBF. Node 1 has no QuiescentAction, so its prior contribution + // is adjusted to the RBF feerate via for_acceptor_at_feerate. + provide_utxo_reserves(&nodes, 2, added_value * 2); + + let rbf_feerate = + FeeRate::from_sat_per_kwu((FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24)); + let _rbf_funding_contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + + let tx_ack_rbf = complete_rbf_handshake(&nodes[0], &nodes[1]); + assert!(tx_ack_rbf.funding_output_contribution.is_some()); + + // Step 4: Abort the RBF. Node 0 sends tx_abort; node 1's prior contribution retains the + // adjusted feerate. + // Drain node 0's pending TxAddInput from the interactive tx negotiation start. + nodes[0].node.get_and_clear_pending_msg_events(); + + let tx_abort = msgs::TxAbort { channel_id, data: vec![] }; + nodes[1].node.handle_tx_abort(node_id_0, &tx_abort); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert!(!msg_events.is_empty()); + let tx_abort_echo = match &msg_events[0] { + MessageSendEvent::SendTxAbort { msg, .. } => msg.clone(), + other => panic!("Expected SendTxAbort, got {:?}", other), + }; + + nodes[0].node.handle_tx_abort(node_id_1, &tx_abort_echo); + nodes[0].node.get_and_clear_pending_msg_events(); + nodes[0].node.get_and_clear_pending_events(); + nodes[1].node.get_and_clear_pending_events(); + + // Step 5: Node 1 initiates its own RBF via splice_channel → rbf_sync. + // The prior contribution's feerate is now >= rbf_feerate. This must not panic. + provide_utxo_reserves(&nodes, 2, added_value * 2); + + let funding_template = nodes[1].node.splice_channel(&channel_id, &node_id_0).unwrap(); + assert!(funding_template.min_rbf_feerate().is_some()); + + let wallet = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let rbf_contribution = funding_template.rbf_sync(FeeRate::MAX, &wallet); + assert!(rbf_contribution.is_ok()); +} + #[test] fn test_splice_rbf_recontributes_feerate_too_high() { // When the counterparty RBFs at a feerate too high for our prior contribution, @@ -5288,7 +5443,7 @@ fn test_splice_rbf_recontributes_feerate_too_high() { provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000)); let high_feerate = FeeRate::from_sat_per_kwu(20_000); - let funding_template = nodes[0].node.rbf_channel(&channel_id, &node_id_1).unwrap(); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); let rbf_funding_contribution = funding_template .splice_in_sync(Amount::from_sat(50_000), high_feerate, FeeRate::MAX, &wallet) @@ -5630,8 +5785,8 @@ fn test_splice_rbf_disconnect_filters_prior_contributions() { #[test] fn test_splice_channel_with_pending_splice_includes_rbf_floor() { - // Test that splice_channel (not rbf_channel) includes the RBF floor when a pending splice - // exists with negotiated candidates. + // Test that splice_channel includes the RBF floor when a pending splice exists with + // negotiated candidates. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); @@ -5646,33 +5801,39 @@ fn test_splice_channel_with_pending_splice_includes_rbf_floor() { let added_value = Amount::from_sat(50_000); provide_utxo_reserves(&nodes, 2, added_value * 2); + // Fresh splice — no pending splice, so no prior contribution or minimum RBF feerate. + { + let template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + assert!(template.min_rbf_feerate().is_none()); + assert!(template.prior_contribution().is_none()); + } + // Complete a splice-in at floor feerate. let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); let (_splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); - // Call splice_channel (not rbf_channel) — the pending splice should cause - // min_rbf_feerate to be set. + // Call splice_channel again — the pending splice should cause min_rbf_feerate to be set + // and the prior contribution to be available. let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let expected_floor = FeeRate::from_sat_per_kwu(((FEERATE_FLOOR_SATS_PER_KW as u64) * 25).div_ceil(24)); assert_eq!(funding_template.min_rbf_feerate(), Some(expected_floor)); + assert!(funding_template.prior_contribution().is_some()); - // Successfully build a contribution at the floor feerate. + // rbf_sync returns the Adjusted prior contribution directly. let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - assert!(funding_template - .splice_in_sync(added_value, expected_floor, FeeRate::MAX, &wallet) - .is_ok()); + assert!(funding_template.rbf_sync(FeeRate::MAX, &wallet).is_ok()); } #[test] fn test_funding_contributed_adjusts_feerate_for_rbf() { - // Test that funding_contributed adjusts the contribution's feerate to the minimum RBF feerate when a - // pending splice appears between splice_channel and funding_contributed. + // Test that funding_contributed adjusts the contribution's feerate to the minimum RBF feerate + // when a pending splice appears between splice_channel and funding_contributed. // // Node 0 calls splice_channel (no pending splice → min_rbf_feerate = None) and builds a // contribution at floor feerate. Node 1 then initiates and completes a splice. When node 0 - // calls funding_contributed, the contribution is adjusted to the minimum RBF feerate and STFU is sent - // immediately. + // calls funding_contributed, the contribution is adjusted to the minimum RBF feerate and STFU + // is sent immediately. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); @@ -5723,9 +5884,9 @@ fn test_funding_contributed_adjusts_feerate_for_rbf() { #[test] fn test_funding_contributed_rbf_adjustment_exceeds_max_feerate() { - // Test that when the minimum RBF feerate exceeds max_feerate, the adjustment in funding_contributed - // fails gracefully and the contribution keeps its original feerate. The splice still - // proceeds (STFU is sent) and the RBF negotiation handles the feerate mismatch. + // Test that when the minimum RBF feerate exceeds max_feerate, the adjustment in + // funding_contributed fails gracefully and the contribution keeps its original feerate. The + // splice still proceeds (STFU is sent) and the RBF negotiation handles the feerate mismatch. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); @@ -5754,8 +5915,8 @@ fn test_funding_contributed_rbf_adjustment_exceeds_max_feerate() { let node_1_contribution = do_initiate_splice_in(&nodes[1], &nodes[0], channel_id, added_value); let (_splice_tx, _) = splice_channel(&nodes[1], &nodes[0], channel_id, node_1_contribution); - // Node 0 calls funding_contributed. The adjustment fails (minimum RBF feerate > max_feerate), but - // funding_contributed still succeeds — the contribution keeps its original feerate. + // Node 0 calls funding_contributed. The adjustment fails (minimum RBF feerate > max_feerate), + // but funding_contributed still succeeds — the contribution keeps its original feerate. nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution, None).unwrap(); // STFU is NOT sent — the feerate is below the minimum RBF feerate so try_send_stfu delays. @@ -5859,3 +6020,151 @@ fn test_funding_contributed_rbf_adjustment_insufficient_budget() { let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); assert_eq!(splice_init.funding_feerate_per_kw, FEERATE_FLOOR_SATS_PER_KW); } + +#[test] +fn test_prior_contribution_unadjusted_when_max_feerate_too_low() { + // Test that rbf_sync re-runs coin selection when the prior contribution's max_feerate is + // too low to accommodate the minimum RBF feerate. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Complete a splice with max_feerate = floor_feerate. This means the prior contribution + // stored in pending_splice.contributions will have a tight max_feerate. + let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let funding_contribution = funding_template + .splice_in_sync(added_value, floor_feerate, floor_feerate, &wallet) + .unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, funding_contribution.clone(), None) + .unwrap(); + let (_splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Call splice_channel again — the minimum RBF feerate (25/24 of floor) exceeds the prior + // contribution's max_feerate (floor), so adjustment fails. rbf_sync re-runs coin selection + // with the caller's max_feerate. + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + assert!(funding_template.min_rbf_feerate().is_some()); + assert!(funding_template.prior_contribution().is_some()); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + assert!(funding_template.rbf_sync(FeeRate::MAX, &wallet).is_ok()); +} + +#[test] +fn test_splice_channel_during_negotiation_includes_rbf_feerate() { + // Test that splice_channel returns min_rbf_feerate derived from the in-progress + // negotiation's feerate when the acceptor calls it during active negotiation. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Node 1 initiates a splice. Perform stfu exchange and splice_init handling, which creates + // a pending_splice with funding_negotiation on node 0 (the acceptor). + let _funding_contribution = + do_initiate_splice_in(&nodes[1], &nodes[0], channel_id, added_value); + let stfu_init = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_init); + let stfu_ack = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_ack); + + let splice_init = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceInit, node_id_0); + nodes[0].node.handle_splice_init(node_id_1, &splice_init); + let _splice_ack = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceAck, node_id_1); + + // Node 0 (acceptor) calls splice_channel while the negotiation is in progress. + // min_rbf_feerate should be derived from the in-progress negotiation's feerate. + let template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + let expected_floor = + FeeRate::from_sat_per_kwu(((FEERATE_FLOOR_SATS_PER_KW as u64) * 25).div_ceil(24)); + assert_eq!(template.min_rbf_feerate(), Some(expected_floor)); + + // No prior contribution since there are no negotiated candidates yet. rbf_sync runs + // fee-bump-only coin selection. + assert!(template.prior_contribution().is_none()); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + assert!(template.rbf_sync(FeeRate::MAX, &wallet).is_ok()); +} + +#[test] +fn test_rbf_sync_returns_err_when_no_min_rbf_feerate() { + // Test that rbf_sync returns Err(()) when there is no pending splice (min_rbf_feerate is + // None), indicating this is not an RBF scenario. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Fresh splice — no pending splice, so min_rbf_feerate is None. + let template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + assert!(template.min_rbf_feerate().is_none()); + assert!(template.prior_contribution().is_none()); + + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + assert!(template.rbf_sync(FeeRate::MAX, &wallet).is_err()); +} + +#[test] +fn test_rbf_sync_returns_err_when_max_feerate_below_min_rbf() { + // Test that rbf_sync returns Err(()) when the caller's max_feerate is below the minimum + // RBF feerate. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Complete a splice to create a pending splice. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Call splice_channel again to get the RBF template. + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + let min_rbf_feerate = funding_template.min_rbf_feerate().unwrap(); + + // Use a max_feerate that is 1 sat/kwu below the minimum RBF feerate. + let too_low_feerate = + FeeRate::from_sat_per_kwu(min_rbf_feerate.to_sat_per_kwu().saturating_sub(1)); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + assert!(funding_template.rbf_sync(too_low_feerate, &wallet).is_err()); +} diff --git a/lightning/src/util/wallet_utils.rs b/lightning/src/util/wallet_utils.rs index b82437c03e8..61228402959 100644 --- a/lightning/src/util/wallet_utils.rs +++ b/lightning/src/util/wallet_utils.rs @@ -148,7 +148,7 @@ impl Utxo { /// /// Can be used as an input to contribute to a channel's funding transaction either when using the /// v2 channel establishment protocol or when splicing. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ConfirmedUtxo { /// The unspent [`TxOut`] found in [`prevtx`]. /// From a547960be5ad4c511b875ca12615c74543281dc5 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 23 Mar 2026 12:23:47 -0500 Subject: [PATCH 230/627] Preserve original contribution on counterparty RBF abort When the counterparty initiates an RBF, the prior contribution was popped and replaced with the feerate-adjusted version. If the RBF aborted, the adjusted version persisted, leaving a stale higher feerate in contributions. Change contributions to be an append-only log where each negotiation round pushes a new entry. On abort, pop the last entry if its feerate doesn't match the locked feerate. This naturally preserves the original contribution as an earlier entry in the vec. Co-Authored-By: Claude Opus 4.6 (1M context) --- lightning/src/ln/channel.rs | 39 +++++++++++++++++++++++------ lightning/src/ln/splicing_tests.rs | 40 +++++++++++++----------------- 2 files changed, 48 insertions(+), 31 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 57aa83a01ae..a0f16090e14 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2908,11 +2908,17 @@ struct PendingFunding { /// Used for validating the 25/24 feerate increase rule on RBF attempts. last_funding_feerate_sat_per_1000_weight: Option, - /// The funding contributions from all explicit splice/RBF attempts on this channel. - /// Each entry reflects the feerate-adjusted contribution that was actually used in that - /// negotiation. The last entry is re-used when the counterparty initiates an RBF and we - /// have no pending `QuiescentAction`. When re-used as acceptor, the last entry is replaced - /// with the version adjusted for the new feerate. + /// The funding contributions from splice/RBF rounds where we contributed. + /// + /// A new entry is appended when we contribute to a negotiation round (either as initiator + /// or acceptor). Rounds where we don't contribute (e.g., counterparty-only splice) do not + /// add an entry. Once non-empty, every subsequent round appends: when the counterparty + /// initiates an RBF, the last entry is adjusted to the new feerate and appended as a new + /// entry (or the RBF is rejected if the adjustment fails, in which case no round starts). + /// + /// If the round aborts, the last entry is popped in + /// [`FundedChannel::reset_pending_splice_state`], restoring the prior round's contribution + /// as the most recent entry. contributions: Vec, } @@ -6958,6 +6964,22 @@ where into_contributed_inputs_and_outputs ); + // Pop the current round's contribution if it wasn't from a negotiated round. Each round + // pushes a new entry to `contributions`; if the round aborts, we undo the push so that + // `contributions.last()` reflects the most recent negotiated round's contribution. This + // must happen after `maybe_create_splice_funding_failed` so that + // `prior_contributed_inputs` still includes the prior rounds' entries for filtering. + if let Some(pending_splice) = self.pending_splice.as_mut() { + if let Some(last) = pending_splice.contributions.last() { + let was_negotiated = pending_splice + .last_funding_feerate_sat_per_1000_weight + .is_some_and(|f| last.feerate() == FeeRate::from_sat_per_kwu(f as u64)); + if !was_negotiated { + pending_splice.contributions.pop(); + } + } + } + if self.pending_funding().is_empty() { self.pending_splice.take(); } @@ -12736,11 +12758,12 @@ where } else if prior_net_value.is_some() { let prior_contribution = self .pending_splice - .as_mut() + .as_ref() .expect("pending_splice is Some") .contributions - .pop() - .expect("prior_net_value was Some"); + .last() + .expect("prior_net_value was Some") + .clone(); let adjusted_contribution = prior_contribution .for_acceptor_at_feerate(feerate, holder_balance.unwrap()) .expect("feerate compatibility already checked"); diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 6971e91f717..c66d1a0a043 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -5239,9 +5239,9 @@ fn test_splice_rbf_acceptor_recontributes() { #[test] fn test_splice_rbf_after_counterparty_rbf_aborted() { - // When a counterparty-initiated RBF is aborted, the acceptor's prior contribution retains - // the adjusted feerate. Initiating our own RBF afterward must not panic even though the - // prior contribution's feerate may be >= the new rbf_feerate. + // When a counterparty-initiated RBF is aborted, the acceptor's prior contribution is + // restored to the original feerate (before adjustment). Initiating our own RBF afterward + // uses this restored contribution. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); @@ -5326,8 +5326,8 @@ fn test_splice_rbf_after_counterparty_rbf_aborted() { let tx_ack_rbf = complete_rbf_handshake(&nodes[0], &nodes[1]); assert!(tx_ack_rbf.funding_output_contribution.is_some()); - // Step 4: Abort the RBF. Node 0 sends tx_abort; node 1's prior contribution retains the - // adjusted feerate. + // Step 4: Abort the RBF. Node 0 sends tx_abort; node 1's prior contribution is restored + // to the original feerate (the RBF round's adjusted entry is popped from contributions). // Drain node 0's pending TxAddInput from the interactive tx negotiation start. nodes[0].node.get_and_clear_pending_msg_events(); @@ -5347,11 +5347,17 @@ fn test_splice_rbf_after_counterparty_rbf_aborted() { nodes[1].node.get_and_clear_pending_events(); // Step 5: Node 1 initiates its own RBF via splice_channel → rbf_sync. - // The prior contribution's feerate is now >= rbf_feerate. This must not panic. + // The prior contribution's feerate is restored to the original floor feerate, not the + // RBF-adjusted feerate. provide_utxo_reserves(&nodes, 2, added_value * 2); let funding_template = nodes[1].node.splice_channel(&channel_id, &node_id_0).unwrap(); assert!(funding_template.min_rbf_feerate().is_some()); + assert_eq!( + funding_template.prior_contribution().unwrap().feerate(), + feerate, + "Prior contribution should have the original feerate, not the RBF-adjusted one", + ); let wallet = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); let rbf_contribution = funding_template.rbf_sync(FeeRate::MAX, &wallet); @@ -5646,24 +5652,12 @@ fn test_splice_rbf_acceptor_contributes_then_disconnects() { other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), } - // The acceptor should also get SpliceFailed + DiscardFunding with its contributed - // inputs/outputs so it can reclaim its UTXOs. + // The acceptor re-contributed the same UTXOs as round 0 (via prior contribution + // adjustment). Since those UTXOs are still committed to round 0's splice, they are + // filtered from the DiscardFunding event. With all inputs/outputs filtered, no events + // are emitted for the acceptor. let events = nodes[1].node.get_and_clear_pending_events(); - assert_eq!(events.len(), 2, "{events:?}"); - match &events[0] { - Event::SpliceFailed { channel_id: cid, .. } => assert_eq!(*cid, channel_id), - other => panic!("Expected SpliceFailed, got {:?}", other), - } - match &events[1] { - Event::DiscardFunding { - funding_info: FundingInfo::Contribution { inputs, outputs }, - .. - } => { - assert!(!inputs.is_empty(), "Expected acceptor inputs, got empty"); - assert!(!outputs.is_empty(), "Expected acceptor outputs, got empty"); - }, - other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), - } + assert_eq!(events.len(), 0, "{events:?}"); // Reconnect. let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); From 9dc529a294347d2449406a2a614b33fe57bc52dd Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 23 Mar 2026 13:26:47 -0500 Subject: [PATCH 231/627] Add FundingContributionError for FundingTemplate methods Replace opaque Err(()) returns from FundingTemplate methods with a descriptive FundingContributionError enum. This gives callers diagnostic information about what went wrong: feerate bounds violations, invalid splice values, coin selection failures, or non-RBF scenarios. Co-Authored-By: Claude Opus 4.6 (1M context) --- fuzz/src/chanmon_consistency.rs | 4 +- lightning/src/ln/funding.rs | 190 +++++++++++++++++++++-------- lightning/src/ln/splicing_tests.rs | 12 +- 3 files changed, 148 insertions(+), 58 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index abbf4736b0a..1e8effedd5f 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -55,7 +55,7 @@ use lightning::ln::channelmanager::{ ChainParameters, ChannelManager, ChannelManagerReadArgs, PaymentId, RecentPaymentDetails, }; use lightning::ln::functional_test_utils::*; -use lightning::ln::funding::{FundingContribution, FundingTemplate}; +use lightning::ln::funding::{FundingContribution, FundingContributionError, FundingTemplate}; use lightning::ln::inbound_payment::ExpandedKey; use lightning::ln::msgs::{ self, BaseMessageHandler, ChannelMessageHandler, CommitmentUpdate, Init, MessageSendEvent, @@ -1392,7 +1392,7 @@ pub fn do_test( |node: &ChanMan, counterparty_node_id: &PublicKey, channel_id: &ChannelId, - f: &dyn Fn(FundingTemplate) -> Result| { + f: &dyn Fn(FundingTemplate) -> Result| { match node.splice_channel(channel_id, counterparty_node_id) { Ok(funding_template) => { if let Ok(contribution) = f(funding_template) { diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 52562fc2118..0b1e4271d84 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -106,6 +106,61 @@ impl core::fmt::Display for FeeRateAdjustmentError { } } +/// Error returned when building a [`FundingContribution`] from a [`FundingTemplate`]. +#[derive(Debug)] +pub enum FundingContributionError { + /// The feerate exceeds the maximum allowed feerate. + FeeRateExceedsMaximum { + /// The requested feerate. + feerate: FeeRate, + /// The maximum allowed feerate. + max_feerate: FeeRate, + }, + /// The feerate is below the minimum RBF feerate. + /// + /// Note: [`FundingTemplate::min_rbf_feerate`] may be derived from an in-progress + /// negotiation that later aborts, leaving a stale (higher than necessary) minimum. If + /// this error occurs after receiving [`Event::SpliceFailed`], call + /// [`ChannelManager::splice_channel`] again to get a fresh template. + /// + /// [`Event::SpliceFailed`]: crate::events::Event::SpliceFailed + /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel + FeeRateBelowRbfMinimum { + /// The requested feerate. + feerate: FeeRate, + /// The minimum RBF feerate. + min_rbf_feerate: FeeRate, + }, + /// The splice value is invalid (zero, empty outputs, or exceeds the maximum money supply). + InvalidSpliceValue, + /// Coin selection failed to find suitable inputs. + CoinSelectionFailed, + /// This is not an RBF scenario (no minimum RBF feerate available). + NotRbfScenario, +} + +impl core::fmt::Display for FundingContributionError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + FundingContributionError::FeeRateExceedsMaximum { feerate, max_feerate } => { + write!(f, "Feerate {} exceeds maximum {}", feerate, max_feerate) + }, + FundingContributionError::FeeRateBelowRbfMinimum { feerate, min_rbf_feerate } => { + write!(f, "Feerate {} is below minimum RBF feerate {}", feerate, min_rbf_feerate) + }, + FundingContributionError::InvalidSpliceValue => { + write!(f, "Invalid splice value (zero, empty, or exceeds limit)") + }, + FundingContributionError::CoinSelectionFailed => { + write!(f, "Coin selection failed to find suitable inputs") + }, + FundingContributionError::NotRbfScenario => { + write!(f, "Not an RBF scenario (no minimum RBF feerate)") + }, + } + } +} + /// The user's prior contribution from a previous splice negotiation on this channel. /// /// When a pending splice exists with negotiated candidates, the prior contribution is @@ -221,12 +276,12 @@ macro_rules! build_funding_contribution { let force_coin_selection: bool = $force_coin_selection; if feerate > max_feerate { - return Err(()); + return Err(FundingContributionError::FeeRateExceedsMaximum { feerate, max_feerate }); } if let Some(min_rbf_feerate) = min_rbf_feerate { if feerate < min_rbf_feerate { - return Err(()); + return Err(FundingContributionError::FeeRateBelowRbfMinimum { feerate, min_rbf_feerate }); } } @@ -235,14 +290,14 @@ macro_rules! build_funding_contribution { // amounts bounded by MAX_MONEY (~2.1e15 sat), the worst-case net_value() // computation is -2 * MAX_MONEY (~-4.2e15), well within i64::MIN (~-9.2e18). if value_added > Amount::MAX_MONEY { - return Err(()); + return Err(FundingContributionError::InvalidSpliceValue); } let mut value_removed = Amount::ZERO; for txout in outputs.iter() { value_removed = match value_removed.checked_add(txout.value) { Some(sum) if sum <= Amount::MAX_MONEY => sum, - _ => return Err(()), + _ => return Err(FundingContributionError::InvalidSpliceValue), }; } @@ -262,9 +317,9 @@ macro_rules! build_funding_contribution { .map(|shared_input| shared_input.previous_utxo.value) .unwrap_or(Amount::ZERO) .checked_add(value_added) - .ok_or(())? + .ok_or(FundingContributionError::InvalidSpliceValue)? .checked_sub(value_removed) - .ok_or(())?, + .ok_or(FundingContributionError::InvalidSpliceValue)?, script_pubkey: make_funding_redeemscript(&dummy_pubkey, &dummy_pubkey).to_p2wsh(), }; @@ -272,10 +327,10 @@ macro_rules! build_funding_contribution { let must_spend = shared_input.map(|input| vec![input]).unwrap_or_default(); if outputs.is_empty() { let must_pay_to = &[shared_output]; - $wallet.select_confirmed_utxos(claim_id, must_spend, must_pay_to, feerate.to_sat_per_kwu() as u32, u64::MAX)$(.$await)*? + $wallet.select_confirmed_utxos(claim_id, must_spend, must_pay_to, feerate.to_sat_per_kwu() as u32, u64::MAX)$(.$await)*.map_err(|_| FundingContributionError::CoinSelectionFailed)? } else { let must_pay_to: Vec<_> = outputs.iter().cloned().chain(core::iter::once(shared_output)).collect(); - $wallet.select_confirmed_utxos(claim_id, must_spend, &must_pay_to, feerate.to_sat_per_kwu() as u32, u64::MAX)$(.$await)*? + $wallet.select_confirmed_utxos(claim_id, must_spend, &must_pay_to, feerate.to_sat_per_kwu() as u32, u64::MAX)$(.$await)*.map_err(|_| FundingContributionError::CoinSelectionFailed)? } }; @@ -314,9 +369,9 @@ impl FundingTemplate { /// combine them: `prior.value_added() + additional_amount`. pub async fn splice_in( self, value_added: Amount, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, - ) -> Result { + ) -> Result { if value_added == Amount::ZERO { - return Err(()); + return Err(FundingContributionError::InvalidSpliceValue); } let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; build_funding_contribution!(value_added, vec![], shared_input, min_rbf_feerate, min_feerate, max_feerate, false, wallet, await) @@ -328,9 +383,9 @@ impl FundingTemplate { /// See [`FundingTemplate::splice_in`] for details. pub fn splice_in_sync( self, value_added: Amount, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, - ) -> Result { + ) -> Result { if value_added == Amount::ZERO { - return Err(()); + return Err(FundingContributionError::InvalidSpliceValue); } let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; build_funding_contribution!( @@ -354,9 +409,9 @@ impl FundingTemplate { /// prior's outputs: combine [`FundingContribution::outputs`] with the new outputs. pub async fn splice_out( self, outputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, - ) -> Result { + ) -> Result { if outputs.is_empty() { - return Err(()); + return Err(FundingContributionError::InvalidSpliceValue); } let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; build_funding_contribution!(Amount::ZERO, outputs, shared_input, min_rbf_feerate, min_feerate, max_feerate, false, wallet, await) @@ -368,9 +423,9 @@ impl FundingTemplate { /// See [`FundingTemplate::splice_out`] for details. pub fn splice_out_sync( self, outputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, - ) -> Result { + ) -> Result { if outputs.is_empty() { - return Err(()); + return Err(FundingContributionError::InvalidSpliceValue); } let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; build_funding_contribution!( @@ -395,9 +450,9 @@ impl FundingTemplate { pub async fn splice_in_and_out( self, value_added: Amount, outputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, - ) -> Result { + ) -> Result { if value_added == Amount::ZERO && outputs.is_empty() { - return Err(()); + return Err(FundingContributionError::InvalidSpliceValue); } let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; build_funding_contribution!(value_added, outputs, shared_input, min_rbf_feerate, min_feerate, max_feerate, false, wallet, await) @@ -410,9 +465,9 @@ impl FundingTemplate { pub fn splice_in_and_out_sync( self, value_added: Amount, outputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, - ) -> Result { + ) -> Result { if value_added == Amount::ZERO && outputs.is_empty() { - return Err(()); + return Err(FundingContributionError::InvalidSpliceValue); } let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; build_funding_contribution!( @@ -448,15 +503,20 @@ impl FundingTemplate { /// a newly selected input. Check [`FundingTemplate::prior_contribution`] to see if this /// is intended. /// - /// Returns `Err(())` if this is not an RBF scenario ([`FundingTemplate::min_rbf_feerate`] - /// is `None`) or if `max_feerate` is below the minimum RBF feerate. + /// # Errors + /// + /// Returns a [`FundingContributionError`] if this is not an RBF scenario, if `max_feerate` + /// is below the minimum RBF feerate, or if coin selection fails. pub async fn rbf( self, max_feerate: FeeRate, wallet: W, - ) -> Result { + ) -> Result { let FundingTemplate { shared_input, min_rbf_feerate, prior_contribution } = self; - let rbf_feerate = min_rbf_feerate.ok_or(())?; + let rbf_feerate = min_rbf_feerate.ok_or(FundingContributionError::NotRbfScenario)?; if rbf_feerate > max_feerate { - return Err(()); + return Err(FundingContributionError::FeeRateExceedsMaximum { + feerate: rbf_feerate, + max_feerate, + }); } match prior_contribution { @@ -491,11 +551,14 @@ impl FundingTemplate { /// See [`FundingTemplate::rbf`] for details. pub fn rbf_sync( self, max_feerate: FeeRate, wallet: W, - ) -> Result { + ) -> Result { let FundingTemplate { shared_input, min_rbf_feerate, prior_contribution } = self; - let rbf_feerate = min_rbf_feerate.ok_or(())?; + let rbf_feerate = min_rbf_feerate.ok_or(FundingContributionError::NotRbfScenario)?; if rbf_feerate > max_feerate { - return Err(()); + return Err(FundingContributionError::FeeRateExceedsMaximum { + feerate: rbf_feerate, + max_feerate, + }); } match prior_contribution { @@ -1050,8 +1113,8 @@ pub type FundingTxInput = crate::util::wallet_utils::ConfirmedUtxo; #[cfg(test)] mod tests { use super::{ - estimate_transaction_fee, FeeRateAdjustmentError, FundingContribution, FundingTemplate, - FundingTxInput, PriorContribution, + estimate_transaction_fee, FeeRateAdjustmentError, FundingContribution, + FundingContributionError, FundingTemplate, FundingTxInput, PriorContribution, }; use crate::chain::ClaimId; use crate::util::wallet_utils::{CoinSelection, CoinSelectionSourceSync, Input}; @@ -1361,18 +1424,20 @@ mod tests { // splice_in_sync with value_added > MAX_MONEY { let template = FundingTemplate::new(None, None, None); - assert!(template - .splice_in_sync(over_max, feerate, feerate, UnreachableWallet) - .is_err()); + assert!(matches!( + template.splice_in_sync(over_max, feerate, feerate, UnreachableWallet), + Err(FundingContributionError::InvalidSpliceValue), + )); } // splice_out_sync with single output value > MAX_MONEY { let template = FundingTemplate::new(None, None, None); let outputs = vec![funding_output_sats(over_max.to_sat())]; - assert!(template - .splice_out_sync(outputs, feerate, feerate, UnreachableWallet) - .is_err()); + assert!(matches!( + template.splice_out_sync(outputs, feerate, feerate, UnreachableWallet), + Err(FundingContributionError::InvalidSpliceValue), + )); } // splice_out_sync with multiple outputs summing > MAX_MONEY @@ -1383,33 +1448,42 @@ mod tests { funding_output_sats(half_over.to_sat()), funding_output_sats(half_over.to_sat()), ]; - assert!(template - .splice_out_sync(outputs, feerate, feerate, UnreachableWallet) - .is_err()); + assert!(matches!( + template.splice_out_sync(outputs, feerate, feerate, UnreachableWallet), + Err(FundingContributionError::InvalidSpliceValue), + )); } // splice_in_and_out_sync with value_added > MAX_MONEY { let template = FundingTemplate::new(None, None, None); let outputs = vec![funding_output_sats(1_000)]; - assert!(template - .splice_in_and_out_sync(over_max, outputs, feerate, feerate, UnreachableWallet) - .is_err()); + assert!(matches!( + template.splice_in_and_out_sync( + over_max, + outputs, + feerate, + feerate, + UnreachableWallet + ), + Err(FundingContributionError::InvalidSpliceValue), + )); } // splice_in_and_out_sync with output sum > MAX_MONEY { let template = FundingTemplate::new(None, None, None); let outputs = vec![funding_output_sats(over_max.to_sat())]; - assert!(template - .splice_in_and_out_sync( + assert!(matches!( + template.splice_in_and_out_sync( Amount::from_sat(1_000), outputs, feerate, feerate, UnreachableWallet, - ) - .is_err()); + ), + Err(FundingContributionError::InvalidSpliceValue), + )); } } @@ -1421,17 +1495,24 @@ mod tests { // min_feerate > max_feerate is rejected { let template = FundingTemplate::new(None, None, None); - assert!(template - .splice_in_sync(Amount::from_sat(10_000), high, low, UnreachableWallet) - .is_err()); + assert!(matches!( + template.splice_in_sync(Amount::from_sat(10_000), high, low, UnreachableWallet), + Err(FundingContributionError::FeeRateExceedsMaximum { .. }), + )); } // min_feerate < min_rbf_feerate is rejected { let template = FundingTemplate::new(None, Some(high), None); - assert!(template - .splice_in_sync(Amount::from_sat(10_000), low, FeeRate::MAX, UnreachableWallet) - .is_err()); + assert!(matches!( + template.splice_in_sync( + Amount::from_sat(10_000), + low, + FeeRate::MAX, + UnreachableWallet + ), + Err(FundingContributionError::FeeRateBelowRbfMinimum { .. }), + )); } } @@ -2191,7 +2272,10 @@ mod tests { Some(min_rbf_feerate), Some(PriorContribution::new(prior, None)), ); - assert!(template.rbf_sync(max_feerate, UnreachableWallet).is_err()); + assert!(matches!( + template.rbf_sync(max_feerate, UnreachableWallet), + Err(FundingContributionError::FeeRateExceedsMaximum { .. }), + )); } #[test] diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index c66d1a0a043..fa95e4a0c07 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -6127,12 +6127,15 @@ fn test_rbf_sync_returns_err_when_no_min_rbf_feerate() { assert!(template.prior_contribution().is_none()); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - assert!(template.rbf_sync(FeeRate::MAX, &wallet).is_err()); + assert!(matches!( + template.rbf_sync(FeeRate::MAX, &wallet), + Err(crate::ln::funding::FundingContributionError::NotRbfScenario), + )); } #[test] fn test_rbf_sync_returns_err_when_max_feerate_below_min_rbf() { - // Test that rbf_sync returns Err(()) when the caller's max_feerate is below the minimum + // Test that rbf_sync returns Err when the caller's max_feerate is below the minimum // RBF feerate. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); @@ -6160,5 +6163,8 @@ fn test_rbf_sync_returns_err_when_max_feerate_below_min_rbf() { let too_low_feerate = FeeRate::from_sat_per_kwu(min_rbf_feerate.to_sat_per_kwu().saturating_sub(1)); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - assert!(funding_template.rbf_sync(too_low_feerate, &wallet).is_err()); + assert!(matches!( + funding_template.rbf_sync(too_low_feerate, &wallet), + Err(crate::ln::funding::FundingContributionError::FeeRateExceedsMaximum { .. }), + )); } From a3dded178bbd00b58322153d3067dc98fbe9fe8d Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 23 Mar 2026 03:38:05 +0000 Subject: [PATCH 232/627] Set the correct floor for the reserves in inbound V2 channels The floor for *our* selected reserve is *their* dust limit. --- lightning/src/ln/channel.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 8b05d984e30..c2b7e0662c4 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -14623,9 +14623,9 @@ impl PendingV2Channel { let channel_value_satoshis = our_funding_contribution_sats.saturating_add(msg.common_fields.funding_satoshis); 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); let channel_type = channel_type_from_open_channel(&msg.common_fields, our_supported_features)?; From 98b71c8804b6a627b3b7e5836a7100103505be26 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 23 Mar 2026 03:39:31 +0000 Subject: [PATCH 233/627] Add inbound and outbound checks for zero reserve channels The goal is to prevent any commitments with no outputs, since these are not broadcastable. --- lightning/src/ln/channel.rs | 93 ++++++---- lightning/src/ln/channel_open_tests.rs | 2 +- lightning/src/ln/functional_tests.rs | 2 +- lightning/src/ln/htlc_reserve_unit_tests.rs | 12 +- lightning/src/ln/payment_tests.rs | 2 +- lightning/src/ln/update_fee_tests.rs | 5 +- lightning/src/sign/tx_builder.rs | 184 ++++++++++++++++++-- 7 files changed, 247 insertions(+), 53 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index c2b7e0662c4..c8c93eece74 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2781,11 +2781,18 @@ impl FundingScope { .funding_pubkey = counterparty_funding_pubkey; // New reserve values are based on the new channel value and are v2-specific - let counterparty_selected_channel_reserve_satoshis = - get_v2_channel_reserve_satoshis(post_channel_value, MIN_CHAN_DUST_LIMIT_SATOSHIS); + let counterparty_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis( + post_channel_value, + MIN_CHAN_DUST_LIMIT_SATOSHIS, + prev_funding + .counterparty_selected_channel_reserve_satoshis + .expect("counterparty reserve is set") + == 0, + ); let holder_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis( post_channel_value, context.counterparty_dust_limit_satoshis, + prev_funding.holder_selected_channel_reserve_satoshis == 0, ); Self { @@ -5155,27 +5162,27 @@ impl ChannelContext { )); } - if funding.is_outbound() { - let (local_stats, _local_htlcs) = self - .get_next_local_commitment_stats( - funding, - Some(HTLCAmountDirection { outbound: false, amount_msat: msg.amount_msat }), - include_counterparty_unknown_htlcs, - fee_spike_buffer_htlc, - self.feerate_per_kw, - dust_exposure_limiting_feerate, - ) - .map_err(|()| { - ChannelError::close(String::from("Balance exhausted on local commitment")) - })?; - // Check that they won't violate our local required channel reserve by adding this HTLC. - if local_stats.commitment_stats.holder_balance_msat + let (local_stats, _local_htlcs) = self + .get_next_local_commitment_stats( + funding, + Some(HTLCAmountDirection { outbound: false, amount_msat: msg.amount_msat }), + include_counterparty_unknown_htlcs, + fee_spike_buffer_htlc, + self.feerate_per_kw, + dust_exposure_limiting_feerate, + ) + .map_err(|()| { + ChannelError::close(String::from("Balance exhausted on local commitment")) + })?; + + // Check that they won't violate our local required channel reserve by adding this HTLC. + if funding.is_outbound() + && local_stats.commitment_stats.holder_balance_msat < funding.counterparty_selected_channel_reserve_satoshis.unwrap() * 1000 - { - return Err(ChannelError::close( - "Cannot accept HTLC that would put our balance under counterparty-announced channel reserve value".to_owned() - )); - } + { + return Err(ChannelError::close( + "Cannot accept HTLC that would put our balance under counterparty-announced channel reserve value".to_owned() + )); } Ok(()) @@ -5269,6 +5276,12 @@ impl ChannelContext { let commitment_txid = { let trusted_tx = commitment_data.tx.trust(); let bitcoin_tx = trusted_tx.built_transaction(); + if bitcoin_tx.transaction.output.is_empty() { + return Err(ChannelError::close( + "Commitment tx from peer has 0 outputs".to_owned(), + )); + } + let sighash = bitcoin_tx.get_sighash_all(&funding_script, funding.get_value_satoshis()); log_trace!(logger, "Checking commitment tx signature {} by key {} against tx {} (sighash {}) with redeemscript {} in channel {}", @@ -6395,7 +6408,11 @@ fn get_holder_max_htlc_value_in_flight_msat( /// the counterparty. pub(crate) fn get_holder_selected_channel_reserve_satoshis( channel_value_satoshis: u64, their_dust_limit_satoshis: u64, config: &UserConfig, + is_0reserve: bool, ) -> u64 { + if is_0reserve { + return 0; + } let counterparty_chan_reserve_prop_mil = config.channel_handshake_config.their_channel_reserve_proportional_millionths as u64; let calculated_reserve = @@ -6423,7 +6440,12 @@ pub(crate) fn get_legacy_default_holder_selected_channel_reserve_satoshis( /// /// This is used both for outbound and inbound channels and has lower bound /// of `dust_limit_satoshis`. -fn get_v2_channel_reserve_satoshis(channel_value_satoshis: u64, dust_limit_satoshis: u64) -> u64 { +fn get_v2_channel_reserve_satoshis( + channel_value_satoshis: u64, dust_limit_satoshis: u64, is_0reserve: bool, +) -> u64 { + if is_0reserve { + return 0; + } // Fixed at 1% of channel value by spec. let (q, _) = channel_value_satoshis.overflowing_div(100); cmp::min(channel_value_satoshis, cmp::max(q, dust_limit_satoshis)) @@ -12363,12 +12385,19 @@ where our_funding_contribution.to_sat(), their_funding_contribution.to_sat(), ); - let counterparty_selected_channel_reserve = Amount::from_sat( - get_v2_channel_reserve_satoshis(post_channel_value, MIN_CHAN_DUST_LIMIT_SATOSHIS), - ); + let counterparty_selected_channel_reserve = + Amount::from_sat(get_v2_channel_reserve_satoshis( + post_channel_value, + MIN_CHAN_DUST_LIMIT_SATOSHIS, + self.funding + .counterparty_selected_channel_reserve_satoshis + .expect("counterparty reserve is set") + == 0, + )); let holder_selected_channel_reserve = Amount::from_sat(get_v2_channel_reserve_satoshis( post_channel_value, self.context.counterparty_dust_limit_satoshis, + self.funding.holder_selected_channel_reserve_satoshis == 0, )); // We allow parties to draw from their previous reserve, as long as they satisfy their v2 reserve @@ -13846,7 +13875,8 @@ impl OutboundV1Channel { let holder_selected_channel_reserve_satoshis = get_holder_selected_channel_reserve_satoshis( channel_value_satoshis, their_dust_limit_satoshis, - config + config, + false, ); if holder_selected_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS { // Protocol level safety check in place, although it should never happen because @@ -14231,7 +14261,8 @@ impl InboundV1Channel { let holder_selected_channel_reserve_satoshis = get_holder_selected_channel_reserve_satoshis( msg.common_fields.funding_satoshis, msg.common_fields.dust_limit_satoshis, - config + config, + false, ); let counterparty_pubkeys = ChannelPublicKeys { funding_pubkey: msg.common_fields.funding_pubkey, @@ -14484,7 +14515,7 @@ impl PendingV2Channel { }); let holder_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis( - funding_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS); + funding_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS, false); let funding_feerate_sat_per_1000_weight = fee_estimator.bounded_sat_per_1000_weight(funding_confirmation_target); let funding_tx_locktime = LockTime::from_height(current_chain_height) @@ -14623,9 +14654,9 @@ impl PendingV2Channel { let channel_value_satoshis = our_funding_contribution_sats.saturating_add(msg.common_fields.funding_satoshis); let counterparty_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis( - channel_value_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS); + channel_value_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS, false); let holder_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis( - channel_value_satoshis, msg.common_fields.dust_limit_satoshis); + channel_value_satoshis, msg.common_fields.dust_limit_satoshis, false); let channel_type = channel_type_from_open_channel(&msg.common_fields, our_supported_features)?; diff --git a/lightning/src/ln/channel_open_tests.rs b/lightning/src/ln/channel_open_tests.rs index e13343ade76..1de51bff5f7 100644 --- a/lightning/src/ln/channel_open_tests.rs +++ b/lightning/src/ln/channel_open_tests.rs @@ -470,7 +470,7 @@ pub fn test_insane_channel_opens() { // funding satoshis let channel_value_sat = 31337; // same as funding satoshis let channel_reserve_satoshis = - get_holder_selected_channel_reserve_satoshis(channel_value_sat, 0, &legacy_cfg); + get_holder_selected_channel_reserve_satoshis(channel_value_sat, 0, &legacy_cfg, false); let push_msat = (channel_value_sat - channel_reserve_satoshis) * 1000; // Have node0 initiate a channel to node1 with aforementioned parameters diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index a3252475965..12b6aab14f0 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -414,7 +414,7 @@ pub fn test_inbound_outbound_capacity_is_not_zero() { assert_eq!(channels0.len(), 1); assert_eq!(channels1.len(), 1); - let reserve = get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config); + let reserve = get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config, false); assert_eq!(channels0[0].inbound_capacity_msat, 95000000 - reserve * 1000); assert_eq!(channels1[0].outbound_capacity_msat, 95000000 - reserve * 1000); diff --git a/lightning/src/ln/htlc_reserve_unit_tests.rs b/lightning/src/ln/htlc_reserve_unit_tests.rs index 3069783dffa..862d94740e1 100644 --- a/lightning/src/ln/htlc_reserve_unit_tests.rs +++ b/lightning/src/ln/htlc_reserve_unit_tests.rs @@ -51,7 +51,8 @@ fn do_test_counterparty_no_reserve(send_from_initiator: bool) { push_amt -= feerate_per_kw as u64 * (commitment_tx_base_weight(&channel_type_features) + 4 * COMMITMENT_TX_WEIGHT_PER_HTLC) / 1000 * 1000; - push_amt -= get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config) * 1000; + push_amt -= + get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config, false) * 1000; let push = if send_from_initiator { 0 } else { push_amt }; let temp_channel_id = @@ -997,7 +998,8 @@ pub fn test_chan_reserve_violation_outbound_htlc_inbound_chan() { &channel_type_features, ); - push_amt -= get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config) * 1000; + push_amt -= + get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config, false) * 1000; let _ = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, push_amt); @@ -1041,7 +1043,8 @@ pub fn test_chan_reserve_violation_inbound_htlc_outbound_channel() { MIN_AFFORDABLE_HTLC_COUNT as u64, &channel_type_features, ); - push_amt -= get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config) * 1000; + push_amt -= + get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config, false) * 1000; let chan = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, push_amt); // Send four HTLCs to cover the initial push_msat buffer we're required to include @@ -1119,7 +1122,8 @@ pub fn test_chan_reserve_dust_inbound_htlcs_outbound_chan() { MIN_AFFORDABLE_HTLC_COUNT as u64, &channel_type_features, ); - push_amt -= get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config) * 1000; + push_amt -= + get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config, false) * 1000; create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100000, push_amt); let (htlc_success_tx_fee_sat, _) = diff --git a/lightning/src/ln/payment_tests.rs b/lightning/src/ln/payment_tests.rs index 7d198d2d70d..be52459a872 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -4985,7 +4985,7 @@ fn test_htlc_forward_considers_anchor_outputs_value() { create_announced_chan_between_nodes_with_value(&nodes, 1, 2, CHAN_AMT, PUSH_MSAT); let channel_reserve_msat = - get_holder_selected_channel_reserve_satoshis(CHAN_AMT, 0, &config) * 1000; + get_holder_selected_channel_reserve_satoshis(CHAN_AMT, 0, &config, false) * 1000; let commitment_fee_msat = chan_utils::commit_tx_fee_sat( *nodes[1].fee_estimator.sat_per_kw.lock().unwrap(), 2, diff --git a/lightning/src/ln/update_fee_tests.rs b/lightning/src/ln/update_fee_tests.rs index 9c309b59519..fc80059bbd3 100644 --- a/lightning/src/ln/update_fee_tests.rs +++ b/lightning/src/ln/update_fee_tests.rs @@ -410,7 +410,7 @@ pub fn do_test_update_fee_that_funder_cannot_afford(channel_type_features: Chann let channel_id = chan.2; let secp_ctx = Secp256k1::new(); let bs_channel_reserve_sats = - get_holder_selected_channel_reserve_satoshis(channel_value, 0, &cfg); + get_holder_selected_channel_reserve_satoshis(channel_value, 0, &cfg, false); let (anchor_outputs_value_sats, outputs_num_no_htlcs) = if channel_type_features.supports_anchors_zero_fee_htlc_tx() { (ANCHOR_OUTPUT_VALUE_SATOSHI * 2, 4) @@ -886,7 +886,8 @@ pub fn test_chan_init_feerate_unaffordability() { // During open, we don't have a "counterparty channel reserve" to check against, so that // requirement only comes into play on the open_channel handling side. - push_amt -= get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config) * 1000; + push_amt -= + get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config, false) * 1000; nodes[0].node.create_channel(node_b_id, 100_000, push_amt, 42, None, None).unwrap(); let mut open_channel_msg = get_event_msg!(nodes[0], MessageSendEvent::SendOpenChannel, node_b_id); diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index 4273b62c7b7..ca61b27b78d 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -206,6 +206,35 @@ fn get_dust_exposure_stats( } } +fn has_output( + is_outbound_from_holder: bool, holder_balance_before_fee_msat: u64, + counterparty_balance_before_fee_msat: u64, feerate_per_kw: u32, nondust_htlc_count: usize, + broadcaster_dust_limit_satoshis: u64, channel_type: &ChannelTypeFeatures, +) -> bool { + let commit_tx_fee_sat = commit_tx_fee_sat(feerate_per_kw, nondust_htlc_count, channel_type); + + let (real_holder_balance_msat, real_counterparty_balance_msat) = if is_outbound_from_holder { + ( + holder_balance_before_fee_msat.saturating_sub(commit_tx_fee_sat * 1000), + counterparty_balance_before_fee_msat, + ) + } else { + ( + holder_balance_before_fee_msat, + counterparty_balance_before_fee_msat.saturating_sub(commit_tx_fee_sat * 1000), + ) + }; + + // Make sure the commitment transaction has at least one output + let dust_limit_msat = broadcaster_dust_limit_satoshis * 1000; + let has_no_output = real_holder_balance_msat < dust_limit_msat + && real_counterparty_balance_msat < dust_limit_msat + && nondust_htlc_count == 0 + // 0FC channels always have a P2A output on the commitment transaction + && !channel_type.supports_anchor_zero_fee_commitments(); + !has_no_output +} + fn get_next_commitment_stats( local: bool, is_outbound_from_holder: bool, channel_value_satoshis: u64, value_to_holder_msat: u64, next_commitment_htlcs: &[HTLCAmountDirection], @@ -250,6 +279,15 @@ fn get_next_commitment_stats( channel_type, )?; + let (dust_exposure_msat, _extra_accepted_htlc_dust_exposure_msat) = get_dust_exposure_stats( + local, + next_commitment_htlcs, + feerate_per_kw, + dust_exposure_limiting_feerate, + broadcaster_dust_limit_satoshis, + channel_type, + ); + // Calculate fees on commitment transaction let nondust_htlc_count = next_commitment_htlcs .iter() @@ -257,18 +295,27 @@ fn get_next_commitment_stats( !htlc.is_dust(local, feerate_per_kw, broadcaster_dust_limit_satoshis, channel_type) }) .count(); - let commit_tx_fee_sat = commit_tx_fee_sat( + + // For zero-reserve channels, we check two things independently: + // 1) Given the current set of HTLCs and feerate, does the commitment have at least one output ? + if !has_output( + is_outbound_from_holder, + holder_balance_before_fee_msat, + counterparty_balance_before_fee_msat, feerate_per_kw, - nondust_htlc_count + addl_nondust_htlc_count, + nondust_htlc_count, + broadcaster_dust_limit_satoshis, channel_type, - ); + ) { + return Err(()); + } - let (dust_exposure_msat, _extra_accepted_htlc_dust_exposure_msat) = get_dust_exposure_stats( - local, - next_commitment_htlcs, + // 2) Now including any additional non-dust HTLCs (usually the fee spike buffer HTLC), does the funder cover + // this bigger transaction fee ? The funder can dip below their dust limit to cover this case, as the + // commitment will have at least one output: the non-dust fee spike buffer HTLC offered by the counterparty. + let commit_tx_fee_sat = commit_tx_fee_sat( feerate_per_kw, - dust_exposure_limiting_feerate, - broadcaster_dust_limit_satoshis, + nondust_htlc_count + addl_nondust_htlc_count, channel_type, ); @@ -316,7 +363,7 @@ fn get_available_balances( if channel_type.supports_anchor_zero_fee_commitments() { 0 } else { 1 }; // Note that the feerate is 0 in zero-fee commitment channels, so this statement is a noop - let local_feerate = feerate_per_kw + let spiked_feerate = feerate_per_kw * if is_outbound_from_holder && !channel_type.supports_anchors_zero_fee_htlc_tx() { crate::ln::channel::FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 } else { @@ -328,19 +375,19 @@ fn get_available_balances( .filter(|htlc| { !htlc.is_dust( true, - local_feerate, + spiked_feerate, channel_constraints.holder_dust_limit_satoshis, channel_type, ) }) .count(); let local_max_commit_tx_fee_sat = commit_tx_fee_sat( - local_feerate, + spiked_feerate, local_nondust_htlc_count + fee_spike_buffer_htlc + 1, channel_type, ); let local_min_commit_tx_fee_sat = commit_tx_fee_sat( - local_feerate, + spiked_feerate, local_nondust_htlc_count + fee_spike_buffer_htlc, channel_type, ); @@ -512,7 +559,49 @@ fn get_available_balances( available_capacity_msat = 0; } - #[allow(deprecated)] // TODO: Remove once balance_msat is removed + // Now adjust our min and max size HTLC to make sure both the local and the remote commitments still have + // at least one output at the spiked feerate. + + let remote_nondust_htlc_count = pending_htlcs + .iter() + .filter(|htlc| { + !htlc.is_dust( + false, + spiked_feerate, + channel_constraints.counterparty_dust_limit_satoshis, + channel_type, + ) + }) + .count(); + + let (next_outbound_htlc_minimum_msat, available_capacity_msat) = + adjust_boundaries_if_max_dust_htlc_produces_no_output( + true, + is_outbound_from_holder, + local_balance_before_fee_msat, + remote_balance_before_fee_msat, + local_nondust_htlc_count, + spiked_feerate, + channel_constraints.holder_dust_limit_satoshis, + channel_type, + next_outbound_htlc_minimum_msat, + available_capacity_msat, + ); + + let (next_outbound_htlc_minimum_msat, available_capacity_msat) = + adjust_boundaries_if_max_dust_htlc_produces_no_output( + false, + is_outbound_from_holder, + local_balance_before_fee_msat, + remote_balance_before_fee_msat, + remote_nondust_htlc_count, + spiked_feerate, + channel_constraints.counterparty_dust_limit_satoshis, + channel_type, + next_outbound_htlc_minimum_msat, + available_capacity_msat, + ); + crate::ln::channel::AvailableBalances { inbound_capacity_msat: remote_balance_before_fee_msat .saturating_sub(channel_constraints.holder_selected_channel_reserve_satoshis * 1000), @@ -522,6 +611,75 @@ fn get_available_balances( } } +fn adjust_boundaries_if_max_dust_htlc_produces_no_output( + local: bool, is_outbound_from_holder: bool, holder_balance_before_fee_msat: u64, + counterparty_balance_before_fee_msat: u64, nondust_htlc_count: usize, spiked_feerate: u32, + dust_limit_satoshis: u64, channel_type: &ChannelTypeFeatures, + next_outbound_htlc_minimum_msat: u64, available_capacity_msat: u64, +) -> (u64, u64) { + // First, determine the biggest dust HTLC we could send + let (htlc_success_tx_fee_sat, htlc_timeout_tx_fee_sat) = + second_stage_tx_fees_sat(channel_type, spiked_feerate); + let min_nondust_htlc_sat = + dust_limit_satoshis + if local { htlc_timeout_tx_fee_sat } else { htlc_success_tx_fee_sat }; + let max_dust_htlc_msat = (min_nondust_htlc_sat.saturating_mul(1000)).saturating_sub(1); + + // 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( + is_outbound_from_holder, + holder_balance_before_fee_msat.saturating_sub(max_dust_htlc_msat), + counterparty_balance_before_fee_msat, + spiked_feerate, + nondust_htlc_count, + dust_limit_satoshis, + channel_type, + ) { + // If we are allowed to send non-dust HTLCs, set the min HTLC to the smallest non-dust HTLC... + if available_capacity_msat >= min_nondust_htlc_sat.saturating_mul(1000) { + ( + cmp::max( + min_nondust_htlc_sat.saturating_mul(1000), + next_outbound_htlc_minimum_msat, + ), + available_capacity_msat, + ) + // Otherwise, set the max HTLC to the biggest that still leaves our main balance output untrimmed. + // Note that this will be a dust HTLC. + } else { + // 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); + // In case we are the funder, we must cover the greater of + // 1) The dust_limit_satoshis plus the fee of the existing 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 = if is_outbound_from_holder { + cmp::max(dust_limit_satoshis + current_spiked_tx_fee_sat, spike_buffer_tx_fee_sat) + * 1000 + // In case we are the fundee, we can send dust HTLCs as long as our own balance output + // remains above the dust limit. + } else { + dust_limit_satoshis * 1000 + }; + ( + next_outbound_htlc_minimum_msat, + // We make no assumptions about the size of `available_capacity_msat` passed to this + // function, we only care that the new `available_capacity_msat` is under + // `holder_balance_before_fee_msat - min_balance_msat` + cmp::min( + holder_balance_before_fee_msat.saturating_sub(min_balance_msat), + available_capacity_msat, + ), + ) + } + // Otherwise, it is impossible to produce no outputs with this upcoming HTLC add, so we stay quiet + } else { + (next_outbound_htlc_minimum_msat, available_capacity_msat) + } +} + pub(crate) trait TxBuilder { fn get_channel_stats( &self, local: bool, is_outbound_from_holder: bool, channel_value_satoshis: u64, From 4e805629837764c2f020e30d3d430bf5b31f96c1 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 23 Mar 2026 18:10:25 -0500 Subject: [PATCH 234/627] Re-validate contribution at quiescence time Outbound HTLCs can be sent between funding_contributed and quiescence, reducing the holder's balance. Re-validate the contribution when quiescence is achieved and balances are stable. On failure, emit SpliceFailed + DiscardFunding events and disconnect the peer so both sides cleanly exit quiescence. Co-Authored-By: Claude Opus 4.6 (1M context) --- fuzz/src/chanmon_consistency.rs | 4 + lightning/src/ln/channel.rs | 46 +++++++--- lightning/src/ln/channelmanager.rs | 134 +++++++++++++++++----------- lightning/src/ln/funding.rs | 10 +++ lightning/src/ln/splicing_tests.rs | 136 +++++++++++++++++++++++++++++ 5 files changed, 265 insertions(+), 65 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 1e8effedd5f..9fafa4fd483 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1949,6 +1949,10 @@ pub fn do_test( chain_state.confirm_tx(splice_tx); }, events::Event::SpliceFailed { .. } => {}, + events::Event::DiscardFunding { + funding_info: events::FundingInfo::Contribution { .. }, + .. + } => {}, _ => { if out.may_fail.load(atomic::Ordering::Acquire) { diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index a0f16090e14..0882880d1a3 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -13690,27 +13690,27 @@ where #[rustfmt::skip] pub fn stfu( &mut self, msg: &msgs::Stfu, logger: &L - ) -> Result, ChannelError> { + ) -> Result, (ChannelError, QuiescentError)> { if self.context.channel_state.is_quiescent() { - return Err(ChannelError::Warn("Channel is already quiescent".to_owned())); + return Err((ChannelError::Warn("Channel is already quiescent".to_owned()), QuiescentError::DoNothing)); } if self.context.channel_state.is_remote_stfu_sent() { - return Err(ChannelError::Warn( + return Err((ChannelError::Warn( "Peer sent `stfu` when they already sent it and we've yet to become quiescent".to_owned() - )); + ), QuiescentError::DoNothing)); } if !self.context.is_live() { - return Err(ChannelError::Warn( + return Err((ChannelError::Warn( "Peer sent `stfu` when we were not in a live state".to_owned() - )); + ), QuiescentError::DoNothing)); } if !self.context.channel_state.is_local_stfu_sent() { if !msg.initiator { - return Err(ChannelError::WarnAndDisconnect( + return Err((ChannelError::WarnAndDisconnect( "Peer sent unexpected `stfu` without signaling as initiator".to_owned() - )); + ), QuiescentError::DoNothing)); } // We don't check `is_waiting_on_peer_pending_channel_update` prior to setting the flag @@ -13740,9 +13740,9 @@ where // have a monitor update pending if we've processed a message from the counterparty, but // we don't consider this when becoming quiescent since the states are not mutually // exclusive. - return Err(ChannelError::WarnAndDisconnect( + return Err((ChannelError::WarnAndDisconnect( "Received counterparty stfu while having pending counterparty updates".to_owned() - )); + ), QuiescentError::DoNothing)); } self.context.channel_state.clear_local_stfu_sent(); @@ -13758,11 +13758,33 @@ where match self.quiescent_action.take() { None => { debug_assert!(false); - return Err(ChannelError::WarnAndDisconnect( + return Err((ChannelError::WarnAndDisconnect( "Internal Error: Didn't have anything to do after reaching quiescence".to_owned() - )); + ), QuiescentError::DoNothing)); }, Some(QuiescentAction::Splice { contribution, locktime }) => { + // Re-validate the contribution now that we're quiescent and + // balances are stable. Outbound HTLCs may have been sent between + // funding_contributed and quiescence, reducing the holder's + // balance. If invalid, disconnect and return the contribution so + // the user can reclaim their inputs. + if let Err(e) = contribution.validate().and_then(|()| { + let our_funding_contribution = contribution.net_value(); + self.validate_splice_contributions( + our_funding_contribution, + SignedAmount::ZERO, + ) + }) { + let failed = self.splice_funding_failed_for(contribution); + return Err(( + ChannelError::WarnAndDisconnect(format!( + "Channel {} contribution no longer valid at quiescence: {}", + self.context.channel_id(), + e, + )), + QuiescentError::FailSplice(failed), + )); + } let prior_contribution = contribution.clone(); let prev_funding_input = self.funding.to_splice_funding_input(); let our_funding_contribution = contribution.net_value(); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index a2df8bd0951..f33873c2eab 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -6487,6 +6487,57 @@ impl< result } + /// Emits events for a [`QuiescentError`], if applicable. + fn handle_quiescent_error( + &self, channel_id: ChannelId, counterparty_node_id: PublicKey, user_channel_id: u128, + error: QuiescentError, + ) { + match error { + QuiescentError::DoNothing => {}, + QuiescentError::DiscardFunding { inputs, outputs } => { + if !inputs.is_empty() || !outputs.is_empty() { + self.pending_events.lock().unwrap().push_back(( + events::Event::DiscardFunding { + channel_id, + funding_info: FundingInfo::Contribution { inputs, outputs }, + }, + None, + )); + } + }, + QuiescentError::FailSplice(SpliceFundingFailed { + funding_txo, + channel_type, + contributed_inputs, + contributed_outputs, + }) => { + let pending_events = &mut self.pending_events.lock().unwrap(); + pending_events.push_back(( + events::Event::SpliceFailed { + channel_id, + counterparty_node_id, + user_channel_id, + abandoned_funding_txo: funding_txo, + channel_type, + }, + None, + )); + if !contributed_inputs.is_empty() || !contributed_outputs.is_empty() { + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id, + funding_info: FundingInfo::Contribution { + inputs: contributed_inputs, + outputs: contributed_outputs, + }, + }, + None, + )); + } + }, + } + } + /// Adds or removes funds from the given channel as specified by a [`FundingContribution`]. /// /// Used after [`ChannelManager::splice_channel`] by constructing a [`FundingContribution`] @@ -6593,62 +6644,29 @@ impl< ); } }, - Err(QuiescentError::DoNothing) => { - result = Err(APIError::APIMisuseError { - err: format!( - "Duplicate funding contribution for channel {}", - channel_id - ), - }); - }, - Err(QuiescentError::DiscardFunding { inputs, outputs }) => { - self.pending_events.lock().unwrap().push_back(( - events::Event::DiscardFunding { - channel_id: *channel_id, - funding_info: FundingInfo::Contribution { inputs, outputs }, - }, - None, - )); + Err(e) => { result = Err(APIError::APIMisuseError { - err: format!( - "Channel {} already has a pending funding contribution", - channel_id - ), - }); - }, - Err(QuiescentError::FailSplice(SpliceFundingFailed { - funding_txo, - channel_type, - contributed_inputs, - contributed_outputs, - })) => { - let pending_events = &mut self.pending_events.lock().unwrap(); - pending_events.push_back(( - events::Event::SpliceFailed { - channel_id: *channel_id, - counterparty_node_id: *counterparty_node_id, - user_channel_id: channel.context().get_user_id(), - abandoned_funding_txo: funding_txo, - channel_type, - }, - None, - )); - pending_events.push_back(( - events::Event::DiscardFunding { - channel_id: *channel_id, - funding_info: FundingInfo::Contribution { - inputs: contributed_inputs, - outputs: contributed_outputs, - }, + err: match &e { + QuiescentError::DoNothing => format!( + "Duplicate funding contribution for channel {}", + channel_id, + ), + QuiescentError::DiscardFunding { .. } => format!( + "Channel {} already has a pending funding contribution", + channel_id, + ), + QuiescentError::FailSplice(_) => format!( + "Channel {} cannot accept funding contribution", + channel_id, + ), }, - None, - )); - result = Err(APIError::APIMisuseError { - err: format!( - "Channel {} cannot accept funding contribution", - channel_id - ), }); + self.handle_quiescent_error( + *channel_id, + *counterparty_node_id, + channel.context().get_user_id(), + e, + ); }, } @@ -12793,6 +12811,16 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ ); let res = chan.stfu(&msg, &&logger); + let (res, quiescent_error) = match res { + Ok(resp) => (Ok(resp), QuiescentError::DoNothing), + Err((chan_err, quiescent_err)) => (Err(chan_err), quiescent_err), + }; + self.handle_quiescent_error( + chan_entry.get().context().channel_id(), + *counterparty_node_id, + chan_entry.get().context().get_user_id(), + quiescent_error, + ); let resp = try_channel_entry!(self, peer_state, res, chan_entry); match resp { None => Ok(false), diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 0b1e4271d84..0ba4ed188e6 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -176,6 +176,16 @@ pub(super) struct PriorContribution { contribution: FundingContribution, /// The holder's balance, used for feerate adjustment. `None` when the balance computation /// fails, in which case adjustment is skipped and coin selection is re-run. + /// + /// This value is captured at [`ChannelManager::splice_channel`] time and may become stale + /// if balances change before the contribution is used. Staleness is acceptable here because + /// this is only used as an optimization to determine if the prior contribution can be + /// reused with adjusted fees — the contribution is re-validated at + /// [`ChannelManager::funding_contributed`] time and again at quiescence time against the + /// current balances. + /// + /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel + /// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed holder_balance: Option, } diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index fa95e4a0c07..7c169b40f7a 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -6168,3 +6168,139 @@ fn test_rbf_sync_returns_err_when_max_feerate_below_min_rbf() { Err(crate::ln::funding::FundingContributionError::FeeRateExceedsMaximum { .. }), )); } + +#[test] +fn test_splice_revalidation_at_quiescence() { + // When an outbound HTLC is committed between funding_contributed and quiescence, the + // holder's balance decreases. If the splice-out was marginal at funding_contributed time, + // the re-validation at quiescence should fail and emit SpliceFailed + DiscardFunding. + // + // Flow: + // 1. Send payment #1 (update_add + CS) → node 0 awaits RAA + // 2. funding_contributed with splice-out → passes, stfu delayed (awaiting RAA) + // 3. Process node 1's RAA → node 0 free to send + // 4. Send payment #2 (update_add + CS) → balance reduced + // 5. Process node 1's CS → node 0 sends RAA, stfu delayed (payment #2 pending) + // 6. Complete payment #2's exchange → stfu fires + // 7. stfu exchange → quiescence → re-validation fails + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut config = test_default_channel_config(); + config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let _ = provide_anchor_reserves(&nodes); + + // Step 1: Send payment #1 (update_add + CS). Node 0 awaits RAA. + let payment_1_msat = 20_000_000; + let (route_1, payment_hash_1, _, payment_secret_1) = + get_route_and_payment_hash!(nodes[0], nodes[1], payment_1_msat); + nodes[0] + .node + .send_payment_with_route( + route_1, + payment_hash_1, + RecipientOnionFields::secret_only(payment_secret_1, payment_1_msat), + PaymentId(payment_hash_1.0), + ) + .unwrap(); + check_added_monitors(&nodes[0], 1); + let payment_1_msgs = nodes[0].node.get_and_clear_pending_msg_events(); + + // Step 2: funding_contributed with splice-out. Passes because the balance floor only + // includes payment #1. stfu is delayed — awaiting RAA. + let outputs = vec![TxOut { + value: Amount::from_sat(70_000), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]; + + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let contribution = + funding_template.splice_out_sync(outputs, feerate, FeeRate::MAX, &wallet).unwrap(); + + nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution.clone(), None).unwrap(); + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty(), "stfu should be delayed"); + + // Step 3: Deliver payment #1 to node 1 and process RAA. + let payment_1_event = SendEvent::from_event(payment_1_msgs.into_iter().next().unwrap()); + nodes[1].node.handle_update_add_htlc(node_id_0, &payment_1_event.msgs[0]); + nodes[1].node.handle_commitment_signed_batch_test(node_id_0, &payment_1_event.commitment_msg); + check_added_monitors(&nodes[1], 1); + let (raa, cs) = get_revoke_commit_msgs(&nodes[1], &node_id_0); + + // Process node 1's RAA. After this, node 0 is free to send new HTLCs. + nodes[0].node.handle_revoke_and_ack(node_id_1, &raa); + check_added_monitors(&nodes[0], 1); + + // Step 4: Send payment #2 in the window between RAA and CS processing. + let payment_2_msat = 20_000_000; + let (route_2, payment_hash_2, _, payment_secret_2) = + get_route_and_payment_hash!(nodes[0], nodes[1], payment_2_msat); + nodes[0] + .node + .send_payment_with_route( + route_2, + payment_hash_2, + RecipientOnionFields::secret_only(payment_secret_2, payment_2_msat), + PaymentId(payment_hash_2.0), + ) + .unwrap(); + check_added_monitors(&nodes[0], 1); + let payment_2_msgs = nodes[0].node.get_and_clear_pending_msg_events(); + + // Step 5: Process node 1's CS. Node 0 sends RAA but stfu is delayed (payment #2 pending). + nodes[0].node.handle_commitment_signed_batch_test(node_id_1, &cs); + check_added_monitors(&nodes[0], 1); + let raa_0 = get_event_msg!(nodes[0], MessageSendEvent::SendRevokeAndACK, node_id_1); + nodes[1].node.handle_revoke_and_ack(node_id_0, &raa_0); + check_added_monitors(&nodes[1], 1); + + // Step 6: Complete payment #2's commitment exchange. stfu fires afterward. + let payment_2_event = SendEvent::from_event(payment_2_msgs.into_iter().next().unwrap()); + nodes[1].node.handle_update_add_htlc(node_id_0, &payment_2_event.msgs[0]); + nodes[1].node.handle_commitment_signed_batch_test(node_id_0, &payment_2_event.commitment_msg); + check_added_monitors(&nodes[1], 1); + let (raa_1b, cs_1b) = get_revoke_commit_msgs(&nodes[1], &node_id_0); + nodes[0].node.handle_revoke_and_ack(node_id_1, &raa_1b); + check_added_monitors(&nodes[0], 1); + nodes[0].node.handle_commitment_signed_batch_test(node_id_1, &cs_1b); + check_added_monitors(&nodes[0], 1); + + // RAA and stfu sent together. + let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + let raa_0b = match &msg_events[0] { + MessageSendEvent::SendRevokeAndACK { msg, .. } => msg.clone(), + other => panic!("Expected SendRevokeAndACK, got {:?}", other), + }; + let stfu_0 = match &msg_events[1] { + MessageSendEvent::SendStfu { msg, .. } => msg.clone(), + other => panic!("Expected SendStfu, got {:?}", other), + }; + + nodes[1].node.handle_revoke_and_ack(node_id_0, &raa_0b); + check_added_monitors(&nodes[1], 1); + + // Step 7: stfu exchange → quiescence → re-validation fails → disconnect. + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + // handle_stfu returns WarnAndDisconnect (triggering disconnect) alongside the + // QuiescentError containing the failed contribution's events. + let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + assert!(matches!(msg_events[0], MessageSendEvent::HandleError { .. })); + + expect_splice_failed_events(&nodes[0], &channel_id, contribution); +} From 8d001392c1ff4ad01f230563b8d493d3477e43cb Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 24 Mar 2026 16:42:06 -0500 Subject: [PATCH 235/627] Reject RBF with non-confirming feerate after several attempts After a few RBF attempts, both our own and the counterparty's RBF should target a feerate that will actually confirm. Reject attempts with feerates below the fee estimator's NonAnchorChannelFee target to prevent exhausting the RBF budget at low feerates. The spec requires: "MUST set a high enough feerate to ensure quick confirmation." Co-Authored-By: Claude Opus 4.6 (1M context) --- lightning/src/ln/channel.rs | 42 ++++++++- lightning/src/ln/channelmanager.rs | 7 +- lightning/src/ln/splicing_tests.rs | 147 +++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 3 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 0882880d1a3..3cc6a6b0d86 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -3100,6 +3100,22 @@ impl PendingFunding { } } + /// After several RBF attempts, checks that the feerate is high enough to confirm. Returns + /// `true` if the feerate is sufficient or the threshold hasn't been reached. + /// + /// The spec requires: "MUST set a high enough feerate to ensure quick confirmation." + fn is_rbf_feerate_sufficient( + &self, feerate_sat_per_kw: u32, fee_estimator: &LowerBoundedFeeEstimator, + ) -> bool { + const MAX_LOW_FEERATE_RBF_ATTEMPTS: usize = 10; + if self.negotiated_candidates.len() <= MAX_LOW_FEERATE_RBF_ATTEMPTS { + return true; + } + let min_feerate = + fee_estimator.bounded_sat_per_1000_weight(ConfirmationTarget::NonAnchorChannelFee); + feerate_sat_per_kw >= min_feerate + } + fn contributed_inputs(&self) -> impl Iterator + '_ { self.contributions.iter().flat_map(|c| c.contributed_inputs()) } @@ -12153,8 +12169,9 @@ where .expect("feerate compatibility already checked") } - pub fn funding_contributed( - &mut self, contribution: FundingContribution, locktime: LockTime, logger: &L, + pub fn funding_contributed( + &mut self, contribution: FundingContribution, locktime: LockTime, + fee_estimator: &LowerBoundedFeeEstimator, logger: &L, ) -> Result, QuiescentError> { debug_assert!(contribution.is_splice()); @@ -12229,6 +12246,23 @@ where return Err(QuiescentError::FailSplice(self.splice_funding_failed_for(contribution))); } + if let Some(pending_splice) = self.pending_splice.as_ref() { + if !pending_splice.is_rbf_feerate_sufficient( + contribution.feerate().to_sat_per_kwu() as u32, + fee_estimator, + ) { + log_error!( + logger, + "Channel {} RBF feerate {} below fee estimator minimum", + self.context.channel_id(), + contribution.feerate(), + ); + return Err(QuiescentError::FailSplice( + self.splice_funding_failed_for(contribution), + )); + } + } + // If a pending splice exists with negotiated candidates, attempt to adjust the // contribution's feerate to the minimum RBF feerate so it can proceed as an RBF immediately // rather than waiting for the splice to lock. @@ -12682,6 +12716,10 @@ where return Err(ChannelError::Abort(AbortReason::InsufficientRbfFeerate)); } + if !pending_splice.is_rbf_feerate_sufficient(new_feerate, fee_estimator) { + return Err(ChannelError::Abort(AbortReason::InsufficientRbfFeerate)); + } + let their_funding_contribution = match msg.funding_output_contribution { Some(value) => SignedAmount::from_sat(value), None => SignedAmount::ZERO, diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index f33873c2eab..8356e5f32fc 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -6633,7 +6633,12 @@ impl< locktime.unwrap_or_else(|| self.current_best_block().height), ); let logger = WithChannelContext::from(&self.logger, chan.context(), None); - match chan.funding_contributed(contribution, locktime, &&logger) { + match chan.funding_contributed( + contribution, + locktime, + &self.fee_estimator, + &&logger, + ) { Ok(msg_opt) => { if let Some(msg) = msg_opt { peer_state.pending_msg_events.push( diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 7c169b40f7a..20339e445bf 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -6304,3 +6304,150 @@ fn test_splice_revalidation_at_quiescence() { expect_splice_failed_events(&nodes[0], &channel_id, contribution); } + +#[test] +fn test_splice_rbf_rejects_low_feerate_after_several_attempts() { + // After several RBF attempts, the counterparty's RBF feerate must be high enough to + // confirm (per the fee estimator). Early attempts at low feerates are accepted, but + // once the threshold is crossed and the fee estimator expects a higher feerate, the + // attempt is rejected. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Round 0: Initial splice-in at floor feerate (253). + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Bump the fee estimator on node 1 (the RBF receiver) early so the feerate check + // would reject once the threshold is crossed. + let high_feerate = 10_000; + *chanmon_cfgs[1].fee_estimator.sat_per_kw.lock().unwrap() = high_feerate; + + // Rounds 1-10: RBF at minimum bump. Accepted (at or below threshold). + let mut prev_feerate = FEERATE_FLOOR_SATS_PER_KW as u64; + for _ in 0..10 { + let feerate = (prev_feerate * 25).div_ceil(24); + provide_utxo_reserves(&nodes, 2, added_value * 2); + let rbf_feerate = FeeRate::from_sat_per_kwu(feerate); + let contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + complete_rbf_handshake(&nodes[0], &nodes[1]); + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + contribution, + new_funding_script.clone(), + ); + let (_, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + assert!(splice_locked.is_none()); + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + prev_feerate = feerate; + } + + // Round 11: RBF at minimum bump. Should be rejected because feerate < fee estimator. + let next_feerate = (prev_feerate * 25).div_ceil(24); + provide_utxo_reserves(&nodes, 2, added_value * 2); + let rbf_feerate = FeeRate::from_sat_per_kwu(next_feerate); + let _contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + // Node 0 sends tx_init_rbf. Node 1 rejects the low feerate after the threshold. + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); +} + +#[test] +fn test_splice_rbf_rejects_own_low_feerate_after_several_attempts() { + // Same as test_splice_rbf_rejects_low_feerate_after_several_attempts, but for our own + // initiated RBF. The spec requires: "MUST set a high enough feerate to ensure quick + // confirmation." After several attempts, funding_contributed should reject our contribution + // if the feerate is below the fee estimator's target. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Round 0: Initial splice-in at floor feerate (253). + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Bump node 0's fee estimator early so the feerate check would reject once the + // threshold is crossed. + let high_feerate = 10_000; + *chanmon_cfgs[0].fee_estimator.sat_per_kw.lock().unwrap() = high_feerate; + + // Rounds 1-10: RBF at minimum bump. Accepted (at or below threshold). + let mut prev_feerate = FEERATE_FLOOR_SATS_PER_KW as u64; + for _ in 0..10 { + let feerate = (prev_feerate * 25).div_ceil(24); + provide_utxo_reserves(&nodes, 2, added_value * 2); + let rbf_feerate = FeeRate::from_sat_per_kwu(feerate); + let contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + complete_rbf_handshake(&nodes[0], &nodes[1]); + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + contribution, + new_funding_script.clone(), + ); + let (_, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + assert!(splice_locked.is_none()); + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + prev_feerate = feerate; + } + + // Round 11: Our own RBF at minimum bump. funding_contributed should reject it. + let next_feerate = (prev_feerate * 25).div_ceil(24); + provide_utxo_reserves(&nodes, 2, added_value * 2); + let rbf_feerate = FeeRate::from_sat_per_kwu(next_feerate); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let contribution = + funding_template.splice_in_sync(added_value, rbf_feerate, FeeRate::MAX, &wallet).unwrap(); + + let result = nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution, None); + assert!(result.is_err(), "Expected rejection for low feerate: {:?}", result); + + // SpliceFailed is emitted. DiscardFunding is not emitted because all inputs/outputs + // are filtered out (same UTXOs reused for RBF, still committed to the prior splice tx). + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1, "{events:?}"); + match &events[0] { + Event::SpliceFailed { channel_id: cid, .. } => assert_eq!(*cid, channel_id), + other => panic!("Expected SpliceFailed, got {:?}", other), + } +} From 63e4538ed4a030bc19cab2d01438de37badb370b Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 25 Mar 2026 19:56:58 +0000 Subject: [PATCH 236/627] Prevent downgrades in case holder-selected reserve is zero satoshis We prevent downgrades from 0.3 only in the case where the holder-selected reserve is 0, as we've had support for counterparty selected 0-reserves in prior releases. There is no need for this sentinel in `FundingScope` serialization code as this would only apply to pending `FundingScope`'s. Also, if the current scope has some zero-reserve, that reserve is carried over to all pending scopes automatically. Therefore it is not possible for a pending scope to have some 0-reserve without the current one also having it. --- lightning/src/ln/channel.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index c8c93eece74..53709201259 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -15302,6 +15302,11 @@ impl Writeable for FundedChannel { } let is_manual_broadcast = Some(self.context.is_manual_broadcast); + // We prevent downgrades from 0.3 only in the case where the holder-selected reserve + // is 0, as we've had support for counterparty selected 0-reserves in prior + // releases. + let has_0reserve = + (self.funding.holder_selected_channel_reserve_satoshis == 0).then_some(()); let holder_commitment_point_previous_revoked = self.holder_commitment_point.previous_revoked_point(); let holder_commitment_point_last_revoked = @@ -15371,6 +15376,7 @@ impl Writeable for FundedChannel { // 65 was previously used for quiescent_action (67, pending_outbound_held_htlc_flags, optional_vec), // Added in 0.2 (69, holding_cell_held_htlc_flags, optional_vec), // Added in 0.2 + (70, has_0reserve, option), // Added in 0.3 to prevent downgrades (71, holder_commitment_point_previous_revoked, option), // Added in 0.3 (73, holder_commitment_point_last_revoked, option), // Added in 0.3 (75, inbound_committed_update_adds, optional_vec), @@ -15744,6 +15750,7 @@ impl<'a, 'b, 'c, ES: EntropySource, SP: SignerProvider> let mut malformed_htlcs: Option> = None; let mut monitor_pending_update_adds: Option> = None; + let mut _has_0reserve: Option<()> = None; let mut holder_commitment_point_previous_revoked_opt: Option = None; let mut holder_commitment_point_last_revoked_opt: Option = None; let mut holder_commitment_point_current_opt: Option = None; @@ -15814,6 +15821,7 @@ impl<'a, 'b, 'c, ES: EntropySource, SP: SignerProvider> // 65 quiescent_action: Added in 0.2; removed in 0.3 (67, pending_outbound_held_htlc_flags_opt, optional_vec), // Added in 0.2 (69, holding_cell_held_htlc_flags_opt, optional_vec), // Added in 0.2 + (70, _has_0reserve, option), // Added in 0.3 to prevent downgrades (71, holder_commitment_point_previous_revoked_opt, option), // Added in 0.3 (73, holder_commitment_point_last_revoked_opt, option), // Added in 0.3 (75, inbound_committed_update_adds_opt, optional_vec), From 954bf2df42eda1d6387beef95e726d8488021c3a Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 26 Feb 2026 03:10:47 +0000 Subject: [PATCH 237/627] Add 0-reserve to `accept_inbound_channel_from_trusted_peer` This new flag sets 0-reserve for the channel opener. --- .../tests/lsps2_integration_tests.rs | 7 +- lightning/src/events/mod.rs | 2 +- lightning/src/ln/async_signer_tests.rs | 8 +- lightning/src/ln/chanmon_update_fail_tests.rs | 18 ++++- lightning/src/ln/channel.rs | 44 +++++----- lightning/src/ln/channel_open_tests.rs | 10 ++- lightning/src/ln/channel_type_tests.rs | 14 ++-- lightning/src/ln/channelmanager.rs | 80 ++++++++++++++----- lightning/src/ln/functional_test_utils.rs | 5 +- lightning/src/ln/priv_short_conf_tests.rs | 15 ++-- lightning/src/util/config.rs | 4 +- 11 files changed, 134 insertions(+), 73 deletions(-) diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index b8a4a5adebb..fbff2eae4cd 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -9,7 +9,9 @@ use common::{ use lightning::events::{ClosureReason, Event}; use lightning::get_event_msg; -use lightning::ln::channelmanager::{OptionalBolt11PaymentParams, PaymentId}; +use lightning::ln::channelmanager::{ + OptionalBolt11PaymentParams, PaymentId, TrustedChannelFeatures, +}; use lightning::ln::functional_test_utils::*; use lightning::ln::msgs::BaseMessageHandler; use lightning::ln::msgs::ChannelMessageHandler; @@ -1503,10 +1505,11 @@ fn create_channel_with_manual_broadcast( Event::OpenChannelRequest { temporary_channel_id, .. } => { client_node .node - .accept_inbound_channel_from_trusted_peer_0conf( + .accept_inbound_channel_from_trusted_peer( &temporary_channel_id, &service_node_id, user_channel_id, + TrustedChannelFeatures::ZeroConf, None, ) .unwrap(); diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 011b7f595bc..73c4a39c76f 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -1657,7 +1657,7 @@ pub enum Event { /// Furthermore, note that if [`ChannelTypeFeatures::supports_zero_conf`] returns true on this type, /// the resulting [`ChannelManager`] will not be readable by versions of LDK prior to /// 0.0.107. Channels setting this type also need to get manually accepted via - /// [`crate::ln::channelmanager::ChannelManager::accept_inbound_channel_from_trusted_peer_0conf`], + /// [`crate::ln::channelmanager::ChannelManager::accept_inbound_channel_from_trusted_peer`], /// or will be rejected otherwise. /// /// [`ChannelManager`]: crate::ln::channelmanager::ChannelManager diff --git a/lightning/src/ln/async_signer_tests.rs b/lightning/src/ln/async_signer_tests.rs index 8d47b6f8dc1..f238c1db060 100644 --- a/lightning/src/ln/async_signer_tests.rs +++ b/lightning/src/ln/async_signer_tests.rs @@ -22,7 +22,7 @@ use crate::events::{ClosureReason, Event}; use crate::ln::chan_utils::ClosingTransaction; use crate::ln::channel::DISCONNECT_PEER_AWAITING_RESPONSE_TICKS; use crate::ln::channel_state::{ChannelDetails, ChannelShutdownState}; -use crate::ln::channelmanager::{PaymentId, RAACommitmentOrder}; +use crate::ln::channelmanager::{PaymentId, RAACommitmentOrder, TrustedChannelFeatures}; use crate::ln::msgs::{BaseMessageHandler, ChannelMessageHandler, ErrorAction, MessageSendEvent}; use crate::ln::outbound_payment::RecipientOnionFields; use crate::ln::{functional_test_utils::*, msgs}; @@ -78,10 +78,11 @@ fn do_test_open_channel(zero_conf: bool) { Event::OpenChannelRequest { temporary_channel_id, .. } => { nodes[1] .node - .accept_inbound_channel_from_trusted_peer_0conf( + .accept_inbound_channel_from_trusted_peer( temporary_channel_id, &node_a_id, 0, + TrustedChannelFeatures::ZeroConf, None, ) .expect("Unable to accept inbound zero-conf channel"); @@ -383,10 +384,11 @@ fn do_test_funding_signed_0conf(signer_ops: Vec) { Event::OpenChannelRequest { temporary_channel_id, .. } => { nodes[1] .node - .accept_inbound_channel_from_trusted_peer_0conf( + .accept_inbound_channel_from_trusted_peer( temporary_channel_id, &node_a_id, 0, + TrustedChannelFeatures::ZeroConf, None, ) .expect("Unable to accept inbound zero-conf channel"); diff --git a/lightning/src/ln/chanmon_update_fail_tests.rs b/lightning/src/ln/chanmon_update_fail_tests.rs index 0d8a4a020f0..9c81b903fed 100644 --- a/lightning/src/ln/chanmon_update_fail_tests.rs +++ b/lightning/src/ln/chanmon_update_fail_tests.rs @@ -19,7 +19,7 @@ use crate::chain::transaction::OutPoint; use crate::chain::{ChannelMonitorUpdateStatus, Confirm, Listen, Watch}; use crate::events::{ClosureReason, Event, HTLCHandlingFailureType, PaymentPurpose}; use crate::ln::channel::AnnouncementSigsState; -use crate::ln::channelmanager::{PaymentId, RAACommitmentOrder}; +use crate::ln::channelmanager::{PaymentId, RAACommitmentOrder, TrustedChannelFeatures}; use crate::ln::msgs; use crate::ln::msgs::{ BaseMessageHandler, ChannelMessageHandler, MessageSendEvent, RoutingMessageHandler, @@ -3241,7 +3241,13 @@ fn do_test_outbound_reload_without_init_mon(use_0conf: bool) { if use_0conf { nodes[1] .node - .accept_inbound_channel_from_trusted_peer_0conf(&chan_id, &node_a_id, 0, None) + .accept_inbound_channel_from_trusted_peer( + &chan_id, + &node_a_id, + 0, + TrustedChannelFeatures::ZeroConf, + None, + ) .unwrap(); } else { nodes[1].node.accept_inbound_channel(&chan_id, &node_a_id, 0, None).unwrap(); @@ -3350,7 +3356,13 @@ fn do_test_inbound_reload_without_init_mon(use_0conf: bool, lock_commitment: boo if use_0conf { nodes[1] .node - .accept_inbound_channel_from_trusted_peer_0conf(&chan_id, &node_a_id, 0, None) + .accept_inbound_channel_from_trusted_peer( + &chan_id, + &node_a_id, + 0, + TrustedChannelFeatures::ZeroConf, + None, + ) .unwrap(); } else { nodes[1].node.accept_inbound_channel(&chan_id, &node_a_id, 0, None).unwrap(); diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 53709201259..0b6d17350d2 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -52,7 +52,7 @@ use crate::ln::channel_state::{ use crate::ln::channelmanager::{ self, BlindedFailure, ChannelReadyOrder, FundingConfirmedMessage, HTLCFailureMsg, HTLCPreviousHopData, HTLCSource, OpenChannelMessage, PaymentClaimDetails, PendingHTLCInfo, - PendingHTLCStatus, RAACommitmentOrder, SentHTLCId, BREAKDOWN_TIMEOUT, + PendingHTLCStatus, RAACommitmentOrder, SentHTLCId, TrustedChannelFeatures, BREAKDOWN_TIMEOUT, MAX_LOCAL_BREAKDOWN_TIMEOUT, MIN_CLTV_EXPIRY_DELTA, }; use crate::ln::funding::{ @@ -3693,7 +3693,7 @@ impl ChannelContext { config: &'a UserConfig, current_chain_height: u32, logger: &'a L, - is_0conf: bool, + trusted_channel_features: Option, our_funding_satoshis: u64, counterparty_pubkeys: ChannelPublicKeys, channel_type: ChannelTypeFeatures, @@ -3780,7 +3780,7 @@ impl ChannelContext { } } - if holder_selected_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS { + if holder_selected_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS && holder_selected_channel_reserve_satoshis != 0 { // Protocol level safety check in place, although it should never happen because // of `MIN_THEIR_CHAN_RESERVE_SATOSHIS` return Err(ChannelError::close(format!("Suitable channel reserve not found. remote_channel_reserve was ({}). dust_limit_satoshis is ({}).", holder_selected_channel_reserve_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS))); @@ -3792,7 +3792,7 @@ impl ChannelContext { log_debug!(logger, "channel_reserve_satoshis ({}) is smaller than our dust limit ({}). We can broadcast stale states without any risk, implying this channel is very insecure for our counterparty.", msg_channel_reserve_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS); } - if holder_selected_channel_reserve_satoshis < open_channel_fields.dust_limit_satoshis { + if holder_selected_channel_reserve_satoshis < open_channel_fields.dust_limit_satoshis && holder_selected_channel_reserve_satoshis != 0 { return Err(ChannelError::close(format!("Dust limit ({}) too high for the channel reserve we require the remote to keep ({})", open_channel_fields.dust_limit_satoshis, holder_selected_channel_reserve_satoshis))); } @@ -3841,7 +3841,7 @@ impl ChannelContext { let mut secp_ctx = Secp256k1::new(); secp_ctx.seeded_randomize(&entropy_source.get_secure_random_bytes()); - let minimum_depth = if is_0conf { + let minimum_depth = if trusted_channel_features.is_some_and(|f| f.is_0conf()) { Some(0) } else { Some(cmp::max(config.channel_handshake_config.minimum_depth, 1)) @@ -14250,7 +14250,8 @@ impl InboundV1Channel { fee_estimator: &LowerBoundedFeeEstimator, entropy_source: &ES, signer_provider: &SP, counterparty_node_id: PublicKey, our_supported_features: &ChannelTypeFeatures, their_features: &InitFeatures, msg: &msgs::OpenChannel, user_id: u128, config: &UserConfig, - current_chain_height: u32, logger: &L, is_0conf: bool, + current_chain_height: u32, logger: &L, + trusted_channel_features: Option, ) -> Result, ChannelError> { let logger = WithContext::from(logger, Some(counterparty_node_id), Some(msg.common_fields.temporary_channel_id), None); @@ -14262,7 +14263,7 @@ impl InboundV1Channel { msg.common_fields.funding_satoshis, msg.common_fields.dust_limit_satoshis, config, - false, + trusted_channel_features.is_some_and(|f| f.is_0reserve()), ); let counterparty_pubkeys = ChannelPublicKeys { funding_pubkey: msg.common_fields.funding_pubkey, @@ -14282,7 +14283,7 @@ impl InboundV1Channel { config, current_chain_height, &&logger, - is_0conf, + trusted_channel_features, 0, counterparty_pubkeys, @@ -14678,7 +14679,7 @@ impl PendingV2Channel { config, current_chain_height, logger, - false, + None, our_funding_contribution_sats, counterparty_pubkeys, channel_type, @@ -16327,7 +16328,7 @@ mod tests { MIN_THEIR_CHAN_RESERVE_SATOSHIS, }; use crate::ln::channel_keys::{RevocationBasepoint, RevocationKey}; - use crate::ln::channelmanager::{self, HTLCSource, PaymentId}; + use crate::ln::channelmanager::{self, HTLCSource, PaymentId, TrustedChannelFeatures}; use crate::ln::msgs; use crate::ln::msgs::{ChannelUpdate, UnsignedChannelUpdate, MAX_VALUE_MSAT}; use crate::ln::onion_utils::{AttributionData, LocalHTLCFailureReason}; @@ -16531,7 +16532,7 @@ mod tests { // Make sure A's dust limit is as we expect. let open_channel_msg = node_a_chan.get_open_channel(ChainHash::using_genesis_block(network), &&logger).unwrap(); let node_b_node_id = PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[7; 32]).unwrap()); - let mut node_b_chan = InboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, node_b_node_id, &channelmanager::provided_channel_type_features(&config), &channelmanager::provided_init_features(&config), &open_channel_msg, 7, &config, 0, &&logger, /*is_0conf=*/false).unwrap(); + let mut node_b_chan = InboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, node_b_node_id, &channelmanager::provided_channel_type_features(&config), &channelmanager::provided_init_features(&config), &open_channel_msg, 7, &config, 0, &&logger, None).unwrap(); // Node B --> Node A: accept channel, explicitly setting B's dust limit. let mut accept_channel_msg = node_b_chan.accept_inbound_channel(&&logger).unwrap(); @@ -16676,7 +16677,7 @@ mod tests { // Create Node B's channel by receiving Node A's open_channel message let open_channel_msg = node_a_chan.get_open_channel(chain_hash, &&logger).unwrap(); let node_b_node_id = PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[7; 32]).unwrap()); - let mut node_b_chan = InboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, node_b_node_id, &channelmanager::provided_channel_type_features(&config), &channelmanager::provided_init_features(&config), &open_channel_msg, 7, &config, 0, &&logger, /*is_0conf=*/false).unwrap(); + let mut node_b_chan = InboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, node_b_node_id, &channelmanager::provided_channel_type_features(&config), &channelmanager::provided_init_features(&config), &open_channel_msg, 7, &config, 0, &&logger, None).unwrap(); // Node B --> Node A: accept channel let accept_channel_msg = node_b_chan.accept_inbound_channel(&&logger).unwrap(); @@ -16751,12 +16752,12 @@ mod tests { // Test that `InboundV1Channel::new` creates a channel with the correct value for // `holder_max_htlc_value_in_flight_msat`, when configured with a valid percentage value, // which is set to the lower bound - 1 (2%) of the `channel_value`. - let chan_3 = InboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, inbound_node_id, &channelmanager::provided_channel_type_features(&config_2_percent), &channelmanager::provided_init_features(&config_2_percent), &chan_1_open_channel_msg, 7, &config_2_percent, 0, &&logger, /*is_0conf=*/false).unwrap(); + let chan_3 = InboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, inbound_node_id, &channelmanager::provided_channel_type_features(&config_2_percent), &channelmanager::provided_init_features(&config_2_percent), &chan_1_open_channel_msg, 7, &config_2_percent, 0, &&logger, None).unwrap(); let chan_3_value_msat = chan_3.funding.get_value_satoshis() * 1000; assert_eq!(chan_3.context.holder_max_htlc_value_in_flight_msat, (chan_3_value_msat as f64 * 0.02) as u64); // Test with the upper bound - 1 of valid values (99%). - let chan_4 = InboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, inbound_node_id, &channelmanager::provided_channel_type_features(&config_99_percent), &channelmanager::provided_init_features(&config_99_percent), &chan_1_open_channel_msg, 7, &config_99_percent, 0, &&logger, /*is_0conf=*/false).unwrap(); + let chan_4 = InboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, inbound_node_id, &channelmanager::provided_channel_type_features(&config_99_percent), &channelmanager::provided_init_features(&config_99_percent), &chan_1_open_channel_msg, 7, &config_99_percent, 0, &&logger, None).unwrap(); let chan_4_value_msat = chan_4.funding.get_value_satoshis() * 1000; assert_eq!(chan_4.context.holder_max_htlc_value_in_flight_msat, (chan_4_value_msat as f64 * 0.99) as u64); @@ -16775,14 +16776,14 @@ mod tests { // Test that `InboundV1Channel::new` uses the lower bound of the configurable percentage values (1%) // if `max_inbound_htlc_value_in_flight_percent_of_channel` is set to a value less than 1. - let chan_7 = InboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, inbound_node_id, &channelmanager::provided_channel_type_features(&config_0_percent), &channelmanager::provided_init_features(&config_0_percent), &chan_1_open_channel_msg, 7, &config_0_percent, 0, &&logger, /*is_0conf=*/false).unwrap(); + let chan_7 = InboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, inbound_node_id, &channelmanager::provided_channel_type_features(&config_0_percent), &channelmanager::provided_init_features(&config_0_percent), &chan_1_open_channel_msg, 7, &config_0_percent, 0, &&logger, None).unwrap(); let chan_7_value_msat = chan_7.funding.get_value_satoshis() * 1000; assert_eq!(chan_7.context.holder_max_htlc_value_in_flight_msat, (chan_7_value_msat as f64 * 0.01) as u64); // Test that `InboundV1Channel::new` uses the upper bound of the configurable percentage values // (100%) if `max_inbound_htlc_value_in_flight_percent_of_channel` is set to a larger value // than 100. - let chan_8 = InboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, inbound_node_id, &channelmanager::provided_channel_type_features(&config_101_percent), &channelmanager::provided_init_features(&config_101_percent), &chan_1_open_channel_msg, 7, &config_101_percent, 0, &&logger, /*is_0conf=*/false).unwrap(); + let chan_8 = InboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, inbound_node_id, &channelmanager::provided_channel_type_features(&config_101_percent), &channelmanager::provided_init_features(&config_101_percent), &chan_1_open_channel_msg, 7, &config_101_percent, 0, &&logger, None).unwrap(); let chan_8_value_msat = chan_8.funding.get_value_satoshis() * 1000; assert_eq!(chan_8.context.holder_max_htlc_value_in_flight_msat, chan_8_value_msat); } @@ -16835,7 +16836,7 @@ mod tests { inbound_node_config.channel_handshake_config.their_channel_reserve_proportional_millionths = (inbound_selected_channel_reserve_perc * 1_000_000.0) as u32; if outbound_selected_channel_reserve_perc + inbound_selected_channel_reserve_perc < 1.0 { - let chan_inbound_node = InboundV1Channel::<&TestKeysInterface>::new(&&fee_est, &&keys_provider, &&keys_provider, inbound_node_id, &channelmanager::provided_channel_type_features(&inbound_node_config), &channelmanager::provided_init_features(&outbound_node_config), &chan_open_channel_msg, 7, &inbound_node_config, 0, &&logger, /*is_0conf=*/false).unwrap(); + let chan_inbound_node = InboundV1Channel::<&TestKeysInterface>::new(&&fee_est, &&keys_provider, &&keys_provider, inbound_node_id, &channelmanager::provided_channel_type_features(&inbound_node_config), &channelmanager::provided_init_features(&outbound_node_config), &chan_open_channel_msg, 7, &inbound_node_config, 0, &&logger, None).unwrap(); let expected_inbound_selected_chan_reserve = cmp::max(MIN_THEIR_CHAN_RESERVE_SATOSHIS, (chan.funding.get_value_satoshis() as f64 * inbound_selected_channel_reserve_perc) as u64); @@ -16843,7 +16844,7 @@ mod tests { assert_eq!(chan_inbound_node.funding.counterparty_selected_channel_reserve_satoshis.unwrap(), expected_outbound_selected_chan_reserve); } else { // Channel Negotiations failed - let result = InboundV1Channel::<&TestKeysInterface>::new(&&fee_est, &&keys_provider, &&keys_provider, inbound_node_id, &channelmanager::provided_channel_type_features(&inbound_node_config), &channelmanager::provided_init_features(&outbound_node_config), &chan_open_channel_msg, 7, &inbound_node_config, 0, &&logger, /*is_0conf=*/false); + let result = InboundV1Channel::<&TestKeysInterface>::new(&&fee_est, &&keys_provider, &&keys_provider, inbound_node_id, &channelmanager::provided_channel_type_features(&inbound_node_config), &channelmanager::provided_init_features(&outbound_node_config), &chan_open_channel_msg, 7, &inbound_node_config, 0, &&logger, None); assert!(result.is_err()); } } @@ -16870,7 +16871,7 @@ mod tests { // Make sure A's dust limit is as we expect. let open_channel_msg = node_a_chan.get_open_channel(ChainHash::using_genesis_block(network), &&logger).unwrap(); let node_b_node_id = PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[7; 32]).unwrap()); - let mut node_b_chan = InboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, node_b_node_id, &channelmanager::provided_channel_type_features(&config), &channelmanager::provided_init_features(&config), &open_channel_msg, 7, &config, 0, &&logger, /*is_0conf=*/false).unwrap(); + let mut node_b_chan = InboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, node_b_node_id, &channelmanager::provided_channel_type_features(&config), &channelmanager::provided_init_features(&config), &open_channel_msg, 7, &config, 0, &&logger, None).unwrap(); // Node B --> Node A: accept channel, explicitly setting B's dust limit. let mut accept_channel_msg = node_b_chan.accept_inbound_channel(&&logger).unwrap(); @@ -16973,7 +16974,7 @@ mod tests { &config, 0, &&logger, - false, + None, ) .unwrap(); outbound_chan @@ -18628,7 +18629,8 @@ mod tests { &config, 0, &&logger, - true, // Allow node b to send a 0conf channel_ready. + // Allow node b to send a 0conf channel_ready. + Some(TrustedChannelFeatures::ZeroConf), ).unwrap(); let accept_channel_msg = node_b_chan.accept_inbound_channel(&&logger).unwrap(); diff --git a/lightning/src/ln/channel_open_tests.rs b/lightning/src/ln/channel_open_tests.rs index 1de51bff5f7..9645d3c23b5 100644 --- a/lightning/src/ln/channel_open_tests.rs +++ b/lightning/src/ln/channel_open_tests.rs @@ -19,7 +19,8 @@ use crate::ln::channel::{ OutboundV1Channel, COINBASE_MATURITY, UNFUNDED_CHANNEL_AGE_LIMIT_TICKS, }; use crate::ln::channelmanager::{ - self, BREAKDOWN_TIMEOUT, MAX_UNFUNDED_CHANNEL_PEERS, MAX_UNFUNDED_CHANS_PER_PEER, + self, TrustedChannelFeatures, BREAKDOWN_TIMEOUT, MAX_UNFUNDED_CHANNEL_PEERS, + MAX_UNFUNDED_CHANS_PER_PEER, }; use crate::ln::msgs::{ AcceptChannel, BaseMessageHandler, ChannelMessageHandler, ErrorAction, MessageSendEvent, @@ -157,10 +158,11 @@ fn test_0conf_limiting() { Event::OpenChannelRequest { temporary_channel_id, .. } => { nodes[1] .node - .accept_inbound_channel_from_trusted_peer_0conf( + .accept_inbound_channel_from_trusted_peer( &temporary_channel_id, &last_random_pk, 23, + TrustedChannelFeatures::ZeroConf, None, ) .unwrap(); @@ -968,7 +970,7 @@ pub fn test_user_configurable_csv_delay() { &low_our_to_self_config, 0, &nodes[0].logger, - /*is_0conf=*/ false, + None, ) { match error { ChannelError::Close((err, _)) => { @@ -1028,7 +1030,7 @@ pub fn test_user_configurable_csv_delay() { &high_their_to_self_config, 0, &nodes[0].logger, - /*is_0conf=*/ false, + None, ) { match error { ChannelError::Close((err, _)) => { diff --git a/lightning/src/ln/channel_type_tests.rs b/lightning/src/ln/channel_type_tests.rs index 2b069a6d314..dc586555f39 100644 --- a/lightning/src/ln/channel_type_tests.rs +++ b/lightning/src/ln/channel_type_tests.rs @@ -167,7 +167,7 @@ fn test_zero_conf_channel_type_support() { &config, 0, &&logger, - /*is_0conf=*/ false, + None, ); assert!(res.is_ok()); } @@ -282,7 +282,7 @@ fn do_test_supports_channel_type(config: UserConfig, expected_channel_type: Chan &config, 0, &&logger, - /*is_0conf=*/ false, + None, ) .unwrap(); @@ -350,7 +350,7 @@ fn test_rejects_if_channel_type_not_set() { &config, 0, &&logger, - /*is_0conf=*/ false, + None, ); assert!(channel_b.is_err()); @@ -368,7 +368,7 @@ fn test_rejects_if_channel_type_not_set() { &config, 0, &&logger, - /*is_0conf=*/ false, + None, ) .unwrap(); @@ -434,7 +434,7 @@ fn test_rejects_if_channel_type_differ() { &config, 0, &&logger, - /*is_0conf=*/ false, + None, ) .unwrap(); @@ -518,7 +518,7 @@ fn test_rejects_simple_anchors_channel_type() { &config, 0, &&logger, - /*is_0conf=*/ false, + None, ); assert!(res.is_err()); @@ -558,7 +558,7 @@ fn test_rejects_simple_anchors_channel_type() { &config, 0, &&logger, - /*is_0conf=*/ false, + None, ) .unwrap(); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 30eb7f85d71..d8302eed76a 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -3536,6 +3536,48 @@ fn create_htlc_intercepted_event( }) } +/// Sets the features of the accepted channel in [`ChannelManager::accept_inbound_channel_from_trusted_peer`] +#[derive(Clone, Copy)] +pub enum TrustedChannelFeatures { + /// Accepts the incoming channel and (if the counterparty agrees), enables forwarding of payments immediately. + /// + /// This fully trusts that the counterparty has honestly and correctly constructed the funding transaction and + /// blindly assumes that it will eventually confirm. + /// + /// 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*. + ZeroConf, + /// Accepts the incoming channel and sets the reserve the counterparty must keep at all times in the channel to + /// zero. + /// + /// This allows the counterparty to spend their entire channel balance, and attempt to force-close the channel + /// with a revoked commitment transaction *for free*. + /// + /// Note that there is no guarantee that the counterparty accepts such a channel themselves. + ZeroReserve, + /// Sets the combination of [`TrustedChannelFeatures::ZeroConf`] and [`TrustedChannelFeatures::ZeroReserve`] + ZeroConfZeroReserve, +} + +impl TrustedChannelFeatures { + /// True if and only if `ZeroConf` is set + pub fn is_0conf(&self) -> bool { + match self { + TrustedChannelFeatures::ZeroConf | TrustedChannelFeatures::ZeroConfZeroReserve => true, + TrustedChannelFeatures::ZeroReserve => false, + } + } + /// True if and only if `ZeroReserve` is set + pub fn is_0reserve(&self) -> bool { + match self { + TrustedChannelFeatures::ZeroReserve | TrustedChannelFeatures::ZeroConfZeroReserve => { + true + }, + TrustedChannelFeatures::ZeroConf => false, + } + } +} + impl< M: chain::Watch, T: BroadcasterInterface, @@ -11057,10 +11099,10 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ /// /// The `user_channel_id` parameter will be provided back in /// [`Event::ChannelClosed::user_channel_id`] to allow tracking of which events correspond - /// with which `accept_inbound_channel`/`accept_inbound_channel_from_trusted_peer_0conf` call. + /// with which `accept_inbound_channel`/`accept_inbound_channel_from_trusted_peer` call. /// /// Note that this method will return an error and reject the channel, if it requires support - /// for zero confirmations. Instead, `accept_inbound_channel_from_trusted_peer_0conf` must be + /// for zero confirmations. Instead, `accept_inbound_channel_from_trusted_peer` must be /// used to accept such channels. /// /// NOTE: LDK makes no attempt to prevent the counterparty from using non-standard inputs which @@ -11076,38 +11118,32 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ self.do_accept_inbound_channel( temporary_channel_id, counterparty_node_id, - false, + None, user_channel_id, config_overrides, ) } - /// Accepts a request to open a channel after a [`Event::OpenChannelRequest`], treating - /// it as confirmed immediately. + /// Accepts a request to open a channel after a [`Event::OpenChannelRequest`]. Unlike + /// [`ChannelManager::accept_inbound_channel`], this method allows some combination of the + /// zero-conf and zero-reserve features to be set for the channel, see a description of these + /// features in [`TrustedChannelFeatures`]. /// /// The `user_channel_id` parameter will be provided back in /// [`Event::ChannelClosed::user_channel_id`] to allow tracking of which events correspond - /// with which `accept_inbound_channel`/`accept_inbound_channel_from_trusted_peer_0conf` call. - /// - /// Unlike [`ChannelManager::accept_inbound_channel`], this method accepts the incoming channel - /// and (if the counterparty agrees), enables forwarding of payments immediately. - /// - /// This fully trusts that the counterparty has honestly and correctly constructed the funding - /// transaction and blindly assumes that it will eventually confirm. - /// - /// 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*. + /// with which `accept_inbound_channel`/`accept_inbound_channel_from_trusted_peer` call. /// /// [`Event::OpenChannelRequest`]: events::Event::OpenChannelRequest /// [`Event::ChannelClosed::user_channel_id`]: events::Event::ChannelClosed::user_channel_id - pub fn accept_inbound_channel_from_trusted_peer_0conf( + pub fn accept_inbound_channel_from_trusted_peer( &self, temporary_channel_id: &ChannelId, counterparty_node_id: &PublicKey, - user_channel_id: u128, config_overrides: Option, + user_channel_id: u128, trusted_channel_features: TrustedChannelFeatures, + config_overrides: Option, ) -> Result<(), APIError> { self.do_accept_inbound_channel( temporary_channel_id, counterparty_node_id, - true, + Some(trusted_channel_features), user_channel_id, config_overrides, ) @@ -11116,7 +11152,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ /// TODO(dual_funding): Allow contributions, pass intended amount and inputs fn do_accept_inbound_channel( &self, temporary_channel_id: &ChannelId, counterparty_node_id: &PublicKey, - accept_0conf: bool, user_channel_id: u128, + trusted_channel_features: Option, user_channel_id: u128, config_overrides: Option, ) -> Result<(), APIError> { let mut config = self.config.read().unwrap().clone(); @@ -11165,7 +11201,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ &config, best_block_height, &self.logger, - accept_0conf, + trusted_channel_features, ) .map_err(|err| { MsgHandleErrInternal::from_chan_no_close(err, *temporary_channel_id) @@ -11242,7 +11278,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }, }; - if accept_0conf { + if trusted_channel_features.is_some_and(|f| f.is_0conf()) { // This should have been correctly configured by the call to Inbound(V1/V2)Channel::new. debug_assert!(channel.minimum_depth().unwrap() == 0); } else if channel.funding().get_channel_type().requires_zero_conf() { @@ -11257,7 +11293,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }; debug_assert!(peer_state.is_connected); peer_state.pending_msg_events.push(send_msg_err_event); - let err_str = "Please use accept_inbound_channel_from_trusted_peer_0conf to accept channels with zero confirmations.".to_owned(); + let err_str = "Please use accept_inbound_channel_from_trusted_peer to accept channels with zero confirmations.".to_owned(); log_error!(logger, "{}", err_str); return Err(APIError::APIMisuseError { err: err_str }); diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 80274d180b4..7b7408121ce 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -25,7 +25,7 @@ use crate::ln::chan_utils::{ }; use crate::ln::channelmanager::{ AChannelManager, ChainParameters, ChannelManager, ChannelManagerReadArgs, PaymentId, - RAACommitmentOrder, MIN_CLTV_EXPIRY_DELTA, + RAACommitmentOrder, TrustedChannelFeatures, MIN_CLTV_EXPIRY_DELTA, }; use crate::ln::funding::{FundingContribution, FundingTxInput}; use crate::ln::msgs::{self, OpenChannel}; @@ -1646,10 +1646,11 @@ pub fn exchange_open_accept_zero_conf_chan<'a, 'b, 'c, 'd>( Event::OpenChannelRequest { temporary_channel_id, .. } => { receiver .node - .accept_inbound_channel_from_trusted_peer_0conf( + .accept_inbound_channel_from_trusted_peer( &temporary_channel_id, &initiator_node_id, 0, + TrustedChannelFeatures::ZeroConf, None, ) .unwrap(); diff --git a/lightning/src/ln/priv_short_conf_tests.rs b/lightning/src/ln/priv_short_conf_tests.rs index ffe5ea6cbb1..6ea67f235e7 100644 --- a/lightning/src/ln/priv_short_conf_tests.rs +++ b/lightning/src/ln/priv_short_conf_tests.rs @@ -14,7 +14,7 @@ use crate::chain::ChannelMonitorUpdateStatus; use crate::events::{ClosureReason, Event, HTLCHandlingFailureType, PaymentFailureReason}; use crate::ln::channel::CHANNEL_ANNOUNCEMENT_PROPAGATION_DELAY; -use crate::ln::channelmanager::{PaymentId, MIN_CLTV_EXPIRY_DELTA}; +use crate::ln::channelmanager::{PaymentId, TrustedChannelFeatures, MIN_CLTV_EXPIRY_DELTA}; use crate::ln::msgs; use crate::ln::msgs::{ BaseMessageHandler, ChannelMessageHandler, ErrorAction, MessageSendEvent, RoutingMessageHandler, @@ -774,7 +774,7 @@ fn test_simple_0conf_channel() { // If our peer tells us they will accept our channel with 0 confs, and we funded the channel, // we should trust the funding won't be double-spent (assuming `trust_own_funding_0conf` is // set)! - // Further, if we `accept_inbound_channel_from_trusted_peer_0conf`, `channel_ready` messages + // Further, if we `accept_inbound_channel_from_trusted_peer`, `channel_ready` messages // should fly immediately and the channel should be available for use as soon as they are // received. @@ -818,10 +818,11 @@ fn test_0conf_channel_with_async_monitor() { Event::OpenChannelRequest { temporary_channel_id, .. } => { nodes[1] .node - .accept_inbound_channel_from_trusted_peer_0conf( + .accept_inbound_channel_from_trusted_peer( &temporary_channel_id, &node_a_id, 0, + TrustedChannelFeatures::ZeroConf, None, ) .unwrap(); @@ -1369,11 +1370,12 @@ fn test_zero_conf_accept_reject() { // Assert we can accept via the 0conf method assert!(nodes[1] .node - .accept_inbound_channel_from_trusted_peer_0conf( + .accept_inbound_channel_from_trusted_peer( &temporary_channel_id, &node_a_id, 0, - None + TrustedChannelFeatures::ZeroConf, + None, ) .is_ok()); }, @@ -1411,10 +1413,11 @@ fn test_connect_before_funding() { Event::OpenChannelRequest { temporary_channel_id, .. } => { nodes[1] .node - .accept_inbound_channel_from_trusted_peer_0conf( + .accept_inbound_channel_from_trusted_peer( &temporary_channel_id, &node_a_id, 0, + TrustedChannelFeatures::ZeroConf, None, ) .unwrap(); diff --git a/lightning/src/util/config.rs b/lightning/src/util/config.rs index e4158910b9a..14c507184ac 100644 --- a/lightning/src/util/config.rs +++ b/lightning/src/util/config.rs @@ -31,11 +31,11 @@ pub struct ChannelHandshakeConfig { /// A lower-bound of `1` is applied, requiring all channels to have a confirmed commitment /// transaction before operation. If you wish to accept channels with zero confirmations, /// manually accept them via [`Event::OpenChannelRequest`] using - /// [`ChannelManager::accept_inbound_channel_from_trusted_peer_0conf`]. + /// [`ChannelManager::accept_inbound_channel_from_trusted_peer`]. /// /// Default value: `6` /// - /// [`ChannelManager::accept_inbound_channel_from_trusted_peer_0conf`]: crate::ln::channelmanager::ChannelManager::accept_inbound_channel_from_trusted_peer_0conf + /// [`ChannelManager::accept_inbound_channel_from_trusted_peer`]: crate::ln::channelmanager::ChannelManager::accept_inbound_channel_from_trusted_peer /// [`Event::OpenChannelRequest`]: crate::events::Event::OpenChannelRequest pub minimum_depth: u32, /// Set to the number of blocks we require our counterparty to wait to claim their money (ie From ef7a0d11d6478d02461950043963400b448c4a36 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 19 Feb 2026 07:32:12 +0000 Subject: [PATCH 238/627] Add `ChannelManager::create_channel_to_trusted_peer_0reserve` This new method sets 0-reserve for the channel accepter. --- lightning/src/ln/channel.rs | 38 +++++++++++--------- lightning/src/ln/channel_open_tests.rs | 1 + lightning/src/ln/channel_type_tests.rs | 7 ++++ lightning/src/ln/channelmanager.rs | 50 ++++++++++++++++++++++++-- 4 files changed, 77 insertions(+), 19 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 0b6d17350d2..a0b3bb141ae 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -4510,7 +4510,7 @@ impl ChannelContext { if channel_reserve_satoshis > funding.get_value_satoshis() { return Err(ChannelError::close(format!("Bogus channel_reserve_satoshis ({}). Must not be greater than ({})", channel_reserve_satoshis, funding.get_value_satoshis()))); } - if common_fields.dust_limit_satoshis > funding.holder_selected_channel_reserve_satoshis { + if common_fields.dust_limit_satoshis > funding.holder_selected_channel_reserve_satoshis && funding.holder_selected_channel_reserve_satoshis != 0 { return Err(ChannelError::close(format!("Dust limit ({}) is bigger than our channel reserve ({})", common_fields.dust_limit_satoshis, funding.holder_selected_channel_reserve_satoshis))); } if channel_reserve_satoshis > funding.get_value_satoshis() - funding.holder_selected_channel_reserve_satoshis { @@ -13866,23 +13866,24 @@ impl OutboundV1Channel { pub fn new( fee_estimator: &LowerBoundedFeeEstimator, entropy_source: &ES, signer_provider: &SP, counterparty_node_id: PublicKey, their_features: &InitFeatures, channel_value_satoshis: u64, push_msat: u64, user_id: u128, config: &UserConfig, current_chain_height: u32, - outbound_scid_alias: u64, temporary_channel_id: Option, logger: L + outbound_scid_alias: u64, temporary_channel_id: Option, logger: L, trusted_channel_features: Option, ) -> Result, APIError> { // At this point, we do not know what `dust_limit_satoshis` the counterparty will want for themselves, // so we set the channel reserve with no regard for their dust limit, and fail the channel if they want // a dust limit higher than our selected reserve. let their_dust_limit_satoshis = 0; + let is_0reserve = trusted_channel_features.is_some_and(|f| f.is_0reserve()); let holder_selected_channel_reserve_satoshis = get_holder_selected_channel_reserve_satoshis( channel_value_satoshis, their_dust_limit_satoshis, config, - false, + is_0reserve, ); - if holder_selected_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS { + if holder_selected_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS && !is_0reserve { // Protocol level safety check in place, although it should never happen because // of `MIN_THEIR_CHAN_RESERVE_SATOSHIS` return Err(APIError::APIMisuseError { err: format!("Holder selected channel reserve below \ - implemention limit dust_limit_satoshis {}", holder_selected_channel_reserve_satoshis) }); + implementation limit dust_limit_satoshis {}", holder_selected_channel_reserve_satoshis) }); } let channel_keys_id = signer_provider.generate_channel_keys_id(false, user_id); @@ -16470,6 +16471,7 @@ mod tests { 42, None, &logger, + None, ); match res { Err(APIError::IncompatibleShutdownScript { script }) => { @@ -16496,7 +16498,7 @@ mod tests { let node_a_node_id = PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap()); let config = UserConfig::default(); - let mut node_a_chan = OutboundV1Channel::<&TestKeysInterface>::new(&bounded_fee_estimator, &&keys_provider, &&keys_provider, node_a_node_id, &channelmanager::provided_init_features(&config), 10000000, 100000, 42, &config, 0, 42, None, &logger).unwrap(); + let mut node_a_chan = OutboundV1Channel::<&TestKeysInterface>::new(&bounded_fee_estimator, &&keys_provider, &&keys_provider, node_a_node_id, &channelmanager::provided_init_features(&config), 10000000, 100000, 42, &config, 0, 42, None, &logger, None).unwrap(); // Now change the fee so we can check that the fee in the open_channel message is the // same as the old fee. @@ -16526,7 +16528,7 @@ mod tests { let node_b_node_id = PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap()); let mut config = UserConfig::default(); config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; - let mut node_a_chan = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, node_b_node_id, &channelmanager::provided_init_features(&config), 10_000_000, 100_000_000, 42, &config, 0, 42, None, &logger).unwrap(); + let mut node_a_chan = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, node_b_node_id, &channelmanager::provided_init_features(&config), 10_000_000, 100_000_000, 42, &config, 0, 42, None, &logger, None).unwrap(); // Create Node B's channel by receiving Node A's open_channel message // Make sure A's dust limit is as we expect. @@ -16617,7 +16619,7 @@ mod tests { let node_id = PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap()); let mut config = UserConfig::default(); config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; - let mut chan = OutboundV1Channel::<&TestKeysInterface>::new(&fee_est, &&keys_provider, &&keys_provider, node_id, &channelmanager::provided_init_features(&config), 10_000_000, 100_000_000, 42, &config, 0, 42, None, &logger).unwrap(); + let mut chan = OutboundV1Channel::<&TestKeysInterface>::new(&fee_est, &&keys_provider, &&keys_provider, node_id, &channelmanager::provided_init_features(&config), 10_000_000, 100_000_000, 42, &config, 0, 42, None, &logger, None).unwrap(); chan.context.counterparty_max_htlc_value_in_flight_msat = 1_000_000_000; let commitment_tx_fee_0_htlcs = commit_tx_fee_sat(chan.context.feerate_per_kw, 0, chan.funding.get_channel_type()) * 1000; @@ -16672,7 +16674,7 @@ mod tests { // Create Node A's channel pointing to Node B's pubkey let node_b_node_id = PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap()); let config = UserConfig::default(); - let mut node_a_chan = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, node_b_node_id, &channelmanager::provided_init_features(&config), 10000000, 100000, 42, &config, 0, 42, None, &logger).unwrap(); + let mut node_a_chan = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, node_b_node_id, &channelmanager::provided_init_features(&config), 10000000, 100000, 42, &config, 0, 42, None, &logger, None).unwrap(); // Create Node B's channel by receiving Node A's open_channel message let open_channel_msg = node_a_chan.get_open_channel(chain_hash, &&logger).unwrap(); @@ -16738,12 +16740,12 @@ mod tests { // Test that `OutboundV1Channel::new` creates a channel with the correct value for // `holder_max_htlc_value_in_flight_msat`, when configured with a valid percentage value, // which is set to the lower bound + 1 (2%) of the `channel_value`. - let mut chan_1 = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, outbound_node_id, &channelmanager::provided_init_features(&config_2_percent), 10000000, 100000, 42, &config_2_percent, 0, 42, None, &logger).unwrap(); + let mut chan_1 = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, outbound_node_id, &channelmanager::provided_init_features(&config_2_percent), 10000000, 100000, 42, &config_2_percent, 0, 42, None, &logger, None).unwrap(); let chan_1_value_msat = chan_1.funding.get_value_satoshis() * 1000; assert_eq!(chan_1.context.holder_max_htlc_value_in_flight_msat, (chan_1_value_msat as f64 * 0.02) as u64); // Test with the upper bound - 1 of valid values (99%). - let chan_2 = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, outbound_node_id, &channelmanager::provided_init_features(&config_99_percent), 10000000, 100000, 42, &config_99_percent, 0, 42, None, &logger).unwrap(); + let chan_2 = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, outbound_node_id, &channelmanager::provided_init_features(&config_99_percent), 10000000, 100000, 42, &config_99_percent, 0, 42, None, &logger, None).unwrap(); let chan_2_value_msat = chan_2.funding.get_value_satoshis() * 1000; assert_eq!(chan_2.context.holder_max_htlc_value_in_flight_msat, (chan_2_value_msat as f64 * 0.99) as u64); @@ -16763,14 +16765,14 @@ mod tests { // Test that `OutboundV1Channel::new` uses the lower bound of the configurable percentage values (1%) // if `max_inbound_htlc_value_in_flight_percent_of_channel` is set to a value less than 1. - let chan_5 = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, outbound_node_id, &channelmanager::provided_init_features(&config_0_percent), 10000000, 100000, 42, &config_0_percent, 0, 42, None, &logger).unwrap(); + let chan_5 = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, outbound_node_id, &channelmanager::provided_init_features(&config_0_percent), 10000000, 100000, 42, &config_0_percent, 0, 42, None, &logger, None).unwrap(); let chan_5_value_msat = chan_5.funding.get_value_satoshis() * 1000; assert_eq!(chan_5.context.holder_max_htlc_value_in_flight_msat, (chan_5_value_msat as f64 * 0.01) as u64); // Test that `OutboundV1Channel::new` uses the upper bound of the configurable percentage values // (100%) if `max_inbound_htlc_value_in_flight_percent_of_channel` is set to a larger value // than 100. - let chan_6 = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, outbound_node_id, &channelmanager::provided_init_features(&config_101_percent), 10000000, 100000, 42, &config_101_percent, 0, 42, None, &logger).unwrap(); + let chan_6 = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, outbound_node_id, &channelmanager::provided_init_features(&config_101_percent), 10000000, 100000, 42, &config_101_percent, 0, 42, None, &logger, None).unwrap(); let chan_6_value_msat = chan_6.funding.get_value_satoshis() * 1000; assert_eq!(chan_6.context.holder_max_htlc_value_in_flight_msat, chan_6_value_msat); @@ -16826,7 +16828,7 @@ mod tests { let mut outbound_node_config = UserConfig::default(); outbound_node_config.channel_handshake_config.their_channel_reserve_proportional_millionths = (outbound_selected_channel_reserve_perc * 1_000_000.0) as u32; - let mut chan = OutboundV1Channel::<&TestKeysInterface>::new(&&fee_est, &&keys_provider, &&keys_provider, outbound_node_id, &channelmanager::provided_init_features(&outbound_node_config), channel_value_satoshis, 100_000, 42, &outbound_node_config, 0, 42, None, &logger).unwrap(); + let mut chan = OutboundV1Channel::<&TestKeysInterface>::new(&&fee_est, &&keys_provider, &&keys_provider, outbound_node_id, &channelmanager::provided_init_features(&outbound_node_config), channel_value_satoshis, 100_000, 42, &outbound_node_config, 0, 42, None, &logger, None).unwrap(); let expected_outbound_selected_chan_reserve = cmp::max(MIN_THEIR_CHAN_RESERVE_SATOSHIS, (chan.funding.get_value_satoshis() as f64 * outbound_selected_channel_reserve_perc) as u64); assert_eq!(chan.funding.holder_selected_channel_reserve_satoshis, expected_outbound_selected_chan_reserve); @@ -16865,7 +16867,7 @@ mod tests { // Create Node A's channel pointing to Node B's pubkey let node_b_node_id = PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap()); let config = UserConfig::default(); - let mut node_a_chan = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, node_b_node_id, &channelmanager::provided_init_features(&config), 10000000, 100000, 42, &config, 0, 42, None, &logger).unwrap(); + let mut node_a_chan = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, node_b_node_id, &channelmanager::provided_init_features(&config), 10000000, 100000, 42, &config, 0, 42, None, &logger, None).unwrap(); // Create Node B's channel by receiving Node A's open_channel message // Make sure A's dust limit is as we expect. @@ -16957,6 +16959,7 @@ mod tests { 42, None, &logger, + None, ) .unwrap(); let open_channel_msg = &outbound_chan @@ -17313,6 +17316,7 @@ mod tests { 42, None, &*logger, + None, ) .unwrap(); // Nothing uses their network key in this test chan.context.holder_dust_limit_satoshis = 546; @@ -18037,6 +18041,7 @@ mod tests { 0, None, &*logger, + None, ) .unwrap(); @@ -18612,7 +18617,8 @@ mod tests { 0, 42, None, - &logger + &logger, + None, ).unwrap(); let open_channel_msg = node_a_chan.get_open_channel(ChainHash::using_genesis_block(network), &&logger).unwrap(); diff --git a/lightning/src/ln/channel_open_tests.rs b/lightning/src/ln/channel_open_tests.rs index 9645d3c23b5..d28d157488d 100644 --- a/lightning/src/ln/channel_open_tests.rs +++ b/lightning/src/ln/channel_open_tests.rs @@ -939,6 +939,7 @@ pub fn test_user_configurable_csv_delay() { 42, None, &logger, + None, ) { match error { APIError::APIMisuseError { err } => { diff --git a/lightning/src/ln/channel_type_tests.rs b/lightning/src/ln/channel_type_tests.rs index dc586555f39..77caa8a2bc4 100644 --- a/lightning/src/ln/channel_type_tests.rs +++ b/lightning/src/ln/channel_type_tests.rs @@ -144,6 +144,7 @@ fn test_zero_conf_channel_type_support() { 42, None, &logger, + None, ) .unwrap(); @@ -244,6 +245,7 @@ fn do_test_supports_channel_type(config: UserConfig, expected_channel_type: Chan 42, None, &logger, + None, ) .unwrap(); assert_eq!( @@ -265,6 +267,7 @@ fn do_test_supports_channel_type(config: UserConfig, expected_channel_type: Chan 42, None, &logger, + None, ) .unwrap(); @@ -330,6 +333,7 @@ fn test_rejects_if_channel_type_not_set() { 42, None, &logger, + None, ) .unwrap(); @@ -416,6 +420,7 @@ fn test_rejects_if_channel_type_differ() { 42, None, &logger, + None, ) .unwrap(); @@ -499,6 +504,7 @@ fn test_rejects_simple_anchors_channel_type() { 42, None, &logger, + None, ) .unwrap(); @@ -540,6 +546,7 @@ fn test_rejects_simple_anchors_channel_type() { 42, None, &logger, + None, ) .unwrap(); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index d8302eed76a..d896fbe947b 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -3789,8 +3789,52 @@ impl< /// [`Event::FundingGenerationReady::user_channel_id`]: events::Event::FundingGenerationReady::user_channel_id /// [`Event::FundingGenerationReady::temporary_channel_id`]: events::Event::FundingGenerationReady::temporary_channel_id /// [`Event::ChannelClosed::channel_id`]: events::Event::ChannelClosed::channel_id - #[rustfmt::skip] - pub fn create_channel(&self, their_network_key: PublicKey, channel_value_satoshis: u64, push_msat: u64, user_channel_id: u128, temporary_channel_id: Option, override_config: Option) -> Result { + pub fn create_channel( + &self, their_network_key: PublicKey, channel_value_satoshis: u64, push_msat: u64, + user_channel_id: u128, temporary_channel_id: Option, + override_config: Option, + ) -> Result { + self.create_channel_internal( + their_network_key, + channel_value_satoshis, + push_msat, + user_channel_id, + temporary_channel_id, + override_config, + None, + ) + } + + /// 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 + /// spend their entire channel balance, and attempt to force-close the channel with a revoked commitment + /// transaction *for free*. + /// + /// Note that there is no guarantee that the counterparty accepts such a channel. + pub fn create_channel_to_trusted_peer_0reserve( + &self, their_network_key: PublicKey, channel_value_satoshis: u64, push_msat: u64, + user_channel_id: u128, temporary_channel_id: Option, + override_config: Option, + ) -> Result { + self.create_channel_internal( + their_network_key, + channel_value_satoshis, + push_msat, + user_channel_id, + temporary_channel_id, + override_config, + Some(TrustedChannelFeatures::ZeroReserve), + ) + } + + fn create_channel_internal( + &self, their_network_key: PublicKey, channel_value_satoshis: u64, push_msat: u64, + user_channel_id: u128, temporary_channel_id: Option, + override_config: Option, + trusted_channel_features: Option, + ) -> Result { if channel_value_satoshis < 1000 { return Err(APIError::APIMisuseError { err: format!("Channel value must be at least 1000 satoshis. It was {}", channel_value_satoshis) }); } @@ -3826,7 +3870,7 @@ impl< }; match OutboundV1Channel::new(&self.fee_estimator, &self.entropy_source, &self.signer_provider, their_network_key, their_features, channel_value_satoshis, push_msat, user_channel_id, config, - self.best_block.read().unwrap().height, outbound_scid_alias, temporary_channel_id, &self.logger) + self.best_block.read().unwrap().height, outbound_scid_alias, temporary_channel_id, &self.logger, trusted_channel_features) { Ok(res) => res, Err(e) => { From d6fc690d587701751466b5da4417e88aaa7d2aa0 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Sun, 8 Feb 2026 01:24:17 +0000 Subject: [PATCH 239/627] Shakedown zero reserve channels --- lightning/src/ln/htlc_reserve_unit_tests.rs | 1066 ++++++++++++++++++- 1 file changed, 1060 insertions(+), 6 deletions(-) diff --git a/lightning/src/ln/htlc_reserve_unit_tests.rs b/lightning/src/ln/htlc_reserve_unit_tests.rs index 862d94740e1..3c91808fa07 100644 --- a/lightning/src/ln/htlc_reserve_unit_tests.rs +++ b/lightning/src/ln/htlc_reserve_unit_tests.rs @@ -2,30 +2,34 @@ use crate::events::{ClosureReason, Event, HTLCHandlingFailureType, PaymentPurpose}; use crate::ln::chan_utils::{ - self, commitment_tx_base_weight, second_stage_tx_fees_sat, CommitmentTransaction, - COMMITMENT_TX_WEIGHT_PER_HTLC, + self, commit_tx_fee_sat, commitment_tx_base_weight, second_stage_tx_fees_sat, + shared_anchor_script_pubkey, CommitmentTransaction, COMMITMENT_TX_WEIGHT_PER_HTLC, + TRUC_CHILD_MAX_WEIGHT, }; use crate::ln::channel::{ - get_holder_selected_channel_reserve_satoshis, Channel, FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE, - MIN_AFFORDABLE_HTLC_COUNT, MIN_CHAN_DUST_LIMIT_SATOSHIS, + get_holder_selected_channel_reserve_satoshis, Channel, ANCHOR_OUTPUT_VALUE_SATOSHI, + FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE, MIN_AFFORDABLE_HTLC_COUNT, + MIN_CHAN_DUST_LIMIT_SATOSHIS, }; -use crate::ln::channelmanager::{PaymentId, RAACommitmentOrder}; +use crate::ln::channelmanager::{PaymentId, RAACommitmentOrder, TrustedChannelFeatures}; use crate::ln::functional_test_utils::*; use crate::ln::msgs::{self, BaseMessageHandler, ChannelMessageHandler, MessageSendEvent}; use crate::ln::onion_utils::{self, AttributionData}; use crate::ln::outbound_payment::RecipientOnionFields; +use crate::ln::types::ChannelId; use crate::routing::router::PaymentParameters; use crate::sign::ecdsa::EcdsaChannelSigner; use crate::sign::tx_builder::{SpecTxBuilder, TxBuilder}; use crate::sign::ChannelSigner; use crate::types::features::ChannelTypeFeatures; -use crate::types::payment::PaymentPreimage; +use crate::types::payment::{PaymentHash, PaymentPreimage}; use crate::util::config::UserConfig; use crate::util::errors::APIError; use lightning_macros::xtest; use bitcoin::secp256k1::{Secp256k1, SecretKey}; +use bitcoin::{Amount, Transaction}; fn do_test_counterparty_no_reserve(send_from_initiator: bool) { // A peer providing a channel_reserve_satoshis of 0 (or less than our dust limit) is insecure, @@ -2423,3 +2427,1053 @@ pub fn do_test_dust_limit_fee_accounting(can_afford: bool) { check_added_monitors(&nodes[1], 3); } } + +#[xtest(feature = "_externalize_tests")] +fn test_create_channel_to_trusted_peer_0reserve() { + let mut config = test_default_channel_config(); + + // Legacy channels + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + let channel_type = do_test_create_channel_to_trusted_peer_0reserve(config.clone()); + assert_eq!(channel_type, ChannelTypeFeatures::only_static_remote_key()); + + // Anchor channels + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + let channel_type = do_test_create_channel_to_trusted_peer_0reserve(config.clone()); + assert_eq!(channel_type, ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies()); + + // 0FC channels + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = true; + let channel_type = do_test_create_channel_to_trusted_peer_0reserve(config.clone()); + assert_eq!(channel_type, ChannelTypeFeatures::anchors_zero_fee_commitments()); +} + +fn do_test_create_channel_to_trusted_peer_0reserve(mut config: UserConfig) -> ChannelTypeFeatures { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_a_id = nodes[0].node.get_our_node_id(); + let node_b_id = nodes[1].node.get_our_node_id(); + + let channel_value_sat = 100_000; + + let temp_channel_id = nodes[0] + .node + .create_channel_to_trusted_peer_0reserve(node_b_id, channel_value_sat, 0, 42, None, None) + .unwrap(); + let mut open_channel_message = + get_event_msg!(nodes[0], MessageSendEvent::SendOpenChannel, node_b_id); + handle_and_accept_open_channel(&nodes[1], node_a_id, &open_channel_message); + let mut accept_channel_message = + get_event_msg!(nodes[1], MessageSendEvent::SendAcceptChannel, node_a_id); + nodes[0].node.handle_accept_channel(node_b_id, &accept_channel_message); + let funding_tx = sign_funding_transaction(&nodes[0], &nodes[1], 100_000, temp_channel_id); + let funding_msgs = + create_chan_between_nodes_with_value_confirm(&nodes[0], &nodes[1], &funding_tx); + create_chan_between_nodes_with_value_b(&nodes[0], &nodes[1], &funding_msgs.0); + + let details = &nodes[0].node.list_channels()[0]; + let reserve_sat = details.unspendable_punishment_reserve.unwrap(); + assert_ne!(reserve_sat, 0); + let channel_type = details.channel_type.clone().unwrap(); + 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; + assert_eq!(details.next_outbound_htlc_limit_msat, max_outbound_htlc_sat * 1000); + send_payment(&nodes[0], &[&nodes[1]], max_outbound_htlc_sat * 1000); + + let details = &nodes[1].node.list_channels()[0]; + assert_eq!(details.unspendable_punishment_reserve.unwrap(), 0); + // Assert that the fundee can send back the full amount they just received, since they have 0-reserve. + assert_eq!(details.next_outbound_htlc_limit_msat, max_outbound_htlc_sat * 1000); + send_payment(&nodes[1], &[&nodes[0]], max_outbound_htlc_sat * 1000); + + channel_type +} + +#[xtest(feature = "_externalize_tests")] +fn test_accept_inbound_channel_from_trusted_peer_0reserve() { + let mut config = test_default_channel_config(); + + // Legacy channels + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + let channel_type = do_test_accept_inbound_channel_from_trusted_peer_0reserve(config.clone()); + assert_eq!(channel_type, ChannelTypeFeatures::only_static_remote_key()); + + // Anchor channels + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + let channel_type = do_test_accept_inbound_channel_from_trusted_peer_0reserve(config.clone()); + assert_eq!(channel_type, ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies()); + + // 0FC channels + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = true; + let channel_type = do_test_accept_inbound_channel_from_trusted_peer_0reserve(config.clone()); + assert_eq!(channel_type, ChannelTypeFeatures::anchors_zero_fee_commitments()); +} + +fn do_test_accept_inbound_channel_from_trusted_peer_0reserve( + mut config: UserConfig, +) -> ChannelTypeFeatures { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_a_id = nodes[0].node.get_our_node_id(); + let node_b_id = nodes[1].node.get_our_node_id(); + + let channel_value_sat = 100_000; + + nodes[0].node.create_channel(node_b_id, channel_value_sat, 0, 42, None, None).unwrap(); + + let mut open_channel = get_event_msg!(nodes[0], MessageSendEvent::SendOpenChannel, node_b_id); + nodes[1].node.handle_open_channel(node_a_id, &open_channel); + let events = nodes[1].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match events[0] { + Event::OpenChannelRequest { temporary_channel_id: chan_id, .. } => { + nodes[1] + .node + .accept_inbound_channel_from_trusted_peer( + &chan_id, + &node_a_id, + 0, + TrustedChannelFeatures::ZeroReserve, + None, + ) + .unwrap(); + }, + _ => panic!("Unexpected event"), + }; + + let mut accept_channel_msg = + get_event_msg!(nodes[1], MessageSendEvent::SendAcceptChannel, node_a_id); + nodes[0].node.handle_accept_channel(node_b_id, &accept_channel_msg); + + let (chan_id, tx, _) = create_funding_transaction(&nodes[0], &node_b_id, channel_value_sat, 42); + + nodes[0].node.funding_transaction_generated(chan_id, node_b_id, tx.clone()).unwrap(); + nodes[1].node.handle_funding_created( + node_a_id, + &get_event_msg!(nodes[0], MessageSendEvent::SendFundingCreated, node_b_id), + ); + check_added_monitors(&nodes[1], 1); + expect_channel_pending_event(&nodes[1], &node_a_id); + + nodes[0].node.handle_funding_signed( + node_b_id, + &get_event_msg!(nodes[1], MessageSendEvent::SendFundingSigned, node_a_id), + ); + check_added_monitors(&nodes[0], 1); + expect_channel_pending_event(&nodes[0], &node_b_id); + + let (channel_ready, _channel_id) = + create_chan_between_nodes_with_value_confirm(&nodes[0], &nodes[1], &tx); + let (announcement, as_update, bs_update) = + create_chan_between_nodes_with_value_b(&nodes[0], &nodes[1], &channel_ready); + update_nodes_with_chan_announce(&nodes, 0, 1, &announcement, &as_update, &bs_update); + + let details = &nodes[0].node.list_channels()[0]; + assert_eq!(details.unspendable_punishment_reserve.unwrap(), 0); + let channel_type = details.channel_type.clone().unwrap(); + 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 - reserved_commit_tx_fee_sat - anchors_sat; + assert_eq!(details.next_outbound_htlc_limit_msat, max_outbound_htlc_sat * 1000); + send_payment(&nodes[0], &[&nodes[1]], max_outbound_htlc_sat * 1000); + + let details = &nodes[1].node.list_channels()[0]; + let reserve_sat = details.unspendable_punishment_reserve.unwrap(); + assert_ne!(reserve_sat, 0); + let max_outbound_htlc_sat = max_outbound_htlc_sat - reserve_sat; + assert_eq!(details.next_outbound_htlc_limit_msat, max_outbound_htlc_sat * 1000); + send_payment(&nodes[1], &[&nodes[0]], max_outbound_htlc_sat * 1000); + + channel_type +} + +enum LegacyChannelsNoOutputs { + PaymentSucceeds, + FailsReceiverUpdateAddHTLC, + FailsReceiverCanAcceptHTLCA, + FailsReceiverCanAcceptHTLCB, +} + +#[xtest(feature = "_externalize_tests")] +fn test_0reserve_no_outputs() { + do_test_0reserve_no_outputs_legacy(LegacyChannelsNoOutputs::PaymentSucceeds); + do_test_0reserve_no_outputs_legacy(LegacyChannelsNoOutputs::FailsReceiverCanAcceptHTLCA); + do_test_0reserve_no_outputs_legacy(LegacyChannelsNoOutputs::FailsReceiverCanAcceptHTLCB); + do_test_0reserve_no_outputs_legacy(LegacyChannelsNoOutputs::FailsReceiverUpdateAddHTLC); + + do_test_0reserve_no_outputs_keyed_anchors(true); + do_test_0reserve_no_outputs_keyed_anchors(false); + + do_test_0reserve_no_outputs_p2a_anchor(); +} + +fn setup_0reserve_no_outputs_channels<'a, 'b, 'c, 'd>( + nodes: &'a Vec>, channel_value_sat: u64, dust_limit_satoshis: u64, +) -> (ChannelId, Transaction) { + let node_a_id = nodes[0].node.get_our_node_id(); + let node_b_id = nodes[1].node.get_our_node_id(); + + // Create a channel with an identical, high dust limit and zero-reserve on both sides to make our lives easier + + nodes[0] + .node + .create_channel_to_trusted_peer_0reserve(node_b_id, channel_value_sat, 0, 42, None, None) + .unwrap(); + + let mut open_channel = get_event_msg!(nodes[0], MessageSendEvent::SendOpenChannel, node_b_id); + open_channel.common_fields.dust_limit_satoshis = dust_limit_satoshis; + nodes[1].node.handle_open_channel(node_a_id, &open_channel); + let events = nodes[1].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match events[0] { + Event::OpenChannelRequest { temporary_channel_id: chan_id, .. } => { + nodes[1] + .node + .accept_inbound_channel_from_trusted_peer( + &chan_id, + &node_a_id, + 0, + TrustedChannelFeatures::ZeroReserve, + None, + ) + .unwrap(); + }, + _ => panic!("Unexpected event"), + }; + + let mut accept_channel_msg = + get_event_msg!(nodes[1], MessageSendEvent::SendAcceptChannel, node_a_id); + accept_channel_msg.common_fields.dust_limit_satoshis = dust_limit_satoshis; + nodes[0].node.handle_accept_channel(node_b_id, &accept_channel_msg); + + let (chan_id, tx, _) = create_funding_transaction(&nodes[0], &node_b_id, channel_value_sat, 42); + + nodes[0].node.funding_transaction_generated(chan_id, node_b_id, tx.clone()).unwrap(); + nodes[1].node.handle_funding_created( + node_a_id, + &get_event_msg!(nodes[0], MessageSendEvent::SendFundingCreated, node_b_id), + ); + check_added_monitors(&nodes[1], 1); + expect_channel_pending_event(&nodes[1], &node_a_id); + + nodes[0].node.handle_funding_signed( + node_b_id, + &get_event_msg!(nodes[1], MessageSendEvent::SendFundingSigned, node_a_id), + ); + check_added_monitors(&nodes[0], 1); + expect_channel_pending_event(&nodes[0], &node_b_id); + + assert_eq!(nodes[0].tx_broadcaster.txn_broadcasted.lock().unwrap().len(), 1); + assert_eq!(nodes[0].tx_broadcaster.txn_broadcasted.lock().unwrap()[0], tx); + nodes[0].tx_broadcaster.clear(); + + let (channel_ready, channel_id) = + create_chan_between_nodes_with_value_confirm(&nodes[0], &nodes[1], &tx); + let (announcement, as_update, bs_update) = + create_chan_between_nodes_with_value_b(&nodes[0], &nodes[1], &channel_ready); + update_nodes_with_chan_announce(nodes, 0, 1, &announcement, &as_update, &bs_update); + + { + let mut per_peer_lock; + let mut peer_state_lock; + let channel = + get_channel_ref!(nodes[0], nodes[1], per_peer_lock, peer_state_lock, channel_id); + if let Some(mut chan) = channel.as_funded_mut() { + chan.context.holder_dust_limit_satoshis = dust_limit_satoshis; + } else { + panic!("Unexpected Channel phase"); + } + } + + { + let mut per_peer_lock; + let mut peer_state_lock; + let channel = + get_channel_ref!(nodes[1], nodes[0], per_peer_lock, peer_state_lock, channel_id); + if let Some(mut chan) = channel.as_funded_mut() { + chan.context.holder_dust_limit_satoshis = dust_limit_satoshis; + } else { + panic!("Unexpected Channel phase"); + } + } + + (channel_id, tx) +} + +fn do_test_0reserve_no_outputs_legacy(no_outputs_case: LegacyChannelsNoOutputs) { + let mut config = test_default_channel_config(); + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + + let channel_type = ChannelTypeFeatures::only_static_remote_key(); + + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_a_id = nodes[0].node.get_our_node_id(); + let _node_b_id = nodes[1].node.get_our_node_id(); + + let feerate_per_kw = 253; + let spike_multiple = FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32; + let dust_limit_satoshis: u64 = 546; + let channel_value_sat = 1000; + + let (channel_id, _funding_tx) = + setup_0reserve_no_outputs_channels(&nodes, channel_value_sat, dust_limit_satoshis); + assert_eq!(nodes[0].node.list_channels()[0].channel_type.as_ref().unwrap(), &channel_type); + + // Sending the biggest dust HTLC possible trims our balance output! + let (timeout_tx_fee_sat, success_tx_fee_sat) = + second_stage_tx_fees_sat(&channel_type, spike_multiple * feerate_per_kw); + let max_dust_htlc_sat = dust_limit_satoshis + success_tx_fee_sat - 1; + assert!( + channel_value_sat + .saturating_sub(commit_tx_fee_sat(feerate_per_kw, 0, &channel_type)) + .saturating_sub(max_dust_htlc_sat) + < dust_limit_satoshis + ); + + // We can't afford the fee for an additional non-dust HTLC + the fee spike HTLC, so we can only send + // dust HTLCs... + let min_local_nondust_htlc_sat = dust_limit_satoshis + timeout_tx_fee_sat; + assert!( + channel_value_sat - commit_tx_fee_sat(spike_multiple * feerate_per_kw, 2, &channel_type) + < min_local_nondust_htlc_sat + ); + + // We cannot trim our own balance output, otherwise we'd have no outputs on the commitment. We must + // also reserve enough fees to pay for an incoming non-dust HTLC, aka the fee spike buffer HTLC. + let min_value_sat = core::cmp::max( + commit_tx_fee_sat(spike_multiple * feerate_per_kw, 0, &channel_type) + dust_limit_satoshis, + commit_tx_fee_sat(spike_multiple * feerate_per_kw, 1, &channel_type), + ); + // At this point the tighter requirement is "must have an output" + assert!( + commit_tx_fee_sat(spike_multiple * feerate_per_kw, 0, &channel_type) + dust_limit_satoshis + > commit_tx_fee_sat(spike_multiple * feerate_per_kw, 1, &channel_type) + ); + // But say at 9sat/vb with default dust limit, + // the tighter requirement is actually "must have funds for an inbound HTLC" ! + assert!( + commit_tx_fee_sat(9 * 250, 0, &channel_type) + 354 + < commit_tx_fee_sat(9 * 250, 1, &channel_type) + ); + 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); + + let (sender_amount_msat, receiver_amount_msat) = match no_outputs_case { + LegacyChannelsNoOutputs::PaymentSucceeds => (sender_amount_msat, sender_amount_msat), + LegacyChannelsNoOutputs::FailsReceiverCanAcceptHTLCA => { + // A dust HTLC with 1msat added to it will break counterparty `can_accept_incoming_htlc` + // validation, as this dust HTLC would push the holder's balance output below the + // dust limit at the spike multiple feerate. + (sender_amount_msat, sender_amount_msat + 1) + }, + LegacyChannelsNoOutputs::FailsReceiverCanAcceptHTLCB => { + // In `validate_update_add_htlc`, we check that there is still some output present on + // the commitment given the *current* set of HTLCs, and the *current* feerate. So this + // HTLC will pass at `validate_update_add_htlc`, but will fail in + // `can_accept_incoming_htlc` due to failed fee spike buffer checks. + let receiver_amount_msat = (channel_value_sat + - commit_tx_fee_sat(feerate_per_kw, 0, &channel_type) + - dust_limit_satoshis) + * 1000; + (sender_amount_msat, receiver_amount_msat) + }, + LegacyChannelsNoOutputs::FailsReceiverUpdateAddHTLC => { + // Same value as above, just add 1msat, and this fails at `validate_update_add_htlc` + let receiver_amount_msat = (channel_value_sat + - commit_tx_fee_sat(feerate_per_kw, 0, &channel_type) + - dust_limit_satoshis) + * 1000; + (sender_amount_msat, receiver_amount_msat + 1) + }, + }; + + if let LegacyChannelsNoOutputs::PaymentSucceeds = no_outputs_case { + send_payment(&nodes[0], &[&nodes[1]], sender_amount_msat); + // Node 1 the fundee has 0-reserve too, so whatever they receive, they can send right back! + // Node 0 should *always* have the funds to cover the fee of a single non-dust HTLC from node 1. + assert_eq!( + nodes[1].node.list_channels()[0].next_outbound_htlc_limit_msat, + sender_amount_msat + ); + send_payment(&nodes[1], &[&nodes[0]], sender_amount_msat); + } else { + let (route, payment_hash, _, payment_secret) = + get_route_and_payment_hash!(nodes[0], nodes[1], sender_amount_msat); + let secp_ctx = Secp256k1::new(); + let session_priv = SecretKey::from_slice(&[42; 32]).unwrap(); + let cur_height = nodes[0].node.best_block.read().unwrap().height + 1; + let onion_keys = + onion_utils::construct_onion_keys(&secp_ctx, &route.paths[0], &session_priv); + let recipient_onion_fields = + RecipientOnionFields::secret_only(payment_secret, sender_amount_msat); + let (onion_payloads, htlc_msat, htlc_cltv) = onion_utils::test_build_onion_payloads( + &route.paths[0], + &recipient_onion_fields, + cur_height, + &None, + None, + None, + ) + .unwrap(); + assert_eq!(htlc_msat, sender_amount_msat); + let onion_packet = + onion_utils::construct_onion_packet(onion_payloads, onion_keys, [0; 32], &payment_hash) + .unwrap(); + let msg = msgs::UpdateAddHTLC { + channel_id, + htlc_id: 0, + amount_msat: receiver_amount_msat, + payment_hash, + cltv_expiry: htlc_cltv, + onion_routing_packet: onion_packet, + skimmed_fee_msat: None, + blinding_point: None, + hold_htlc: None, + accountable: None, + }; + + nodes[1].node.handle_update_add_htlc(node_a_id, &msg); + + if let LegacyChannelsNoOutputs::FailsReceiverUpdateAddHTLC = no_outputs_case { + nodes[1].logger.assert_log_contains( + "lightning::ln::channelmanager", + "Remote HTLC add would overdraw remaining funds", + 3, + ); + assert_eq!(nodes[1].node.list_channels().len(), 0); + let err_msg = check_closed_broadcast(&nodes[1], 1, true).pop().unwrap(); + assert_eq!(err_msg.data, "Remote HTLC add would overdraw remaining funds"); + let reason = ClosureReason::ProcessingError { + err: "Remote HTLC add would overdraw remaining funds".to_string(), + }; + check_added_monitors(&nodes[1], 1); + check_closed_event(&nodes[1], 1, reason, &[node_a_id], channel_value_sat); + + return; + } + + manually_trigger_update_fail_htlc( + &nodes, + channel_id, + channel_value_sat, + dust_limit_satoshis, + receiver_amount_msat, + htlc_cltv, + payment_hash, + ); + } +} + +fn manually_trigger_update_fail_htlc<'a, 'b, 'c, 'd>( + nodes: &'a Vec>, channel_id: ChannelId, channel_value_sat: u64, + dust_limit_satoshis: u64, receiver_amount_msat: u64, htlc_cltv: u32, payment_hash: PaymentHash, +) { + let node_a_id = nodes[0].node.get_our_node_id(); + let node_b_id = nodes[1].node.get_our_node_id(); + let secp_ctx = Secp256k1::new(); + + // Now manually create the commitment_signed message corresponding to the update_add + // nodes[0] just sent. In the code for construction of this message, "local" refers + // to the sender of the message, and "remote" refers to the receiver. + + let feerate_per_kw = get_feerate!(nodes[0], nodes[1], channel_id); + + const INITIAL_COMMITMENT_NUMBER: u64 = (1 << 48) - 1; + + let (local_secret, next_local_point) = { + let per_peer_state = nodes[0].node.per_peer_state.read().unwrap(); + let chan_lock = per_peer_state.get(&node_b_id).unwrap().lock().unwrap(); + let local_chan = + chan_lock.channel_by_id.get(&channel_id).and_then(Channel::as_funded).unwrap(); + let chan_signer = local_chan.get_signer(); + // Make the signer believe we validated another commitment, so we can release the secret + chan_signer.get_enforcement_state().last_holder_commitment -= 1; + + ( + chan_signer.release_commitment_secret(INITIAL_COMMITMENT_NUMBER).unwrap(), + chan_signer.get_per_commitment_point(INITIAL_COMMITMENT_NUMBER - 2, &secp_ctx).unwrap(), + ) + }; + let remote_point = { + let per_peer_lock; + let mut peer_state_lock; + + let channel = + get_channel_ref!(nodes[1], nodes[0], per_peer_lock, peer_state_lock, channel_id); + let chan_signer = channel.as_funded().unwrap().get_signer(); + chan_signer.get_per_commitment_point(INITIAL_COMMITMENT_NUMBER - 1, &secp_ctx).unwrap() + }; + + // Build the remote commitment transaction so we can sign it, and then later use the + // signature for the commitment_signed message. + let accepted_htlc_info = chan_utils::HTLCOutputInCommitment { + offered: false, + amount_msat: receiver_amount_msat, + cltv_expiry: htlc_cltv, + payment_hash, + transaction_output_index: Some(1), + }; + + let local_chan_balance_msat = channel_value_sat * 1000; + let commitment_number = INITIAL_COMMITMENT_NUMBER - 1; + + let res = { + let per_peer_lock; + let mut peer_state_lock; + + let channel = + get_channel_ref!(nodes[0], nodes[1], per_peer_lock, peer_state_lock, channel_id); + let chan_signer = channel.as_funded().unwrap().get_signer(); + + let (commitment_tx, _stats) = SpecTxBuilder {}.build_commitment_transaction( + false, + commitment_number, + &remote_point, + &channel.funding().channel_transaction_parameters, + &secp_ctx, + local_chan_balance_msat, + vec![accepted_htlc_info], + feerate_per_kw, + dust_limit_satoshis, + &nodes[0].logger, + ); + let params = &channel.funding().channel_transaction_parameters; + chan_signer + .sign_counterparty_commitment(params, &commitment_tx, Vec::new(), Vec::new(), &secp_ctx) + .unwrap() + }; + + let commit_signed_msg = msgs::CommitmentSigned { + channel_id, + signature: res.0, + htlc_signatures: res.1, + funding_txid: None, + }; + + // Send the commitment_signed message to the nodes[1]. + nodes[1].node.handle_commitment_signed(node_a_id, &commit_signed_msg); + let _ = nodes[1].node.get_and_clear_pending_msg_events(); + + // Send the RAA to nodes[1]. + let raa_msg = msgs::RevokeAndACK { + channel_id, + per_commitment_secret: local_secret, + next_per_commitment_point: next_local_point, + release_htlc_message_paths: Vec::new(), + }; + nodes[1].node.handle_revoke_and_ack(node_a_id, &raa_msg); + expect_and_process_pending_htlcs(&nodes[1], false); + + expect_htlc_handling_failed_destinations!( + nodes[1].node.get_and_clear_pending_events(), + &[HTLCHandlingFailureType::Receive { payment_hash }] + ); + + let events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + + // Make sure the HTLC failed in the way we expect. + match events[0] { + MessageSendEvent::UpdateHTLCs { + updates: msgs::CommitmentUpdate { ref update_fail_htlcs, .. }, + .. + } => { + assert_eq!(update_fail_htlcs.len(), 1); + update_fail_htlcs[0].clone() + }, + _ => panic!("Unexpected event"), + }; + nodes[1].logger.assert_log( + "lightning::ln::channel", + "Attempting to fail HTLC due to balance exhausted on remote commitment".to_string(), + 1, + ); + + check_added_monitors(&nodes[1], 3); +} + +fn do_test_0reserve_no_outputs_keyed_anchors(payment_success: bool) { + let mut config = test_default_channel_config(); + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + + let channel_type = ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies(); + + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_a_id = nodes[0].node.get_our_node_id(); + let _node_b_id = nodes[1].node.get_our_node_id(); + + let feerate_per_kw = 253; + let anchors_sat = 2 * ANCHOR_OUTPUT_VALUE_SATOSHI; + let dust_limit_satoshis: u64 = 546; + let channel_value_sat = { + // min opener balance is the fee for 4 HTLCs, the anchors, and the dust limit + let min_channel_size = + commit_tx_fee_sat(feerate_per_kw, MIN_AFFORDABLE_HTLC_COUNT, &channel_type) + + anchors_sat + dust_limit_satoshis; + assert!(min_channel_size > 1002); + min_channel_size + }; + + let (channel_id, _funding_tx) = + setup_0reserve_no_outputs_channels(&nodes, channel_value_sat, dust_limit_satoshis); + assert_eq!(nodes[0].node.list_channels()[0].channel_type.as_ref().unwrap(), &channel_type); + + // Sending the biggest dust HTLC possible trims our balance output! + let max_dust_htlc_sat = dust_limit_satoshis - 1; + assert!( + channel_value_sat + .saturating_sub(anchors_sat) + .saturating_sub(commit_tx_fee_sat(feerate_per_kw, 0, &channel_type)) + .saturating_sub(max_dust_htlc_sat) + < dust_limit_satoshis + ); + + // We can afford the fee for an additional non-dust HTLC plus the fee spike HTLC, so we can send + // non-dust HTLCs + let capacity_minus_max_commitment_fee_sat = + channel_value_sat - anchors_sat - commit_tx_fee_sat(feerate_per_kw, 2, &channel_type); + assert!(capacity_minus_max_commitment_fee_sat > dust_limit_satoshis); + // And since the biggest dust HTLC results in no outputs on the commitment, + // we can *only* send non-dust HTLCs + let details_0 = &nodes[0].node.list_channels()[0]; + assert_eq!(details_0.next_outbound_htlc_minimum_msat, dust_limit_satoshis * 1000); + assert_eq!( + details_0.next_outbound_htlc_limit_msat, + capacity_minus_max_commitment_fee_sat * 1000 + ); + + // Send the smallest non-dust HTLC possible, this will pass both holder and counterparty validation + // + // One msat below the non-dust HTLC value will break counterparty validation at + // `validate_update_add_htlc`. This is why we don't bother taking a look at the range between the + // failure of `can_accept_incoming_htlc` and the failure of `validate_update_add_htlc`. + let sender_amount_msat = dust_limit_satoshis * 1000; + + let (sender_amount_msat, receiver_amount_msat) = if payment_success { + (sender_amount_msat, sender_amount_msat) + } else { + (sender_amount_msat, sender_amount_msat - 1) + }; + + if payment_success { + send_payment(&nodes[0], &[&nodes[1]], sender_amount_msat); + // Node 1 the fundee has 0-reserve too, so whatever they receive, they can send right back! + // Node 0 should *always* have the funds to cover the fee of a single non-dust HTLC from node 1. + assert_eq!( + nodes[1].node.list_channels()[0].next_outbound_htlc_limit_msat, + sender_amount_msat + ); + send_payment(&nodes[1], &[&nodes[0]], sender_amount_msat); + } else { + let (route, payment_hash, _, payment_secret) = + get_route_and_payment_hash!(nodes[0], nodes[1], sender_amount_msat); + let secp_ctx = Secp256k1::new(); + let session_priv = SecretKey::from_slice(&[42; 32]).unwrap(); + let cur_height = nodes[0].node.best_block.read().unwrap().height + 1; + let onion_keys = + onion_utils::construct_onion_keys(&secp_ctx, &route.paths[0], &session_priv); + let recipient_onion_fields = + RecipientOnionFields::secret_only(payment_secret, sender_amount_msat); + let (onion_payloads, htlc_msat, htlc_cltv) = onion_utils::test_build_onion_payloads( + &route.paths[0], + &recipient_onion_fields, + cur_height, + &None, + None, + None, + ) + .unwrap(); + assert_eq!(htlc_msat, sender_amount_msat); + let onion_packet = + onion_utils::construct_onion_packet(onion_payloads, onion_keys, [0; 32], &payment_hash) + .unwrap(); + let msg = msgs::UpdateAddHTLC { + channel_id, + htlc_id: 0, + amount_msat: receiver_amount_msat, + payment_hash, + cltv_expiry: htlc_cltv, + onion_routing_packet: onion_packet, + skimmed_fee_msat: None, + blinding_point: None, + hold_htlc: None, + accountable: None, + }; + + nodes[1].node.handle_update_add_htlc(node_a_id, &msg); + + nodes[1].logger.assert_log_contains( + "lightning::ln::channelmanager", + "Remote HTLC add would overdraw remaining funds", + 3, + ); + assert_eq!(nodes[1].node.list_channels().len(), 0); + let err_msg = check_closed_broadcast(&nodes[1], 1, true).pop().unwrap(); + assert_eq!(err_msg.data, "Remote HTLC add would overdraw remaining funds"); + let reason = ClosureReason::ProcessingError { + err: "Remote HTLC add would overdraw remaining funds".to_string(), + }; + check_added_monitors(&nodes[1], 1); + check_closed_event(&nodes[1], 1, reason, &[node_a_id], channel_value_sat); + } +} + +fn do_test_0reserve_no_outputs_p2a_anchor() { + let mut config = test_default_channel_config(); + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = true; + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + + let channel_type = ChannelTypeFeatures::anchors_zero_fee_commitments(); + + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let _node_a_id = nodes[0].node.get_our_node_id(); + let _node_b_id = nodes[1].node.get_our_node_id(); + + let dust_limit_satoshis: u64 = 546; + let channel_value_sat = 1000; + + let _channel_id = + setup_0reserve_no_outputs_channels(&nodes, channel_value_sat, dust_limit_satoshis); + assert_eq!(nodes[0].node.list_channels()[0].channel_type.as_ref().unwrap(), &channel_type); + + // Sending the biggest dust HTLC possible trims our balance output! + let max_dust_htlc_sat = dust_limit_satoshis - 1; + assert!(channel_value_sat.saturating_sub(max_dust_htlc_sat) < dust_limit_satoshis); + + // We'll always have the P2A output on the commitment, so we are free to send any size HTLC, + // including those that result in only a single output on the commitment, the P2A output. + let details_0 = &nodes[0].node.list_channels()[0]; + assert_eq!(details_0.next_outbound_htlc_minimum_msat, 1000); + // 0FC + 0-reserve baby! + assert_eq!(details_0.next_outbound_htlc_limit_msat, channel_value_sat * 1000); + + // Send the max size dust HTLC; this results in a commitment with only the P2A output present + let sender_amount_msat = max_dust_htlc_sat * 1000; + + send_payment(&nodes[0], &[&nodes[1]], sender_amount_msat); + // Node 1 the fundee has 0-reserve too, so whatever they receive, they can send right back! + assert_eq!(nodes[1].node.list_channels()[0].next_outbound_htlc_limit_msat, sender_amount_msat); + send_payment(&nodes[1], &[&nodes[0]], sender_amount_msat); +} + +#[xtest(feature = "_externalize_tests")] +pub fn test_0reserve_force_close_with_single_p2a_output() { + do_test_0reserve_force_close_with_single_p2a_output(false); + do_test_0reserve_force_close_with_single_p2a_output(true); +} + +fn do_test_0reserve_force_close_with_single_p2a_output(high_feerate: bool) { + let mut config = test_default_channel_config(); + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = true; + + let chanmon_cfgs = create_chanmon_cfgs(2); + if high_feerate { + let mut feerate_lock = chanmon_cfgs[0].fee_estimator.sat_per_kw.lock().unwrap(); + *feerate_lock = 2500; + } + if high_feerate { + let mut feerate_lock = chanmon_cfgs[1].fee_estimator.sat_per_kw.lock().unwrap(); + *feerate_lock = 2500; + } + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + + let channel_type = ChannelTypeFeatures::anchors_zero_fee_commitments(); + + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let coinbase_tx = provide_anchor_reserves(&nodes); + + let _node_a_id = nodes[0].node.get_our_node_id(); + let _node_b_id = nodes[1].node.get_our_node_id(); + + let dust_limit_satoshis: u64 = 546; + // This is the fundee 1000sat reserve + 2 min HTLCs + let channel_value_sat = 1002; + + let (channel_id, funding_tx) = + setup_0reserve_no_outputs_channels(&nodes, channel_value_sat, dust_limit_satoshis); + assert_eq!(nodes[0].node.list_channels()[0].channel_type.as_ref().unwrap(), &channel_type); + + // Send the smallest HTLC possible that trims our own balance output, this will be a dust HTLC + let htlc_sat = channel_value_sat - dust_limit_satoshis + 1; + assert!(htlc_sat < dust_limit_satoshis); + route_payment(&nodes[0], &[&nodes[1]], htlc_sat * 1000); + + let commitment_tx = get_local_commitment_txn!(nodes[0], channel_id).pop().unwrap(); + let commitment_txid = commitment_tx.compute_txid(); + + let message = "Channel force-closed".to_owned(); + nodes[0] + .node + .force_close_broadcasting_latest_txn( + &channel_id, + &nodes[1].node.get_our_node_id(), + message.clone(), + ) + .unwrap(); + check_closed_broadcast(&nodes[0], 1, true); + check_added_monitors(&nodes[0], 1); + let reason = ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message }; + check_closed_event(&nodes[0], 1, reason, &[nodes[1].node.get_our_node_id()], channel_value_sat); + + let mut events = nodes[0].chain_monitor.chain_monitor.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match events.pop().unwrap() { + Event::BumpTransaction(bump_event) => { + nodes[0].bump_tx_handler.handle_event(&bump_event); + }, + _ => panic!("Unexpected event"), + } + let txns = nodes[0].tx_broadcaster.txn_broadcast(); + + if high_feerate { + assert_eq!(txns.len(), 2); + check_spends!(txns[1], txns[0], coinbase_tx); + assert!(txns[1].weight().to_wu() < TRUC_CHILD_MAX_WEIGHT); + assert_eq!(txns[1].input.len(), 2); + assert_eq!(txns[1].output.len(), 1); + + assert_eq!(txns[0].compute_txid(), commitment_txid); + assert_eq!(txns[0].input.len(), 1); + assert_eq!(txns[0].output.len(), 1); + assert_eq!(txns[0].output[0].value, Amount::from_sat(240)); + assert_eq!(txns[0].output[0].script_pubkey, shared_anchor_script_pubkey()); + check_spends!(txns[0], funding_tx); + + nodes[0].logger.assert_log( + "lightning::events::bump_transaction", + format!( + "Broadcasting anchor transaction {} to bump channel close with txid {}", + txns[1].compute_txid(), + txns[0].compute_txid() + ), + 1, + ); + } else { + assert_eq!(txns.len(), 1); + assert_eq!(txns[0].compute_txid(), commitment_txid); + assert_eq!(txns[0].input.len(), 1); + assert_eq!(txns[0].output.len(), 1); + assert_eq!(txns[0].output[0].value, Amount::from_sat(240)); + assert_eq!(txns[0].output[0].script_pubkey, shared_anchor_script_pubkey()); + check_spends!(txns[0], funding_tx); + + let weight = txns[0].weight(); + let feerate = (channel_value_sat - 240) * 1000 / weight.to_wu(); + + nodes[0].logger.assert_log( + "lightning::events::bump_transaction", + format!( + "Pre-signed commitment {} already has feerate {} sat/kW above required 253 sat/kW, broadcasting.", + txns[0].compute_txid(), + feerate, + ), + 1, + ); + } +} + +#[xtest(feature = "_externalize_tests")] +fn test_0reserve_zero_conf_combined() { + // Test that zero-reserve and zero-conf features work together: a channel that + // is immediately usable (no confirmations needed) and has zero reserve for the opener. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut config = test_default_channel_config(); + config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_a_id = nodes[0].node.get_our_node_id(); + let node_b_id = nodes[1].node.get_our_node_id(); + + let channel_value_sat = 100_000; + + // Node 0 creates a channel to node 1. + nodes[0].node.create_channel(node_b_id, channel_value_sat, 0, 42, None, None).unwrap(); + let open_channel = get_event_msg!(nodes[0], MessageSendEvent::SendOpenChannel, node_b_id); + + // Node 1 accepts with both zero-conf AND zero-reserve. + nodes[1].node.handle_open_channel(node_a_id, &open_channel); + let events = nodes[1].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match events[0] { + Event::OpenChannelRequest { temporary_channel_id: chan_id, .. } => { + nodes[1] + .node + .accept_inbound_channel_from_trusted_peer( + &chan_id, + &node_a_id, + 0, + TrustedChannelFeatures::ZeroConfZeroReserve, + None, + ) + .unwrap(); + }, + _ => panic!("Unexpected event"), + }; + + // Verify zero-conf: minimum_depth should be 0. + let accept_channel = get_event_msg!(nodes[1], MessageSendEvent::SendAcceptChannel, node_a_id); + assert_eq!(accept_channel.common_fields.minimum_depth, 0); + nodes[0].node.handle_accept_channel(node_b_id, &accept_channel); + + // Create the funding transaction (no block confirmations needed for zero-conf). + let (temporary_channel_id, tx, _) = + create_funding_transaction(&nodes[0], &node_b_id, channel_value_sat, 42); + nodes[0] + .node + .funding_transaction_generated(temporary_channel_id, node_b_id, tx.clone()) + .unwrap(); + let funding_created = get_event_msg!(nodes[0], MessageSendEvent::SendFundingCreated, node_b_id); + + // Node 1 handles funding_created and immediately sends both FundingSigned and ChannelReady. + nodes[1].node.handle_funding_created(node_a_id, &funding_created); + check_added_monitors(&nodes[1], 1); + let bs_signed_locked = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(bs_signed_locked.len(), 2); + + let as_channel_ready; + match &bs_signed_locked[0] { + MessageSendEvent::SendFundingSigned { node_id, msg } => { + assert_eq!(*node_id, node_a_id); + nodes[0].node.handle_funding_signed(node_b_id, &msg); + expect_channel_pending_event(&nodes[0], &node_b_id); + expect_channel_pending_event(&nodes[1], &node_a_id); + check_added_monitors(&nodes[0], 1); + + assert_eq!(nodes[0].tx_broadcaster.txn_broadcasted.lock().unwrap().len(), 1); + assert_eq!(nodes[0].tx_broadcaster.txn_broadcasted.lock().unwrap()[0], tx); + nodes[0].tx_broadcaster.clear(); + + as_channel_ready = + get_event_msg!(nodes[0], MessageSendEvent::SendChannelReady, node_b_id); + }, + _ => panic!("Unexpected event"), + } + match &bs_signed_locked[1] { + MessageSendEvent::SendChannelReady { node_id, msg } => { + assert_eq!(*node_id, node_a_id); + nodes[0].node.handle_channel_ready(node_b_id, &msg); + expect_channel_ready_event(&nodes[0], &node_b_id); + }, + _ => panic!("Unexpected event"), + } + + nodes[1].node.handle_channel_ready(node_a_id, &as_channel_ready); + expect_channel_ready_event(&nodes[1], &node_a_id); + + let as_channel_update = + get_event_msg!(nodes[0], MessageSendEvent::SendChannelUpdate, node_b_id); + let bs_channel_update = + get_event_msg!(nodes[1], MessageSendEvent::SendChannelUpdate, node_a_id); + nodes[0].node.handle_channel_update(node_b_id, &bs_channel_update); + nodes[1].node.handle_channel_update(node_a_id, &as_channel_update); + + // Channel should be immediately usable without any block confirmations. + assert_eq!(nodes[0].node.list_usable_channels().len(), 1); + assert_eq!(nodes[1].node.list_usable_channels().len(), 1); + + // Verify zero-reserve: opener (node 0) should have 0 reserve. + let details_a = &nodes[0].node.list_channels()[0]; + let node_0_reserve = details_a.unspendable_punishment_reserve.unwrap(); + let node_0_max_htlc = details_a.next_outbound_htlc_limit_msat; + let channel_type = details_a.channel_type.clone().unwrap(); + assert_eq!(node_0_reserve, 0); + assert_eq!(channel_type, ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies()); + assert!(details_a.is_usable); + assert_eq!(details_a.confirmations.unwrap(), 0); + assert_eq!( + node_0_max_htlc, + (channel_value_sat - commit_tx_fee_sat(253, 2, &channel_type) - 2 * 330) * 1000 + ); + + // Verify acceptor (node 1) has a non-zero reserve. + let details_b = &nodes[1].node.list_channels()[0]; + assert_ne!(details_b.unspendable_punishment_reserve.unwrap(), 0); + assert!(details_b.is_usable); + + // Send payments in both directions to verify the combined feature works end-to-end. + send_payment(&nodes[0], &[&nodes[1]], node_0_max_htlc); + + let details_b = &nodes[1].node.list_channels()[0]; + let node_1_reserve = details_b.unspendable_punishment_reserve.unwrap(); + let node_1_max_htlc = details_b.next_outbound_htlc_limit_msat; + assert_eq!(node_1_reserve, 1000); + assert_eq!(node_1_max_htlc, node_0_max_htlc - node_1_reserve * 1000); + send_payment(&nodes[1], &[&nodes[0]], node_1_max_htlc); +} From 62c58b83cb6256ab8d4c6ecfa112978569e76e76 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 5 Mar 2026 09:00:32 +0000 Subject: [PATCH 240/627] Update `chanmon_consistency` to include 0FC and 0-reserve channels Co-Authored-By: HAL 9000 --- fuzz/src/chanmon_consistency.rs | 135 +++++++++++++++++++++++--------- 1 file changed, 99 insertions(+), 36 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index e4fd3475024..9e88083e31a 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -53,6 +53,7 @@ use lightning::ln::channel::{ use lightning::ln::channel_state::ChannelDetails; use lightning::ln::channelmanager::{ ChainParameters, ChannelManager, ChannelManagerReadArgs, PaymentId, RecentPaymentDetails, + TrustedChannelFeatures, }; use lightning::ln::functional_test_utils::*; use lightning::ln::funding::{FundingContribution, FundingTemplate}; @@ -862,30 +863,41 @@ fn assert_action_timeout_awaiting_response(action: &msgs::ErrorAction) { )); } +enum ChanType { + Legacy, + KeyedAnchors, + ZeroFeeCommitments, +} + #[inline] -pub fn do_test( - data: &[u8], underlying_out: Out, anchors: bool, -) { +pub fn do_test(data: &[u8], underlying_out: Out) { let out = SearchingOutput::new(underlying_out); let broadcast_a = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); let broadcast_b = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); let broadcast_c = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); let router = FuzzRouter {}; - // Read initial monitor styles from fuzz input (1 byte: 2 bits per node) - let initial_mon_styles = if !data.is_empty() { data[0] } else { 0 }; + // Read initial monitor styles and channel type from fuzz input byte 0: + // bits 0-2: monitor styles (1 bit per node) + // bits 3-4: channel type (0=Legacy, 1=KeyedAnchors, 2=ZeroFeeCommitments) + let config_byte = if !data.is_empty() { data[0] } else { 0 }; + let chan_type = match (config_byte >> 3) & 0b11 { + 0 => ChanType::Legacy, + 1 => ChanType::KeyedAnchors, + _ => ChanType::ZeroFeeCommitments, + }; let mon_style = [ - RefCell::new(if initial_mon_styles & 0b01 != 0 { + RefCell::new(if config_byte & 0b01 != 0 { ChannelMonitorUpdateStatus::InProgress } else { ChannelMonitorUpdateStatus::Completed }), - RefCell::new(if initial_mon_styles & 0b10 != 0 { + RefCell::new(if config_byte & 0b10 != 0 { ChannelMonitorUpdateStatus::InProgress } else { ChannelMonitorUpdateStatus::Completed }), - RefCell::new(if initial_mon_styles & 0b100 != 0 { + RefCell::new(if config_byte & 0b100 != 0 { ChannelMonitorUpdateStatus::InProgress } else { ChannelMonitorUpdateStatus::Completed @@ -925,8 +937,19 @@ pub fn do_test( config.channel_config.forwarding_fee_proportional_millionths = 0; config.channel_handshake_config.announce_for_forwarding = true; config.reject_inbound_splices = false; - if !anchors { - config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + match chan_type { + ChanType::Legacy => { + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + }, + ChanType::KeyedAnchors => { + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + }, + ChanType::ZeroFeeCommitments => { + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = true; + }, } let network = Network::Bitcoin; let best_block_timestamp = genesis_block(network).header.time; @@ -977,8 +1000,19 @@ pub fn do_test( config.channel_config.forwarding_fee_proportional_millionths = 0; config.channel_handshake_config.announce_for_forwarding = true; config.reject_inbound_splices = false; - if !anchors { - config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + match chan_type { + ChanType::Legacy => { + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + }, + ChanType::KeyedAnchors => { + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + }, + ChanType::ZeroFeeCommitments => { + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = true; + }, } let mut monitors = new_hash_map(); @@ -1077,8 +1111,23 @@ pub fn do_test( }}; } macro_rules! make_channel { - ($source: expr, $dest: expr, $source_monitor: expr, $dest_monitor: expr, $dest_keys_manager: expr, $chan_id: expr) => {{ - $source.create_channel($dest.get_our_node_id(), 100_000, 42, 0, None, None).unwrap(); + ($source: expr, $dest: expr, $source_monitor: expr, $dest_monitor: expr, $dest_keys_manager: expr, $chan_id: expr, $trusted_open: expr, $trusted_accept: expr) => {{ + if $trusted_open { + $source + .create_channel_to_trusted_peer_0reserve( + $dest.get_our_node_id(), + 100_000, + 42, + 0, + None, + None, + ) + .unwrap(); + } else { + $source + .create_channel($dest.get_our_node_id(), 100_000, 42, 0, None, None) + .unwrap(); + } let open_channel = { let events = $source.get_and_clear_pending_msg_events(); assert_eq!(events.len(), 1); @@ -1103,14 +1152,26 @@ pub fn do_test( 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, + TrustedChannelFeatures::ZeroReserve, + None, + ) + .unwrap(); + } else { + $dest + .accept_inbound_channel( + temporary_channel_id, + counterparty_node_id, + user_channel_id, + None, + ) + .unwrap(); + } } else { panic!("Wrong event type"); } @@ -1286,12 +1347,16 @@ pub fn do_test( // Fuzz mode uses XOR-based hashing (all bytes XOR to one byte), and // versions 0-5 cause collisions between A-B and B-C channel pairs // (e.g., A-B with Version(1) collides with B-C with Version(3)). - make_channel!(nodes[0], nodes[1], monitor_a, monitor_b, keys_manager_b, 1); - make_channel!(nodes[0], nodes[1], monitor_a, monitor_b, keys_manager_b, 2); - make_channel!(nodes[0], nodes[1], monitor_a, monitor_b, keys_manager_b, 3); - make_channel!(nodes[1], nodes[2], monitor_b, monitor_c, keys_manager_c, 4); - make_channel!(nodes[1], nodes[2], monitor_b, monitor_c, keys_manager_c, 5); - make_channel!(nodes[1], nodes[2], monitor_b, monitor_c, keys_manager_c, 6); + // A-B: channel 2 A and B have 0-reserve (trusted open + trusted accept), + // channel 3 A has 0-reserve (trusted accept) + make_channel!(nodes[0], nodes[1], monitor_a, monitor_b, keys_manager_b, 1, false, false); + make_channel!(nodes[0], nodes[1], monitor_a, monitor_b, keys_manager_b, 2, true, true); + make_channel!(nodes[0], nodes[1], monitor_a, monitor_b, keys_manager_b, 3, false, true); + // B-C: channel 4 B has 0-reserve (via trusted accept), + // channel 5 C has 0-reserve (via trusted open) + make_channel!(nodes[1], nodes[2], monitor_b, monitor_c, keys_manager_c, 4, false, true); + make_channel!(nodes[1], nodes[2], monitor_b, monitor_c, keys_manager_c, 5, true, false); + make_channel!(nodes[1], nodes[2], monitor_b, monitor_c, keys_manager_c, 6, false, false); // Wipe the transactions-broadcasted set to make sure we don't broadcast any transactions // during normal operation in `test_return`. @@ -1375,7 +1440,7 @@ pub fn do_test( }}; } - let mut read_pos = 1; // First byte was consumed for initial mon_style + let mut read_pos = 1; // First byte was consumed for initial config (mon_style + chan_type) macro_rules! get_slice { ($len: expr) => {{ let slice_len = $len as usize; @@ -2332,7 +2397,7 @@ pub fn do_test( 0x80 => { let mut max_feerate = last_htlc_clear_fee_a; - if !anchors { + if matches!(chan_type, ChanType::Legacy) { max_feerate *= FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32; } if fee_est_a.ret_val.fetch_add(250, atomic::Ordering::AcqRel) + 250 > max_feerate { @@ -2347,7 +2412,7 @@ pub fn do_test( 0x84 => { let mut max_feerate = last_htlc_clear_fee_b; - if !anchors { + if matches!(chan_type, ChanType::Legacy) { max_feerate *= FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32; } if fee_est_b.ret_val.fetch_add(250, atomic::Ordering::AcqRel) + 250 > max_feerate { @@ -2362,7 +2427,7 @@ pub fn do_test( 0x88 => { let mut max_feerate = last_htlc_clear_fee_c; - if !anchors { + if matches!(chan_type, ChanType::Legacy) { max_feerate *= FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32; } if fee_est_c.ret_val.fetch_add(250, atomic::Ordering::AcqRel) + 250 > max_feerate { @@ -2832,12 +2897,10 @@ impl SearchingOutput { } pub fn chanmon_consistency_test(data: &[u8], out: Out) { - do_test(data, out.clone(), false); - do_test(data, out, true); + do_test(data, out); } #[no_mangle] pub extern "C" fn chanmon_consistency_run(data: *const u8, datalen: usize) { - do_test(unsafe { std::slice::from_raw_parts(data, datalen) }, test_logger::DevNull {}, false); - do_test(unsafe { std::slice::from_raw_parts(data, datalen) }, test_logger::DevNull {}, true); + do_test(unsafe { std::slice::from_raw_parts(data, datalen) }, test_logger::DevNull {}); } From 396af7cf4cf6577f0dc877e587b2347ade4d03ec Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Tue, 24 Mar 2026 19:19:59 +0000 Subject: [PATCH 241/627] Don't fail channel if inbound UA breaches counterparty-selected reserve We do not care if our balance drops below the counterparty-selected reserve upon an inbound `update_add_htlc`. This is the counterparty's problem. Hence, we drop the assumption that once our balance rises above the counterparty-selected reserve, it will always remain above this reserve for the lifetime of a funding scope. In the following commit, we make the assumption that the counterparty does not complain if we push them below our selected reserve when adding a HTLC, so we accommodate this assumption here. --- lightning/src/ln/channel.rs | 20 +++--- lightning/src/ln/htlc_reserve_unit_tests.rs | 78 --------------------- 2 files changed, 9 insertions(+), 89 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index a0b3bb141ae..67ada5a6975 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -5162,7 +5162,10 @@ impl ChannelContext { )); } - let (local_stats, _local_htlcs) = self + // Here we check two things 1) that our local commitment still has at least 1 output + // (particularly relevant in 0-reserve channels), and 2) that the counterparty can + // still afford the fee on our commitment if they are the funder. + let (_local_stats, _local_htlcs) = self .get_next_local_commitment_stats( funding, Some(HTLCAmountDirection { outbound: false, amount_msat: msg.amount_msat }), @@ -5175,16 +5178,6 @@ impl ChannelContext { ChannelError::close(String::from("Balance exhausted on local commitment")) })?; - // Check that they won't violate our local required channel reserve by adding this HTLC. - if funding.is_outbound() - && local_stats.commitment_stats.holder_balance_msat - < funding.counterparty_selected_channel_reserve_satoshis.unwrap() * 1000 - { - return Err(ChannelError::close( - "Cannot accept HTLC that would put our balance under counterparty-announced channel reserve value".to_owned() - )); - } - Ok(()) } @@ -5717,6 +5710,11 @@ impl ChannelContext { funding.counterparty_prev_commitment_tx_balance.lock().unwrap() }; + // This assumes that once our balance rises above the counterparty selected + // reserve, it never drops below again. But we allow our counterparty to + // push us under our reserve when we are the funder and they add a HTLC, as + // this is really their problem. Hence, we only run this assert in tests. + #[cfg(test)] if _stats.local_balance_before_fee_msat / 1000 < funding.counterparty_selected_channel_reserve_satoshis.unwrap() { // If the local balance is below the reserve on this new commitment, it MUST be // greater than or equal to the one on the previous commitment. diff --git a/lightning/src/ln/htlc_reserve_unit_tests.rs b/lightning/src/ln/htlc_reserve_unit_tests.rs index 3c91808fa07..608ac143c8d 100644 --- a/lightning/src/ln/htlc_reserve_unit_tests.rs +++ b/lightning/src/ln/htlc_reserve_unit_tests.rs @@ -1023,84 +1023,6 @@ pub fn test_chan_reserve_violation_outbound_htlc_inbound_chan() { assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); } -#[xtest(feature = "_externalize_tests")] -pub fn test_chan_reserve_violation_inbound_htlc_outbound_channel() { - let mut chanmon_cfgs = create_chanmon_cfgs(2); - let feerate_per_kw = *chanmon_cfgs[0].fee_estimator.sat_per_kw.lock().unwrap(); - let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - let legacy_cfg = test_legacy_channel_config(); - let node_chanmgrs = - create_node_chanmgrs(2, &node_cfgs, &[Some(legacy_cfg.clone()), Some(legacy_cfg)]); - let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); - - let node_b_id = nodes[1].node.get_our_node_id(); - - let default_config = UserConfig::default(); - let channel_type_features = ChannelTypeFeatures::only_static_remote_key(); - - // Set nodes[0]'s balance such that they will consider any above-dust received HTLC to be a - // channel reserve violation (so their balance is channel reserve (1000 sats) + commitment - // transaction fee with 0 HTLCs (183 sats)). - let mut push_amt = 100_000_000; - push_amt -= commit_tx_fee_msat( - feerate_per_kw, - MIN_AFFORDABLE_HTLC_COUNT as u64, - &channel_type_features, - ); - push_amt -= - get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config, false) * 1000; - let chan = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, push_amt); - - // Send four HTLCs to cover the initial push_msat buffer we're required to include - for _ in 0..MIN_AFFORDABLE_HTLC_COUNT { - route_payment(&nodes[1], &[&nodes[0]], 1_000_000); - } - - let (mut route, payment_hash, _, payment_secret) = - get_route_and_payment_hash!(nodes[1], nodes[0], 1000); - route.paths[0].hops[0].fee_msat = 700_000; - // Need to manually create the update_add_htlc message to go around the channel reserve check in send_htlc() - let secp_ctx = Secp256k1::new(); - let session_priv = SecretKey::from_slice(&[42; 32]).unwrap(); - let cur_height = nodes[1].node.best_block.read().unwrap().height + 1; - let onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route.paths[0], &session_priv); - let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret, 700_000); - let (onion_payloads, htlc_msat, htlc_cltv) = onion_utils::test_build_onion_payloads( - &route.paths[0], - &recipient_onion_fields, - cur_height, - &None, - None, - None, - ) - .unwrap(); - let onion_packet = - onion_utils::construct_onion_packet(onion_payloads, onion_keys, [0; 32], &payment_hash) - .unwrap(); - let msg = msgs::UpdateAddHTLC { - channel_id: chan.2, - htlc_id: MIN_AFFORDABLE_HTLC_COUNT as u64, - amount_msat: htlc_msat, - payment_hash, - cltv_expiry: htlc_cltv, - onion_routing_packet: onion_packet, - skimmed_fee_msat: None, - blinding_point: None, - hold_htlc: None, - accountable: None, - }; - - nodes[0].node.handle_update_add_htlc(node_b_id, &msg); - // Check that the payment failed and the channel is closed in response to the malicious UpdateAdd. - nodes[0].logger.assert_log_contains("lightning::ln::channelmanager", "Cannot accept HTLC that would put our balance under counterparty-announced channel reserve value", 3); - assert_eq!(nodes[0].node.list_channels().len(), 0); - let err_msg = check_closed_broadcast(&nodes[0], 1, true).pop().unwrap(); - assert_eq!(err_msg.data, "Cannot accept HTLC that would put our balance under counterparty-announced channel reserve value"); - let reason = ClosureReason::ProcessingError { err: "Cannot accept HTLC that would put our balance under counterparty-announced channel reserve value".to_string() }; - check_added_monitors(&nodes[0], 1); - check_closed_event(&nodes[0], 1, reason, &[node_b_id], 100000); -} - #[xtest(feature = "_externalize_tests")] pub fn test_chan_reserve_dust_inbound_htlcs_outbound_chan() { // Test that if we receive many dust HTLCs over an outbound channel, they don't count when From 4bd906b6ee44b792613fc5c59ab432c6096d4ccf Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 18 Mar 2026 23:10:10 +0000 Subject: [PATCH 242/627] Add 0-reserve to the internal API of V2 channels Note that this currently does not match the spec as we use an odd TLV for the `disable_channel_reserve` field in `open_channel2` and `accept_channel2` msgs. If the counterparty does not understand this field, that's ok as it just means that the counterparty will not send some HTLCs we would have accepted. We make the assumption that the counterparty will not complain if we send a HTLC that pushes their balance below our selected reserve; this could happen if the counterparty is the funder of the channel. They should not complain because if we push them below our selected reserve, this is our problem. --- lightning/src/ln/channel.rs | 15 ++-- lightning/src/ln/channelmanager.rs | 1 + lightning/src/ln/msgs.rs | 108 +++++++++++++++++++++++------ 3 files changed, 96 insertions(+), 28 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 67ada5a6975..c05cd26a291 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -14505,7 +14505,7 @@ impl PendingV2Channel { counterparty_node_id: PublicKey, their_features: &InitFeatures, funding_satoshis: u64, funding_inputs: Vec, user_id: u128, config: &UserConfig, current_chain_height: u32, outbound_scid_alias: u64, funding_confirmation_target: ConfirmationTarget, - logger: L, + logger: L, trusted_channel_features: Option, ) -> Result { let channel_keys_id = signer_provider.generate_channel_keys_id(false, user_id); let holder_signer = signer_provider.derive_channel_signer(channel_keys_id); @@ -14515,7 +14515,7 @@ impl PendingV2Channel { }); let holder_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis( - funding_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS, false); + funding_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS, trusted_channel_features.is_some_and(|f| f.is_0reserve())); let funding_feerate_sat_per_1000_weight = fee_estimator.bounded_sat_per_1000_weight(funding_confirmation_target); let funding_tx_locktime = LockTime::from_height(current_chain_height) @@ -14633,6 +14633,7 @@ impl PendingV2Channel { second_per_commitment_point, locktime: self.funding_negotiation_context.funding_tx_locktime.to_consensus_u32(), require_confirmed_inputs: None, + disable_channel_reserve: (self.funding.holder_selected_channel_reserve_satoshis == 0).then_some(()), } } @@ -14645,7 +14646,7 @@ impl PendingV2Channel { fee_estimator: &LowerBoundedFeeEstimator, entropy_source: &ES, signer_provider: &SP, holder_node_id: PublicKey, counterparty_node_id: PublicKey, our_supported_features: &ChannelTypeFeatures, their_features: &InitFeatures, msg: &msgs::OpenChannelV2, - user_id: u128, config: &UserConfig, current_chain_height: u32, logger: &L, + user_id: u128, config: &UserConfig, current_chain_height: u32, logger: &L, trusted_channel_features: Option, ) -> Result { // TODO(dual_funding): Take these as input once supported let (our_funding_contribution, our_funding_contribution_sats) = (SignedAmount::ZERO, 0u64); @@ -14654,9 +14655,9 @@ impl PendingV2Channel { let channel_value_satoshis = our_funding_contribution_sats.saturating_add(msg.common_fields.funding_satoshis); let counterparty_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis( - channel_value_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS, false); + channel_value_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS, msg.disable_channel_reserve.is_some()); let holder_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis( - channel_value_satoshis, msg.common_fields.dust_limit_satoshis, false); + channel_value_satoshis, msg.common_fields.dust_limit_satoshis, trusted_channel_features.is_some_and(|f| f.is_0reserve())); let channel_type = channel_type_from_open_channel(&msg.common_fields, our_supported_features)?; @@ -14678,7 +14679,7 @@ impl PendingV2Channel { config, current_chain_height, logger, - None, + trusted_channel_features, our_funding_contribution_sats, counterparty_pubkeys, channel_type, @@ -14797,6 +14798,8 @@ impl PendingV2Channel { as u64, second_per_commitment_point, require_confirmed_inputs: None, + disable_channel_reserve: (self.funding.holder_selected_channel_reserve_satoshis == 0) + .then_some(()), } } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index d896fbe947b..1b3206a9242 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -11274,6 +11274,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ &config, best_block_height, &self.logger, + trusted_channel_features, ) .map_err(|e| { let channel_id = open_channel_msg.common_fields.temporary_channel_id; diff --git a/lightning/src/ln/msgs.rs b/lightning/src/ln/msgs.rs index 29089032843..d49c57388a5 100644 --- a/lightning/src/ln/msgs.rs +++ b/lightning/src/ln/msgs.rs @@ -303,6 +303,8 @@ pub struct OpenChannelV2 { pub second_per_commitment_point: PublicKey, /// Optionally, a requirement that only confirmed inputs can be added pub require_confirmed_inputs: Option<()>, + /// Optionally, disables the channel reserve of the receiver + pub disable_channel_reserve: Option<()>, } /// Contains fields that are both common to [`accept_channel`] and [`accept_channel2`] messages. @@ -379,6 +381,8 @@ pub struct AcceptChannelV2 { pub second_per_commitment_point: PublicKey, /// Optionally, a requirement that only confirmed inputs can be added pub require_confirmed_inputs: Option<()>, + /// Optionally, disables the channel reserve of the receiver + pub disable_channel_reserve: Option<()>, } /// A [`funding_created`] message to be sent to or received from a peer. @@ -2960,6 +2964,7 @@ impl Writeable for AcceptChannelV2 { (0, self.common_fields.shutdown_scriptpubkey.as_ref().map(|s| WithoutLength(s)), option), // Don't encode length twice. (1, self.common_fields.channel_type, option), (2, self.require_confirmed_inputs, option), + (103, self.disable_channel_reserve, option), }); Ok(()) } @@ -2986,10 +2991,12 @@ impl LengthReadable for AcceptChannelV2 { let mut shutdown_scriptpubkey: Option = None; let mut channel_type: Option = None; let mut require_confirmed_inputs: Option<()> = None; + let mut disable_channel_reserve: Option<()> = None; decode_tlv_stream!(r, { (0, shutdown_scriptpubkey, (option, encoding: (ScriptBuf, WithoutLength))), (1, channel_type, option), (2, require_confirmed_inputs, option), + (103, disable_channel_reserve, option), }); Ok(AcceptChannelV2 { @@ -3013,6 +3020,7 @@ impl LengthReadable for AcceptChannelV2 { funding_satoshis, second_per_commitment_point, require_confirmed_inputs, + disable_channel_reserve, }) } } @@ -3390,6 +3398,7 @@ impl Writeable for OpenChannelV2 { (0, self.common_fields.shutdown_scriptpubkey.as_ref().map(|s| WithoutLength(s)), option), // Don't encode length twice. (1, self.common_fields.channel_type, option), (2, self.require_confirmed_inputs, option), + (103, self.disable_channel_reserve, option), }); Ok(()) } @@ -3420,10 +3429,12 @@ impl LengthReadable for OpenChannelV2 { let mut shutdown_scriptpubkey: Option = None; let mut channel_type: Option = None; let mut require_confirmed_inputs: Option<()> = None; + let mut disable_channel_reserve: Option<()> = None; decode_tlv_stream!(r, { (0, shutdown_scriptpubkey, (option, encoding: (ScriptBuf, WithoutLength))), (1, channel_type, option), (2, require_confirmed_inputs, option), + (103, disable_channel_reserve, option), }); Ok(OpenChannelV2 { common_fields: CommonOpenChannelFields { @@ -3450,6 +3461,7 @@ impl LengthReadable for OpenChannelV2 { locktime, second_per_commitment_point, require_confirmed_inputs, + disable_channel_reserve, }) } } @@ -5187,6 +5199,7 @@ mod tests { fn do_encoding_open_channelv2( random_bit: bool, shutdown: bool, incl_chan_type: bool, require_confirmed_inputs: bool, + disable_channel_reserve: bool, ) { let secp_ctx = Secp256k1::new(); let (_, pubkey_1) = get_keys_from!( @@ -5255,7 +5268,8 @@ mod tests { funding_feerate_sat_per_1000_weight: 821716, locktime: 305419896, second_per_commitment_point: pubkey_7, - require_confirmed_inputs: if require_confirmed_inputs { Some(()) } else { None }, + require_confirmed_inputs: require_confirmed_inputs.then_some(()), + disable_channel_reserve: disable_channel_reserve.then_some(()), }; let encoded_value = open_channelv2.encode(); let mut target_value = Vec::new(); @@ -5340,27 +5354,46 @@ mod tests { if require_confirmed_inputs { target_value.append(&mut >::from_hex("0200").unwrap()); } + if disable_channel_reserve { + target_value.append(&mut >::from_hex("6700").unwrap()); + } assert_eq!(encoded_value, target_value); } #[test] fn encoding_open_channelv2() { - do_encoding_open_channelv2(false, false, false, false); - do_encoding_open_channelv2(false, false, false, true); - do_encoding_open_channelv2(false, false, true, false); - do_encoding_open_channelv2(false, false, true, true); - do_encoding_open_channelv2(false, true, false, false); - do_encoding_open_channelv2(false, true, false, true); - do_encoding_open_channelv2(false, true, true, false); - do_encoding_open_channelv2(false, true, true, true); - do_encoding_open_channelv2(true, false, false, false); - do_encoding_open_channelv2(true, false, false, true); - do_encoding_open_channelv2(true, false, true, false); - do_encoding_open_channelv2(true, false, true, true); - do_encoding_open_channelv2(true, true, false, false); - do_encoding_open_channelv2(true, true, false, true); - do_encoding_open_channelv2(true, true, true, false); - do_encoding_open_channelv2(true, true, true, true); + do_encoding_open_channelv2(false, false, false, false, false); + do_encoding_open_channelv2(false, false, false, false, true); + do_encoding_open_channelv2(false, false, false, true, false); + do_encoding_open_channelv2(false, false, false, true, true); + do_encoding_open_channelv2(false, false, true, false, false); + do_encoding_open_channelv2(false, false, true, false, true); + do_encoding_open_channelv2(false, false, true, true, false); + do_encoding_open_channelv2(false, false, true, true, true); + do_encoding_open_channelv2(false, true, false, false, false); + do_encoding_open_channelv2(false, true, false, false, true); + do_encoding_open_channelv2(false, true, false, true, false); + do_encoding_open_channelv2(false, true, false, true, true); + do_encoding_open_channelv2(false, true, true, false, false); + do_encoding_open_channelv2(false, true, true, false, true); + do_encoding_open_channelv2(false, true, true, true, false); + do_encoding_open_channelv2(false, true, true, true, true); + do_encoding_open_channelv2(true, false, false, false, false); + do_encoding_open_channelv2(true, false, false, false, true); + do_encoding_open_channelv2(true, false, false, true, false); + do_encoding_open_channelv2(true, false, false, true, true); + do_encoding_open_channelv2(true, false, true, false, false); + do_encoding_open_channelv2(true, false, true, false, true); + do_encoding_open_channelv2(true, false, true, true, false); + do_encoding_open_channelv2(true, false, true, true, true); + do_encoding_open_channelv2(true, true, false, false, false); + do_encoding_open_channelv2(true, true, false, false, true); + do_encoding_open_channelv2(true, true, false, true, false); + do_encoding_open_channelv2(true, true, false, true, true); + do_encoding_open_channelv2(true, true, true, false, false); + do_encoding_open_channelv2(true, true, true, false, true); + do_encoding_open_channelv2(true, true, true, true, false); + do_encoding_open_channelv2(true, true, true, true, true); } fn do_encoding_accept_channel(shutdown: bool) { @@ -5436,7 +5469,10 @@ mod tests { do_encoding_accept_channel(true); } - fn do_encoding_accept_channelv2(shutdown: bool) { + fn do_encoding_accept_channelv2( + shutdown: bool, incl_chan_type: bool, require_confirmed_inputs: bool, + disable_channel_reserve: bool, + ) { let secp_ctx = Secp256k1::new(); let (_, pubkey_1) = get_keys_from!( "0101010101010101010101010101010101010101010101010101010101010101", @@ -5492,11 +5528,16 @@ mod tests { } else { None }, - channel_type: None, + channel_type: if incl_chan_type { + Some(ChannelTypeFeatures::empty()) + } else { + None + }, }, funding_satoshis: 1311768467284833366, second_per_commitment_point: pubkey_7, - require_confirmed_inputs: None, + require_confirmed_inputs: require_confirmed_inputs.then_some(()), + disable_channel_reserve: disable_channel_reserve.then_some(()), }; let encoded_value = accept_channelv2.encode(); let mut target_value = @@ -5557,13 +5598,36 @@ mod tests { .unwrap(), ); } + if incl_chan_type { + target_value.append(&mut >::from_hex("0100").unwrap()); + } + if require_confirmed_inputs { + target_value.append(&mut >::from_hex("0200").unwrap()); + } + if disable_channel_reserve { + target_value.append(&mut >::from_hex("6700").unwrap()); + } assert_eq!(encoded_value, target_value); } #[test] fn encoding_accept_channelv2() { - do_encoding_accept_channelv2(false); - do_encoding_accept_channelv2(true); + do_encoding_accept_channelv2(false, false, false, false); + do_encoding_accept_channelv2(false, false, false, true); + do_encoding_accept_channelv2(false, false, true, false); + do_encoding_accept_channelv2(false, false, true, true); + do_encoding_accept_channelv2(false, true, false, false); + do_encoding_accept_channelv2(false, true, false, true); + do_encoding_accept_channelv2(false, true, true, false); + do_encoding_accept_channelv2(false, true, true, true); + do_encoding_accept_channelv2(true, false, false, false); + do_encoding_accept_channelv2(true, false, false, true); + do_encoding_accept_channelv2(true, false, true, false); + do_encoding_accept_channelv2(true, false, true, true); + do_encoding_accept_channelv2(true, true, false, false); + do_encoding_accept_channelv2(true, true, false, true); + do_encoding_accept_channelv2(true, true, true, false); + do_encoding_accept_channelv2(true, true, true, true); } #[test] From 2e8655446b96ba4ea7607f6913a66445c3eb1431 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 26 Feb 2026 03:01:46 +0000 Subject: [PATCH 243/627] Format `ChannelManager::create_channel_internal` and... `ChannelContext::do_accept_channel_checks`, `ChannelContext::new_for_outbound_channel`, `ChannelContext::new_for_inbound_channel`, `InboundV1Channel::new`, `OutboundV1Channel::new`. --- lightning/src/ln/channel.rs | 737 ++++++++++++++++++++--------- lightning/src/ln/channelmanager.rs | 61 ++- 2 files changed, 559 insertions(+), 239 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index c05cd26a291..0a2d952b488 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -3682,160 +3682,266 @@ impl InitialRemoteCommitmentReceiver for FundedChannel ChannelContext { - #[rustfmt::skip] fn new_for_inbound_channel<'a, ES: EntropySource, F: FeeEstimator, L: Logger>( - fee_estimator: &'a LowerBoundedFeeEstimator, - 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, - 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, 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, 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, ) -> Result<(FundingScope, ChannelContext), ChannelError> { - let logger = WithContext::from(logger, Some(counterparty_node_id), Some(open_channel_fields.temporary_channel_id), None); - let announce_for_forwarding = if (open_channel_fields.channel_flags & 1) == 1 { true } else { false }; + let logger = WithContext::from( + logger, + Some(counterparty_node_id), + Some(open_channel_fields.temporary_channel_id), + None, + ); + let announce_for_forwarding = + if (open_channel_fields.channel_flags & 1) == 1 { true } else { false }; - let channel_value_satoshis = our_funding_satoshis.saturating_add(open_channel_fields.funding_satoshis); + let channel_value_satoshis = + our_funding_satoshis.saturating_add(open_channel_fields.funding_satoshis); let channel_keys_id = signer_provider.generate_channel_keys_id(true, user_id); let holder_signer = signer_provider.derive_channel_signer(channel_keys_id); if config.channel_handshake_config.our_to_self_delay < BREAKDOWN_TIMEOUT { - return Err(ChannelError::close(format!("Configured with an unreasonable our_to_self_delay ({}) putting user funds at risks. It must be greater than {}", config.channel_handshake_config.our_to_self_delay, BREAKDOWN_TIMEOUT))); + return Err(ChannelError::close(format!( + "Configured with an unreasonable our_to_self_delay ({}) putting user funds at risks. It must be greater than {}", + config.channel_handshake_config.our_to_self_delay, BREAKDOWN_TIMEOUT + ))); } if channel_value_satoshis >= TOTAL_BITCOIN_SUPPLY_SATOSHIS { - return Err(ChannelError::close(format!("Funding must be smaller than the total bitcoin supply. It was {}", channel_value_satoshis))); + return Err(ChannelError::close(format!( + "Funding must be smaller than the total bitcoin supply. It was {}", + channel_value_satoshis + ))); } if msg_channel_reserve_satoshis > channel_value_satoshis { - return Err(ChannelError::close(format!("Bogus channel_reserve_satoshis ({}). Must be no greater than channel_value_satoshis: {}", msg_channel_reserve_satoshis, channel_value_satoshis))); + return Err(ChannelError::close(format!( + "Bogus channel_reserve_satoshis ({}). Must be no greater than channel_value_satoshis: {}", + msg_channel_reserve_satoshis, channel_value_satoshis + ))); } - let full_channel_value_msat = (channel_value_satoshis - msg_channel_reserve_satoshis) * 1000; + let full_channel_value_msat = + (channel_value_satoshis - msg_channel_reserve_satoshis) * 1000; if msg_push_msat > full_channel_value_msat { - return Err(ChannelError::close(format!("push_msat {} was larger than channel amount minus reserve ({})", msg_push_msat, full_channel_value_msat))); + return Err(ChannelError::close(format!( + "push_msat {} was larger than channel amount minus reserve ({})", + msg_push_msat, full_channel_value_msat + ))); } if open_channel_fields.dust_limit_satoshis > channel_value_satoshis { - return Err(ChannelError::close(format!("dust_limit_satoshis {} was larger than channel_value_satoshis {}. Peer never wants payout outputs?", open_channel_fields.dust_limit_satoshis, channel_value_satoshis))); + return Err(ChannelError::close(format!( + "dust_limit_satoshis {} was larger than channel_value_satoshis {}. Peer never wants payout outputs?", + open_channel_fields.dust_limit_satoshis, channel_value_satoshis + ))); } if open_channel_fields.htlc_minimum_msat >= full_channel_value_msat { - return Err(ChannelError::close(format!("Minimum htlc value ({}) was larger than full channel value ({})", open_channel_fields.htlc_minimum_msat, full_channel_value_msat))); + return Err(ChannelError::close(format!( + "Minimum htlc value ({}) was larger than full channel value ({})", + open_channel_fields.htlc_minimum_msat, full_channel_value_msat + ))); } - FundedChannel::::check_remote_fee(&channel_type, fee_estimator, open_channel_fields.commitment_feerate_sat_per_1000_weight, None, &&logger)?; + FundedChannel::::check_remote_fee( + &channel_type, + fee_estimator, + open_channel_fields.commitment_feerate_sat_per_1000_weight, + None, + &&logger, + )?; - let max_counterparty_selected_contest_delay = u16::min(config.channel_handshake_limits.their_to_self_delay, MAX_LOCAL_BREAKDOWN_TIMEOUT); + let max_counterparty_selected_contest_delay = u16::min( + config.channel_handshake_limits.their_to_self_delay, + MAX_LOCAL_BREAKDOWN_TIMEOUT, + ); if open_channel_fields.to_self_delay > max_counterparty_selected_contest_delay { - return Err(ChannelError::close(format!("They wanted our payments to be delayed by a needlessly long period. Upper limit: {}. Actual: {}", max_counterparty_selected_contest_delay, open_channel_fields.to_self_delay))); + return Err(ChannelError::close(format!( + "They wanted our payments to be delayed by a needlessly long period. Upper limit: {}. Actual: {}", + max_counterparty_selected_contest_delay, open_channel_fields.to_self_delay + ))); } if open_channel_fields.max_accepted_htlcs < 1 { - return Err(ChannelError::close("0 max_accepted_htlcs makes for a useless channel".to_owned())); + return Err(ChannelError::close( + "0 max_accepted_htlcs makes for a useless channel".to_owned(), + )); } if open_channel_fields.max_accepted_htlcs > max_htlcs(&channel_type) { - return Err(ChannelError::close(format!("max_accepted_htlcs was {}. It must not be larger than {}", open_channel_fields.max_accepted_htlcs, max_htlcs(&channel_type)))); + return Err(ChannelError::close(format!( + "max_accepted_htlcs was {}. It must not be larger than {}", + open_channel_fields.max_accepted_htlcs, + max_htlcs(&channel_type) + ))); } // Now check against optional parameters as set by config... if channel_value_satoshis < config.channel_handshake_limits.min_funding_satoshis { - return Err(ChannelError::close(format!("Funding satoshis ({}) is less than the user specified limit ({})", channel_value_satoshis, config.channel_handshake_limits.min_funding_satoshis))); + return Err(ChannelError::close(format!( + "Funding satoshis ({}) is less than the user specified limit ({})", + channel_value_satoshis, config.channel_handshake_limits.min_funding_satoshis + ))); } - if open_channel_fields.htlc_minimum_msat > config.channel_handshake_limits.max_htlc_minimum_msat { - return Err(ChannelError::close(format!("htlc_minimum_msat ({}) is higher than the user specified limit ({})", open_channel_fields.htlc_minimum_msat, config.channel_handshake_limits.max_htlc_minimum_msat))); + if open_channel_fields.htlc_minimum_msat + > config.channel_handshake_limits.max_htlc_minimum_msat + { + return Err(ChannelError::close(format!( + "htlc_minimum_msat ({}) is higher than the user specified limit ({})", + open_channel_fields.htlc_minimum_msat, + config.channel_handshake_limits.max_htlc_minimum_msat + ))); } - if open_channel_fields.max_htlc_value_in_flight_msat < config.channel_handshake_limits.min_max_htlc_value_in_flight_msat { - return Err(ChannelError::close(format!("max_htlc_value_in_flight_msat ({}) is less than the user specified limit ({})", open_channel_fields.max_htlc_value_in_flight_msat, config.channel_handshake_limits.min_max_htlc_value_in_flight_msat))); + if open_channel_fields.max_htlc_value_in_flight_msat + < config.channel_handshake_limits.min_max_htlc_value_in_flight_msat + { + return Err(ChannelError::close(format!( + "max_htlc_value_in_flight_msat ({}) is less than the user specified limit ({})", + open_channel_fields.max_htlc_value_in_flight_msat, + config.channel_handshake_limits.min_max_htlc_value_in_flight_msat + ))); } - if msg_channel_reserve_satoshis > config.channel_handshake_limits.max_channel_reserve_satoshis { - return Err(ChannelError::close(format!("channel_reserve_satoshis ({}) is higher than the user specified limit ({})", msg_channel_reserve_satoshis, config.channel_handshake_limits.max_channel_reserve_satoshis))); + if msg_channel_reserve_satoshis + > config.channel_handshake_limits.max_channel_reserve_satoshis + { + return Err(ChannelError::close(format!( + "channel_reserve_satoshis ({}) is higher than the user specified limit ({})", + msg_channel_reserve_satoshis, + config.channel_handshake_limits.max_channel_reserve_satoshis + ))); } - if open_channel_fields.max_accepted_htlcs < config.channel_handshake_limits.min_max_accepted_htlcs { - return Err(ChannelError::close(format!("max_accepted_htlcs ({}) is less than the user specified limit ({})", open_channel_fields.max_accepted_htlcs, config.channel_handshake_limits.min_max_accepted_htlcs))); + if open_channel_fields.max_accepted_htlcs + < config.channel_handshake_limits.min_max_accepted_htlcs + { + return Err(ChannelError::close(format!( + "max_accepted_htlcs ({}) is less than the user specified limit ({})", + open_channel_fields.max_accepted_htlcs, + config.channel_handshake_limits.min_max_accepted_htlcs + ))); } if open_channel_fields.dust_limit_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS { - return Err(ChannelError::close(format!("dust_limit_satoshis ({}) is less than the implementation limit ({})", open_channel_fields.dust_limit_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS))); + return Err(ChannelError::close(format!( + "dust_limit_satoshis ({}) is less than the implementation limit ({})", + open_channel_fields.dust_limit_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS + ))); } - let max_chan_dust_limit_satoshis = if channel_type.supports_anchors_zero_fee_htlc_tx() || channel_type.supports_anchor_zero_fee_commitments() { + let max_chan_dust_limit_satoshis = if channel_type.supports_anchors_zero_fee_htlc_tx() + || channel_type.supports_anchor_zero_fee_commitments() + { MAX_CHAN_DUST_LIMIT_SATOSHIS } else { MAX_LEGACY_CHAN_DUST_LIMIT_SATOSHIS }; if open_channel_fields.dust_limit_satoshis > max_chan_dust_limit_satoshis { - return Err(ChannelError::close(format!("dust_limit_satoshis ({}) is greater than the implementation limit ({})", open_channel_fields.dust_limit_satoshis, max_chan_dust_limit_satoshis))); + return Err(ChannelError::close(format!( + "dust_limit_satoshis ({}) is greater than the implementation limit ({})", + open_channel_fields.dust_limit_satoshis, max_chan_dust_limit_satoshis + ))); } // Convert things into internal flags and prep our state: if config.channel_handshake_limits.force_announced_channel_preference { if config.channel_handshake_config.announce_for_forwarding != announce_for_forwarding { - return Err(ChannelError::close("Peer tried to open channel but their announcement preference is different from ours".to_owned())); + return Err(ChannelError::close(String::from( + "Peer tried to open channel but their announcement preference is different from ours" + ))); } } - if holder_selected_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS && holder_selected_channel_reserve_satoshis != 0 { + if holder_selected_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS + && holder_selected_channel_reserve_satoshis != 0 + { // Protocol level safety check in place, although it should never happen because // of `MIN_THEIR_CHAN_RESERVE_SATOSHIS` - return Err(ChannelError::close(format!("Suitable channel reserve not found. remote_channel_reserve was ({}). dust_limit_satoshis is ({}).", holder_selected_channel_reserve_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS))); + return Err(ChannelError::close(format!( + "Suitable channel reserve not found. remote_channel_reserve was ({}). dust_limit_satoshis is ({}).", + holder_selected_channel_reserve_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS + ))); } if holder_selected_channel_reserve_satoshis * 1000 >= full_channel_value_msat { - return Err(ChannelError::close(format!("Suitable channel reserve not found. remote_channel_reserve was ({})msats. Channel value is ({} - {})msats.", holder_selected_channel_reserve_satoshis * 1000, full_channel_value_msat, msg_push_msat))); + return Err(ChannelError::close(format!( + "Suitable channel reserve not found. remote_channel_reserve was ({})msats. Channel value is ({} - {})msats.", + holder_selected_channel_reserve_satoshis * 1000, full_channel_value_msat, msg_push_msat + ))); } if msg_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS { - log_debug!(logger, "channel_reserve_satoshis ({}) is smaller than our dust limit ({}). We can broadcast stale states without any risk, implying this channel is very insecure for our counterparty.", + log_debug!( + logger, + "channel_reserve_satoshis ({}) is smaller than our dust limit ({}). We can broadcast \ + stale states without any risk, implying this channel is very insecure for our counterparty.", msg_channel_reserve_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS); } - if holder_selected_channel_reserve_satoshis < open_channel_fields.dust_limit_satoshis && holder_selected_channel_reserve_satoshis != 0 { - return Err(ChannelError::close(format!("Dust limit ({}) too high for the channel reserve we require the remote to keep ({})", open_channel_fields.dust_limit_satoshis, holder_selected_channel_reserve_satoshis))); + if holder_selected_channel_reserve_satoshis < open_channel_fields.dust_limit_satoshis + && holder_selected_channel_reserve_satoshis != 0 + { + return Err(ChannelError::close(format!( + "Dust limit ({}) too high for the channel reserve we require the remote to keep ({})", + open_channel_fields.dust_limit_satoshis, holder_selected_channel_reserve_satoshis + ))); } // v1 channel opens set `our_funding_satoshis` to 0, and v2 channel opens set `msg_push_msat` to 0. debug_assert!(our_funding_satoshis == 0 || msg_push_msat == 0); let value_to_self_msat = our_funding_satoshis * 1000 + msg_push_msat; - let counterparty_shutdown_scriptpubkey = if their_features.supports_upfront_shutdown_script() { - match &open_channel_fields.shutdown_scriptpubkey { - &Some(ref script) => { - // Peer is signaling upfront_shutdown and has opt-out with a 0-length script. We don't enforce anything - if script.len() == 0 { - None - } else { - if !script::is_bolt2_compliant(&script, their_features) { - return Err(ChannelError::close(format!("Peer is signaling upfront_shutdown but has provided an unacceptable scriptpubkey format: {}", script))) + let counterparty_shutdown_scriptpubkey = + if their_features.supports_upfront_shutdown_script() { + match &open_channel_fields.shutdown_scriptpubkey { + &Some(ref script) => { + // Peer is signaling upfront_shutdown and has opt-out with a 0-length script. We don't enforce anything + if script.len() == 0 { + None + } else { + if !script::is_bolt2_compliant(&script, their_features) { + return Err(ChannelError::close(format!( + "Peer is signaling upfront_shutdown but has provided an unacceptable scriptpubkey format: {}", + script + ))); + } + Some(script.clone()) } - Some(script.clone()) - } - }, - // Peer is signaling upfront shutdown but don't opt-out with correct mechanism (a.k.a 0-length script). Peer looks buggy, we fail the channel - &None => { - return Err(ChannelError::close("Peer is signaling upfront_shutdown but we don't get any script. Use 0-length script to opt-out".to_owned())); + }, + // Peer is signaling upfront shutdown but don't opt-out with correct mechanism (a.k.a 0-length script). Peer looks buggy, we fail the channel + &None => { + return Err(ChannelError::close(String::from( + "Peer is signaling upfront_shutdown but we don't get any script. Use 0-length script to opt-out" + ))); + }, } - } - } else { None }; + } else { + None + }; - let shutdown_scriptpubkey = if config.channel_handshake_config.commit_upfront_shutdown_pubkey { - match signer_provider.get_shutdown_scriptpubkey() { - Ok(scriptpubkey) => Some(scriptpubkey), - Err(_) => return Err(ChannelError::close("Failed to get upfront shutdown scriptpubkey".to_owned())), - } - } else { None }; + let shutdown_scriptpubkey = + if config.channel_handshake_config.commit_upfront_shutdown_pubkey { + match signer_provider.get_shutdown_scriptpubkey() { + Ok(scriptpubkey) => Some(scriptpubkey), + Err(_) => { + return Err(ChannelError::close( + "Failed to get upfront shutdown scriptpubkey".to_owned(), + )) + }, + } + } else { + None + }; if let Some(shutdown_scriptpubkey) = &shutdown_scriptpubkey { if !shutdown_scriptpubkey.is_compatible(&their_features) { - return Err(ChannelError::close(format!("Provided a scriptpubkey format not accepted by peer: {}", shutdown_scriptpubkey))); + return Err(ChannelError::close(format!( + "Provided a scriptpubkey format not accepted by peer: {}", + shutdown_scriptpubkey + ))); } } let destination_script = match signer_provider.get_destination_script(channel_keys_id) { Ok(script) => script, - Err(_) => return Err(ChannelError::close("Failed to get destination script".to_owned())), + Err(_) => { + return Err(ChannelError::close("Failed to get destination script".to_owned())) + }, }; let mut secp_ctx = Secp256k1::new(); @@ -3857,9 +3963,15 @@ impl ChannelContext { holder_selected_channel_reserve_satoshis, #[cfg(debug_assertions)] - holder_prev_commitment_tx_balance: Mutex::new((value_to_self_msat, (channel_value_satoshis * 1000 - msg_push_msat).saturating_sub(value_to_self_msat))), + holder_prev_commitment_tx_balance: Mutex::new(( + value_to_self_msat, + (channel_value_satoshis * 1000 - msg_push_msat).saturating_sub(value_to_self_msat), + )), #[cfg(debug_assertions)] - counterparty_prev_commitment_tx_balance: Mutex::new((value_to_self_msat, (channel_value_satoshis * 1000 - msg_push_msat).saturating_sub(value_to_self_msat))), + counterparty_prev_commitment_tx_balance: Mutex::new(( + value_to_self_msat, + (channel_value_satoshis * 1000 - msg_push_msat).saturating_sub(value_to_self_msat), + )), #[cfg(any(test, fuzzing))] next_local_fee: Mutex::new(PredictedNextFee::default()), @@ -3891,7 +4003,9 @@ impl ChannelContext { config: LegacyChannelConfig { options: config.channel_config.clone(), announce_for_forwarding, - commit_upfront_shutdown_pubkey: config.channel_handshake_config.commit_upfront_shutdown_pubkey, + commit_upfront_shutdown_pubkey: config + .channel_handshake_config + .commit_upfront_shutdown_pubkey, }, prev_config: None, @@ -3901,7 +4015,7 @@ impl ChannelContext { temporary_channel_id: Some(open_channel_fields.temporary_channel_id), channel_id: open_channel_fields.temporary_channel_id, channel_state: ChannelState::NegotiatingFunding( - NegotiatingFundingFlags::OUR_INIT_SENT | NegotiatingFundingFlags::THEIR_INIT_SENT + NegotiatingFundingFlags::OUR_INIT_SENT | NegotiatingFundingFlags::THEIR_INIT_SENT, ), announcement_sigs_state: AnnouncementSigsState::NotSent, secp_ctx, @@ -3953,19 +4067,35 @@ impl ChannelContext { feerate_per_kw: open_channel_fields.commitment_feerate_sat_per_1000_weight, counterparty_dust_limit_satoshis: open_channel_fields.dust_limit_satoshis, holder_dust_limit_satoshis: MIN_CHAN_DUST_LIMIT_SATOSHIS, - counterparty_max_htlc_value_in_flight_msat: cmp::min(open_channel_fields.max_htlc_value_in_flight_msat, channel_value_satoshis * 1000), - holder_max_htlc_value_in_flight_msat: get_holder_max_htlc_value_in_flight_msat(channel_value_satoshis, &config.channel_handshake_config), + counterparty_max_htlc_value_in_flight_msat: cmp::min( + open_channel_fields.max_htlc_value_in_flight_msat, + channel_value_satoshis * 1000, + ), + holder_max_htlc_value_in_flight_msat: get_holder_max_htlc_value_in_flight_msat( + channel_value_satoshis, + &config.channel_handshake_config, + ), counterparty_htlc_minimum_msat: open_channel_fields.htlc_minimum_msat, - holder_htlc_minimum_msat: if config.channel_handshake_config.our_htlc_minimum_msat == 0 { 1 } else { config.channel_handshake_config.our_htlc_minimum_msat }, + holder_htlc_minimum_msat: if config.channel_handshake_config.our_htlc_minimum_msat == 0 + { + 1 + } else { + config.channel_handshake_config.our_htlc_minimum_msat + }, counterparty_max_accepted_htlcs: open_channel_fields.max_accepted_htlcs, - holder_max_accepted_htlcs: cmp::min(config.channel_handshake_config.our_max_accepted_htlcs, max_htlcs(&channel_type)), + holder_max_accepted_htlcs: cmp::min( + config.channel_handshake_config.our_max_accepted_htlcs, + max_htlcs(&channel_type), + ), minimum_depth, counterparty_forwarding_info: None, is_batch_funding: None, - counterparty_next_commitment_point: Some(open_channel_fields.first_per_commitment_point), + counterparty_next_commitment_point: Some( + open_channel_fields.first_per_commitment_point, + ), counterparty_current_commitment_point: None, counterparty_node_id, @@ -4002,100 +4132,139 @@ impl ChannelContext { // check if the funder's amount for the initial commitment tx is sufficient // for full fee payment plus a few HTLCs to ensure the channel will be useful. - let funders_amount_msat = funding.get_value_satoshis() * 1000 - funding.get_value_to_self_msat(); + let funders_amount_msat = + funding.get_value_satoshis() * 1000 - funding.get_value_to_self_msat(); let htlc_candidate = None; let include_counterparty_unknown_htlcs = false; let addl_nondust_htlc_count = MIN_AFFORDABLE_HTLC_COUNT; - let dust_exposure_limiting_feerate = channel_context.get_dust_exposure_limiting_feerate(&fee_estimator, funding.get_channel_type()); - let (remote_stats, _remote_htlcs) = channel_context.get_next_remote_commitment_stats( - &funding, - htlc_candidate, - include_counterparty_unknown_htlcs, - addl_nondust_htlc_count, - channel_context.feerate_per_kw, - dust_exposure_limiting_feerate - ).map_err(|()| ChannelError::close(format!("Funding amount ({} sats) can't even pay fee for initial commitment transaction.", funders_amount_msat / 1000)))?; + let dust_exposure_limiting_feerate = channel_context + .get_dust_exposure_limiting_feerate(&fee_estimator, funding.get_channel_type()); + let (remote_stats, _remote_htlcs) = channel_context + .get_next_remote_commitment_stats( + &funding, + htlc_candidate, + include_counterparty_unknown_htlcs, + addl_nondust_htlc_count, + channel_context.feerate_per_kw, + dust_exposure_limiting_feerate, + ) + .map_err(|()| { + ChannelError::close(format!( + "Funding amount ({} sats) can't even pay fee for initial commitment transaction.", + funders_amount_msat / 1000 + )) + })?; // While it's reasonable for us to not meet the channel reserve initially (if they don't // want to push much to us), our counterparty should always have more than our reserve. - if remote_stats.commitment_stats.counterparty_balance_msat / 1000 < funding.holder_selected_channel_reserve_satoshis { - return Err(ChannelError::close("Insufficient funding amount for initial reserve".to_owned())); + if remote_stats.commitment_stats.counterparty_balance_msat / 1000 + < funding.holder_selected_channel_reserve_satoshis + { + return Err(ChannelError::close( + "Insufficient funding amount for initial reserve".to_owned(), + )); } Ok((funding, channel_context)) } - #[rustfmt::skip] fn new_for_outbound_channel<'a, ES: EntropySource, F: FeeEstimator, L: Logger>( - fee_estimator: &'a LowerBoundedFeeEstimator, - entropy_source: &'a ES, - signer_provider: &'a SP, - counterparty_node_id: PublicKey, - their_features: &'a InitFeatures, - funding_satoshis: u64, - push_msat: u64, - user_id: u128, - config: &'a UserConfig, - current_chain_height: u32, - outbound_scid_alias: u64, + fee_estimator: &'a LowerBoundedFeeEstimator, entropy_source: &'a ES, + signer_provider: &'a SP, counterparty_node_id: PublicKey, their_features: &'a InitFeatures, + funding_satoshis: u64, push_msat: u64, user_id: u128, config: &'a UserConfig, + current_chain_height: u32, outbound_scid_alias: u64, temporary_channel_id_fn: Option ChannelId>, - holder_selected_channel_reserve_satoshis: u64, - channel_keys_id: [u8; 32], - holder_signer: SP::EcdsaSigner, - _logger: L, + holder_selected_channel_reserve_satoshis: u64, channel_keys_id: [u8; 32], + holder_signer: SP::EcdsaSigner, _logger: L, ) -> Result<(FundingScope, ChannelContext), APIError> { // This will be updated with the counterparty contribution if this is a dual-funded channel let channel_value_satoshis = funding_satoshis; let holder_selected_contest_delay = config.channel_handshake_config.our_to_self_delay; - if !their_features.supports_wumbo() && channel_value_satoshis > MAX_FUNDING_SATOSHIS_NO_WUMBO { - return Err(APIError::APIMisuseError{err: format!("funding_value must not exceed {}, it was {}", MAX_FUNDING_SATOSHIS_NO_WUMBO, channel_value_satoshis)}); + if !their_features.supports_wumbo() + && channel_value_satoshis > MAX_FUNDING_SATOSHIS_NO_WUMBO + { + return Err(APIError::APIMisuseError { + err: format!( + "funding_value must not exceed {}, it was {}", + MAX_FUNDING_SATOSHIS_NO_WUMBO, channel_value_satoshis + ), + }); } if channel_value_satoshis >= TOTAL_BITCOIN_SUPPLY_SATOSHIS { - return Err(APIError::APIMisuseError{err: format!("funding_value must be smaller than the total bitcoin supply, it was {}", channel_value_satoshis)}); + return Err(APIError::APIMisuseError { + err: format!( + "funding_value must be smaller than the total bitcoin supply, it was {}", + channel_value_satoshis + ), + }); } let channel_value_msat = channel_value_satoshis * 1000; if push_msat > channel_value_msat { - return Err(APIError::APIMisuseError { err: format!("Push value ({}) was larger than channel_value ({})", push_msat, channel_value_msat) }); + return Err(APIError::APIMisuseError { + err: format!( + "Push value ({}) was larger than channel_value ({})", + push_msat, channel_value_msat + ), + }); } if holder_selected_contest_delay < BREAKDOWN_TIMEOUT { - return Err(APIError::APIMisuseError {err: format!("Configured with an unreasonable our_to_self_delay ({}) putting user funds at risks", holder_selected_contest_delay)}); + return Err(APIError::APIMisuseError { + err: format!( + "Configured with an unreasonable our_to_self_delay ({}) putting user funds at risks", + holder_selected_contest_delay + ), + }); } let channel_type = get_initial_channel_type(&config, their_features); debug_assert!(!channel_type.supports_any_optional_bits()); - debug_assert!(!channel_type.requires_unknown_bits_from(&channelmanager::provided_channel_type_features(&config))); + debug_assert!(!channel_type + .requires_unknown_bits_from(&channelmanager::provided_channel_type_features(&config))); - let commitment_feerate = selected_commitment_sat_per_1000_weight( - &fee_estimator, &channel_type, - ); + let commitment_feerate = + selected_commitment_sat_per_1000_weight(&fee_estimator, &channel_type); let value_to_self_msat = channel_value_satoshis * 1000 - push_msat; let mut secp_ctx = Secp256k1::new(); secp_ctx.seeded_randomize(&entropy_source.get_secure_random_bytes()); - let shutdown_scriptpubkey = if config.channel_handshake_config.commit_upfront_shutdown_pubkey { - match signer_provider.get_shutdown_scriptpubkey() { - Ok(scriptpubkey) => Some(scriptpubkey), - Err(_) => return Err(APIError::ChannelUnavailable { err: "Failed to get shutdown scriptpubkey".to_owned()}), - } - } else { None }; + let shutdown_scriptpubkey = + if config.channel_handshake_config.commit_upfront_shutdown_pubkey { + match signer_provider.get_shutdown_scriptpubkey() { + Ok(scriptpubkey) => Some(scriptpubkey), + Err(_) => { + return Err(APIError::ChannelUnavailable { + err: "Failed to get shutdown scriptpubkey".to_owned(), + }) + }, + } + } else { + None + }; if let Some(shutdown_scriptpubkey) = &shutdown_scriptpubkey { if !shutdown_scriptpubkey.is_compatible(&their_features) { - return Err(APIError::IncompatibleShutdownScript { script: shutdown_scriptpubkey.clone() }); + return Err(APIError::IncompatibleShutdownScript { + script: shutdown_scriptpubkey.clone(), + }); } } let destination_script = match signer_provider.get_destination_script(channel_keys_id) { Ok(script) => script, - Err(_) => return Err(APIError::ChannelUnavailable { err: "Failed to get destination script".to_owned()}), + Err(_) => { + return Err(APIError::ChannelUnavailable { + err: "Failed to get destination script".to_owned(), + }) + }, }; let pubkeys = holder_signer.pubkeys(&secp_ctx); - let temporary_channel_id = temporary_channel_id_fn.map(|f| f(&pubkeys)) + let temporary_channel_id = temporary_channel_id_fn + .map(|f| f(&pubkeys)) .unwrap_or_else(|| ChannelId::temporary_from_entropy_source(entropy_source)); let funding = FundingScope { @@ -4106,9 +4275,15 @@ impl ChannelContext { // We'll add our counterparty's `funding_satoshis` to these max commitment output assertions // when we receive `accept_channel2`. #[cfg(debug_assertions)] - holder_prev_commitment_tx_balance: Mutex::new((channel_value_satoshis * 1000 - push_msat, push_msat)), + holder_prev_commitment_tx_balance: Mutex::new(( + channel_value_satoshis * 1000 - push_msat, + push_msat, + )), #[cfg(debug_assertions)] - counterparty_prev_commitment_tx_balance: Mutex::new((channel_value_satoshis * 1000 - push_msat, push_msat)), + counterparty_prev_commitment_tx_balance: Mutex::new(( + channel_value_satoshis * 1000 - push_msat, + push_msat, + )), #[cfg(any(test, fuzzing))] next_local_fee: Mutex::new(PredictedNextFee::default()), @@ -4138,7 +4313,9 @@ impl ChannelContext { config: LegacyChannelConfig { options: config.channel_config.clone(), announce_for_forwarding: config.channel_handshake_config.announce_for_forwarding, - commit_upfront_shutdown_pubkey: config.channel_handshake_config.commit_upfront_shutdown_pubkey, + commit_upfront_shutdown_pubkey: config + .channel_handshake_config + .commit_upfront_shutdown_pubkey, }, prev_config: None, @@ -4201,11 +4378,22 @@ impl ChannelContext { counterparty_max_htlc_value_in_flight_msat: 0, // We'll adjust this to include our counterparty's `funding_satoshis` when we // receive `accept_channel2`. - holder_max_htlc_value_in_flight_msat: get_holder_max_htlc_value_in_flight_msat(channel_value_satoshis, &config.channel_handshake_config), + holder_max_htlc_value_in_flight_msat: get_holder_max_htlc_value_in_flight_msat( + channel_value_satoshis, + &config.channel_handshake_config, + ), counterparty_htlc_minimum_msat: 0, - holder_htlc_minimum_msat: if config.channel_handshake_config.our_htlc_minimum_msat == 0 { 1 } else { config.channel_handshake_config.our_htlc_minimum_msat }, + holder_htlc_minimum_msat: if config.channel_handshake_config.our_htlc_minimum_msat == 0 + { + 1 + } else { + config.channel_handshake_config.our_htlc_minimum_msat + }, counterparty_max_accepted_htlcs: 0, - holder_max_accepted_htlcs: cmp::min(config.channel_handshake_config.our_max_accepted_htlcs, max_htlcs(&channel_type)), + holder_max_accepted_htlcs: cmp::min( + config.channel_handshake_config.our_max_accepted_htlcs, + max_htlcs(&channel_type), + ), minimum_depth: None, // Filled in in accept_channel counterparty_forwarding_info: None, @@ -4248,15 +4436,23 @@ impl ChannelContext { let htlc_candidate = None; let include_counterparty_unknown_htlcs = false; let addl_nondust_htlc_count = MIN_AFFORDABLE_HTLC_COUNT; - let dust_exposure_limiting_feerate = channel_context.get_dust_exposure_limiting_feerate(&fee_estimator, funding.get_channel_type()); - let _local_stats = channel_context.get_next_local_commitment_stats( - &funding, - htlc_candidate, - include_counterparty_unknown_htlcs, - addl_nondust_htlc_count, - channel_context.feerate_per_kw, - dust_exposure_limiting_feerate, - ).map_err(|()| APIError::APIMisuseError { err: format!("Funding amount ({}) can't even pay fee for initial commitment transaction.", funding.get_value_to_self_msat() / 1000)})?; + let dust_exposure_limiting_feerate = channel_context + .get_dust_exposure_limiting_feerate(&fee_estimator, funding.get_channel_type()); + let _local_stats = channel_context + .get_next_local_commitment_stats( + &funding, + htlc_candidate, + include_counterparty_unknown_htlcs, + addl_nondust_htlc_count, + channel_context.feerate_per_kw, + dust_exposure_limiting_feerate, + ) + .map_err(|()| APIError::APIMisuseError { + err: format!( + "Funding amount ({}) can't even pay fee for initial commitment transaction.", + funding.get_value_to_self_msat() / 1000 + ), + })?; Ok((funding, channel_context)) } @@ -4482,109 +4678,189 @@ impl ChannelContext { /// Performs checks against necessary constraints after receiving either an `accept_channel` or /// `accept_channel2` message. - #[rustfmt::skip] pub fn do_accept_channel_checks( &mut self, funding: &mut FundingScope, default_limits: &ChannelHandshakeLimits, their_features: &InitFeatures, common_fields: &msgs::CommonAcceptChannelFields, channel_reserve_satoshis: u64, ) -> Result<(), ChannelError> { - let peer_limits = if let Some(ref limits) = self.inbound_handshake_limits_override { limits } else { default_limits }; + let peer_limits = if let Some(ref limits) = self.inbound_handshake_limits_override { + limits + } else { + default_limits + }; // Check sanity of message fields: if !funding.is_outbound() { - return Err(ChannelError::close("Got an accept_channel message from an inbound peer".to_owned())); + return Err(ChannelError::close( + "Got an accept_channel message from an inbound peer".to_owned(), + )); } - if !matches!(self.channel_state, ChannelState::NegotiatingFunding(flags) if flags == NegotiatingFundingFlags::OUR_INIT_SENT) { - return Err(ChannelError::close("Got an accept_channel message at a strange time".to_owned())); + if !matches!(self.channel_state, ChannelState::NegotiatingFunding(flags) + if flags == NegotiatingFundingFlags::OUR_INIT_SENT) + { + return Err(ChannelError::close( + "Got an accept_channel message at a strange time".to_owned(), + )); } - let channel_type = common_fields.channel_type.as_ref() - .ok_or_else(|| ChannelError::close("option_channel_type assumed to be supported".to_owned()))?; + let channel_type = common_fields.channel_type.as_ref().ok_or_else(|| { + ChannelError::close("option_channel_type assumed to be supported".to_owned()) + })?; if channel_type != funding.get_channel_type() { - return Err(ChannelError::close("Channel Type in accept_channel didn't match the one sent in open_channel.".to_owned())); + return Err(ChannelError::close(String::from( + "Channel Type in accept_channel didn't match the one sent in open_channel.", + ))); } if common_fields.dust_limit_satoshis > 21000000 * 100000000 { - return Err(ChannelError::close(format!("Peer never wants payout outputs? dust_limit_satoshis was {}", common_fields.dust_limit_satoshis))); + return Err(ChannelError::close(format!( + "Peer never wants payout outputs? dust_limit_satoshis was {}", + common_fields.dust_limit_satoshis + ))); } if channel_reserve_satoshis > funding.get_value_satoshis() { - return Err(ChannelError::close(format!("Bogus channel_reserve_satoshis ({}). Must not be greater than ({})", channel_reserve_satoshis, funding.get_value_satoshis()))); + return Err(ChannelError::close(format!( + "Bogus channel_reserve_satoshis ({}). Must not be greater than ({})", + channel_reserve_satoshis, + funding.get_value_satoshis() + ))); } - if common_fields.dust_limit_satoshis > funding.holder_selected_channel_reserve_satoshis && funding.holder_selected_channel_reserve_satoshis != 0 { - return Err(ChannelError::close(format!("Dust limit ({}) is bigger than our channel reserve ({})", common_fields.dust_limit_satoshis, funding.holder_selected_channel_reserve_satoshis))); + if common_fields.dust_limit_satoshis > funding.holder_selected_channel_reserve_satoshis + && funding.holder_selected_channel_reserve_satoshis != 0 + { + return Err(ChannelError::close(format!( + "Dust limit ({}) is bigger than our channel reserve ({})", + common_fields.dust_limit_satoshis, funding.holder_selected_channel_reserve_satoshis + ))); } - if channel_reserve_satoshis > funding.get_value_satoshis() - funding.holder_selected_channel_reserve_satoshis { - return Err(ChannelError::close(format!("Bogus channel_reserve_satoshis ({}). Must not be greater than channel value minus our reserve ({})", - channel_reserve_satoshis, funding.get_value_satoshis() - funding.holder_selected_channel_reserve_satoshis))); + if channel_reserve_satoshis + > funding.get_value_satoshis() - funding.holder_selected_channel_reserve_satoshis + { + return Err(ChannelError::close(format!( + "Bogus channel_reserve_satoshis ({}). Must not be greater than channel value minus our reserve ({})", + channel_reserve_satoshis, + funding.get_value_satoshis() - funding.holder_selected_channel_reserve_satoshis + ))); } - let full_channel_value_msat = (funding.get_value_satoshis() - channel_reserve_satoshis) * 1000; + let full_channel_value_msat = + (funding.get_value_satoshis() - channel_reserve_satoshis) * 1000; if common_fields.htlc_minimum_msat >= full_channel_value_msat { - return Err(ChannelError::close(format!("Minimum htlc value ({}) is full channel value ({})", common_fields.htlc_minimum_msat, full_channel_value_msat))); + return Err(ChannelError::close(format!( + "Minimum htlc value ({}) is full channel value ({})", + common_fields.htlc_minimum_msat, full_channel_value_msat + ))); } - let max_delay_acceptable = u16::min(peer_limits.their_to_self_delay, MAX_LOCAL_BREAKDOWN_TIMEOUT); + let max_delay_acceptable = + u16::min(peer_limits.their_to_self_delay, MAX_LOCAL_BREAKDOWN_TIMEOUT); if common_fields.to_self_delay > max_delay_acceptable { - return Err(ChannelError::close(format!("They wanted our payments to be delayed by a needlessly long period. Upper limit: {}. Actual: {}", max_delay_acceptable, common_fields.to_self_delay))); + return Err(ChannelError::close(format!( + "They wanted our payments to be delayed by a needlessly long period. Upper limit: {}. Actual: {}", + max_delay_acceptable, common_fields.to_self_delay + ))); } if common_fields.max_accepted_htlcs < 1 { - return Err(ChannelError::close("0 max_accepted_htlcs makes for a useless channel".to_owned())); + return Err(ChannelError::close( + "0 max_accepted_htlcs makes for a useless channel".to_owned(), + )); } let channel_type = funding.get_channel_type(); if common_fields.max_accepted_htlcs > max_htlcs(channel_type) { - return Err(ChannelError::close(format!("max_accepted_htlcs was {}. It must not be larger than {}", common_fields.max_accepted_htlcs, max_htlcs(channel_type)))); + return Err(ChannelError::close(format!( + "max_accepted_htlcs was {}. It must not be larger than {}", + common_fields.max_accepted_htlcs, + max_htlcs(channel_type) + ))); } // Now check against optional parameters as set by config... if common_fields.htlc_minimum_msat > peer_limits.max_htlc_minimum_msat { - return Err(ChannelError::close(format!("htlc_minimum_msat ({}) is higher than the user specified limit ({})", common_fields.htlc_minimum_msat, peer_limits.max_htlc_minimum_msat))); + return Err(ChannelError::close(format!( + "htlc_minimum_msat ({}) is higher than the user specified limit ({})", + common_fields.htlc_minimum_msat, peer_limits.max_htlc_minimum_msat + ))); } - if common_fields.max_htlc_value_in_flight_msat < peer_limits.min_max_htlc_value_in_flight_msat { - return Err(ChannelError::close(format!("max_htlc_value_in_flight_msat ({}) is less than the user specified limit ({})", common_fields.max_htlc_value_in_flight_msat, peer_limits.min_max_htlc_value_in_flight_msat))); + if common_fields.max_htlc_value_in_flight_msat + < peer_limits.min_max_htlc_value_in_flight_msat + { + return Err(ChannelError::close(format!( + "max_htlc_value_in_flight_msat ({}) is less than the user specified limit ({})", + common_fields.max_htlc_value_in_flight_msat, + peer_limits.min_max_htlc_value_in_flight_msat + ))); } if channel_reserve_satoshis > peer_limits.max_channel_reserve_satoshis { - return Err(ChannelError::close(format!("channel_reserve_satoshis ({}) is higher than the user specified limit ({})", channel_reserve_satoshis, peer_limits.max_channel_reserve_satoshis))); + return Err(ChannelError::close(format!( + "channel_reserve_satoshis ({}) is higher than the user specified limit ({})", + channel_reserve_satoshis, peer_limits.max_channel_reserve_satoshis + ))); } if common_fields.max_accepted_htlcs < peer_limits.min_max_accepted_htlcs { - return Err(ChannelError::close(format!("max_accepted_htlcs ({}) is less than the user specified limit ({})", common_fields.max_accepted_htlcs, peer_limits.min_max_accepted_htlcs))); + return Err(ChannelError::close(format!( + "max_accepted_htlcs ({}) is less than the user specified limit ({})", + common_fields.max_accepted_htlcs, peer_limits.min_max_accepted_htlcs + ))); } if common_fields.dust_limit_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS { - return Err(ChannelError::close(format!("dust_limit_satoshis ({}) is less than the implementation limit ({})", common_fields.dust_limit_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS))); + return Err(ChannelError::close(format!( + "dust_limit_satoshis ({}) is less than the implementation limit ({})", + common_fields.dust_limit_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS + ))); } - let max_chan_dust_limit_satoshis = if channel_type.supports_anchors_zero_fee_htlc_tx() || channel_type.supports_anchor_zero_fee_commitments() { + let max_chan_dust_limit_satoshis = if channel_type.supports_anchors_zero_fee_htlc_tx() + || channel_type.supports_anchor_zero_fee_commitments() + { MAX_CHAN_DUST_LIMIT_SATOSHIS } else { MAX_LEGACY_CHAN_DUST_LIMIT_SATOSHIS }; if common_fields.dust_limit_satoshis > max_chan_dust_limit_satoshis { - return Err(ChannelError::close(format!("dust_limit_satoshis ({}) is greater than the implementation limit ({})", common_fields.dust_limit_satoshis, max_chan_dust_limit_satoshis))); + return Err(ChannelError::close(format!( + "dust_limit_satoshis ({}) is greater than the implementation limit ({})", + common_fields.dust_limit_satoshis, max_chan_dust_limit_satoshis + ))); } if common_fields.minimum_depth > peer_limits.max_minimum_depth { - return Err(ChannelError::close(format!("We consider the minimum depth to be unreasonably large. Expected minimum: ({}). Actual: ({})", peer_limits.max_minimum_depth, common_fields.minimum_depth))); + return Err(ChannelError::close(format!( + "We consider the minimum depth to be unreasonably large. Expected minimum: ({}). Actual: ({})", + peer_limits.max_minimum_depth, common_fields.minimum_depth + ))); } - let counterparty_shutdown_scriptpubkey = if their_features.supports_upfront_shutdown_script() { - match &common_fields.shutdown_scriptpubkey { - &Some(ref script) => { - // Peer is signaling upfront_shutdown and has opt-out with a 0-length script. We don't enforce anything - if script.len() == 0 { - None - } else { - if !script::is_bolt2_compliant(&script, their_features) { - return Err(ChannelError::close(format!("Peer is signaling upfront_shutdown but has provided an unacceptable scriptpubkey format: {}", script))); + let counterparty_shutdown_scriptpubkey = + if their_features.supports_upfront_shutdown_script() { + match &common_fields.shutdown_scriptpubkey { + &Some(ref script) => { + // Peer is signaling upfront_shutdown and has opt-out with a 0-length script. We don't enforce anything + if script.len() == 0 { + None + } else { + if !script::is_bolt2_compliant(&script, their_features) { + return Err(ChannelError::close(format!( + "Peer is signaling upfront_shutdown but has provided an unacceptable scriptpubkey format: {}", + script + ))); + } + Some(script.clone()) } - Some(script.clone()) - } - }, - // Peer is signaling upfront shutdown but don't opt-out with correct mechanism (a.k.a 0-length script). Peer looks buggy, we fail the channel - &None => { - return Err(ChannelError::close("Peer is signaling upfront_shutdown but we don't get any script. Use 0-length script to opt-out".to_owned())); + }, + // Peer is signaling upfront shutdown but don't opt-out with correct mechanism (a.k.a 0-length script). Peer looks buggy, we fail the channel + &None => { + return Err(ChannelError::close(String::from( + "Peer is signaling upfront_shutdown but we don't get any script. Use 0-length script to opt-out" + ))); + }, } - } - } else { None }; + } else { + None + }; self.counterparty_dust_limit_satoshis = common_fields.dust_limit_satoshis; - self.counterparty_max_htlc_value_in_flight_msat = cmp::min(common_fields.max_htlc_value_in_flight_msat, funding.get_value_satoshis() * 1000); + self.counterparty_max_htlc_value_in_flight_msat = cmp::min( + common_fields.max_htlc_value_in_flight_msat, + funding.get_value_satoshis() * 1000, + ); funding.counterparty_selected_channel_reserve_satoshis = Some(channel_reserve_satoshis); self.counterparty_htlc_minimum_msat = common_fields.htlc_minimum_msat; self.counterparty_max_accepted_htlcs = common_fields.max_accepted_htlcs; @@ -4599,20 +4875,23 @@ impl ChannelContext { funding_pubkey: common_fields.funding_pubkey, revocation_basepoint: RevocationBasepoint::from(common_fields.revocation_basepoint), payment_point: common_fields.payment_basepoint, - delayed_payment_basepoint: DelayedPaymentBasepoint::from(common_fields.delayed_payment_basepoint), - htlc_basepoint: HtlcBasepoint::from(common_fields.htlc_basepoint) + delayed_payment_basepoint: DelayedPaymentBasepoint::from( + common_fields.delayed_payment_basepoint, + ), + htlc_basepoint: HtlcBasepoint::from(common_fields.htlc_basepoint), }; - funding.channel_transaction_parameters.counterparty_parameters = Some(CounterpartyChannelTransactionParameters { - selected_contest_delay: common_fields.to_self_delay, - pubkeys: counterparty_pubkeys, - }); + funding.channel_transaction_parameters.counterparty_parameters = + Some(CounterpartyChannelTransactionParameters { + selected_contest_delay: common_fields.to_self_delay, + pubkeys: counterparty_pubkeys, + }); self.counterparty_next_commitment_point = Some(common_fields.first_per_commitment_point); self.counterparty_shutdown_scriptpubkey = counterparty_shutdown_scriptpubkey; self.channel_state = ChannelState::NegotiatingFunding( - NegotiatingFundingFlags::OUR_INIT_SENT | NegotiatingFundingFlags::THEIR_INIT_SENT + NegotiatingFundingFlags::OUR_INIT_SENT | NegotiatingFundingFlags::THEIR_INIT_SENT, ); self.inbound_handshake_limits_override = None; // We're done enforcing limits on our peer's handshake now. @@ -13860,11 +14139,13 @@ impl OutboundV1Channel { } #[allow(dead_code)] // TODO(dual_funding): Remove once opending V2 channels is enabled. - #[rustfmt::skip] pub fn new( - fee_estimator: &LowerBoundedFeeEstimator, entropy_source: &ES, signer_provider: &SP, counterparty_node_id: PublicKey, their_features: &InitFeatures, - channel_value_satoshis: u64, push_msat: u64, user_id: u128, config: &UserConfig, current_chain_height: u32, - outbound_scid_alias: u64, temporary_channel_id: Option, logger: L, trusted_channel_features: Option, + fee_estimator: &LowerBoundedFeeEstimator, entropy_source: &ES, signer_provider: &SP, + counterparty_node_id: PublicKey, their_features: &InitFeatures, + channel_value_satoshis: u64, push_msat: u64, user_id: u128, config: &UserConfig, + current_chain_height: u32, outbound_scid_alias: u64, + temporary_channel_id: Option, logger: L, + trusted_channel_features: Option, ) -> Result, APIError> { // At this point, we do not know what `dust_limit_satoshis` the counterparty will want for themselves, // so we set the channel reserve with no regard for their dust limit, and fail the channel if they want @@ -13880,16 +14161,19 @@ impl OutboundV1Channel { if holder_selected_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS && !is_0reserve { // Protocol level safety check in place, although it should never happen because // of `MIN_THEIR_CHAN_RESERVE_SATOSHIS` - return Err(APIError::APIMisuseError { err: format!("Holder selected channel reserve below \ - implementation limit dust_limit_satoshis {}", holder_selected_channel_reserve_satoshis) }); + return Err(APIError::APIMisuseError { + err: format!( + "Holder selected channel reserve below implementation limit dust_limit_satoshis {}", + holder_selected_channel_reserve_satoshis, + ), + }); } let channel_keys_id = signer_provider.generate_channel_keys_id(false, user_id); let holder_signer = signer_provider.derive_channel_signer(channel_keys_id); - let temporary_channel_id_fn = temporary_channel_id.map(|id| { - move |_: &ChannelPublicKeys| id - }); + let temporary_channel_id_fn = + temporary_channel_id.map(|id| move |_: &ChannelPublicKeys| id); let (funding, context) = ChannelContext::new_for_outbound_channel( fee_estimator, @@ -13911,7 +14195,10 @@ impl OutboundV1Channel { )?; let unfunded_context = UnfundedChannelContext { unfunded_channel_age_ticks: 0, - holder_commitment_point: HolderCommitmentPoint::new(&context.holder_signer, &context.secp_ctx), + holder_commitment_point: HolderCommitmentPoint::new( + &context.holder_signer, + &context.secp_ctx, + ), }; // We initialize `signer_pending_open_channel` to false, and leave setting the flag @@ -14244,7 +14531,6 @@ pub(super) fn channel_type_from_open_channel( impl InboundV1Channel { /// Creates a new channel from a remote sides' request for one. /// Assumes chain_hash has already been checked and corresponds with what we expect! - #[rustfmt::skip] pub fn new( fee_estimator: &LowerBoundedFeeEstimator, entropy_source: &ES, signer_provider: &SP, counterparty_node_id: PublicKey, our_supported_features: &ChannelTypeFeatures, @@ -14252,11 +14538,17 @@ impl InboundV1Channel { current_chain_height: u32, logger: &L, trusted_channel_features: Option, ) -> Result, ChannelError> { - let logger = WithContext::from(logger, Some(counterparty_node_id), Some(msg.common_fields.temporary_channel_id), None); + let logger = WithContext::from( + logger, + Some(counterparty_node_id), + Some(msg.common_fields.temporary_channel_id), + None, + ); // First check the channel type is known, failing before we do anything else if we don't // support this channel type. - let channel_type = channel_type_from_open_channel(&msg.common_fields, our_supported_features)?; + let channel_type = + channel_type_from_open_channel(&msg.common_fields, our_supported_features)?; let holder_selected_channel_reserve_satoshis = get_holder_selected_channel_reserve_satoshis( msg.common_fields.funding_satoshis, @@ -14268,8 +14560,10 @@ impl InboundV1Channel { funding_pubkey: msg.common_fields.funding_pubkey, revocation_basepoint: RevocationBasepoint::from(msg.common_fields.revocation_basepoint), payment_point: msg.common_fields.payment_basepoint, - delayed_payment_basepoint: DelayedPaymentBasepoint::from(msg.common_fields.delayed_payment_basepoint), - htlc_basepoint: HtlcBasepoint::from(msg.common_fields.htlc_basepoint) + delayed_payment_basepoint: DelayedPaymentBasepoint::from( + msg.common_fields.delayed_payment_basepoint, + ), + htlc_basepoint: HtlcBasepoint::from(msg.common_fields.htlc_basepoint), }; let (funding, context) = ChannelContext::new_for_inbound_channel( @@ -14284,7 +14578,6 @@ impl InboundV1Channel { &&logger, trusted_channel_features, 0, - counterparty_pubkeys, channel_type, holder_selected_channel_reserve_satoshis, @@ -14294,9 +14587,13 @@ impl InboundV1Channel { )?; let unfunded_context = UnfundedChannelContext { unfunded_channel_age_ticks: 0, - holder_commitment_point: HolderCommitmentPoint::new(&context.holder_signer, &context.secp_ctx), + holder_commitment_point: HolderCommitmentPoint::new( + &context.holder_signer, + &context.secp_ctx, + ), }; - let chan = Self { funding, context, unfunded_context, signer_pending_accept_channel: false }; + let chan = + Self { funding, context, unfunded_context, signer_pending_accept_channel: false }; Ok(chan) } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 1b3206a9242..a4225c51951 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -3836,7 +3836,12 @@ impl< trusted_channel_features: Option, ) -> Result { if channel_value_satoshis < 1000 { - return Err(APIError::APIMisuseError { err: format!("Channel value must be at least 1000 satoshis. It was {}", channel_value_satoshis) }); + return Err(APIError::APIMisuseError { + err: format!( + "Channel value must be at least 1000 satoshis. It was {}", + channel_value_satoshis + ), + }); } let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); @@ -3845,17 +3850,26 @@ impl< let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = per_peer_state.get(&their_network_key) - .ok_or_else(|| APIError::APIMisuseError{ err: format!("Not connected to node: {}", their_network_key) })?; + let peer_state_mutex = + per_peer_state.get(&their_network_key).ok_or_else(|| APIError::APIMisuseError { + err: format!("Not connected to node: {}", their_network_key), + })?; let mut peer_state = peer_state_mutex.lock().unwrap(); if !peer_state.is_connected { - return Err(APIError::APIMisuseError{ err: format!("Not connected to node: {}", their_network_key) }); + return Err(APIError::APIMisuseError { + err: format!("Not connected to node: {}", their_network_key), + }); } if let Some(temporary_channel_id) = temporary_channel_id { if peer_state.channel_by_id.contains_key(&temporary_channel_id) { - return Err(APIError::APIMisuseError{ err: format!("Channel with temporary channel ID {} already exists!", temporary_channel_id)}); + return Err(APIError::APIMisuseError { + err: format!( + "Channel with temporary channel ID {} already exists!", + temporary_channel_id + ), + }); } } @@ -3863,15 +3877,23 @@ impl< let outbound_scid_alias = self.create_and_insert_outbound_scid_alias(); let their_features = &peer_state.latest_features; let config = self.config.read().unwrap(); - let config = if let Some(config) = &override_config { - config - } else { - &*config - }; - match OutboundV1Channel::new(&self.fee_estimator, &self.entropy_source, &self.signer_provider, their_network_key, - their_features, channel_value_satoshis, push_msat, user_channel_id, config, - self.best_block.read().unwrap().height, outbound_scid_alias, temporary_channel_id, &self.logger, trusted_channel_features) - { + let config = if let Some(config) = &override_config { config } else { &*config }; + match OutboundV1Channel::new( + &self.fee_estimator, + &self.entropy_source, + &self.signer_provider, + their_network_key, + their_features, + channel_value_satoshis, + push_msat, + user_channel_id, + config, + self.best_block.read().unwrap().height, + outbound_scid_alias, + temporary_channel_id, + &self.logger, + trusted_channel_features, + ) { Ok(res) => res, Err(e) => { self.outbound_scid_aliases.lock().unwrap().remove(&outbound_scid_alias); @@ -3891,14 +3913,15 @@ impl< panic!("RNG is bad???"); } }, - hash_map::Entry::Vacant(entry) => { entry.insert(Channel::from(channel)); } + hash_map::Entry::Vacant(entry) => { + entry.insert(Channel::from(channel)); + }, } if let Some(msg) = res { - peer_state.pending_msg_events.push(MessageSendEvent::SendOpenChannel { - node_id: their_network_key, - msg, - }); + peer_state + .pending_msg_events + .push(MessageSendEvent::SendOpenChannel { node_id: their_network_key, msg }); } Ok(temporary_channel_id) } From 670e5f81315c12ec6caf808a0811c1f814e6a5e7 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 23 Mar 2026 23:12:04 +0000 Subject: [PATCH 244/627] Create better helper functions in `tx_builder` Reduce line count and indentation --- lightning/src/sign/tx_builder.rs | 118 ++++++++++++------------------- 1 file changed, 44 insertions(+), 74 deletions(-) diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index ca61b27b78d..a54f8f70f8d 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -95,49 +95,33 @@ fn commit_plus_htlc_tx_fees_msat( (total_fees_msat, extra_accepted_htlc_total_fees_msat) } -fn checked_sub_anchor_outputs( - is_outbound_from_holder: bool, value_to_self_after_htlcs_msat: u64, - value_to_remote_after_htlcs_msat: u64, channel_type: &ChannelTypeFeatures, -) -> Result<(u64, u64), ()> { - let total_anchors_sat = if channel_type.supports_anchors_zero_fee_htlc_tx() { +fn total_anchors_sat(channel_type: &ChannelTypeFeatures) -> u64 { + if channel_type.supports_anchors_zero_fee_htlc_tx() { ANCHOR_OUTPUT_VALUE_SATOSHI * 2 } else { 0 - }; + } +} +fn checked_sub_from_funder( + is_outbound_from_holder: bool, value_to_holder: u64, value_to_counterparty: u64, + value_to_subtract: u64, +) -> Result<(u64, u64), ()> { if is_outbound_from_holder { - Ok(( - value_to_self_after_htlcs_msat.checked_sub(total_anchors_sat * 1000).ok_or(())?, - value_to_remote_after_htlcs_msat, - )) + Ok((value_to_holder.checked_sub(value_to_subtract).ok_or(())?, value_to_counterparty)) } else { - Ok(( - value_to_self_after_htlcs_msat, - value_to_remote_after_htlcs_msat.checked_sub(total_anchors_sat * 1000).ok_or(())?, - )) + Ok((value_to_holder, value_to_counterparty.checked_sub(value_to_subtract).ok_or(())?)) } } -fn saturating_sub_anchor_outputs( - is_outbound_from_holder: bool, value_to_self_after_htlcs: u64, - value_to_remote_after_htlcs: u64, channel_type: &ChannelTypeFeatures, +fn saturating_sub_from_funder( + is_outbound_from_holder: bool, value_to_holder: u64, value_to_counterparty: u64, + value_to_subtract: u64, ) -> (u64, u64) { - let total_anchors_sat = if channel_type.supports_anchors_zero_fee_htlc_tx() { - ANCHOR_OUTPUT_VALUE_SATOSHI * 2 - } else { - 0 - }; - if is_outbound_from_holder { - ( - value_to_self_after_htlcs.saturating_sub(total_anchors_sat * 1000), - value_to_remote_after_htlcs, - ) + (value_to_holder.saturating_sub(value_to_subtract), value_to_counterparty) } else { - ( - value_to_self_after_htlcs, - value_to_remote_after_htlcs.saturating_sub(total_anchors_sat * 1000), - ) + (value_to_holder, value_to_counterparty.saturating_sub(value_to_subtract)) } } @@ -212,23 +196,17 @@ fn has_output( broadcaster_dust_limit_satoshis: u64, channel_type: &ChannelTypeFeatures, ) -> bool { let commit_tx_fee_sat = commit_tx_fee_sat(feerate_per_kw, nondust_htlc_count, channel_type); - - let (real_holder_balance_msat, real_counterparty_balance_msat) = if is_outbound_from_holder { - ( - holder_balance_before_fee_msat.saturating_sub(commit_tx_fee_sat * 1000), - counterparty_balance_before_fee_msat, - ) - } else { - ( - holder_balance_before_fee_msat, - counterparty_balance_before_fee_msat.saturating_sub(commit_tx_fee_sat * 1000), - ) - }; + let (holder_balance_msat, counterparty_balance_msat) = saturating_sub_from_funder( + is_outbound_from_holder, + holder_balance_before_fee_msat, + counterparty_balance_before_fee_msat, + commit_tx_fee_sat.saturating_mul(1000), + ); // Make sure the commitment transaction has at least one output let dust_limit_msat = broadcaster_dust_limit_satoshis * 1000; - let has_no_output = real_holder_balance_msat < dust_limit_msat - && real_counterparty_balance_msat < dust_limit_msat + let has_no_output = holder_balance_msat < dust_limit_msat + && counterparty_balance_msat < dust_limit_msat && nondust_htlc_count == 0 // 0FC channels always have a P2A output on the commitment transaction && !channel_type.supports_anchor_zero_fee_commitments(); @@ -271,12 +249,13 @@ fn get_next_commitment_stats( // commitment transaction *before* checking whether the remote party's balance is enough to // cover the total anchor sum. + let total_anchors_sat = total_anchors_sat(channel_type); let (holder_balance_before_fee_msat, counterparty_balance_before_fee_msat) = - checked_sub_anchor_outputs( + checked_sub_from_funder( is_outbound_from_holder, value_to_holder_after_htlcs_msat, value_to_counterparty_after_htlcs_msat, - channel_type, + total_anchors_sat.saturating_mul(1000), )?; let (dust_exposure_msat, _extra_accepted_htlc_dust_exposure_msat) = get_dust_exposure_stats( @@ -318,18 +297,12 @@ fn get_next_commitment_stats( nondust_htlc_count + addl_nondust_htlc_count, channel_type, ); - - let (holder_balance_msat, counterparty_balance_msat) = if is_outbound_from_holder { - ( - holder_balance_before_fee_msat.checked_sub(commit_tx_fee_sat * 1000).ok_or(())?, - counterparty_balance_before_fee_msat, - ) - } else { - ( - holder_balance_before_fee_msat, - counterparty_balance_before_fee_msat.checked_sub(commit_tx_fee_sat * 1000).ok_or(())?, - ) - }; + let (holder_balance_msat, counterparty_balance_msat) = checked_sub_from_funder( + is_outbound_from_holder, + holder_balance_before_fee_msat, + counterparty_balance_before_fee_msat, + commit_tx_fee_sat.saturating_mul(1000), + )?; Ok(NextCommitmentStats { holder_balance_msat, @@ -425,15 +398,16 @@ fn get_available_balances( pending_htlcs.iter().filter_map(|htlc| htlc.outbound.then_some(htlc.amount_msat)).sum(); let inbound_htlcs_value_msat: u64 = pending_htlcs.iter().filter_map(|htlc| (!htlc.outbound).then_some(htlc.amount_msat)).sum(); + let total_anchors_sat = total_anchors_sat(channel_type); let (local_balance_before_fee_msat, remote_balance_before_fee_msat) = - saturating_sub_anchor_outputs( + saturating_sub_from_funder( is_outbound_from_holder, value_to_holder_msat.saturating_sub(outbound_htlcs_value_msat), (channel_value_satoshis * 1000) .checked_sub(value_to_holder_msat) .unwrap() .saturating_sub(inbound_htlcs_value_msat), - &channel_type, + total_anchors_sat.saturating_mul(1000), ); let outbound_capacity_msat = local_balance_before_fee_msat @@ -821,12 +795,13 @@ impl TxBuilder for SpecTxBuilder { // commitment transaction *before* checking whether the remote party's balance is enough to // cover the total anchor sum. + let total_anchors_sat = total_anchors_sat(&channel_parameters.channel_type_features); let (local_balance_before_fee_msat, remote_balance_before_fee_msat) = - saturating_sub_anchor_outputs( + saturating_sub_from_funder( channel_parameters.is_outbound_from_holder, value_to_self_after_htlcs_msat, value_to_remote_after_htlcs_msat, - &channel_parameters.channel_type_features, + total_anchors_sat.saturating_mul(1000), ); // We MUST use saturating subs here, as the funder's balance is not guaranteed to be greater @@ -836,17 +811,12 @@ impl TxBuilder for SpecTxBuilder { // commitment transaction *before* checking whether the remote party's balance is enough to // cover the total fee. - let (value_to_self, value_to_remote) = if channel_parameters.is_outbound_from_holder { - ( - (local_balance_before_fee_msat / 1000).saturating_sub(commit_tx_fee_sat), - remote_balance_before_fee_msat / 1000, - ) - } else { - ( - local_balance_before_fee_msat / 1000, - (remote_balance_before_fee_msat / 1000).saturating_sub(commit_tx_fee_sat), - ) - }; + let (value_to_self, value_to_remote) = saturating_sub_from_funder( + channel_parameters.is_outbound_from_holder, + local_balance_before_fee_msat / 1000, + remote_balance_before_fee_msat / 1000, + commit_tx_fee_sat, + ); let mut to_broadcaster_value_sat = if local { value_to_self } else { value_to_remote }; let mut to_countersignatory_value_sat = if local { value_to_remote } else { value_to_self }; From 32a67f80b7221928c3d4608bab375839128bc3eb Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 23 Mar 2026 23:33:33 +0000 Subject: [PATCH 245/627] Use inline format variables in channel/channelmanager format strings Convert format string arguments to inline `{var}` captures where the argument is a simple identifier (variable or constant). Field accesses, method calls, and expressions remain as positional args. Co-Authored-By: Claude Opus 4.6 (1M context) --- lightning/src/ln/channel.rs | 106 ++++++++++++----------------- lightning/src/ln/channelmanager.rs | 15 ++-- 2 files changed, 51 insertions(+), 70 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 0a2d952b488..5227f5745d0 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -3708,41 +3708,38 @@ impl ChannelContext { if config.channel_handshake_config.our_to_self_delay < BREAKDOWN_TIMEOUT { return Err(ChannelError::close(format!( - "Configured with an unreasonable our_to_self_delay ({}) putting user funds at risks. It must be greater than {}", - config.channel_handshake_config.our_to_self_delay, BREAKDOWN_TIMEOUT + "Configured with an unreasonable our_to_self_delay ({}) putting user funds at risks. It must be greater than {BREAKDOWN_TIMEOUT}", + config.channel_handshake_config.our_to_self_delay ))); } if channel_value_satoshis >= TOTAL_BITCOIN_SUPPLY_SATOSHIS { return Err(ChannelError::close(format!( - "Funding must be smaller than the total bitcoin supply. It was {}", - channel_value_satoshis + "Funding must be smaller than the total bitcoin supply. It was {channel_value_satoshis}" ))); } if msg_channel_reserve_satoshis > channel_value_satoshis { return Err(ChannelError::close(format!( - "Bogus channel_reserve_satoshis ({}). Must be no greater than channel_value_satoshis: {}", - msg_channel_reserve_satoshis, channel_value_satoshis + "Bogus channel_reserve_satoshis ({msg_channel_reserve_satoshis}). Must be no greater than channel_value_satoshis: {channel_value_satoshis}" ))); } let full_channel_value_msat = (channel_value_satoshis - msg_channel_reserve_satoshis) * 1000; if msg_push_msat > full_channel_value_msat { return Err(ChannelError::close(format!( - "push_msat {} was larger than channel amount minus reserve ({})", - msg_push_msat, full_channel_value_msat + "push_msat {msg_push_msat} was larger than channel amount minus reserve ({full_channel_value_msat})" ))); } if open_channel_fields.dust_limit_satoshis > channel_value_satoshis { return Err(ChannelError::close(format!( - "dust_limit_satoshis {} was larger than channel_value_satoshis {}. Peer never wants payout outputs?", - open_channel_fields.dust_limit_satoshis, channel_value_satoshis + "dust_limit_satoshis {} was larger than channel_value_satoshis {channel_value_satoshis}. Peer never wants payout outputs?", + open_channel_fields.dust_limit_satoshis ))); } if open_channel_fields.htlc_minimum_msat >= full_channel_value_msat { return Err(ChannelError::close(format!( - "Minimum htlc value ({}) was larger than full channel value ({})", - open_channel_fields.htlc_minimum_msat, full_channel_value_msat + "Minimum htlc value ({}) was larger than full channel value ({full_channel_value_msat})", + open_channel_fields.htlc_minimum_msat ))); } FundedChannel::::check_remote_fee( @@ -3759,8 +3756,8 @@ impl ChannelContext { ); if open_channel_fields.to_self_delay > max_counterparty_selected_contest_delay { return Err(ChannelError::close(format!( - "They wanted our payments to be delayed by a needlessly long period. Upper limit: {}. Actual: {}", - max_counterparty_selected_contest_delay, open_channel_fields.to_self_delay + "They wanted our payments to be delayed by a needlessly long period. Upper limit: {max_counterparty_selected_contest_delay}. Actual: {}", + open_channel_fields.to_self_delay ))); } if open_channel_fields.max_accepted_htlcs < 1 { @@ -3779,8 +3776,8 @@ impl ChannelContext { // Now check against optional parameters as set by config... if channel_value_satoshis < config.channel_handshake_limits.min_funding_satoshis { return Err(ChannelError::close(format!( - "Funding satoshis ({}) is less than the user specified limit ({})", - channel_value_satoshis, config.channel_handshake_limits.min_funding_satoshis + "Funding satoshis ({channel_value_satoshis}) is less than the user specified limit ({})", + config.channel_handshake_limits.min_funding_satoshis ))); } if open_channel_fields.htlc_minimum_msat @@ -3805,8 +3802,7 @@ impl ChannelContext { > config.channel_handshake_limits.max_channel_reserve_satoshis { return Err(ChannelError::close(format!( - "channel_reserve_satoshis ({}) is higher than the user specified limit ({})", - msg_channel_reserve_satoshis, + "channel_reserve_satoshis ({msg_channel_reserve_satoshis}) is higher than the user specified limit ({})", config.channel_handshake_limits.max_channel_reserve_satoshis ))); } @@ -3821,8 +3817,8 @@ impl ChannelContext { } if open_channel_fields.dust_limit_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS { return Err(ChannelError::close(format!( - "dust_limit_satoshis ({}) is less than the implementation limit ({})", - open_channel_fields.dust_limit_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS + "dust_limit_satoshis ({}) is less than the implementation limit ({MIN_CHAN_DUST_LIMIT_SATOSHIS})", + open_channel_fields.dust_limit_satoshis ))); } @@ -3835,8 +3831,8 @@ impl ChannelContext { }; if open_channel_fields.dust_limit_satoshis > max_chan_dust_limit_satoshis { return Err(ChannelError::close(format!( - "dust_limit_satoshis ({}) is greater than the implementation limit ({})", - open_channel_fields.dust_limit_satoshis, max_chan_dust_limit_satoshis + "dust_limit_satoshis ({}) is greater than the implementation limit ({max_chan_dust_limit_satoshis})", + open_channel_fields.dust_limit_satoshis ))); } @@ -3856,29 +3852,27 @@ impl ChannelContext { // Protocol level safety check in place, although it should never happen because // of `MIN_THEIR_CHAN_RESERVE_SATOSHIS` return Err(ChannelError::close(format!( - "Suitable channel reserve not found. remote_channel_reserve was ({}). dust_limit_satoshis is ({}).", - holder_selected_channel_reserve_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS + "Suitable channel reserve not found. remote_channel_reserve was ({holder_selected_channel_reserve_satoshis}). dust_limit_satoshis is ({MIN_CHAN_DUST_LIMIT_SATOSHIS})." ))); } if holder_selected_channel_reserve_satoshis * 1000 >= full_channel_value_msat { return Err(ChannelError::close(format!( - "Suitable channel reserve not found. remote_channel_reserve was ({})msats. Channel value is ({} - {})msats.", - holder_selected_channel_reserve_satoshis * 1000, full_channel_value_msat, msg_push_msat + "Suitable channel reserve not found. remote_channel_reserve was ({})msats. Channel value is ({full_channel_value_msat} - {msg_push_msat})msats.", + holder_selected_channel_reserve_satoshis * 1000 ))); } if msg_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS { log_debug!( logger, - "channel_reserve_satoshis ({}) is smaller than our dust limit ({}). We can broadcast \ - stale states without any risk, implying this channel is very insecure for our counterparty.", - msg_channel_reserve_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS); + "channel_reserve_satoshis ({msg_channel_reserve_satoshis}) is smaller than our dust limit ({MIN_CHAN_DUST_LIMIT_SATOSHIS}). We can broadcast \ + stale states without any risk, implying this channel is very insecure for our counterparty."); } if holder_selected_channel_reserve_satoshis < open_channel_fields.dust_limit_satoshis && holder_selected_channel_reserve_satoshis != 0 { return Err(ChannelError::close(format!( - "Dust limit ({}) too high for the channel reserve we require the remote to keep ({})", - open_channel_fields.dust_limit_satoshis, holder_selected_channel_reserve_satoshis + "Dust limit ({}) too high for the channel reserve we require the remote to keep ({holder_selected_channel_reserve_satoshis})", + open_channel_fields.dust_limit_satoshis ))); } @@ -3896,8 +3890,7 @@ impl ChannelContext { } else { if !script::is_bolt2_compliant(&script, their_features) { return Err(ChannelError::close(format!( - "Peer is signaling upfront_shutdown but has provided an unacceptable scriptpubkey format: {}", - script + "Peer is signaling upfront_shutdown but has provided an unacceptable scriptpubkey format: {script}" ))); } Some(script.clone()) @@ -3931,8 +3924,7 @@ impl ChannelContext { if let Some(shutdown_scriptpubkey) = &shutdown_scriptpubkey { if !shutdown_scriptpubkey.is_compatible(&their_features) { return Err(ChannelError::close(format!( - "Provided a scriptpubkey format not accepted by peer: {}", - shutdown_scriptpubkey + "Provided a scriptpubkey format not accepted by peer: {shutdown_scriptpubkey}" ))); } } @@ -4187,16 +4179,14 @@ impl ChannelContext { { return Err(APIError::APIMisuseError { err: format!( - "funding_value must not exceed {}, it was {}", - MAX_FUNDING_SATOSHIS_NO_WUMBO, channel_value_satoshis + "funding_value must not exceed {MAX_FUNDING_SATOSHIS_NO_WUMBO}, it was {channel_value_satoshis}" ), }); } if channel_value_satoshis >= TOTAL_BITCOIN_SUPPLY_SATOSHIS { return Err(APIError::APIMisuseError { err: format!( - "funding_value must be smaller than the total bitcoin supply, it was {}", - channel_value_satoshis + "funding_value must be smaller than the total bitcoin supply, it was {channel_value_satoshis}" ), }); } @@ -4204,16 +4194,14 @@ impl ChannelContext { if push_msat > channel_value_msat { return Err(APIError::APIMisuseError { err: format!( - "Push value ({}) was larger than channel_value ({})", - push_msat, channel_value_msat + "Push value ({push_msat}) was larger than channel_value ({channel_value_msat})" ), }); } if holder_selected_contest_delay < BREAKDOWN_TIMEOUT { return Err(APIError::APIMisuseError { err: format!( - "Configured with an unreasonable our_to_self_delay ({}) putting user funds at risks", - holder_selected_contest_delay + "Configured with an unreasonable our_to_self_delay ({holder_selected_contest_delay}) putting user funds at risks" ), }); } @@ -4720,8 +4708,7 @@ impl ChannelContext { } if channel_reserve_satoshis > funding.get_value_satoshis() { return Err(ChannelError::close(format!( - "Bogus channel_reserve_satoshis ({}). Must not be greater than ({})", - channel_reserve_satoshis, + "Bogus channel_reserve_satoshis ({channel_reserve_satoshis}). Must not be greater than ({})", funding.get_value_satoshis() ))); } @@ -4737,8 +4724,7 @@ impl ChannelContext { > funding.get_value_satoshis() - funding.holder_selected_channel_reserve_satoshis { return Err(ChannelError::close(format!( - "Bogus channel_reserve_satoshis ({}). Must not be greater than channel value minus our reserve ({})", - channel_reserve_satoshis, + "Bogus channel_reserve_satoshis ({channel_reserve_satoshis}). Must not be greater than channel value minus our reserve ({})", funding.get_value_satoshis() - funding.holder_selected_channel_reserve_satoshis ))); } @@ -4746,16 +4732,16 @@ impl ChannelContext { (funding.get_value_satoshis() - channel_reserve_satoshis) * 1000; if common_fields.htlc_minimum_msat >= full_channel_value_msat { return Err(ChannelError::close(format!( - "Minimum htlc value ({}) is full channel value ({})", - common_fields.htlc_minimum_msat, full_channel_value_msat + "Minimum htlc value ({}) is full channel value ({full_channel_value_msat})", + common_fields.htlc_minimum_msat ))); } let max_delay_acceptable = u16::min(peer_limits.their_to_self_delay, MAX_LOCAL_BREAKDOWN_TIMEOUT); if common_fields.to_self_delay > max_delay_acceptable { return Err(ChannelError::close(format!( - "They wanted our payments to be delayed by a needlessly long period. Upper limit: {}. Actual: {}", - max_delay_acceptable, common_fields.to_self_delay + "They wanted our payments to be delayed by a needlessly long period. Upper limit: {max_delay_acceptable}. Actual: {}", + common_fields.to_self_delay ))); } if common_fields.max_accepted_htlcs < 1 { @@ -4791,8 +4777,8 @@ impl ChannelContext { } if channel_reserve_satoshis > peer_limits.max_channel_reserve_satoshis { return Err(ChannelError::close(format!( - "channel_reserve_satoshis ({}) is higher than the user specified limit ({})", - channel_reserve_satoshis, peer_limits.max_channel_reserve_satoshis + "channel_reserve_satoshis ({channel_reserve_satoshis}) is higher than the user specified limit ({})", + peer_limits.max_channel_reserve_satoshis ))); } if common_fields.max_accepted_htlcs < peer_limits.min_max_accepted_htlcs { @@ -4803,8 +4789,8 @@ impl ChannelContext { } if common_fields.dust_limit_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS { return Err(ChannelError::close(format!( - "dust_limit_satoshis ({}) is less than the implementation limit ({})", - common_fields.dust_limit_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS + "dust_limit_satoshis ({}) is less than the implementation limit ({MIN_CHAN_DUST_LIMIT_SATOSHIS})", + common_fields.dust_limit_satoshis ))); } @@ -4817,8 +4803,8 @@ impl ChannelContext { }; if common_fields.dust_limit_satoshis > max_chan_dust_limit_satoshis { return Err(ChannelError::close(format!( - "dust_limit_satoshis ({}) is greater than the implementation limit ({})", - common_fields.dust_limit_satoshis, max_chan_dust_limit_satoshis + "dust_limit_satoshis ({}) is greater than the implementation limit ({max_chan_dust_limit_satoshis})", + common_fields.dust_limit_satoshis ))); } if common_fields.minimum_depth > peer_limits.max_minimum_depth { @@ -4838,8 +4824,7 @@ impl ChannelContext { } else { if !script::is_bolt2_compliant(&script, their_features) { return Err(ChannelError::close(format!( - "Peer is signaling upfront_shutdown but has provided an unacceptable scriptpubkey format: {}", - script + "Peer is signaling upfront_shutdown but has provided an unacceptable scriptpubkey format: {script}" ))); } Some(script.clone()) @@ -14163,8 +14148,7 @@ impl OutboundV1Channel { // of `MIN_THEIR_CHAN_RESERVE_SATOSHIS` return Err(APIError::APIMisuseError { err: format!( - "Holder selected channel reserve below implementation limit dust_limit_satoshis {}", - holder_selected_channel_reserve_satoshis, + "Holder selected channel reserve below implementation limit dust_limit_satoshis {holder_selected_channel_reserve_satoshis}" ), }); } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index a4225c51951..184bc405200 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -3838,8 +3838,7 @@ impl< if channel_value_satoshis < 1000 { return Err(APIError::APIMisuseError { err: format!( - "Channel value must be at least 1000 satoshis. It was {}", - channel_value_satoshis + "Channel value must be at least 1000 satoshis. It was {channel_value_satoshis}" ), }); } @@ -3850,15 +3849,14 @@ impl< let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = - per_peer_state.get(&their_network_key).ok_or_else(|| APIError::APIMisuseError { - err: format!("Not connected to node: {}", their_network_key), - })?; + let peer_state_mutex = per_peer_state.get(&their_network_key).ok_or_else(|| { + APIError::APIMisuseError { err: format!("Not connected to node: {their_network_key}") } + })?; let mut peer_state = peer_state_mutex.lock().unwrap(); if !peer_state.is_connected { return Err(APIError::APIMisuseError { - err: format!("Not connected to node: {}", their_network_key), + err: format!("Not connected to node: {their_network_key}"), }); } @@ -3866,8 +3864,7 @@ impl< if peer_state.channel_by_id.contains_key(&temporary_channel_id) { return Err(APIError::APIMisuseError { err: format!( - "Channel with temporary channel ID {} already exists!", - temporary_channel_id + "Channel with temporary channel ID {temporary_channel_id} already exists!" ), }); } From 5ab28b1914eaeb37c20451104a90c4c1966d250e Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 26 Mar 2026 22:27:26 +0000 Subject: [PATCH 246/627] Switch 0FC to production feature bit --- lightning-types/src/features.rs | 50 +++++++++++++-------------------- 1 file changed, 19 insertions(+), 31 deletions(-) diff --git a/lightning-types/src/features.rs b/lightning-types/src/features.rs index 22493efc556..3cd15685f79 100644 --- a/lightning-types/src/features.rs +++ b/lightning-types/src/features.rs @@ -162,17 +162,13 @@ mod sealed { // Byte 4 Quiescence | OnionMessages, // Byte 5 - ProvideStorage | ChannelType | SCIDPrivacy, + AnchorZeroFeeCommitments | ProvideStorage | ChannelType | SCIDPrivacy, // Byte 6 ZeroConf, // Byte 7 Trampoline | SimpleClose | Splice, - // Byte 8 - 16 - ,,,,,,,,, - // Byte 17 - AnchorZeroFeeCommitmentsStaging, - // Byte 18 - , + // Byte 8 - 18 + ,,,,,,,,,,, // Byte 19 HtlcHold, ] @@ -191,17 +187,13 @@ mod sealed { // Byte 4 Quiescence | OnionMessages, // Byte 5 - ProvideStorage | ChannelType | SCIDPrivacy, + AnchorZeroFeeCommitments | ProvideStorage | ChannelType | SCIDPrivacy, // Byte 6 ZeroConf | Keysend, // Byte 7 Trampoline | SimpleClose | Splice, - // Byte 8 - 16 - ,,,,,,,,, - // Byte 17 - AnchorZeroFeeCommitmentsStaging, - // Byte 18 - , + // Byte 8 - 18 + ,,,,,,,,,,, // Byte 19 HtlcHold, // Byte 20 - 31 @@ -264,13 +256,9 @@ mod sealed { // Byte 4 , // Byte 5 - SCIDPrivacy, + AnchorZeroFeeCommitments | SCIDPrivacy, // Byte 6 ZeroConf, - // Byte 7 - 16 - ,,,,,,,,,, - // Byte 17 - AnchorZeroFeeCommitmentsStaging, ]); /// Defines a feature with the given bits for the specified [`Context`]s. The generated trait is @@ -606,6 +594,17 @@ mod sealed { supports_onion_messages, requires_onion_messages ); + define_feature!( + 41, + AnchorZeroFeeCommitments, + [InitContext, NodeContext, ChannelTypeContext], + "Feature flags for `option_zero_fee_commitments`.", + set_anchor_zero_fee_commitments_optional, + set_anchor_zero_fee_commitments_required, + clear_anchor_zero_fee_commitments, + supports_anchor_zero_fee_commitments, + requires_anchor_zero_fee_commitments + ); define_feature!( 43, ProvideStorage, @@ -699,17 +698,6 @@ mod sealed { // By default, allocate enough bytes to cover up to Splice. Update this as new features are // added which we expect to appear commonly across contexts. pub(super) const MIN_FEATURES_ALLOCATION_BYTES: usize = 63_usize.div_ceil(8); - define_feature!( - 141, // The BOLTs PR uses feature bit 40/41, so add +100 for the experimental bit - AnchorZeroFeeCommitmentsStaging, - [InitContext, NodeContext, ChannelTypeContext], - "Feature flags for `option_zero_fee_commitments`.", - set_anchor_zero_fee_commitments_optional, - set_anchor_zero_fee_commitments_required, - clear_anchor_zero_fee_commitments, - supports_anchor_zero_fee_commitments, - requires_anchor_zero_fee_commitments - ); define_feature!( 153, // The BOLTs PR uses feature bit 52/53, so add +100 for the experimental bit HtlcHold, @@ -1086,7 +1074,7 @@ impl ChannelTypeFeatures { /// Constructs a ChannelTypeFeatures with zero fee commitment anchors support. pub fn anchors_zero_fee_commitments() -> Self { let mut ret = Self::empty(); - ::set_required_bit( + ::set_required_bit( &mut ret, ); ret From 28f10a547639f0a0a22022b9ab24378aad49596a Mon Sep 17 00:00:00 2001 From: Philip Kannegaard Hayes Date: Thu, 26 Mar 2026 18:31:36 -0700 Subject: [PATCH 247/627] types: fix zero conf feature missing `clear_zero_conf` --- lightning-types/src/features.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lightning-types/src/features.rs b/lightning-types/src/features.rs index 22493efc556..8ce14a86580 100644 --- a/lightning-types/src/features.rs +++ b/lightning-types/src/features.rs @@ -649,9 +649,17 @@ mod sealed { supports_payment_metadata, requires_payment_metadata ); - define_feature!(51, ZeroConf, [InitContext, NodeContext, ChannelTypeContext], + define_feature!( + 51, + ZeroConf, + [InitContext, NodeContext, ChannelTypeContext], "Feature flags for accepting channels with zero confirmations. Called `option_zeroconf` in the BOLTs", - set_zero_conf_optional, set_zero_conf_required, supports_zero_conf, requires_zero_conf); + set_zero_conf_optional, + set_zero_conf_required, + clear_zero_conf, + supports_zero_conf, + requires_zero_conf + ); define_feature!( 55, Keysend, From 922d9f1f7e9b7586a261e4dc48de311fbadfbd50 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Mon, 30 Mar 2026 14:09:13 +0200 Subject: [PATCH 248/627] fuzz: fix coverage report to include library crates Since cargo-llvm-cov 0.7.0, only workspace members are instrumented by default. Since the fuzz crate is a standalone workspace, library crates like lightning were not instrumented, and the coverage report was empty. Add --dep-coverage to instrument the library path dependencies. This alone is not sufficient for the report: --dep-coverage's report filtering only supports crates.io deps, not path deps (per a TODO in cargo-llvm-cov source). Add --no-default-ignore-filename-regex to include all instrumented code, then use a custom --ignore-filename-regex to exclude unwanted paths (cargo registry, rustup toolchains, fuzz harness). AI tools were used in preparing this commit. --- contrib/generate_fuzz_coverage.sh | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/contrib/generate_fuzz_coverage.sh b/contrib/generate_fuzz_coverage.sh index 22826ca0c38..6be9956bbca 100755 --- a/contrib/generate_fuzz_coverage.sh +++ b/contrib/generate_fuzz_coverage.sh @@ -57,7 +57,11 @@ mkdir -p "$OUTPUT_DIR" # dont run this command when running in CI if [ "$OUTPUT_CODECOV_JSON" = "0" ]; then - cargo llvm-cov --html --ignore-filename-regex "fuzz/" --output-dir "$OUTPUT_DIR" + cargo llvm-cov --html \ + --dep-coverage lightning,lightning-invoice,lightning-liquidity,lightning-rapid-gossip-sync,lightning-persister \ + --no-default-ignore-filename-regex \ + --ignore-filename-regex "(\.cargo/registry|\.rustup/toolchains|/fuzz/)" \ + --output-dir "$OUTPUT_DIR" echo "Coverage report generated in $OUTPUT_DIR/html/index.html" else # Clean previous coverage artifacts to ensure a fresh run. @@ -78,7 +82,10 @@ else fi echo "Replaying imported corpus (if found) via tests to generate coverage..." - cargo llvm-cov -j8 --codecov --ignore-filename-regex "fuzz/" \ + cargo llvm-cov -j8 --codecov \ + --dep-coverage lightning,lightning-invoice,lightning-liquidity,lightning-rapid-gossip-sync,lightning-persister \ + --no-default-ignore-filename-regex \ + --ignore-filename-regex "(\.cargo/registry|\.rustup/toolchains|/fuzz/)" \ --output-path "$OUTPUT_DIR/fuzz-codecov.json" --tests echo "Fuzz codecov report available at $OUTPUT_DIR/fuzz-codecov.json" From b9181c366123541db9bbf987ba33766eca4079a2 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Tue, 24 Mar 2026 15:01:59 +0100 Subject: [PATCH 249/627] Remove dead string search in fuzz SearchingOutput The searched-for log message ("Outbound update_fee HTLC buffer overflow") no longer exists in the lightning crate, so the from_utf8 + contains check on every log line was pure waste. AI tools were used in preparing this commit. --- fuzz/src/chanmon_consistency.rs | 55 +++------------------------------ 1 file changed, 5 insertions(+), 50 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 9716190da72..cdfcb0e4a4b 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -870,8 +870,7 @@ enum ChanType { } #[inline] -pub fn do_test(data: &[u8], underlying_out: Out) { - let out = SearchingOutput::new(underlying_out); +pub fn do_test(data: &[u8], out: Out) { let broadcast_a = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); let broadcast_b = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); let broadcast_c = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); @@ -1859,11 +1858,7 @@ pub fn do_test(data: &[u8], underlying_out: // Can be generated as a result of calling `timer_tick_occurred` enough // times while peers are disconnected }, - _ => if out.may_fail.load(atomic::Ordering::Acquire) { - return; - } else { - panic!("Unhandled message event {:?}", event) - }, + _ => panic!("Unhandled message event {:?}", event), } if $limit_events != ProcessMessages::AllMessages { break; @@ -1903,13 +1898,7 @@ pub fn do_test(data: &[u8], underlying_out: MessageSendEvent::HandleError { ref action, .. } => { assert_action_timeout_awaiting_response(action); }, - _ => { - if out.may_fail.load(atomic::Ordering::Acquire) { - return; - } else { - panic!("Unhandled message event") - } - }, + _ => panic!("Unhandled message event"), } } push_excess_b_events!( @@ -1931,13 +1920,7 @@ pub fn do_test(data: &[u8], underlying_out: MessageSendEvent::HandleError { ref action, .. } => { assert_action_timeout_awaiting_response(action); }, - _ => { - if out.may_fail.load(atomic::Ordering::Acquire) { - return; - } else { - panic!("Unhandled message event") - } - }, + _ => panic!("Unhandled message event"), } } push_excess_b_events!( @@ -2050,13 +2033,7 @@ pub fn do_test(data: &[u8], underlying_out: .. } => {}, - _ => { - if out.may_fail.load(atomic::Ordering::Acquire) { - return; - } else { - panic!("Unhandled event") - } - }, + _ => panic!("Unhandled event"), } } while nodes[$node].needs_pending_htlc_processing() { @@ -2879,28 +2856,6 @@ pub fn do_test(data: &[u8], underlying_out: } } -/// We actually have different behavior based on if a certain log string has been seen, so we have -/// to do a bit more tracking. -#[derive(Clone)] -struct SearchingOutput { - output: O, - may_fail: Arc, -} -impl Output for SearchingOutput { - fn locked_write(&self, data: &[u8]) { - // We hit a design limitation of LN state machine (see CONCURRENT_INBOUND_HTLC_FEE_BUFFER) - if std::str::from_utf8(data).unwrap().contains("Outbound update_fee HTLC buffer overflow - counterparty should force-close this channel") { - self.may_fail.store(true, atomic::Ordering::Release); - } - self.output.locked_write(data) - } -} -impl SearchingOutput { - pub fn new(output: O) -> Self { - Self { output, may_fail: Arc::new(atomic::AtomicBool::new(false)) } - } -} - pub fn chanmon_consistency_test(data: &[u8], out: Out) { do_test(data, out); } From d6ff54eac2d6e5be702f42b70022ad14a4d45a8c Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Tue, 24 Mar 2026 15:02:22 +0100 Subject: [PATCH 250/627] Skip log formatting entirely for DevNull output Even though DevNull discards the bytes, the formatting work (SubstringFormatter, fmt::write, from_utf8) was still being done on every log call. Short-circuit in TestLogger::log via a TypeId check, which monomorphization resolves at compile time. AI tools were used in preparing this commit. --- fuzz/src/utils/test_logger.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/fuzz/src/utils/test_logger.rs b/fuzz/src/utils/test_logger.rs index 193ccc06a54..e629a7f486b 100644 --- a/fuzz/src/utils/test_logger.rs +++ b/fuzz/src/utils/test_logger.rs @@ -8,6 +8,7 @@ // licenses. use lightning::util::logger::{Logger, Record}; +use std::any::TypeId; use std::io::Write; use std::sync::{Arc, Mutex}; @@ -66,6 +67,9 @@ impl<'a, Out: Output> Write for LockedWriteAdapter<'a, Out> { impl Logger for TestLogger { fn log(&self, record: Record) { + if TypeId::of::() == TypeId::of::() { + return; + } writeln!(LockedWriteAdapter(&self.out), "{:<6} {}", self.id, record).unwrap(); } } From 20e943ef43298f887015439119b608c3c03baeca Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 30 Mar 2026 04:54:47 +0000 Subject: [PATCH 251/627] Fix flakiness in `test_tor_connect` Fixes #4519 --- lightning-net-tokio/src/lib.rs | 37 +++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/lightning-net-tokio/src/lib.rs b/lightning-net-tokio/src/lib.rs index ee129669410..2e8e568f97e 100644 --- a/lightning-net-tokio/src/lib.rs +++ b/lightning-net-tokio/src/lib.rs @@ -1120,6 +1120,19 @@ mod tests { // Set TOR_PROXY=127.0.0.1:9050 let tor_proxy_addr: SocketAddr = std::env!("TOR_PROXY").parse().unwrap(); + let mut google_addresses: Vec<_> = + tokio::net::lookup_host("google.com:80").await.unwrap().collect(); + let ipv6_pos = google_addresses + .iter() + .position(|a| a.is_ipv6()) + .expect("must resolve at least one ipv6 address"); + let mut google_ipv6 = google_addresses.remove(ipv6_pos); + let ipv4_pos = google_addresses + .iter() + .position(|a| a.is_ipv4()) + .expect("must resolve at least one ipv4 address"); + let mut google_ipv4 = google_addresses.remove(ipv4_pos); + struct TestEntropySource; impl EntropySource for TestEntropySource { @@ -1132,17 +1145,16 @@ mod tests { // Success cases - for addr_str in [ + for addr in [ // google.com - "142.250.189.196:80", + google_ipv4.into(), // google.com - "[2607:f8b0:4005:813::2004]:80", + google_ipv6.into(), // torproject.org - "torproject.org:80", + "torproject.org:80".parse().unwrap(), // torproject.org - "2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion:80", + "2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion:80".parse().unwrap(), ] { - let addr: SocketAddress = addr_str.parse().unwrap(); let tcp_stream = tor_connect(addr, tor_proxy_addr, &entropy_source).await.unwrap(); assert_eq!( tcp_stream.try_read(&mut [0u8; 1]).unwrap_err().kind(), @@ -1151,18 +1163,19 @@ mod tests { } // Failure cases + google_ipv4.set_port(1234); + google_ipv6.set_port(1234); - for addr_str in [ + for addr in [ // google.com, with some invalid port - "142.250.189.196:1234", + google_ipv4.into(), // google.com, with some invalid port - "[2607:f8b0:4005:813::2004]:1234", + google_ipv6.into(), // torproject.org, with some invalid port - "torproject.org:1234", + "torproject.org:1234".parse().unwrap(), // torproject.org, with a typo - "3gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion:80", + "3gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion:80".parse().unwrap(), ] { - let addr: SocketAddress = addr_str.parse().unwrap(); assert!(tor_connect(addr, tor_proxy_addr, &entropy_source).await.is_err()); } } From 51dfcb56db25c37dd94d0dbb7b9720faa1da6a13 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Sun, 12 Oct 2025 13:48:04 +0000 Subject: [PATCH 252/627] Update BestBlock to store ANTI_REORG_DELAY * 2 recent block hashes On restart, LDK expects the chain to be replayed starting from where it was when objects were last serialized. This is fine in the normal case, but if there was a reorg and the node which we were syncing from either resynced or was changed, the last block that we were synced as of might no longer be available. As a result, it becomes impossible to figure out where the fork point is, and thus to replay the chain. Luckily, changing the block source during a reorg isn't exactly common, but we shouldn't end up with a bricked node. To address this, `lightning-block-sync` allows the user to pass in `Cache` which can be used to cache recent blocks and thus allow for reorg handling in this case. However, serialization for, and a reasonable default implementation of a `Cache` was never built. Instead, here, we start taking a different approach. To avoid developers having to persist yet another object, we move `BestBlock` to storing some number of recent block hashes. This allows us to find the fork point with just the serialized state. In conjunction with 403dc1a48bb71ae794f6883ae0b760aad44cda39 (which allows us to disconnect blocks without having the stored header), this should allow us to replay chain state after a reorg even if we no longer have access to the top few blocks of the old chain tip. While we only really need to store `ANTI_REORG_DELAY` blocks (as we generally assume that any deeper reorg won't happen and thus we don't guarantee we handle it correctly), its nice to store a few more to be able to handle more than a six block reorg. While other parts of the codebase may not be entirely robust against such a reorg if the transactions confirmed change out from under us, its entirely possible (and, indeed, common) for reorgs to contain nearly identical transactions. --- lightning/src/chain/channelmonitor.rs | 16 ++-- lightning/src/chain/mod.rs | 132 +++++++++++++++++++++++++- lightning/src/ln/channelmanager.rs | 25 ++--- lightning/src/util/ser.rs | 27 ++++++ lightning/src/util/sweep.rs | 2 +- 5 files changed, 181 insertions(+), 21 deletions(-) diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs index 1eb1484d07d..5a49c39f1de 100644 --- a/lightning/src/chain/channelmonitor.rs +++ b/lightning/src/chain/channelmonitor.rs @@ -1755,6 +1755,7 @@ pub(crate) fn write_chanmon_internal( (34, channel_monitor.alternative_funding_confirmed, option), (35, channel_monitor.is_manual_broadcast, required), (37, channel_monitor.funding_seen_onchain, required), + (39, channel_monitor.best_block.previous_blocks, required), }); Ok(()) @@ -5390,9 +5391,6 @@ impl ChannelMonitorImpl { &mut self, header: &Header, txdata: &TransactionData, height: u32, broadcaster: B, fee_estimator: F, logger: &WithContext, ) -> Vec { - let block_hash = header.block_hash(); - self.best_block = BestBlock::new(block_hash, height); - let bounded_fee_estimator = LowerBoundedFeeEstimator::new(fee_estimator); self.transactions_confirmed(header, txdata, height, broadcaster, &bounded_fee_estimator, logger) } @@ -5409,7 +5407,7 @@ impl ChannelMonitorImpl { let block_hash = header.block_hash(); if height > self.best_block.height { - self.best_block = BestBlock::new(block_hash, height); + self.best_block.update_for_new_tip(block_hash, height); log_trace!(logger, "Connecting new block {} at height {}", block_hash, height); self.block_confirmed(height, block_hash, vec![], vec![], vec![], &broadcaster, &fee_estimator, logger) } else if block_hash != self.best_block.block_hash { @@ -5683,7 +5681,7 @@ impl ChannelMonitorImpl { } if height > self.best_block.height { - self.best_block = BestBlock::new(block_hash, height); + self.best_block.update_for_new_tip(block_hash, height); } if should_broadcast_commitment { @@ -6644,7 +6642,7 @@ impl<'a, 'b, ES: EntropySource, SP: SignerProvider> ReadableArgs<(&'a ES, &'b SP } } - let best_block = BestBlock::new(Readable::read(reader)?, Readable::read(reader)?); + let mut best_block = BestBlock::new(Readable::read(reader)?, Readable::read(reader)?); let waiting_threshold_conf_len: u64 = Readable::read(reader)?; let mut onchain_events_awaiting_threshold_conf = Vec::with_capacity(cmp::min(waiting_threshold_conf_len as usize, MAX_ALLOC_SIZE / 128)); @@ -6694,6 +6692,7 @@ impl<'a, 'b, ES: EntropySource, SP: SignerProvider> ReadableArgs<(&'a ES, &'b SP let mut alternative_funding_confirmed = None; let mut is_manual_broadcast = RequiredWrapper(None); let mut funding_seen_onchain = RequiredWrapper(None); + let mut best_block_previous_blocks = None; read_tlv_fields!(reader, { (1, funding_spend_confirmed, option), (3, htlcs_resolved_on_chain, optional_vec), @@ -6716,7 +6715,12 @@ impl<'a, 'b, ES: EntropySource, SP: SignerProvider> ReadableArgs<(&'a ES, &'b SP (34, alternative_funding_confirmed, option), (35, is_manual_broadcast, (default_value, false)), (37, funding_seen_onchain, (default_value, true)), + (39, best_block_previous_blocks, option), // Added and always set in 0.3 }); + if let Some(previous_blocks) = best_block_previous_blocks { + best_block.previous_blocks = previous_blocks; + } + // Note that `payment_preimages_with_info` was added (and is always written) in LDK 0.1, so // we can use it to determine if this monitor was last written by LDK 0.1 or later. let written_by_0_1_or_later = payment_preimages_with_info.is_some(); diff --git a/lightning/src/chain/mod.rs b/lightning/src/chain/mod.rs index 99e184d8fda..9692558cf7c 100644 --- a/lightning/src/chain/mod.rs +++ b/lightning/src/chain/mod.rs @@ -18,7 +18,9 @@ use bitcoin::network::Network; use bitcoin::script::{Script, ScriptBuf}; use bitcoin::secp256k1::PublicKey; -use crate::chain::channelmonitor::{ChannelMonitor, ChannelMonitorUpdate, MonitorEvent}; +use crate::chain::channelmonitor::{ + ChannelMonitor, ChannelMonitorUpdate, MonitorEvent, ANTI_REORG_DELAY, +}; use crate::chain::transaction::{OutPoint, TransactionData}; use crate::ln::types::ChannelId; use crate::sign::ecdsa::EcdsaChannelSigner; @@ -43,13 +45,20 @@ pub struct BestBlock { pub block_hash: BlockHash, /// The height at which the block was confirmed. pub height: u32, + /// Previous blocks immediately before [`Self::block_hash`], in reverse chronological order. + /// + /// These ensure we can find the fork point of a reorg if our block source no longer has the + /// previous best tip after a restart. + pub previous_blocks: [Option; ANTI_REORG_DELAY as usize * 2], } impl BestBlock { /// Constructs a `BestBlock` that represents the genesis block at height 0 of the given /// network. pub fn from_network(network: Network) -> Self { - BestBlock { block_hash: genesis_block(network).header.block_hash(), height: 0 } + let block_hash = genesis_block(network).header.block_hash(); + let previous_blocks = [None; ANTI_REORG_DELAY as usize * 2]; + BestBlock { block_hash, height: 0, previous_blocks } } /// Returns a `BestBlock` as identified by the given block hash and height. @@ -57,13 +66,88 @@ impl BestBlock { /// This is not exported to bindings users directly as the bindings auto-generate an /// equivalent `new`. pub fn new(block_hash: BlockHash, height: u32) -> Self { - BestBlock { block_hash, height } + let previous_blocks = [None; ANTI_REORG_DELAY as usize * 2]; + BestBlock { block_hash, height, previous_blocks } + } + + /// Advances to a new block at height [`Self::height`] + 1. + pub fn advance(&mut self, new_hash: BlockHash) { + // Shift all block hashes to the right (making room for the old tip at index 0) + for i in (1..self.previous_blocks.len()).rev() { + self.previous_blocks[i] = self.previous_blocks[i - 1]; + } + + // The old tip becomes the new index 0 (tip-1) + self.previous_blocks[0] = Some(self.block_hash); + + // Update to the new tip + self.block_hash = new_hash; + self.height += 1; + } + + /// Updates this object for a new best-block, either delegating to [`Self::advance`] if the new + /// block is simply one higher than the current tip and wiping [`Self::previous_blocks`] if a + /// few blocks have been skipped. + pub fn update_for_new_tip(&mut self, new_tip_hash: BlockHash, new_tip_height: u32) { + if new_tip_height == self.height + 1 { + self.advance(new_tip_hash); + } else { + *self = BestBlock::new(new_tip_hash, new_tip_height); + } + } + + /// Returns the block hash at the given height, if available in our history. + pub fn get_hash_at_height(&self, height: u32) -> Option { + if height > self.height { + return None; + } + if height == self.height { + return Some(self.block_hash); + } + + // offset = 1 means we want tip-1, which is block_hashes[0] + // offset = 2 means we want tip-2, which is block_hashes[1], etc. + let offset = self.height.saturating_sub(height) as usize; + if offset >= 1 && offset <= self.previous_blocks.len() { + self.previous_blocks[offset - 1] + } else { + None + } + } + + /// Find the most recent common ancestor between two BestBlocks by searching their block hash + /// histories. + /// + /// Returns the common block hash and height, or None if no common block is found in the + /// available histories. + pub fn find_common_ancestor(&self, other: &BestBlock) -> Option<(BlockHash, u32)> { + // First check if either tip matches + if self.block_hash == other.block_hash && self.height == other.height { + return Some((self.block_hash, self.height)); + } + + // Check all heights covered by self's history + let min_height = self.height.saturating_sub(self.previous_blocks.len() as u32); + for check_height in (min_height..=self.height).rev() { + if let Some(self_hash) = self.get_hash_at_height(check_height) { + if let Some(other_hash) = other.get_hash_at_height(check_height) { + if self_hash == other_hash { + return Some((self_hash, check_height)); + } + } + } + } + None } } impl_writeable_tlv_based!(BestBlock, { (0, block_hash, required), + // Note that any change to the previous_blocks array length will change the serialization + // format and thus it is specified without constants here. + (1, previous_blocks_read, (legacy, [Option; 6 * 2], |_| Ok(()), |us: &BestBlock| Some(us.previous_blocks))), (2, height, required), + (unused, previous_blocks, (static_value, previous_blocks_read.unwrap_or([None; 6 * 2]))), }); /// The `Listen` trait is used to notify when blocks have been connected or disconnected from the @@ -491,3 +575,45 @@ impl ClaimId { ClaimId(Sha256::from_engine(engine).to_byte_array()) } } + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::hashes::Hash; + + #[test] + fn test_best_block() { + let hash1 = BlockHash::from_slice(&[1; 32]).unwrap(); + let mut chain_a = BestBlock::new(hash1, 100); + let mut chain_b = BestBlock::new(hash1, 100); + + // Test get_hash_at_height on initial block + assert_eq!(chain_a.get_hash_at_height(100), Some(hash1)); + assert_eq!(chain_a.get_hash_at_height(101), None); + assert_eq!(chain_a.get_hash_at_height(99), None); + + // Test find_common_ancestor with identical blocks + assert_eq!(chain_a.find_common_ancestor(&chain_b), Some((hash1, 100))); + + let hash2 = BlockHash::from_slice(&[2; 32]).unwrap(); + chain_a.advance(hash2); + assert_eq!(chain_a.height, 101); + assert_eq!(chain_a.block_hash, hash2); + assert_eq!(chain_a.previous_blocks[0], Some(hash1)); + assert_eq!(chain_a.get_hash_at_height(101), Some(hash2)); + assert_eq!(chain_a.get_hash_at_height(100), Some(hash1)); + + // Test find_common_ancestor with different heights + assert_eq!(chain_a.find_common_ancestor(&chain_b), Some((hash1, 100))); + + // Test find_common_ancestor with diverged chains but the same height + let hash_b3 = BlockHash::from_slice(&[33; 32]).unwrap(); + chain_b.advance(hash_b3); + assert_eq!(chain_a.find_common_ancestor(&chain_b), Some((hash1, 100))); + + // Test find_common_ancestor with no common history + let hash_other = BlockHash::from_slice(&[99; 32]).unwrap(); + let chain_c = BestBlock::new(hash_other, 200); + assert_eq!(chain_a.find_common_ancestor(&chain_c), None); + } +} diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index d042a69bf80..d63ccca1a4a 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -15887,7 +15887,7 @@ impl< let _persistence_guard = PersistenceNotifierGuard::optionally_notify_skipping_background_events( self, || -> NotifyOption { NotifyOption::DoPersist }); - *self.best_block.write().unwrap() = BestBlock::new(block_hash, height); + self.best_block.write().unwrap().update_for_new_tip(block_hash, height); let mut min_anchor_feerate = None; let mut min_non_anchor_feerate = None; @@ -18215,6 +18215,7 @@ impl< (17, in_flight_monitor_updates, option), (19, peer_storage_dir, optional_vec), (21, WithoutLength(&self.flow.writeable_async_receive_offer_cache()), required), + (23, self.best_block.read().unwrap().previous_blocks, required), }); // Remove the SpliceFailed and DiscardFunding events added earlier. @@ -18284,8 +18285,7 @@ impl Readable for AmountlessClaimablePaymentHTLCOnion { // This is an internal DTO used in the two-stage deserialization process. pub(super) struct ChannelManagerData { chain_hash: ChainHash, - best_block_height: u32, - best_block_hash: BlockHash, + best_block: BestBlock, channels: Vec>, claimable_payments: HashMap, peer_init_features: Vec<(PublicKey, InitFeatures)>, @@ -18493,6 +18493,7 @@ impl<'a, ES: EntropySource, SP: SignerProvider, L: Logger> let mut inbound_payment_id_secret = None; let mut peer_storage_dir: Option)>> = None; let mut async_receive_offer_cache: AsyncReceiveOfferCache = AsyncReceiveOfferCache::new(); + let mut best_block_previous_blocks = None; read_tlv_fields!(reader, { (1, pending_outbound_payments_no_retry, option), (2, pending_intercepted_htlcs_legacy, option), @@ -18511,6 +18512,7 @@ impl<'a, ES: EntropySource, SP: SignerProvider, L: Logger> (17, in_flight_monitor_updates, option), (19, peer_storage_dir, optional_vec), (21, async_receive_offer_cache, (default_value, async_receive_offer_cache)), + (23, best_block_previous_blocks, option), }); // Merge legacy pending_outbound_payments fields into a single HashMap. @@ -18605,8 +18607,11 @@ impl<'a, ES: EntropySource, SP: SignerProvider, L: Logger> Ok(ChannelManagerData { chain_hash, - best_block_height, - best_block_hash, + best_block: BestBlock { + block_hash: best_block_hash, + height: best_block_height, + previous_blocks: best_block_previous_blocks.unwrap_or([None; 12]), + }, channels, forward_htlcs_legacy, claimable_payments, @@ -18910,8 +18915,7 @@ impl< ) -> Result<(BlockHash, Self), DecodeError> { let ChannelManagerData { chain_hash, - best_block_height, - best_block_hash, + best_block, channels, mut forward_htlcs_legacy, claimable_payments, @@ -19596,7 +19600,7 @@ impl< htlc.payment_hash, session_priv_bytes, &path, - best_block_height, + best_block.height, &logger, ); } @@ -19917,7 +19921,7 @@ impl< loop { outbound_scid_alias = fake_scid::Namespace::OutboundAlias .get_fake_scid( - best_block_height, + best_block.height, &chain_hash, fake_scid_rand_bytes.as_ref().unwrap(), &args.entropy_source, @@ -20119,7 +20123,6 @@ impl< } } - let best_block = BestBlock::new(best_block_hash, best_block_height); let flow = OffersMessageFlow::new( chain_hash, best_block, @@ -20531,7 +20534,7 @@ impl< //TODO: Broadcast channel update for closed channels, but only after we've made a //connection or two. - Ok((best_block_hash, channel_manager)) + Ok((best_block.block_hash, channel_manager)) } } diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs index 7d0acacdccb..2b02629d3b0 100644 --- a/lightning/src/util/ser.rs +++ b/lightning/src/util/ser.rs @@ -1439,6 +1439,33 @@ impl Readable for BlockHash { } } +impl Writeable for [Option; 12] { + fn write(&self, w: &mut W) -> Result<(), io::Error> { + for hash_opt in self { + match hash_opt { + Some(hash) => hash.write(w)?, + None => ([0u8; 32]).write(w)?, + } + } + Ok(()) + } +} + +impl Readable for [Option; 12] { + fn read(r: &mut R) -> Result { + use bitcoin::hashes::Hash; + + let mut res = [None; 12]; + for hash_opt in res.iter_mut() { + let buf: [u8; 32] = Readable::read(r)?; + if buf != [0; 32] { + *hash_opt = Some(BlockHash::from_slice(&buf[..]).unwrap()); + } + } + Ok(res) + } +} + impl Writeable for ChainHash { fn write(&self, w: &mut W) -> Result<(), io::Error> { w.write_all(self.as_bytes()) diff --git a/lightning/src/util/sweep.rs b/lightning/src/util/sweep.rs index b70eb274085..bbaaf2905ee 100644 --- a/lightning/src/util/sweep.rs +++ b/lightning/src/util/sweep.rs @@ -734,7 +734,7 @@ where fn best_block_updated_internal( &self, sweeper_state: &mut SweeperState, header: &Header, height: u32, ) { - sweeper_state.best_block = BestBlock::new(header.block_hash(), height); + sweeper_state.best_block.update_for_new_tip(header.block_hash(), height); self.prune_confirmed_outputs(sweeper_state); sweeper_state.dirty = true; From 413c937da064844ce0af894e76840b1ec67fd1e8 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Sun, 12 Oct 2025 14:08:16 +0000 Subject: [PATCH 253/627] Return `BestBlock` when deserializing chain-synced structs The deserialization of `ChannelMonitor`, `ChannelManager`, and `OutputSweeper` is implemented for a `(BlockHash, ...)` pair rather than on the object itself. This ensures developers are pushed to think about initial chain sync after deserialization and provides the latest chain sync state conviniently at deserialization-time. In the previous commit we started storing additional recent block hashes in `BestBlock` for use during initial sync to ensure we can handle reorgs while offline if the chain source loses the reorged-out blocks. Here, we move the deserialization routines to be on a `(BestBlock, ...)` pair instead of `(BlockHash, ...)`, providing access to those recent block hashes at deserialization-time. --- fuzz/src/chanmon_consistency.rs | 8 ++-- fuzz/src/chanmon_deser.rs | 8 ++-- lightning-block-sync/src/init.rs | 17 ++++---- lightning/src/chain/channelmonitor.rs | 16 ++++---- lightning/src/ln/chanmon_update_fail_tests.rs | 4 +- lightning/src/ln/channelmanager.rs | 24 ++++++------ lightning/src/ln/functional_test_utils.rs | 8 ++-- lightning/src/ln/functional_tests.rs | 7 ++-- lightning/src/ln/reload_tests.rs | 11 +++--- lightning/src/util/persist.rs | 39 ++++++++++--------- lightning/src/util/test_utils.rs | 9 +++-- 11 files changed, 74 insertions(+), 77 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index e4fd3475024..98725bd5e44 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -29,7 +29,7 @@ use bitcoin::transaction::{Transaction, TxOut}; use bitcoin::FeeRate; use bitcoin::block::Header; -use bitcoin::hash_types::{BlockHash, Txid}; +use bitcoin::hash_types::Txid; use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::sha256d::Hash as Sha256dHash; use bitcoin::hashes::Hash as TraitImport; @@ -331,7 +331,7 @@ impl chain::Watch for TestChainMonitor { .map(|(_, data)| data) .unwrap_or(&map_entry.persisted_monitor); let deserialized_monitor = - <(BlockHash, channelmonitor::ChannelMonitor)>::read( + <(BestBlock, channelmonitor::ChannelMonitor)>::read( &mut &latest_monitor_data[..], (&*self.keys, &*self.keys), ) @@ -1000,7 +1000,7 @@ pub fn do_test( // Use a different value of `use_old_mons` if we have another monitor (only for node B) // by shifting `use_old_mons` one in base-3. use_old_mons /= 3; - let mon = <(BlockHash, ChannelMonitor)>::read( + let mon = <(BestBlock, ChannelMonitor)>::read( &mut &serialized_mon[..], (&**keys, &**keys), ) @@ -1035,7 +1035,7 @@ pub fn do_test( }; let manager = - <(BlockHash, ChanMan)>::read(&mut &ser[..], read_args).expect("Failed to read manager"); + <(BestBlock, ChanMan)>::read(&mut &ser[..], read_args).expect("Failed to read manager"); let res = (manager.1, chain_monitor.clone()); for (channel_id, mon) in monitors.drain() { assert_eq!( diff --git a/fuzz/src/chanmon_deser.rs b/fuzz/src/chanmon_deser.rs index 4a4e79c83c1..be9ffe8f026 100644 --- a/fuzz/src/chanmon_deser.rs +++ b/fuzz/src/chanmon_deser.rs @@ -1,9 +1,7 @@ // This file is auto-generated by gen_target.sh based on msg_target_template.txt // To modify it, modify msg_target_template.txt and run gen_target.sh instead. -use bitcoin::hash_types::BlockHash; - -use lightning::chain::channelmonitor; +use lightning::chain::{channelmonitor, BestBlock}; use lightning::util::ser::{ReadableArgs, Writeable, Writer}; use lightning::util::test_channel_signer::TestChannelSigner; use lightning::util::test_utils::OnlyReadsKeysInterface; @@ -23,14 +21,14 @@ impl Writer for VecWriter { #[inline] pub fn do_test(data: &[u8], _out: Out) { if let Ok((latest_block_hash, monitor)) = - <(BlockHash, channelmonitor::ChannelMonitor)>::read( + <(BestBlock, channelmonitor::ChannelMonitor)>::read( &mut Cursor::new(data), (&OnlyReadsKeysInterface {}, &OnlyReadsKeysInterface {}), ) { let mut w = VecWriter(Vec::new()); monitor.write(&mut w).unwrap(); let deserialized_copy = - <(BlockHash, channelmonitor::ChannelMonitor)>::read( + <(BestBlock, channelmonitor::ChannelMonitor)>::read( &mut Cursor::new(&w.0), (&OnlyReadsKeysInterface {}, &OnlyReadsKeysInterface {}), ) diff --git a/lightning-block-sync/src/init.rs b/lightning-block-sync/src/init.rs index a870f8ca88c..61f44c6139e 100644 --- a/lightning-block-sync/src/init.rs +++ b/lightning-block-sync/src/init.rs @@ -40,11 +40,10 @@ where /// switching to [`SpvClient`]. For example: /// /// ``` -/// use bitcoin::hash_types::BlockHash; /// use bitcoin::network::Network; /// /// use lightning::chain; -/// use lightning::chain::Watch; +/// use lightning::chain::{BestBlock, Watch}; /// use lightning::chain::chainmonitor; /// use lightning::chain::chainmonitor::ChainMonitor; /// use lightning::chain::channelmonitor::ChannelMonitor; @@ -89,14 +88,14 @@ where /// logger: &L, /// persister: &P, /// ) { -/// // Read a serialized channel monitor paired with the block hash when it was persisted. +/// // Read a serialized channel monitor paired with the best block when it was persisted. /// let serialized_monitor = "..."; -/// let (monitor_block_hash, mut monitor) = <(BlockHash, ChannelMonitor)>::read( +/// let (monitor_best_block, mut monitor) = <(BestBlock, ChannelMonitor)>::read( /// &mut Cursor::new(&serialized_monitor), (entropy_source, signer_provider)).unwrap(); /// -/// // Read the channel manager paired with the block hash when it was persisted. +/// // Read the channel manager paired with the best block when it was persisted. /// let serialized_manager = "..."; -/// let (manager_block_hash, mut manager) = { +/// let (manager_best_block, mut manager) = { /// let read_args = ChannelManagerReadArgs::new( /// entropy_source, /// node_signer, @@ -110,7 +109,7 @@ where /// config, /// vec![&mut monitor], /// ); -/// <(BlockHash, ChannelManager<&ChainMonitor, &T, &ES, &NS, &SP, &F, &R, &MR, &L>)>::read( +/// <(BestBlock, ChannelManager<&ChainMonitor, &T, &ES, &NS, &SP, &F, &R, &MR, &L>)>::read( /// &mut Cursor::new(&serialized_manager), read_args).unwrap() /// }; /// @@ -118,8 +117,8 @@ where /// let mut cache = UnboundedCache::new(); /// let mut monitor_listener = (monitor, &*tx_broadcaster, &*fee_estimator, &*logger); /// let listeners = vec![ -/// (monitor_block_hash, &monitor_listener as &dyn chain::Listen), -/// (manager_block_hash, &manager as &dyn chain::Listen), +/// (monitor_best_block.block_hash, &monitor_listener as &dyn chain::Listen), +/// (manager_best_block.block_hash, &manager as &dyn chain::Listen), /// ]; /// let chain_tip = init::synchronize_listeners( /// block_source, Network::Bitcoin, &mut cache, listeners).await.unwrap(); diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs index 5a49c39f1de..0173e988082 100644 --- a/lightning/src/chain/channelmonitor.rs +++ b/lightning/src/chain/channelmonitor.rs @@ -1058,7 +1058,7 @@ impl Readable for IrrevocablyResolvedHTLC { /// You MUST ensure that no ChannelMonitors for a given channel anywhere contain out-of-date /// information and are actively monitoring the chain. /// -/// Like the [`ChannelManager`], deserialization is implemented for `(BlockHash, ChannelMonitor)`, +/// Like the [`ChannelManager`], deserialization is implemented for `(BestBlock, ChannelMonitor)`, /// providing you with the last block hash which was connected before shutting down. You must begin /// syncing the chain from that point, disconnecting and connecting blocks as required to get to /// the best chain on startup. Note that all [`ChannelMonitor`]s passed to a [`ChainMonitor`] must @@ -1066,7 +1066,7 @@ impl Readable for IrrevocablyResolvedHTLC { /// initialization. /// /// For those loading potentially-ancient [`ChannelMonitor`]s, deserialization is also implemented -/// for `Option<(BlockHash, ChannelMonitor)>`. LDK can no longer deserialize a [`ChannelMonitor`] +/// for `Option<(BestBlock, ChannelMonitor)>`. LDK can no longer deserialize a [`ChannelMonitor`] /// that was first created in LDK prior to 0.0.110 and last updated prior to LDK 0.0.119. In such /// cases, the `Option<(..)>` deserialization option may return `Ok(None)` rather than failing to /// deserialize, allowing you to differentiate between the two cases. @@ -6467,7 +6467,7 @@ where const MAX_ALLOC_SIZE: usize = 64 * 1024; impl<'a, 'b, ES: EntropySource, SP: SignerProvider> ReadableArgs<(&'a ES, &'b SP)> - for (BlockHash, ChannelMonitor) + for (BestBlock, ChannelMonitor) { fn read(reader: &mut R, args: (&'a ES, &'b SP)) -> Result { match >::read(reader, args) { @@ -6479,7 +6479,7 @@ impl<'a, 'b, ES: EntropySource, SP: SignerProvider> ReadableArgs<(&'a ES, &'b SP } impl<'a, 'b, ES: EntropySource, SP: SignerProvider> ReadableArgs<(&'a ES, &'b SP)> - for Option<(BlockHash, ChannelMonitor)> + for Option<(BestBlock, ChannelMonitor)> { #[rustfmt::skip] fn read(reader: &mut R, args: (&'a ES, &'b SP)) -> Result { @@ -6913,7 +6913,7 @@ impl<'a, 'b, ES: EntropySource, SP: SignerProvider> ReadableArgs<(&'a ES, &'b SP To continue, run a v0.1 release, send/route a payment over the channel or close it."); } } - Ok(Some((best_block.block_hash, monitor))) + Ok(Some((best_block, monitor))) } } @@ -6985,7 +6985,7 @@ pub(super) fn dummy_monitor( #[cfg(test)] mod tests { use bitcoin::amount::Amount; - use bitcoin::hash_types::{BlockHash, Txid}; + use bitcoin::hash_types::Txid; use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::Hash; use bitcoin::hex::FromHex; @@ -7011,7 +7011,7 @@ mod tests { weight_revoked_received_htlc, WEIGHT_REVOKED_OUTPUT, }; use crate::chain::transaction::OutPoint; - use crate::chain::Confirm; + use crate::chain::{BestBlock, Confirm}; use crate::io; use crate::ln::chan_utils::{self, HTLCOutputInCommitment, HolderCommitmentTransaction}; use crate::ln::channel_keys::{ @@ -7078,7 +7078,7 @@ mod tests { nodes[1].chain_monitor.chain_monitor.transactions_confirmed(&new_header, &[(0, broadcast_tx)], conf_height); - let (_, pre_update_monitor) = <(BlockHash, ChannelMonitor<_>)>::read( + let (_, pre_update_monitor) = <(BestBlock, ChannelMonitor<_>)>::read( &mut io::Cursor::new(&get_monitor!(nodes[1], channel.2).encode()), (&nodes[1].keys_manager.backing, &nodes[1].keys_manager.backing)).unwrap(); diff --git a/lightning/src/ln/chanmon_update_fail_tests.rs b/lightning/src/ln/chanmon_update_fail_tests.rs index 0d8a4a020f0..0409ce76ca1 100644 --- a/lightning/src/ln/chanmon_update_fail_tests.rs +++ b/lightning/src/ln/chanmon_update_fail_tests.rs @@ -16,7 +16,7 @@ use crate::chain::chaininterface::LowerBoundedFeeEstimator; use crate::chain::chainmonitor::ChainMonitor; use crate::chain::channelmonitor::{ChannelMonitor, MonitorEvent, ANTI_REORG_DELAY}; use crate::chain::transaction::OutPoint; -use crate::chain::{ChannelMonitorUpdateStatus, Confirm, Listen, Watch}; +use crate::chain::{BestBlock, ChannelMonitorUpdateStatus, Confirm, Listen, Watch}; use crate::events::{ClosureReason, Event, HTLCHandlingFailureType, PaymentPurpose}; use crate::ln::channel::AnnouncementSigsState; use crate::ln::channelmanager::{PaymentId, RAACommitmentOrder}; @@ -90,7 +90,7 @@ fn test_monitor_and_persister_update_fail() { let chain_mon = { let new_monitor = { let monitor = nodes[0].chain_monitor.chain_monitor.get_monitor(chan.2).unwrap(); - let (_, new_monitor) = <(BlockHash, ChannelMonitor)>::read( + let (_, new_monitor) = <(BestBlock, ChannelMonitor)>::read( &mut &monitor.encode()[..], (nodes[0].keys_manager, nodes[0].keys_manager), ) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index d63ccca1a4a..cba2f73c392 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -2048,7 +2048,6 @@ impl< /// detailed in the [`ChannelManagerReadArgs`] documentation. /// /// ``` -/// use bitcoin::BlockHash; /// use bitcoin::network::Network; /// use lightning::chain::BestBlock; /// # use lightning::chain::channelmonitor::ChannelMonitor; @@ -2097,8 +2096,8 @@ impl< /// entropy_source, node_signer, signer_provider, fee_estimator, chain_monitor, tx_broadcaster, /// router, message_router, logger, config, channel_monitors.iter().collect(), /// ); -/// let (block_hash, channel_manager) = -/// <(BlockHash, ChannelManager<_, _, _, _, _, _, _, _, _>)>::read(&mut reader, args)?; +/// let (best_block, channel_manager) = +/// <(BestBlock, ChannelManager<_, _, _, _, _, _, _, _, _>)>::read(&mut reader, args)?; /// /// // Update the ChannelManager and ChannelMonitors with the latest chain data /// // ... @@ -2665,7 +2664,7 @@ impl< /// [`read`], those channels will be force-closed based on the `ChannelMonitor` state and no funds /// will be lost (modulo on-chain transaction fees). /// -/// Note that the deserializer is only implemented for `(`[`BlockHash`]`, `[`ChannelManager`]`)`, which +/// Note that the deserializer is only implemented for `(`[`BestBlock`]`, `[`ChannelManager`]`)`, which /// tells you the last block hash which was connected. You should get the best block tip before using the manager. /// See [`chain::Listen`] and [`chain::Confirm`] for more details. /// @@ -2732,7 +2731,6 @@ impl< /// [`peer_disconnected`]: msgs::BaseMessageHandler::peer_disconnected /// [`funding_created`]: msgs::FundingCreated /// [`funding_transaction_generated`]: Self::funding_transaction_generated -/// [`BlockHash`]: bitcoin::hash_types::BlockHash /// [`update_channel`]: chain::Watch::update_channel /// [`ChannelUpdate`]: msgs::ChannelUpdate /// [`read`]: ReadableArgs::read @@ -18644,7 +18642,7 @@ impl<'a, ES: EntropySource, SP: SignerProvider, L: Logger> /// is: /// 1) Deserialize all stored [`ChannelMonitor`]s. /// 2) Deserialize the [`ChannelManager`] by filling in this struct and calling: -/// `<(BlockHash, ChannelManager)>::read(reader, args)` +/// `<(BestBlock, ChannelManager)>::read(reader, args)` /// This may result in closing some channels if the [`ChannelMonitor`] is newer than the stored /// [`ChannelManager`] state to ensure no loss of funds. Thus, transactions may be broadcasted. /// 3) If you are not fetching full blocks, register all relevant [`ChannelMonitor`] outpoints the @@ -18845,14 +18843,14 @@ impl< MR: MessageRouter, L: Logger + Clone, > ReadableArgs> - for (BlockHash, Arc>) + for (BestBlock, Arc>) { fn read( reader: &mut Reader, args: ChannelManagerReadArgs<'a, M, T, ES, NS, SP, F, R, MR, L>, ) -> Result { - let (blockhash, chan_manager) = - <(BlockHash, ChannelManager)>::read(reader, args)?; - Ok((blockhash, Arc::new(chan_manager))) + let (best_block, chan_manager) = + <(BestBlock, ChannelManager)>::read(reader, args)?; + Ok((best_block, Arc::new(chan_manager))) } } @@ -18868,7 +18866,7 @@ impl< MR: MessageRouter, L: Logger + Clone, > ReadableArgs> - for (BlockHash, ChannelManager) + for (BestBlock, ChannelManager) { fn read( reader: &mut Reader, args: ChannelManagerReadArgs<'a, M, T, ES, NS, SP, F, R, MR, L>, @@ -18912,7 +18910,7 @@ impl< pub(super) fn from_channel_manager_data( data: ChannelManagerData, mut args: ChannelManagerReadArgs<'_, M, T, ES, NS, SP, F, R, MR, L>, - ) -> Result<(BlockHash, Self), DecodeError> { + ) -> Result<(BestBlock, Self), DecodeError> { let ChannelManagerData { chain_hash, best_block, @@ -20534,7 +20532,7 @@ impl< //TODO: Broadcast channel update for closed channels, but only after we've made a //connection or two. - Ok((best_block.block_hash, channel_manager)) + Ok((best_block, channel_manager)) } } diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 80274d180b4..0dcac340f99 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -859,7 +859,7 @@ impl<'a, 'b, 'c> Drop for Node<'a, 'b, 'c> { let mon = self.chain_monitor.chain_monitor.get_monitor(channel_id).unwrap(); mon.write(&mut w).unwrap(); let (_, deserialized_monitor) = - <(BlockHash, ChannelMonitor)>::read( + <(BestBlock, ChannelMonitor)>::read( &mut io::Cursor::new(&w.0), (self.keys_manager, self.keys_manager), ) @@ -888,7 +888,7 @@ impl<'a, 'b, 'c> Drop for Node<'a, 'b, 'c> { let mut w = test_utils::TestVecWriter(Vec::new()); self.node.write(&mut w).unwrap(); <( - BlockHash, + BestBlock, ChannelManager< &test_utils::TestChainMonitor, &test_utils::TestBroadcaster, @@ -1327,7 +1327,7 @@ pub fn _reload_node<'a, 'b, 'c>( let mut monitors_read = Vec::with_capacity(monitors_encoded.len()); for encoded in monitors_encoded { let mut monitor_read = &encoded[..]; - let (_, monitor) = <(BlockHash, ChannelMonitor)>::read( + let (_, monitor) = <(BestBlock, ChannelMonitor)>::read( &mut monitor_read, (node.keys_manager, node.keys_manager), ) @@ -1342,7 +1342,7 @@ pub fn _reload_node<'a, 'b, 'c>( for monitor in monitors_read.iter() { assert!(channel_monitors.insert(monitor.channel_id(), monitor).is_none()); } - <(BlockHash, TestChannelManager<'b, 'c>)>::read( + <(BestBlock, TestChannelManager<'b, 'c>)>::read( &mut node_read, ChannelManagerReadArgs { config, diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index a3252475965..6b1b0f664a6 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -19,6 +19,7 @@ use crate::chain::channelmonitor::{ LATENCY_GRACE_PERIOD_BLOCKS, }; use crate::chain::transaction::OutPoint; +use crate::chain::BestBlock; use crate::chain::{ChannelMonitorUpdateStatus, Confirm, Listen, Watch}; use crate::events::{ ClosureReason, Event, HTLCHandlingFailureType, PathFailure, PaymentFailureReason, @@ -7377,7 +7378,7 @@ pub fn test_update_err_monitor_lockdown() { let new_monitor = { let monitor = nodes[0].chain_monitor.chain_monitor.get_monitor(chan_1.2).unwrap(); let new_monitor = - <(BlockHash, channelmonitor::ChannelMonitor)>::read( + <(BestBlock, channelmonitor::ChannelMonitor)>::read( &mut io::Cursor::new(&monitor.encode()), (nodes[0].keys_manager, nodes[0].keys_manager), ) @@ -7485,7 +7486,7 @@ pub fn test_concurrent_monitor_claim() { let new_monitor = { let monitor = nodes[0].chain_monitor.chain_monitor.get_monitor(chan_1.2).unwrap(); let new_monitor = - <(BlockHash, channelmonitor::ChannelMonitor)>::read( + <(BestBlock, channelmonitor::ChannelMonitor)>::read( &mut io::Cursor::new(&monitor.encode()), (nodes[0].keys_manager, nodes[0].keys_manager), ) @@ -7535,7 +7536,7 @@ pub fn test_concurrent_monitor_claim() { let new_monitor = { let monitor = nodes[0].chain_monitor.chain_monitor.get_monitor(chan_1.2).unwrap(); let new_monitor = - <(BlockHash, channelmonitor::ChannelMonitor)>::read( + <(BestBlock, channelmonitor::ChannelMonitor)>::read( &mut io::Cursor::new(&monitor.encode()), (nodes[0].keys_manager, nodes[0].keys_manager), ) diff --git a/lightning/src/ln/reload_tests.rs b/lightning/src/ln/reload_tests.rs index 8d9eac5c001..892a6c62d8f 100644 --- a/lightning/src/ln/reload_tests.rs +++ b/lightning/src/ln/reload_tests.rs @@ -11,7 +11,7 @@ //! Functional tests which test for correct behavior across node restarts. -use crate::chain::{ChannelMonitorUpdateStatus, Watch}; +use crate::chain::{BestBlock, ChannelMonitorUpdateStatus, Watch}; use crate::chain::chaininterface::LowerBoundedFeeEstimator; use crate::chain::channelmonitor::{ChannelMonitor, ChannelMonitorUpdateStep}; use crate::routing::router::{PaymentParameters, RouteParameters}; @@ -30,7 +30,6 @@ use crate::util::ser::{Writeable, ReadableArgs}; use crate::util::config::{HTLCInterceptionFlags, UserConfig}; use bitcoin::hashes::Hash; -use bitcoin::hash_types::BlockHash; use types::payment::{PaymentHash, PaymentPreimage}; use crate::prelude::*; @@ -412,7 +411,7 @@ fn test_manager_serialize_deserialize_inconsistent_monitor() { let mut node_0_stale_monitors = Vec::new(); for serialized in node_0_stale_monitors_serialized.iter() { let mut read = &serialized[..]; - let (_, monitor) = <(BlockHash, ChannelMonitor)>::read(&mut read, (keys_manager, keys_manager)).unwrap(); + let (_, monitor) = <(BestBlock, ChannelMonitor)>::read(&mut read, (keys_manager, keys_manager)).unwrap(); assert!(read.is_empty()); node_0_stale_monitors.push(monitor); } @@ -420,14 +419,14 @@ fn test_manager_serialize_deserialize_inconsistent_monitor() { let mut node_0_monitors = Vec::new(); for serialized in node_0_monitors_serialized.iter() { let mut read = &serialized[..]; - let (_, monitor) = <(BlockHash, ChannelMonitor)>::read(&mut read, (keys_manager, keys_manager)).unwrap(); + let (_, monitor) = <(BestBlock, ChannelMonitor)>::read(&mut read, (keys_manager, keys_manager)).unwrap(); assert!(read.is_empty()); node_0_monitors.push(monitor); } let mut nodes_0_read = &nodes_0_serialized[..]; if let Err(msgs::DecodeError::DangerousValue) = - <(BlockHash, ChannelManager<&test_utils::TestChainMonitor, &test_utils::TestBroadcaster, &test_utils::TestKeysInterface, &test_utils::TestKeysInterface, &test_utils::TestKeysInterface, &test_utils::TestFeeEstimator, &test_utils::TestRouter, &test_utils::TestMessageRouter, &test_utils::TestLogger>)>::read(&mut nodes_0_read, ChannelManagerReadArgs { + <(BestBlock, ChannelManager<&test_utils::TestChainMonitor, &test_utils::TestBroadcaster, &test_utils::TestKeysInterface, &test_utils::TestKeysInterface, &test_utils::TestKeysInterface, &test_utils::TestFeeEstimator, &test_utils::TestRouter, &test_utils::TestMessageRouter, &test_utils::TestLogger>)>::read(&mut nodes_0_read, ChannelManagerReadArgs { config: UserConfig::default(), entropy_source: keys_manager, node_signer: keys_manager, @@ -446,7 +445,7 @@ fn test_manager_serialize_deserialize_inconsistent_monitor() { let mut nodes_0_read = &nodes_0_serialized[..]; let (_, nodes_0_deserialized_tmp) = - <(BlockHash, ChannelManager<&test_utils::TestChainMonitor, &test_utils::TestBroadcaster, &test_utils::TestKeysInterface, &test_utils::TestKeysInterface, &test_utils::TestKeysInterface, &test_utils::TestFeeEstimator, &test_utils::TestRouter, &test_utils::TestMessageRouter, &test_utils::TestLogger>)>::read(&mut nodes_0_read, ChannelManagerReadArgs { + <(BestBlock, ChannelManager<&test_utils::TestChainMonitor, &test_utils::TestBroadcaster, &test_utils::TestKeysInterface, &test_utils::TestKeysInterface, &test_utils::TestKeysInterface, &test_utils::TestFeeEstimator, &test_utils::TestRouter, &test_utils::TestMessageRouter, &test_utils::TestLogger>)>::read(&mut nodes_0_read, ChannelManagerReadArgs { config: UserConfig::default(), entropy_source: keys_manager, node_signer: keys_manager, diff --git a/lightning/src/util/persist.rs b/lightning/src/util/persist.rs index f27ccc1cbac..7df63aa5ac9 100644 --- a/lightning/src/util/persist.rs +++ b/lightning/src/util/persist.rs @@ -14,7 +14,7 @@ use alloc::sync::Arc; use bitcoin::hashes::hex::FromHex; -use bitcoin::{BlockHash, Txid}; +use bitcoin::Txid; use core::convert::Infallible; use core::fmt; @@ -33,6 +33,7 @@ use crate::chain::chaininterface::{BroadcasterInterface, FeeEstimator}; use crate::chain::chainmonitor::Persist; use crate::chain::channelmonitor::{ChannelMonitor, ChannelMonitorUpdate}; use crate::chain::transaction::OutPoint; +use crate::chain::BestBlock; use crate::ln::types::ChannelId; use crate::sign::{ecdsa::EcdsaChannelSigner, EntropySource, SignerProvider}; use crate::sync::Mutex; @@ -653,7 +654,7 @@ impl Persist( kv_store: K, entropy_source: ES, signer_provider: SP, -) -> Result)>, io::Error> +) -> Result)>, io::Error> where K::Target: KVStoreSync, { @@ -663,7 +664,7 @@ where CHANNEL_MONITOR_PERSISTENCE_PRIMARY_NAMESPACE, CHANNEL_MONITOR_PERSISTENCE_SECONDARY_NAMESPACE, )? { - match )>>::read( + match )>>::read( &mut io::Cursor::new(kv_store.read( CHANNEL_MONITOR_PERSISTENCE_PRIMARY_NAMESPACE, CHANNEL_MONITOR_PERSISTENCE_SECONDARY_NAMESPACE, @@ -671,7 +672,7 @@ where )?), (&entropy_source, &signer_provider), ) { - Ok(Some((block_hash, channel_monitor))) => { + Ok(Some((best_block, channel_monitor))) => { let monitor_name = MonitorName::from_str(&stored_key)?; if channel_monitor.persistence_key() != monitor_name { return Err(io::Error::new( @@ -680,7 +681,7 @@ where )); } - res.push((block_hash, channel_monitor)); + res.push((best_block, channel_monitor)); }, Ok(None) => {}, Err(_) => { @@ -856,7 +857,7 @@ where /// Reads all stored channel monitors, along with any stored updates for them. pub fn read_all_channel_monitors_with_updates( &self, - ) -> Result)>, io::Error> { + ) -> Result)>, io::Error> { poll_sync_future(self.0.read_all_channel_monitors_with_updates()) } @@ -877,7 +878,7 @@ where /// function to accomplish this. Take care to limit the number of parallel readers. pub fn read_channel_monitor_with_updates( &self, monitor_key: &str, - ) -> Result<(BlockHash, ChannelMonitor), io::Error> { + ) -> Result<(BestBlock, ChannelMonitor), io::Error> { poll_sync_future(self.0.read_channel_monitor_with_updates(monitor_key)) } @@ -1044,7 +1045,7 @@ impl< /// deserialization as well. pub async fn read_all_channel_monitors_with_updates( &self, - ) -> Result)>, io::Error> { + ) -> Result)>, io::Error> { let primary = CHANNEL_MONITOR_PERSISTENCE_PRIMARY_NAMESPACE; let secondary = CHANNEL_MONITOR_PERSISTENCE_SECONDARY_NAMESPACE; let monitor_list = self.0.kv_store.list(primary, secondary).await?; @@ -1075,7 +1076,7 @@ impl< /// `Arc` that can live for `'static` and be sent and accessed across threads. pub async fn read_all_channel_monitors_with_updates_parallel( self: &Arc, - ) -> Result)>, io::Error> + ) -> Result)>, io::Error> where K: MaybeSend + MaybeSync + 'static, L: MaybeSend + MaybeSync + 'static, @@ -1125,7 +1126,7 @@ impl< /// function to accomplish this. Take care to limit the number of parallel readers. pub async fn read_channel_monitor_with_updates( &self, monitor_key: &str, - ) -> Result<(BlockHash, ChannelMonitor), io::Error> { + ) -> Result<(BestBlock, ChannelMonitor), io::Error> { self.0.read_channel_monitor_with_updates(monitor_key).await } @@ -1236,7 +1237,7 @@ impl< { pub async fn read_channel_monitor_with_updates( &self, monitor_key: &str, - ) -> Result<(BlockHash, ChannelMonitor), io::Error> { + ) -> Result<(BestBlock, ChannelMonitor), io::Error> { match self.maybe_read_channel_monitor_with_updates(monitor_key).await? { Some(res) => Ok(res), None => Err(io::Error::new( @@ -1253,14 +1254,14 @@ impl< async fn maybe_read_channel_monitor_with_updates( &self, monitor_key: &str, - ) -> Result)>, io::Error> { + ) -> Result)>, io::Error> { let monitor_name = MonitorName::from_str(monitor_key)?; let read_future = pin!(self.maybe_read_monitor(&monitor_name, monitor_key)); let list_future = pin!(self .kv_store .list(CHANNEL_MONITOR_UPDATE_PERSISTENCE_PRIMARY_NAMESPACE, monitor_key)); let (read_res, list_res) = TwoFutureJoiner::new(read_future, list_future).await; - let (block_hash, monitor) = match read_res? { + let (best_block, monitor) = match read_res? { Some(res) => res, None => return Ok(None), }; @@ -1291,13 +1292,13 @@ impl< io::Error::new(io::ErrorKind::Other, "Monitor update failed") })?; } - Ok(Some((block_hash, monitor))) + Ok(Some((best_block, monitor))) } /// Read a channel monitor. async fn maybe_read_monitor( &self, monitor_name: &MonitorName, monitor_key: &str, - ) -> Result)>, io::Error> { + ) -> Result)>, io::Error> { let primary = CHANNEL_MONITOR_PERSISTENCE_PRIMARY_NAMESPACE; let secondary = CHANNEL_MONITOR_PERSISTENCE_SECONDARY_NAMESPACE; let monitor_bytes = self.kv_store.read(primary, secondary, monitor_key).await?; @@ -1306,12 +1307,12 @@ impl< if monitor_cursor.get_ref().starts_with(MONITOR_UPDATING_PERSISTER_PREPEND_SENTINEL) { monitor_cursor.set_position(MONITOR_UPDATING_PERSISTER_PREPEND_SENTINEL.len() as u64); } - match )>>::read( + match )>>::read( &mut monitor_cursor, (&self.entropy_source, &self.signer_provider), ) { Ok(None) => Ok(None), - Ok(Some((blockhash, channel_monitor))) => { + Ok(Some((best_block, channel_monitor))) => { if channel_monitor.persistence_key() != *monitor_name { log_error!( self.logger, @@ -1323,7 +1324,7 @@ impl< "ChannelMonitor was stored under the wrong key", )) } else { - Ok(Some((blockhash, channel_monitor))) + Ok(Some((best_block, channel_monitor))) } }, Err(e) => { @@ -1502,7 +1503,7 @@ impl< async fn archive_persisted_channel(&self, monitor_name: MonitorName) { let monitor_key = monitor_name.to_string(); let monitor = match self.read_channel_monitor_with_updates(&monitor_key).await { - Ok((_block_hash, monitor)) => monitor, + Ok((_best_block, monitor)) => monitor, Err(_) => return, }; let primary = ARCHIVED_CHANNEL_MONITOR_PERSISTENCE_PRIMARY_NAMESPACE; diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index abcc24adf8d..4b037cd0ae9 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -20,6 +20,7 @@ use crate::chain::channelmonitor::{ ChannelMonitor, ChannelMonitorUpdate, ChannelMonitorUpdateStep, MonitorEvent, }; use crate::chain::transaction::OutPoint; +use crate::chain::BestBlock; use crate::chain::WatchedOutput; #[cfg(any(test, feature = "_externalize_tests"))] use crate::ln::chan_utils::CommitmentTransaction; @@ -66,7 +67,7 @@ use bitcoin::amount::Amount; use bitcoin::block::Block; use bitcoin::constants::genesis_block; use bitcoin::constants::ChainHash; -use bitcoin::hash_types::{BlockHash, Txid}; +use bitcoin::hash_types::Txid; use bitcoin::hashes::{hex::FromHex, Hash}; use bitcoin::network::Network; use bitcoin::script::{Builder, Script, ScriptBuf}; @@ -605,7 +606,7 @@ impl<'a> TestChainMonitor<'a> { // underlying `ChainMonitor`. let mut w = TestVecWriter(Vec::new()); monitor.write(&mut w).unwrap(); - let new_monitor = <(BlockHash, ChannelMonitor)>::read( + let new_monitor = <(BestBlock, ChannelMonitor)>::read( &mut io::Cursor::new(&w.0), (self.keys_manager, self.keys_manager), ) @@ -642,7 +643,7 @@ impl<'a> chain::Watch for TestChainMonitor<'a> { // monitor to a serialized copy and get he same one back. let mut w = TestVecWriter(Vec::new()); monitor.write(&mut w).unwrap(); - let new_monitor = <(BlockHash, ChannelMonitor)>::read( + let new_monitor = <(BestBlock, ChannelMonitor)>::read( &mut io::Cursor::new(&w.0), (self.keys_manager, self.keys_manager), ) @@ -698,7 +699,7 @@ impl<'a> chain::Watch for TestChainMonitor<'a> { let monitor = self.chain_monitor.get_monitor(channel_id).unwrap(); w.0.clear(); monitor.write(&mut w).unwrap(); - let new_monitor = <(BlockHash, ChannelMonitor)>::read( + let new_monitor = <(BestBlock, ChannelMonitor)>::read( &mut io::Cursor::new(&w.0), (self.keys_manager, self.keys_manager), ) From 8b9ccb6428723f732d86c364b4efdfb1b676d444 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Sun, 12 Oct 2025 16:01:55 +0000 Subject: [PATCH 254/627] Replace `Cache::block_disconnected` with `blocks_disconnected` In 403dc1a48bb71ae794f6883ae0b760aad44cda39 we converted the `Listen` disconnect semantics to only pass the fork point, rather than each block being disconnected. We did not, however, update the semantics of `lightning-block-sync`'s `Cache` to reduce patch size. Here we go ahead and do so, dropping `ChainDifference::disconnected_blocks` as well as its no longer needed. --- lightning-block-sync/src/init.rs | 8 +++---- lightning-block-sync/src/lib.rs | 37 +++++++++++++------------------- 2 files changed, 19 insertions(+), 26 deletions(-) diff --git a/lightning-block-sync/src/init.rs b/lightning-block-sync/src/init.rs index 61f44c6139e..07575c6f523 100644 --- a/lightning-block-sync/src/init.rs +++ b/lightning-block-sync/src/init.rs @@ -175,7 +175,9 @@ where let mut chain_notifier = ChainNotifier { header_cache, chain_listener }; let difference = chain_notifier.find_difference(best_header, &old_header, &mut chain_poller).await?; - chain_notifier.disconnect_blocks(difference.disconnected_blocks); + if difference.common_ancestor != old_header { + chain_notifier.disconnect_blocks(difference.common_ancestor); + } (difference.common_ancestor, difference.connected_blocks) }; @@ -215,9 +217,7 @@ impl<'a, C: Cache> Cache for ReadOnlyCache<'a, C> { unreachable!() } - fn block_disconnected(&mut self, _block_hash: &BlockHash) -> Option { - None - } + fn blocks_disconnected(&mut self, _fork_point: &ValidatedBlockHeader) {} } /// Wrapper for supporting dynamically sized chain listeners. diff --git a/lightning-block-sync/src/lib.rs b/lightning-block-sync/src/lib.rs index 02593047658..3b9b137f21f 100644 --- a/lightning-block-sync/src/lib.rs +++ b/lightning-block-sync/src/lib.rs @@ -202,9 +202,11 @@ pub trait Cache { /// disconnected later if needed. fn block_connected(&mut self, block_hash: BlockHash, block_header: ValidatedBlockHeader); - /// Called when a block has been disconnected from the best chain. Once disconnected, a block's - /// header is no longer needed and thus can be removed. - fn block_disconnected(&mut self, block_hash: &BlockHash) -> Option; + /// Called when blocks have been disconnected from the best chain. Only the fork point + /// (best common ancestor) is provided. + /// + /// Once disconnected, a block's header is no longer needed and thus can be removed. + fn blocks_disconnected(&mut self, fork_point: &ValidatedBlockHeader); } /// Unbounded cache of block headers keyed by block hash. @@ -219,8 +221,8 @@ impl Cache for UnboundedCache { self.insert(block_hash, block_header); } - fn block_disconnected(&mut self, block_hash: &BlockHash) -> Option { - self.remove(block_hash) + fn blocks_disconnected(&mut self, fork_point: &ValidatedBlockHeader) { + self.retain(|_, block_info| block_info.height < fork_point.height); } } @@ -315,9 +317,6 @@ struct ChainDifference { /// If there are any disconnected blocks, this is where the chain forked. common_ancestor: ValidatedBlockHeader, - /// Blocks that were disconnected from the chain since the last poll. - disconnected_blocks: Vec, - /// Blocks that were connected to the chain since the last poll. connected_blocks: Vec, } @@ -341,7 +340,9 @@ where .find_difference(new_header, old_header, chain_poller) .await .map_err(|e| (e, None))?; - self.disconnect_blocks(difference.disconnected_blocks); + if difference.common_ancestor != *old_header { + self.disconnect_blocks(difference.common_ancestor); + } self.connect_blocks(difference.common_ancestor, difference.connected_blocks, chain_poller) .await } @@ -354,7 +355,6 @@ where &self, current_header: ValidatedBlockHeader, prev_header: &ValidatedBlockHeader, chain_poller: &mut P, ) -> BlockSourceResult { - let mut disconnected_blocks = Vec::new(); let mut connected_blocks = Vec::new(); let mut current = current_header; let mut previous = *prev_header; @@ -369,7 +369,6 @@ where let current_height = current.height; let previous_height = previous.height; if current_height <= previous_height { - disconnected_blocks.push(previous); previous = self.look_up_previous_header(chain_poller, &previous).await?; } if current_height >= previous_height { @@ -379,7 +378,7 @@ where } let common_ancestor = current; - Ok(ChainDifference { common_ancestor, disconnected_blocks, connected_blocks }) + Ok(ChainDifference { common_ancestor, connected_blocks }) } /// Returns the previous header for the given header, either by looking it up in the cache or @@ -394,16 +393,10 @@ where } /// Notifies the chain listeners of disconnected blocks. - fn disconnect_blocks(&mut self, disconnected_blocks: Vec) { - for header in disconnected_blocks.iter() { - if let Some(cached_header) = self.header_cache.block_disconnected(&header.block_hash) { - assert_eq!(cached_header, *header); - } - } - if let Some(block) = disconnected_blocks.last() { - let fork_point = BestBlock::new(block.header.prev_blockhash, block.height - 1); - self.chain_listener.blocks_disconnected(fork_point); - } + fn disconnect_blocks(&mut self, fork_point: ValidatedBlockHeader) { + self.header_cache.blocks_disconnected(&fork_point); + let best_block = BestBlock::new(fork_point.block_hash, fork_point.height); + self.chain_listener.blocks_disconnected(best_block); } /// Notifies the chain listeners of connected blocks. From 2664d5992a96e476944b2c640aa2b7ce28359acd Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Sun, 12 Oct 2025 15:49:11 +0000 Subject: [PATCH 255/627] Pass a `BestBlock` to `init::synchronize_listeners` On restart, LDK expects the chain to be replayed starting from where it was when objects were last serialized. This is fine in the normal case, but if there was a reorg and the node which we were syncing from either resynced or was changed, the last block that we were synced as of might no longer be available. As a result, it becomes impossible to figure out where the fork point is, and thus to replay the chain. Luckily, changing the block source during a reorg isn't exactly common, but we shouldn't end up with a bricked node. To address this, `lightning-block-sync` allows the user to pass in `Cache` which can be used to cache recent blocks and thus allow for reorg handling in this case. However, serialization for, and a reasonable default implementation of a `Cache` was never built. Instead, here, we start taking a different approach. To avoid developers having to persist yet another object, we move `BestBlock` to storing some number of recent block hashes. This allows us to find the fork point with just the serialized state. In a previous commit, we moved deserialization of various structs to return the `BestBlock` rather than a `BlockHash`. Here we move to actually using it, taking a `BestBlock` in place of `BlockHash` to `init::synchronize_listeners` and walking the `previous_blocks` list to find the fork point rather than relying on the `Cache`. --- lightning-block-sync/src/init.rs | 51 ++++++++++---------------- lightning-block-sync/src/lib.rs | 45 ++++++++++++++++++++++- lightning-block-sync/src/poll.rs | 13 +++++++ lightning-block-sync/src/test_utils.rs | 17 +++++++++ 4 files changed, 93 insertions(+), 33 deletions(-) diff --git a/lightning-block-sync/src/init.rs b/lightning-block-sync/src/init.rs index 07575c6f523..4fdd3efabd8 100644 --- a/lightning-block-sync/src/init.rs +++ b/lightning-block-sync/src/init.rs @@ -117,8 +117,8 @@ where /// let mut cache = UnboundedCache::new(); /// let mut monitor_listener = (monitor, &*tx_broadcaster, &*fee_estimator, &*logger); /// let listeners = vec![ -/// (monitor_best_block.block_hash, &monitor_listener as &dyn chain::Listen), -/// (manager_best_block.block_hash, &manager as &dyn chain::Listen), +/// (monitor_best_block, &monitor_listener as &dyn chain::Listen), +/// (manager_best_block, &manager as &dyn chain::Listen), /// ]; /// let chain_tip = init::synchronize_listeners( /// block_source, Network::Bitcoin, &mut cache, listeners).await.unwrap(); @@ -143,39 +143,28 @@ pub async fn synchronize_listeners< L: chain::Listen + ?Sized, >( block_source: B, network: Network, header_cache: &mut C, - mut chain_listeners: Vec<(BlockHash, &L)>, + mut chain_listeners: Vec<(BestBlock, &L)>, ) -> BlockSourceResult where B::Target: BlockSource, { let best_header = validate_best_block_header(&*block_source).await?; - // Fetch the header for the block hash paired with each listener. - let mut chain_listeners_with_old_headers = Vec::new(); - for (old_block_hash, chain_listener) in chain_listeners.drain(..) { - let old_header = match header_cache.look_up(&old_block_hash) { - Some(header) => *header, - None => { - block_source.get_header(&old_block_hash, None).await?.validate(old_block_hash)? - }, - }; - chain_listeners_with_old_headers.push((old_header, chain_listener)) - } - // Find differences and disconnect blocks for each listener individually. let mut chain_poller = ChainPoller::new(block_source, network); let mut chain_listeners_at_height = Vec::new(); let mut most_common_ancestor = None; let mut most_connected_blocks = Vec::new(); - for (old_header, chain_listener) in chain_listeners_with_old_headers.drain(..) { + for (old_best_block, chain_listener) in chain_listeners.drain(..) { // Disconnect any stale blocks, but keep them in the cache for the next iteration. let header_cache = &mut ReadOnlyCache(header_cache); let (common_ancestor, connected_blocks) = { let chain_listener = &DynamicChainListener(chain_listener); let mut chain_notifier = ChainNotifier { header_cache, chain_listener }; - let difference = - chain_notifier.find_difference(best_header, &old_header, &mut chain_poller).await?; - if difference.common_ancestor != old_header { + let difference = chain_notifier + .find_difference_from_best_block(best_header, old_best_block, &mut chain_poller) + .await?; + if difference.common_ancestor.block_hash != old_best_block.block_hash { chain_notifier.disconnect_blocks(difference.common_ancestor); } (difference.common_ancestor, difference.connected_blocks) @@ -281,9 +270,9 @@ mod tests { let listener_3 = MockChainListener::new().expect_block_connected(*chain.at_height(4)); let listeners = vec![ - (chain.at_height(1).block_hash, &listener_1 as &dyn chain::Listen), - (chain.at_height(2).block_hash, &listener_2 as &dyn chain::Listen), - (chain.at_height(3).block_hash, &listener_3 as &dyn chain::Listen), + (chain.best_block_at_height(1), &listener_1 as &dyn chain::Listen), + (chain.best_block_at_height(2), &listener_2 as &dyn chain::Listen), + (chain.best_block_at_height(3), &listener_3 as &dyn chain::Listen), ]; let mut cache = chain.header_cache(0..=4); match synchronize_listeners(&chain, Network::Bitcoin, &mut cache, listeners).await { @@ -313,9 +302,9 @@ mod tests { .expect_block_connected(*main_chain.at_height(4)); let listeners = vec![ - (fork_chain_1.tip().block_hash, &listener_1 as &dyn chain::Listen), - (fork_chain_2.tip().block_hash, &listener_2 as &dyn chain::Listen), - (fork_chain_3.tip().block_hash, &listener_3 as &dyn chain::Listen), + (fork_chain_1.best_block(), &listener_1 as &dyn chain::Listen), + (fork_chain_2.best_block(), &listener_2 as &dyn chain::Listen), + (fork_chain_3.best_block(), &listener_3 as &dyn chain::Listen), ]; let mut cache = fork_chain_1.header_cache(2..=4); cache.extend(fork_chain_2.header_cache(3..=4)); @@ -350,9 +339,9 @@ mod tests { .expect_block_connected(*main_chain.at_height(4)); let listeners = vec![ - (fork_chain_1.tip().block_hash, &listener_1 as &dyn chain::Listen), - (fork_chain_2.tip().block_hash, &listener_2 as &dyn chain::Listen), - (fork_chain_3.tip().block_hash, &listener_3 as &dyn chain::Listen), + (fork_chain_1.best_block(), &listener_1 as &dyn chain::Listen), + (fork_chain_2.best_block(), &listener_2 as &dyn chain::Listen), + (fork_chain_3.best_block(), &listener_3 as &dyn chain::Listen), ]; let mut cache = fork_chain_1.header_cache(2..=4); cache.extend(fork_chain_2.header_cache(3..=4)); @@ -368,18 +357,18 @@ mod tests { let main_chain = Blockchain::default().with_height(2); let fork_chain = main_chain.fork_at_height(1); let new_tip = main_chain.tip(); - let old_tip = fork_chain.tip(); + let old_best_block = fork_chain.best_block(); let listener = MockChainListener::new() .expect_blocks_disconnected(*fork_chain.at_height(1)) .expect_block_connected(*new_tip); - let listeners = vec![(old_tip.block_hash, &listener as &dyn chain::Listen)]; + let listeners = vec![(old_best_block, &listener as &dyn chain::Listen)]; let mut cache = fork_chain.header_cache(2..=2); match synchronize_listeners(&main_chain, Network::Bitcoin, &mut cache, listeners).await { Ok(_) => { assert!(cache.contains_key(&new_tip.block_hash)); - assert!(cache.contains_key(&old_tip.block_hash)); + assert!(cache.contains_key(&old_best_block.block_hash)); }, Err(e) => panic!("Unexpected error: {:?}", e), } diff --git a/lightning-block-sync/src/lib.rs b/lightning-block-sync/src/lib.rs index 3b9b137f21f..ba583c2737e 100644 --- a/lightning-block-sync/src/lib.rs +++ b/lightning-block-sync/src/lib.rs @@ -337,7 +337,7 @@ where chain_poller: &mut P, ) -> Result<(), (BlockSourceError, Option)> { let difference = self - .find_difference(new_header, old_header, chain_poller) + .find_difference_from_header(new_header, old_header, chain_poller) .await .map_err(|e| (e, None))?; if difference.common_ancestor != *old_header { @@ -347,11 +347,52 @@ where .await } + /// Returns the changes needed to produce the chain with `current_header` as its tip from the + /// chain with `prev_best_block` as its tip. + /// + /// First resolves `prev_best_block` to a `ValidatedBlockHeader` using the `previous_blocks` + /// field as fallback if needed, then finds the common ancestor. + async fn find_difference_from_best_block( + &self, current_header: ValidatedBlockHeader, prev_best_block: BestBlock, + chain_poller: &mut P, + ) -> BlockSourceResult { + // Try to resolve the header for the previous best block. First try the block_hash, + // then fall back to previous_blocks if that fails. + let cur_tip = core::iter::once((0, &prev_best_block.block_hash)); + let prev_tips = + prev_best_block.previous_blocks.iter().enumerate().filter_map(|(idx, hash_opt)| { + if let Some(block_hash) = hash_opt { + Some((idx as u32 + 1, block_hash)) + } else { + None + } + }); + let mut found_header = None; + for (height_diff, block_hash) in cur_tip.chain(prev_tips) { + if let Some(header) = self.header_cache.look_up(block_hash) { + found_header = Some(*header); + break; + } + let height = prev_best_block.height.checked_sub(height_diff).ok_or( + BlockSourceError::persistent("BestBlock had more previous_blocks than its height"), + )?; + if let Ok(header) = chain_poller.get_header(block_hash, Some(height)).await { + found_header = Some(header); + break; + } + } + let found_header = found_header.ok_or_else(|| { + BlockSourceError::persistent("could not resolve any block from BestBlock") + })?; + + self.find_difference_from_header(current_header, &found_header, chain_poller).await + } + /// Returns the changes needed to produce the chain with `current_header` as its tip from the /// chain with `prev_header` as its tip. /// /// Walks backwards from `current_header` and `prev_header`, finding the common ancestor. - async fn find_difference( + async fn find_difference_from_header( &self, current_header: ValidatedBlockHeader, prev_header: &ValidatedBlockHeader, chain_poller: &mut P, ) -> BlockSourceResult { diff --git a/lightning-block-sync/src/poll.rs b/lightning-block-sync/src/poll.rs index 13e0403c3b6..fd8c546c56f 100644 --- a/lightning-block-sync/src/poll.rs +++ b/lightning-block-sync/src/poll.rs @@ -31,6 +31,11 @@ pub trait Poll { fn fetch_block<'a>( &'a self, header: &'a ValidatedBlockHeader, ) -> impl Future> + Send + 'a; + + /// Returns the header for a given hash and optional height hint. + fn get_header<'a>( + &'a self, block_hash: &'a BlockHash, height_hint: Option, + ) -> impl Future> + Send + 'a; } /// A chain tip relative to another chain tip in terms of block hash and chainwork. @@ -258,6 +263,14 @@ impl + Sized + Send + Sync, T: BlockSource + ?Sized> Poll ) -> impl Future> + Send + 'a { async move { self.block_source.get_block(&header.block_hash).await?.validate(header.block_hash) } } + + fn get_header<'a>( + &'a self, block_hash: &'a BlockHash, height_hint: Option, + ) -> impl Future> + Send + 'a { + Box::pin(async move { + self.block_source.get_header(block_hash, height_hint).await?.validate(*block_hash) + }) + } } #[cfg(test)] diff --git a/lightning-block-sync/src/test_utils.rs b/lightning-block-sync/src/test_utils.rs index 40788e4d08c..3d7870afb1e 100644 --- a/lightning-block-sync/src/test_utils.rs +++ b/lightning-block-sync/src/test_utils.rs @@ -104,6 +104,18 @@ impl Blockchain { block_header.validate(block_hash).unwrap() } + pub fn best_block_at_height(&self, height: usize) -> BestBlock { + let mut previous_blocks = [None; 12]; + for (i, height) in (0..height).rev().take(12).enumerate() { + previous_blocks[i] = Some(self.blocks[height].block_hash()); + } + BestBlock { + height: height as u32, + block_hash: self.blocks[height].block_hash(), + previous_blocks, + } + } + fn at_height_unvalidated(&self, height: usize) -> BlockHeaderData { assert!(!self.blocks.is_empty()); assert!(height < self.blocks.len()); @@ -123,6 +135,11 @@ impl Blockchain { self.at_height(self.blocks.len() - 1) } + pub fn best_block(&self) -> BestBlock { + assert!(!self.blocks.is_empty()); + self.best_block_at_height(self.blocks.len() - 1) + } + pub fn disconnect_tip(&mut self) -> Option { self.blocks.pop() } From d76f43af4635a13a73fda881eb3934db076de808 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 27 Jan 2026 00:37:20 +0000 Subject: [PATCH 256/627] Make the `Cache` trait priv, just use `UnboundedCache` publicly In the previous commit, we moved to relying on `BestBlock::previous_blocks` to find the fork point in `lightning-block-sync`'s `init::synchronize_listeners`. Here we now drop the `Cache` parameter as we no longer rely on it. Because we now have no reason to want a persistent `Cache`, we remove the trait from the public interface. However, to keep disconnections reliable we return the `UnboundedCache` we built up during initial sync from `init::synchronize_listeners` which we expect developers to pass to `SpvClient::new`. --- lightning-block-sync/src/init.rs | 97 ++++++++------------------------ lightning-block-sync/src/lib.rs | 61 +++++++++++--------- 2 files changed, 59 insertions(+), 99 deletions(-) diff --git a/lightning-block-sync/src/init.rs b/lightning-block-sync/src/init.rs index 4fdd3efabd8..a9a7c2be53f 100644 --- a/lightning-block-sync/src/init.rs +++ b/lightning-block-sync/src/init.rs @@ -2,7 +2,7 @@ //! from disk. use crate::poll::{ChainPoller, Validate, ValidatedBlockHeader}; -use crate::{BlockSource, BlockSourceResult, Cache, ChainNotifier}; +use crate::{BlockSource, BlockSourceResult, Cache, ChainNotifier, UnboundedCache}; use bitcoin::block::Header; use bitcoin::hash_types::BlockHash; @@ -32,9 +32,12 @@ where /// Performs a one-time sync of chain listeners using a single *trusted* block source, bringing each /// listener's view of the chain from its paired block hash to `block_source`'s best chain tip. /// -/// Upon success, the returned header can be used to initialize [`SpvClient`]. In the case of -/// failure, each listener may be left at a different block hash than the one it was originally -/// paired with. +/// Upon success, the returned header and header cache can be used to initialize [`SpvClient`]. In +/// the case of failure, *each listener may be left at a different block hash than the one it was +/// originally paired with*. +/// +/// Thus, in case of errors you likely need to reload each object via deserialization or check its +/// current tip directly via accessors on the object before trying again. /// /// Useful during startup to bring the [`ChannelManager`] and each [`ChannelMonitor`] in sync before /// switching to [`SpvClient`]. For example: @@ -114,14 +117,13 @@ where /// }; /// /// // Synchronize any channel monitors and the channel manager to be on the best block. -/// let mut cache = UnboundedCache::new(); /// let mut monitor_listener = (monitor, &*tx_broadcaster, &*fee_estimator, &*logger); /// let listeners = vec![ /// (monitor_best_block, &monitor_listener as &dyn chain::Listen), /// (manager_best_block, &manager as &dyn chain::Listen), /// ]; -/// let chain_tip = init::synchronize_listeners( -/// block_source, Network::Bitcoin, &mut cache, listeners).await.unwrap(); +/// let (chain_cache, chain_tip) = init::synchronize_listeners( +/// block_source, Network::Bitcoin, listeners).await.unwrap(); /// /// // Allow the chain monitor to watch any channels. /// let monitor = monitor_listener.0; @@ -130,21 +132,16 @@ where /// // Create an SPV client to notify the chain monitor and channel manager of block events. /// let chain_poller = poll::ChainPoller::new(block_source, Network::Bitcoin); /// let mut chain_listener = (chain_monitor, &manager); -/// let spv_client = SpvClient::new(chain_tip, chain_poller, &mut cache, &chain_listener); +/// let spv_client = SpvClient::new(chain_tip, chain_poller, chain_cache, &chain_listener); /// } /// ``` /// /// [`SpvClient`]: crate::SpvClient /// [`ChannelManager`]: lightning::ln::channelmanager::ChannelManager /// [`ChannelMonitor`]: lightning::chain::channelmonitor::ChannelMonitor -pub async fn synchronize_listeners< - B: Deref + Sized + Send + Sync, - C: Cache, - L: chain::Listen + ?Sized, ->( - block_source: B, network: Network, header_cache: &mut C, - mut chain_listeners: Vec<(BestBlock, &L)>, -) -> BlockSourceResult +pub async fn synchronize_listeners( + block_source: B, network: Network, mut chain_listeners: Vec<(BestBlock, &L)>, +) -> BlockSourceResult<(UnboundedCache, ValidatedBlockHeader)> where B::Target: BlockSource, { @@ -155,12 +152,13 @@ where let mut chain_listeners_at_height = Vec::new(); let mut most_common_ancestor = None; let mut most_connected_blocks = Vec::new(); + let mut header_cache = UnboundedCache::new(); for (old_best_block, chain_listener) in chain_listeners.drain(..) { // Disconnect any stale blocks, but keep them in the cache for the next iteration. - let header_cache = &mut ReadOnlyCache(header_cache); let (common_ancestor, connected_blocks) = { let chain_listener = &DynamicChainListener(chain_listener); - let mut chain_notifier = ChainNotifier { header_cache, chain_listener }; + let mut chain_notifier = + ChainNotifier { header_cache: &mut header_cache, chain_listener }; let difference = chain_notifier .find_difference_from_best_block(best_header, old_best_block, &mut chain_poller) .await?; @@ -181,32 +179,14 @@ where // Connect new blocks for all listeners at once to avoid re-fetching blocks. if let Some(common_ancestor) = most_common_ancestor { let chain_listener = &ChainListenerSet(chain_listeners_at_height); - let mut chain_notifier = ChainNotifier { header_cache, chain_listener }; + let mut chain_notifier = ChainNotifier { header_cache: &mut header_cache, chain_listener }; chain_notifier .connect_blocks(common_ancestor, most_connected_blocks, &mut chain_poller) .await .map_err(|(e, _)| e)?; } - Ok(best_header) -} - -/// A wrapper to make a cache read-only. -/// -/// Used to prevent losing headers that may be needed to disconnect blocks common to more than one -/// listener. -struct ReadOnlyCache<'a, C: Cache>(&'a mut C); - -impl<'a, C: Cache> Cache for ReadOnlyCache<'a, C> { - fn look_up(&self, block_hash: &BlockHash) -> Option<&ValidatedBlockHeader> { - self.0.look_up(block_hash) - } - - fn block_connected(&mut self, _block_hash: BlockHash, _block_header: ValidatedBlockHeader) { - unreachable!() - } - - fn blocks_disconnected(&mut self, _fork_point: &ValidatedBlockHeader) {} + Ok((header_cache, best_header)) } /// Wrapper for supporting dynamically sized chain listeners. @@ -274,9 +254,8 @@ mod tests { (chain.best_block_at_height(2), &listener_2 as &dyn chain::Listen), (chain.best_block_at_height(3), &listener_3 as &dyn chain::Listen), ]; - let mut cache = chain.header_cache(0..=4); - match synchronize_listeners(&chain, Network::Bitcoin, &mut cache, listeners).await { - Ok(header) => assert_eq!(header, chain.tip()), + match synchronize_listeners(&chain, Network::Bitcoin, listeners).await { + Ok((_, header)) => assert_eq!(header, chain.tip()), Err(e) => panic!("Unexpected error: {:?}", e), } } @@ -306,11 +285,8 @@ mod tests { (fork_chain_2.best_block(), &listener_2 as &dyn chain::Listen), (fork_chain_3.best_block(), &listener_3 as &dyn chain::Listen), ]; - let mut cache = fork_chain_1.header_cache(2..=4); - cache.extend(fork_chain_2.header_cache(3..=4)); - cache.extend(fork_chain_3.header_cache(4..=4)); - match synchronize_listeners(&main_chain, Network::Bitcoin, &mut cache, listeners).await { - Ok(header) => assert_eq!(header, main_chain.tip()), + match synchronize_listeners(&main_chain, Network::Bitcoin, listeners).await { + Ok((_, header)) => assert_eq!(header, main_chain.tip()), Err(e) => panic!("Unexpected error: {:?}", e), } } @@ -343,33 +319,8 @@ mod tests { (fork_chain_2.best_block(), &listener_2 as &dyn chain::Listen), (fork_chain_3.best_block(), &listener_3 as &dyn chain::Listen), ]; - let mut cache = fork_chain_1.header_cache(2..=4); - cache.extend(fork_chain_2.header_cache(3..=4)); - cache.extend(fork_chain_3.header_cache(4..=4)); - match synchronize_listeners(&main_chain, Network::Bitcoin, &mut cache, listeners).await { - Ok(header) => assert_eq!(header, main_chain.tip()), - Err(e) => panic!("Unexpected error: {:?}", e), - } - } - - #[tokio::test] - async fn cache_connected_and_keep_disconnected_blocks() { - let main_chain = Blockchain::default().with_height(2); - let fork_chain = main_chain.fork_at_height(1); - let new_tip = main_chain.tip(); - let old_best_block = fork_chain.best_block(); - - let listener = MockChainListener::new() - .expect_blocks_disconnected(*fork_chain.at_height(1)) - .expect_block_connected(*new_tip); - - let listeners = vec![(old_best_block, &listener as &dyn chain::Listen)]; - let mut cache = fork_chain.header_cache(2..=2); - match synchronize_listeners(&main_chain, Network::Bitcoin, &mut cache, listeners).await { - Ok(_) => { - assert!(cache.contains_key(&new_tip.block_hash)); - assert!(cache.contains_key(&old_best_block.block_hash)); - }, + match synchronize_listeners(&main_chain, Network::Bitcoin, listeners).await { + Ok((_, header)) => assert_eq!(header, main_chain.tip()), Err(e) => panic!("Unexpected error: {:?}", e), } } diff --git a/lightning-block-sync/src/lib.rs b/lightning-block-sync/src/lib.rs index ba583c2737e..e94096ccc58 100644 --- a/lightning-block-sync/src/lib.rs +++ b/lightning-block-sync/src/lib.rs @@ -170,18 +170,13 @@ pub enum BlockData { /// sources for the best chain tip. During this process it detects any chain forks, determines which /// constitutes the best chain, and updates the listener accordingly with any blocks that were /// connected or disconnected since the last poll. -/// -/// Block headers for the best chain are maintained in the parameterized cache, allowing for a -/// custom cache eviction policy. This offers flexibility to those sensitive to resource usage. -/// Hence, there is a trade-off between a lower memory footprint and potentially increased network -/// I/O as headers are re-fetched during fork detection. -pub struct SpvClient<'a, P: Poll, C: Cache, L: Deref> +pub struct SpvClient where L::Target: chain::Listen, { chain_tip: ValidatedBlockHeader, chain_poller: P, - chain_notifier: ChainNotifier<'a, C, L>, + chain_notifier: ChainNotifier, } /// The `Cache` trait defines behavior for managing a block header cache, where block headers are @@ -194,7 +189,7 @@ where /// Implementations may define how long to retain headers such that it's unlikely they will ever be /// needed to disconnect a block. In cases where block sources provide access to headers on stale /// forks reliably, caches may be entirely unnecessary. -pub trait Cache { +pub(crate) trait Cache { /// Retrieves the block header keyed by the given block hash. fn look_up(&self, block_hash: &BlockHash) -> Option<&ValidatedBlockHeader>; @@ -226,7 +221,21 @@ impl Cache for UnboundedCache { } } -impl<'a, P: Poll, C: Cache, L: Deref> SpvClient<'a, P, C, L> +impl Cache for &mut UnboundedCache { + fn look_up(&self, block_hash: &BlockHash) -> Option<&ValidatedBlockHeader> { + self.get(block_hash) + } + + fn block_connected(&mut self, block_hash: BlockHash, block_header: ValidatedBlockHeader) { + self.insert(block_hash, block_header); + } + + fn blocks_disconnected(&mut self, fork_point: &ValidatedBlockHeader) { + self.retain(|_, block_info| block_info.height < fork_point.height); + } +} + +impl SpvClient where L::Target: chain::Listen, { @@ -241,7 +250,7 @@ where /// /// [`poll_best_tip`]: SpvClient::poll_best_tip pub fn new( - chain_tip: ValidatedBlockHeader, chain_poller: P, header_cache: &'a mut C, + chain_tip: ValidatedBlockHeader, chain_poller: P, header_cache: UnboundedCache, chain_listener: L, ) -> Self { let chain_notifier = ChainNotifier { header_cache, chain_listener }; @@ -295,15 +304,15 @@ where /// Notifies [listeners] of blocks that have been connected or disconnected from the chain. /// /// [listeners]: lightning::chain::Listen -pub struct ChainNotifier<'a, C: Cache, L: Deref> +pub(crate) struct ChainNotifier where L::Target: chain::Listen, { /// Cache for looking up headers before fetching from a block source. - header_cache: &'a mut C, + pub(crate) header_cache: C, /// Listener that will be notified of connected or disconnected blocks. - chain_listener: L, + pub(crate) chain_listener: L, } /// Changes made to the chain between subsequent polls that transformed it from having one chain tip @@ -321,7 +330,7 @@ struct ChainDifference { connected_blocks: Vec, } -impl<'a, C: Cache, L: Deref> ChainNotifier<'a, C, L> +impl ChainNotifier where L::Target: chain::Listen, { @@ -481,9 +490,9 @@ mod spv_client_tests { let best_tip = chain.at_height(1); let poller = poll::ChainPoller::new(&mut chain, Network::Testnet); - let mut cache = UnboundedCache::new(); + let cache = UnboundedCache::new(); let mut listener = NullChainListener {}; - let mut client = SpvClient::new(best_tip, poller, &mut cache, &mut listener); + let mut client = SpvClient::new(best_tip, poller, cache, &mut listener); match client.poll_best_tip().await { Err(e) => { assert_eq!(e.kind(), BlockSourceErrorKind::Persistent); @@ -500,9 +509,9 @@ mod spv_client_tests { let common_tip = chain.tip(); let poller = poll::ChainPoller::new(&mut chain, Network::Testnet); - let mut cache = UnboundedCache::new(); + let cache = UnboundedCache::new(); let mut listener = NullChainListener {}; - let mut client = SpvClient::new(common_tip, poller, &mut cache, &mut listener); + let mut client = SpvClient::new(common_tip, poller, cache, &mut listener); match client.poll_best_tip().await { Err(e) => panic!("Unexpected error: {:?}", e), Ok((chain_tip, blocks_connected)) => { @@ -520,9 +529,9 @@ mod spv_client_tests { let old_tip = chain.at_height(1); let poller = poll::ChainPoller::new(&mut chain, Network::Testnet); - let mut cache = UnboundedCache::new(); + let cache = UnboundedCache::new(); let mut listener = NullChainListener {}; - let mut client = SpvClient::new(old_tip, poller, &mut cache, &mut listener); + let mut client = SpvClient::new(old_tip, poller, cache, &mut listener); match client.poll_best_tip().await { Err(e) => panic!("Unexpected error: {:?}", e), Ok((chain_tip, blocks_connected)) => { @@ -540,9 +549,9 @@ mod spv_client_tests { let old_tip = chain.at_height(1); let poller = poll::ChainPoller::new(&mut chain, Network::Testnet); - let mut cache = UnboundedCache::new(); + let cache = UnboundedCache::new(); let mut listener = NullChainListener {}; - let mut client = SpvClient::new(old_tip, poller, &mut cache, &mut listener); + let mut client = SpvClient::new(old_tip, poller, cache, &mut listener); match client.poll_best_tip().await { Err(e) => panic!("Unexpected error: {:?}", e), Ok((chain_tip, blocks_connected)) => { @@ -560,9 +569,9 @@ mod spv_client_tests { let old_tip = chain.at_height(1); let poller = poll::ChainPoller::new(&mut chain, Network::Testnet); - let mut cache = UnboundedCache::new(); + let cache = UnboundedCache::new(); let mut listener = NullChainListener {}; - let mut client = SpvClient::new(old_tip, poller, &mut cache, &mut listener); + let mut client = SpvClient::new(old_tip, poller, cache, &mut listener); match client.poll_best_tip().await { Err(e) => panic!("Unexpected error: {:?}", e), Ok((chain_tip, blocks_connected)) => { @@ -581,9 +590,9 @@ mod spv_client_tests { let worse_tip = chain.tip(); let poller = poll::ChainPoller::new(&mut chain, Network::Testnet); - let mut cache = UnboundedCache::new(); + let cache = UnboundedCache::new(); let mut listener = NullChainListener {}; - let mut client = SpvClient::new(best_tip, poller, &mut cache, &mut listener); + let mut client = SpvClient::new(best_tip, poller, cache, &mut listener); match client.poll_best_tip().await { Err(e) => panic!("Unexpected error: {:?}", e), Ok((chain_tip, blocks_connected)) => { From 09e7734741c549a339b6cfb927e776512fc80d41 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 27 Jan 2026 00:37:58 +0000 Subject: [PATCH 257/627] Make `UnboundedCache` bounded In the previous commit we moved to hard-coding `UnboundedCache` in the `lightning-block-sync` interface. This is great, except that its an unbounded cache that can use arbitrary amounts of memory (though never really all that much - its just headers that come in while we're running). Here we simply limit the size, and while we're at it give it a more generic `HeaderCache` name. --- lightning-block-sync/src/init.rs | 7 ++-- lightning-block-sync/src/lib.rs | 52 +++++++++++++++++--------- lightning-block-sync/src/test_utils.rs | 9 +++-- 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/lightning-block-sync/src/init.rs b/lightning-block-sync/src/init.rs index a9a7c2be53f..3f5abf9670a 100644 --- a/lightning-block-sync/src/init.rs +++ b/lightning-block-sync/src/init.rs @@ -2,10 +2,9 @@ //! from disk. use crate::poll::{ChainPoller, Validate, ValidatedBlockHeader}; -use crate::{BlockSource, BlockSourceResult, Cache, ChainNotifier, UnboundedCache}; +use crate::{BlockSource, BlockSourceResult, ChainNotifier, HeaderCache}; use bitcoin::block::Header; -use bitcoin::hash_types::BlockHash; use bitcoin::network::Network; use lightning::chain; @@ -141,7 +140,7 @@ where /// [`ChannelMonitor`]: lightning::chain::channelmonitor::ChannelMonitor pub async fn synchronize_listeners( block_source: B, network: Network, mut chain_listeners: Vec<(BestBlock, &L)>, -) -> BlockSourceResult<(UnboundedCache, ValidatedBlockHeader)> +) -> BlockSourceResult<(HeaderCache, ValidatedBlockHeader)> where B::Target: BlockSource, { @@ -152,7 +151,7 @@ where let mut chain_listeners_at_height = Vec::new(); let mut most_common_ancestor = None; let mut most_connected_blocks = Vec::new(); - let mut header_cache = UnboundedCache::new(); + let mut header_cache = HeaderCache::new(); for (old_best_block, chain_listener) in chain_listeners.drain(..) { // Disconnect any stale blocks, but keep them in the cache for the next iteration. let (common_ancestor, connected_blocks) = { diff --git a/lightning-block-sync/src/lib.rs b/lightning-block-sync/src/lib.rs index e94096ccc58..c9cffa272d1 100644 --- a/lightning-block-sync/src/lib.rs +++ b/lightning-block-sync/src/lib.rs @@ -176,7 +176,7 @@ where { chain_tip: ValidatedBlockHeader, chain_poller: P, - chain_notifier: ChainNotifier, + chain_notifier: ChainNotifier, } /// The `Cache` trait defines behavior for managing a block header cache, where block headers are @@ -204,34 +204,50 @@ pub(crate) trait Cache { fn blocks_disconnected(&mut self, fork_point: &ValidatedBlockHeader); } -/// Unbounded cache of block headers keyed by block hash. -pub type UnboundedCache = std::collections::HashMap; +/// The maximum number of [`ValidatedBlockHeader`]s stored in a [`HeaderCache`]. +pub const HEADER_CACHE_LIMIT: u32 = 6 * 24 * 7; -impl Cache for UnboundedCache { +/// Bounded cache of block headers keyed by block hash. +/// +/// Retains only the latest [`HEADER_CACHE_LIMIT`] block headers based on height. +pub struct HeaderCache(std::collections::HashMap); + +impl HeaderCache { + /// Creates a new empty header cache. + pub fn new() -> Self { + Self(std::collections::HashMap::new()) + } +} + +impl Cache for HeaderCache { fn look_up(&self, block_hash: &BlockHash) -> Option<&ValidatedBlockHeader> { - self.get(block_hash) + self.0.get(block_hash) } fn block_connected(&mut self, block_hash: BlockHash, block_header: ValidatedBlockHeader) { - self.insert(block_hash, block_header); + self.0.insert(block_hash, block_header); + + // Remove headers older than a week. + let cutoff_height = block_header.height.saturating_sub(HEADER_CACHE_LIMIT); + self.0.retain(|_, header| header.height >= cutoff_height); } fn blocks_disconnected(&mut self, fork_point: &ValidatedBlockHeader) { - self.retain(|_, block_info| block_info.height < fork_point.height); + self.0.retain(|_, block_info| block_info.height <= fork_point.height); } } -impl Cache for &mut UnboundedCache { +impl Cache for &mut HeaderCache { fn look_up(&self, block_hash: &BlockHash) -> Option<&ValidatedBlockHeader> { - self.get(block_hash) + self.0.get(block_hash) } fn block_connected(&mut self, block_hash: BlockHash, block_header: ValidatedBlockHeader) { - self.insert(block_hash, block_header); + (*self).block_connected(block_hash, block_header); } fn blocks_disconnected(&mut self, fork_point: &ValidatedBlockHeader) { - self.retain(|_, block_info| block_info.height < fork_point.height); + self.0.retain(|_, block_info| block_info.height <= fork_point.height); } } @@ -250,7 +266,7 @@ where /// /// [`poll_best_tip`]: SpvClient::poll_best_tip pub fn new( - chain_tip: ValidatedBlockHeader, chain_poller: P, header_cache: UnboundedCache, + chain_tip: ValidatedBlockHeader, chain_poller: P, header_cache: HeaderCache, chain_listener: L, ) -> Self { let chain_notifier = ChainNotifier { header_cache, chain_listener }; @@ -490,7 +506,7 @@ mod spv_client_tests { let best_tip = chain.at_height(1); let poller = poll::ChainPoller::new(&mut chain, Network::Testnet); - let cache = UnboundedCache::new(); + let cache = HeaderCache::new(); let mut listener = NullChainListener {}; let mut client = SpvClient::new(best_tip, poller, cache, &mut listener); match client.poll_best_tip().await { @@ -509,7 +525,7 @@ mod spv_client_tests { let common_tip = chain.tip(); let poller = poll::ChainPoller::new(&mut chain, Network::Testnet); - let cache = UnboundedCache::new(); + let cache = HeaderCache::new(); let mut listener = NullChainListener {}; let mut client = SpvClient::new(common_tip, poller, cache, &mut listener); match client.poll_best_tip().await { @@ -529,7 +545,7 @@ mod spv_client_tests { let old_tip = chain.at_height(1); let poller = poll::ChainPoller::new(&mut chain, Network::Testnet); - let cache = UnboundedCache::new(); + let cache = HeaderCache::new(); let mut listener = NullChainListener {}; let mut client = SpvClient::new(old_tip, poller, cache, &mut listener); match client.poll_best_tip().await { @@ -549,7 +565,7 @@ mod spv_client_tests { let old_tip = chain.at_height(1); let poller = poll::ChainPoller::new(&mut chain, Network::Testnet); - let cache = UnboundedCache::new(); + let cache = HeaderCache::new(); let mut listener = NullChainListener {}; let mut client = SpvClient::new(old_tip, poller, cache, &mut listener); match client.poll_best_tip().await { @@ -569,7 +585,7 @@ mod spv_client_tests { let old_tip = chain.at_height(1); let poller = poll::ChainPoller::new(&mut chain, Network::Testnet); - let cache = UnboundedCache::new(); + let cache = HeaderCache::new(); let mut listener = NullChainListener {}; let mut client = SpvClient::new(old_tip, poller, cache, &mut listener); match client.poll_best_tip().await { @@ -590,7 +606,7 @@ mod spv_client_tests { let worse_tip = chain.tip(); let poller = poll::ChainPoller::new(&mut chain, Network::Testnet); - let cache = UnboundedCache::new(); + let cache = HeaderCache::new(); let mut listener = NullChainListener {}; let mut client = SpvClient::new(best_tip, poller, cache, &mut listener); match client.poll_best_tip().await { diff --git a/lightning-block-sync/src/test_utils.rs b/lightning-block-sync/src/test_utils.rs index 3d7870afb1e..89cb3e81d60 100644 --- a/lightning-block-sync/src/test_utils.rs +++ b/lightning-block-sync/src/test_utils.rs @@ -1,6 +1,7 @@ use crate::poll::{Validate, ValidatedBlockHeader}; use crate::{ - BlockData, BlockHeaderData, BlockSource, BlockSourceError, BlockSourceResult, UnboundedCache, + BlockData, BlockHeaderData, BlockSource, BlockSourceError, BlockSourceResult, Cache, + HeaderCache, }; use bitcoin::block::{Block, Header, Version}; @@ -144,12 +145,12 @@ impl Blockchain { self.blocks.pop() } - pub fn header_cache(&self, heights: std::ops::RangeInclusive) -> UnboundedCache { - let mut cache = UnboundedCache::new(); + pub fn header_cache(&self, heights: std::ops::RangeInclusive) -> HeaderCache { + let mut cache = HeaderCache::new(); for i in heights { let value = self.at_height(i); let key = value.header.block_hash(); - assert!(cache.insert(key, value).is_none()); + cache.block_connected(key, value); } cache } From 1fe6ef1caa242ddfa6d6b5876516aa0c5826f62e Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 26 Mar 2026 16:05:03 +0000 Subject: [PATCH 258/627] Drop the `Cache` trait entirely Now that `Cache` is crate-private, there's not actually any reason to have it at all. In a later commit we'll have to reach into its internals a bit, but all within the `lightning-block-sync` crate, so having a trait indirection is somewhat useless. --- lightning-block-sync/src/lib.rs | 100 +++++++++---------------- lightning-block-sync/src/test_utils.rs | 3 +- 2 files changed, 35 insertions(+), 68 deletions(-) diff --git a/lightning-block-sync/src/lib.rs b/lightning-block-sync/src/lib.rs index c9cffa272d1..cb4e814f9cd 100644 --- a/lightning-block-sync/src/lib.rs +++ b/lightning-block-sync/src/lib.rs @@ -49,7 +49,7 @@ use bitcoin::hash_types::BlockHash; use bitcoin::pow::Work; use lightning::chain; -use lightning::chain::{BestBlock, Listen}; +use lightning::chain::BestBlock; use std::future::Future; use std::ops::Deref; @@ -176,32 +176,8 @@ where { chain_tip: ValidatedBlockHeader, chain_poller: P, - chain_notifier: ChainNotifier, -} - -/// The `Cache` trait defines behavior for managing a block header cache, where block headers are -/// keyed by block hash. -/// -/// Used by [`ChainNotifier`] to store headers along the best chain, which is important for ensuring -/// that blocks can be disconnected if they are no longer accessible from a block source (e.g., if -/// the block source does not store stale forks indefinitely). -/// -/// Implementations may define how long to retain headers such that it's unlikely they will ever be -/// needed to disconnect a block. In cases where block sources provide access to headers on stale -/// forks reliably, caches may be entirely unnecessary. -pub(crate) trait Cache { - /// Retrieves the block header keyed by the given block hash. - fn look_up(&self, block_hash: &BlockHash) -> Option<&ValidatedBlockHeader>; - - /// Called when a block has been connected to the best chain to ensure it is available to be - /// disconnected later if needed. - fn block_connected(&mut self, block_hash: BlockHash, block_header: ValidatedBlockHeader); - - /// Called when blocks have been disconnected from the best chain. Only the fork point - /// (best common ancestor) is provided. - /// - /// Once disconnected, a block's header is no longer needed and thus can be removed. - fn blocks_disconnected(&mut self, fork_point: &ValidatedBlockHeader); + header_cache: HeaderCache, + chain_listener: L, } /// The maximum number of [`ValidatedBlockHeader`]s stored in a [`HeaderCache`]. @@ -210,44 +186,40 @@ pub const HEADER_CACHE_LIMIT: u32 = 6 * 24 * 7; /// Bounded cache of block headers keyed by block hash. /// /// Retains only the latest [`HEADER_CACHE_LIMIT`] block headers based on height. -pub struct HeaderCache(std::collections::HashMap); +pub struct HeaderCache { + headers: std::collections::HashMap, +} impl HeaderCache { /// Creates a new empty header cache. pub fn new() -> Self { - Self(std::collections::HashMap::new()) + Self { headers: std::collections::HashMap::new() } } -} -impl Cache for HeaderCache { - fn look_up(&self, block_hash: &BlockHash) -> Option<&ValidatedBlockHeader> { - self.0.get(block_hash) + /// Retrieves the block header keyed by the given block hash. + pub fn look_up(&self, block_hash: &BlockHash) -> Option<&ValidatedBlockHeader> { + self.headers.get(block_hash) } - fn block_connected(&mut self, block_hash: BlockHash, block_header: ValidatedBlockHeader) { - self.0.insert(block_hash, block_header); + + /// Called when a block has been connected to the best chain to ensure it is available to be + /// disconnected later if needed. + pub(crate) fn block_connected( + &mut self, block_hash: BlockHash, block_header: ValidatedBlockHeader, + ) { + self.headers.insert(block_hash, block_header); // Remove headers older than a week. let cutoff_height = block_header.height.saturating_sub(HEADER_CACHE_LIMIT); - self.0.retain(|_, header| header.height >= cutoff_height); + self.headers.retain(|_, header| header.height >= cutoff_height); } - fn blocks_disconnected(&mut self, fork_point: &ValidatedBlockHeader) { - self.0.retain(|_, block_info| block_info.height <= fork_point.height); - } -} - -impl Cache for &mut HeaderCache { - fn look_up(&self, block_hash: &BlockHash) -> Option<&ValidatedBlockHeader> { - self.0.get(block_hash) - } - - fn block_connected(&mut self, block_hash: BlockHash, block_header: ValidatedBlockHeader) { - (*self).block_connected(block_hash, block_header); - } - - fn blocks_disconnected(&mut self, fork_point: &ValidatedBlockHeader) { - self.0.retain(|_, block_info| block_info.height <= fork_point.height); + /// Called when blocks have been disconnected from the best chain. Only the fork point + /// (best common ancestor) is provided. + /// + /// Once disconnected, a block's header is no longer needed and thus can be removed. + pub(crate) fn blocks_disconnected(&mut self, fork_point: &ValidatedBlockHeader) { + self.headers.retain(|_, block_info| block_info.height <= fork_point.height); } } @@ -269,8 +241,7 @@ where chain_tip: ValidatedBlockHeader, chain_poller: P, header_cache: HeaderCache, chain_listener: L, ) -> Self { - let chain_notifier = ChainNotifier { header_cache, chain_listener }; - Self { chain_tip, chain_poller, chain_notifier } + Self { chain_tip, chain_poller, header_cache, chain_listener } } /// Polls for the best tip and updates the chain listener with any connected or disconnected @@ -299,8 +270,11 @@ where /// Updates the chain tip, syncing the chain listener with any connected or disconnected /// blocks. Returns whether there were any such blocks. async fn update_chain_tip(&mut self, best_chain_tip: ValidatedBlockHeader) -> bool { - match self - .chain_notifier + let mut chain_notifier = ChainNotifier { + header_cache: &mut self.header_cache, + chain_listener: &*self.chain_listener, + }; + match chain_notifier .synchronize_listener(best_chain_tip, &self.chain_tip, &mut self.chain_poller) .await { @@ -320,15 +294,12 @@ where /// Notifies [listeners] of blocks that have been connected or disconnected from the chain. /// /// [listeners]: lightning::chain::Listen -pub(crate) struct ChainNotifier -where - L::Target: chain::Listen, -{ +pub(crate) struct ChainNotifier<'a, L: chain::Listen + ?Sized> { /// Cache for looking up headers before fetching from a block source. - pub(crate) header_cache: C, + pub(crate) header_cache: &'a mut HeaderCache, /// Listener that will be notified of connected or disconnected blocks. - pub(crate) chain_listener: L, + pub(crate) chain_listener: &'a L, } /// Changes made to the chain between subsequent polls that transformed it from having one chain tip @@ -346,10 +317,7 @@ struct ChainDifference { connected_blocks: Vec, } -impl ChainNotifier -where - L::Target: chain::Listen, -{ +impl<'a, L: chain::Listen + ?Sized> ChainNotifier<'a, L> { /// Finds the first common ancestor between `new_header` and `old_header`, disconnecting blocks /// from `old_header` to get to that point and then connecting blocks until `new_header`. /// diff --git a/lightning-block-sync/src/test_utils.rs b/lightning-block-sync/src/test_utils.rs index 89cb3e81d60..01da431c243 100644 --- a/lightning-block-sync/src/test_utils.rs +++ b/lightning-block-sync/src/test_utils.rs @@ -1,7 +1,6 @@ use crate::poll::{Validate, ValidatedBlockHeader}; use crate::{ - BlockData, BlockHeaderData, BlockSource, BlockSourceError, BlockSourceResult, Cache, - HeaderCache, + BlockData, BlockHeaderData, BlockSource, BlockSourceError, BlockSourceResult, HeaderCache, }; use bitcoin::block::{Block, Header, Version}; From 112f2c5234d1c80e574a5b3b386c19be263d28e2 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 8 Dec 2025 01:10:30 +0000 Subject: [PATCH 259/627] Consolidate all the pub aync utils to `native_async` --- fuzz/src/chanmon_consistency.rs | 2 +- fuzz/src/full_stack.rs | 4 +-- lightning-background-processor/src/lib.rs | 4 +-- lightning/src/chain/chainmonitor.rs | 3 +-- lightning/src/ln/funding.rs | 2 +- lightning/src/sign/mod.rs | 2 +- lightning/src/util/async_poll.rs | 28 -------------------- lightning/src/util/mod.rs | 2 +- lightning/src/util/native_async.rs | 31 ++++++++++++++++++++++- lightning/src/util/persist.rs | 4 +-- lightning/src/util/test_utils.rs | 2 +- lightning/src/util/wallet_utils.rs | 3 ++- 12 files changed, 44 insertions(+), 43 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 98725bd5e44..6091c66f513 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -74,11 +74,11 @@ use lightning::sign::{ SignerProvider, }; use lightning::types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; -use lightning::util::async_poll::{MaybeSend, MaybeSync}; use lightning::util::config::UserConfig; use lightning::util::errors::APIError; use lightning::util::hash_tables::*; use lightning::util::logger::Logger; +use lightning::util::native_async::{MaybeSend, MaybeSync}; use lightning::util::ser::{LengthReadable, ReadableArgs, Writeable, Writer}; use lightning::util::test_channel_signer::{EnforcementState, SignerOp, TestChannelSigner}; use lightning::util::test_utils::TestWalletSource; diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index 3b7c99ea0b6..92c854b63ab 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -63,10 +63,10 @@ use lightning::sign::{ SignerProvider, }; use lightning::types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; -use lightning::util::async_poll::{MaybeSend, MaybeSync}; use lightning::util::config::{ChannelConfig, UserConfig}; use lightning::util::hash_tables::*; use lightning::util::logger::Logger; +use lightning::util::native_async::{MaybeSend, MaybeSync}; use lightning::util::ser::{Readable, Writeable}; use lightning::util::test_channel_signer::{EnforcementState, TestChannelSigner}; use lightning::util::test_utils::TestWalletSource; @@ -1954,8 +1954,8 @@ pub fn write_fst_seeds(path: &str) { #[cfg(test)] mod tests { - use lightning::util::async_poll::{MaybeSend, MaybeSync}; use lightning::util::logger::{Logger, Record}; + use lightning::util::native_async::{MaybeSend, MaybeSync}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; diff --git a/lightning-background-processor/src/lib.rs b/lightning-background-processor/src/lib.rs index 4d6e770c099..c796c53a031 100644 --- a/lightning-background-processor/src/lib.rs +++ b/lightning-background-processor/src/lib.rs @@ -55,9 +55,9 @@ use lightning::routing::utxo::UtxoLookup; #[cfg(not(c_bindings))] use lightning::sign::EntropySource; use lightning::sign::{ChangeDestinationSource, ChangeDestinationSourceSync, OutputSpender}; -#[cfg(not(c_bindings))] -use lightning::util::async_poll::MaybeSend; use lightning::util::logger::Logger; +#[cfg(not(c_bindings))] +use lightning::util::native_async::MaybeSend; use lightning::util::persist::{ KVStore, KVStoreSync, KVStoreSyncWrapper, CHANNEL_MANAGER_PERSISTENCE_KEY, CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, diff --git a/lightning/src/chain/chainmonitor.rs b/lightning/src/chain/chainmonitor.rs index 07d835dc785..125f206bbea 100644 --- a/lightning/src/chain/chainmonitor.rs +++ b/lightning/src/chain/chainmonitor.rs @@ -51,10 +51,9 @@ use crate::sign::ecdsa::EcdsaChannelSigner; use crate::sign::{EntropySource, PeerStorageKey, SignerProvider}; use crate::sync::{Mutex, MutexGuard, RwLock, RwLockReadGuard}; use crate::types::features::{InitFeatures, NodeFeatures}; -use crate::util::async_poll::{MaybeSend, MaybeSync}; use crate::util::errors::APIError; use crate::util::logger::{Logger, WithContext}; -use crate::util::native_async::FutureSpawner; +use crate::util::native_async::{FutureSpawner, MaybeSend, MaybeSync}; use crate::util::persist::{KVStore, MonitorName, MonitorUpdatingPersisterAsync}; #[cfg(peer_storage)] use crate::util::ser::{VecWriter, Writeable}; diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index c81024ca080..353d43cfe8e 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -22,7 +22,7 @@ use crate::ln::msgs; use crate::ln::types::ChannelId; use crate::ln::LN_MAX_MSG_LEN; use crate::prelude::*; -use crate::util::async_poll::MaybeSend; +use crate::util::native_async::MaybeSend; use crate::util::wallet_utils::{ CoinSelection, CoinSelectionSource, CoinSelectionSourceSync, Input, }; diff --git a/lightning/src/sign/mod.rs b/lightning/src/sign/mod.rs index fa77b3c0ba1..3237149338b 100644 --- a/lightning/src/sign/mod.rs +++ b/lightning/src/sign/mod.rs @@ -56,7 +56,7 @@ use crate::ln::script::ShutdownScript; use crate::offers::invoice::UnsignedBolt12Invoice; use crate::types::features::ChannelTypeFeatures; use crate::types::payment::PaymentPreimage; -use crate::util::async_poll::MaybeSend; +use crate::util::native_async::MaybeSend; use crate::util::ser::{ReadableArgs, Writeable}; use crate::util::transaction_utils; diff --git a/lightning/src/util/async_poll.rs b/lightning/src/util/async_poll.rs index 57df5b26cb0..23ca1aad603 100644 --- a/lightning/src/util/async_poll.rs +++ b/lightning/src/util/async_poll.rs @@ -164,31 +164,3 @@ const DUMMY_WAKER_VTABLE: RawWakerVTable = RawWakerVTable::new( pub(crate) fn dummy_waker() -> Waker { unsafe { Waker::from_raw(RawWaker::new(core::ptr::null(), &DUMMY_WAKER_VTABLE)) } } - -/// Marker trait to optionally implement `Sync` under std. -/// -/// This is not exported to bindings users as async is only supported in Rust. -#[cfg(feature = "std")] -pub use core::marker::Sync as MaybeSync; - -#[cfg(not(feature = "std"))] -/// Marker trait to optionally implement `Sync` under std. -/// -/// This is not exported to bindings users as async is only supported in Rust. -pub trait MaybeSync {} -#[cfg(not(feature = "std"))] -impl MaybeSync for T where T: ?Sized {} - -/// Marker trait to optionally implement `Send` under std. -/// -/// This is not exported to bindings users as async is only supported in Rust. -#[cfg(feature = "std")] -pub use core::marker::Send as MaybeSend; - -#[cfg(not(feature = "std"))] -/// Marker trait to optionally implement `Send` under std. -/// -/// This is not exported to bindings users as async is only supported in Rust. -pub trait MaybeSend {} -#[cfg(not(feature = "std"))] -impl MaybeSend for T where T: ?Sized {} diff --git a/lightning/src/util/mod.rs b/lightning/src/util/mod.rs index 75434fdabab..4f3e930caf4 100644 --- a/lightning/src/util/mod.rs +++ b/lightning/src/util/mod.rs @@ -20,7 +20,7 @@ pub mod mut_global; pub mod anchor_channel_reserves; -pub mod async_poll; +pub(crate) mod async_poll; #[cfg(fuzzing)] pub mod base32; #[cfg(not(fuzzing))] diff --git a/lightning/src/util/native_async.rs b/lightning/src/util/native_async.rs index 0c380f2b1d1..31b07c2f3b5 100644 --- a/lightning/src/util/native_async.rs +++ b/lightning/src/util/native_async.rs @@ -9,8 +9,9 @@ #[cfg(all(test, feature = "std"))] use crate::sync::{Arc, Mutex}; -use crate::util::async_poll::{MaybeSend, MaybeSync}; +#[cfg(test)] +use alloc::boxed::Box; #[cfg(all(test, not(feature = "std")))] use alloc::rc::Rc; @@ -53,6 +54,34 @@ trait MaybeSendableFuture: Future + MaybeSend + 'static {} #[cfg(test)] impl + MaybeSend + 'static> MaybeSendableFuture for F {} +/// Marker trait to optionally implement `Sync` under std. +/// +/// This is not exported to bindings users as async is only supported in Rust. +#[cfg(feature = "std")] +pub use core::marker::Sync as MaybeSync; + +#[cfg(not(feature = "std"))] +/// Marker trait to optionally implement `Sync` under std. +/// +/// This is not exported to bindings users as async is only supported in Rust. +pub trait MaybeSync {} +#[cfg(not(feature = "std"))] +impl MaybeSync for T where T: ?Sized {} + +/// Marker trait to optionally implement `Send` under std. +/// +/// This is not exported to bindings users as async is only supported in Rust. +#[cfg(feature = "std")] +pub use core::marker::Send as MaybeSend; + +#[cfg(not(feature = "std"))] +/// Marker trait to optionally implement `Send` under std. +/// +/// This is not exported to bindings users as async is only supported in Rust. +pub trait MaybeSend {} +#[cfg(not(feature = "std"))] +impl MaybeSend for T where T: ?Sized {} + /// A simple [`FutureSpawner`] which holds [`Future`]s until they are manually polled via /// [`Self::poll_futures`]. #[cfg(all(test, feature = "std"))] diff --git a/lightning/src/util/persist.rs b/lightning/src/util/persist.rs index 7df63aa5ac9..68359636f6b 100644 --- a/lightning/src/util/persist.rs +++ b/lightning/src/util/persist.rs @@ -38,10 +38,10 @@ use crate::ln::types::ChannelId; use crate::sign::{ecdsa::EcdsaChannelSigner, EntropySource, SignerProvider}; use crate::sync::Mutex; use crate::util::async_poll::{ - dummy_waker, MaybeSend, MaybeSync, MultiResultFuturePoller, ResultFuture, TwoFutureJoiner, + dummy_waker, MultiResultFuturePoller, ResultFuture, TwoFutureJoiner, }; use crate::util::logger::Logger; -use crate::util::native_async::FutureSpawner; +use crate::util::native_async::{FutureSpawner, MaybeSend, MaybeSync}; use crate::util::ser::{Readable, ReadableArgs, Writeable}; use crate::util::wakers::Notifier; diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 4b037cd0ae9..57f9ba6b22f 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -49,7 +49,6 @@ use crate::sign::{self, ReceiveAuthKey}; use crate::sign::{ChannelSigner, PeerStorageKey}; use crate::sync::RwLock; use crate::types::features::{ChannelFeatures, InitFeatures, NodeFeatures}; -use crate::util::async_poll::MaybeSend; use crate::util::config::UserConfig; use crate::util::dyn_signer::{ DynKeysInterface, DynKeysInterfaceTrait, DynPhantomKeysInterface, DynSigner, @@ -57,6 +56,7 @@ use crate::util::dyn_signer::{ use crate::util::logger::{Logger, Record}; #[cfg(feature = "std")] use crate::util::mut_global::MutGlobal; +use crate::util::native_async::MaybeSend; use crate::util::persist::{KVStore, KVStoreSync, MonitorName}; use crate::util::ser::{Readable, ReadableArgs, Writeable, Writer}; use crate::util::test_channel_signer::{EnforcementState, TestChannelSigner}; diff --git a/lightning/src/util/wallet_utils.rs b/lightning/src/util/wallet_utils.rs index b82437c03e8..be8d9475098 100644 --- a/lightning/src/util/wallet_utils.rs +++ b/lightning/src/util/wallet_utils.rs @@ -24,9 +24,10 @@ use crate::ln::chan_utils::{ use crate::prelude::*; use crate::sign::{P2TR_KEY_PATH_WITNESS_WEIGHT, P2WPKH_WITNESS_WEIGHT}; use crate::sync::Mutex; -use crate::util::async_poll::{dummy_waker, MaybeSend, MaybeSync}; +use crate::util::async_poll::dummy_waker; use crate::util::hash_tables::{new_hash_map, HashMap}; use crate::util::logger::Logger; +use crate::util::native_async::{MaybeSend, MaybeSync}; use bitcoin::amount::Amount; use bitcoin::consensus::Encodable; From e68cbb3e0cd09f5b9d90809b8bc8ab3261372763 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Sat, 18 Oct 2025 01:36:01 +0000 Subject: [PATCH 260/627] Add `async_poll.rs` to `lightning-block-sync` In the next commit we'll fetch blocks during initial connection in parallel, which requires a multi-future poller. Here we add a symlink to the existing `lightning` `async_poll.rs` file, making it available in `lightning-block-sync` --- lightning-block-sync/src/async_poll.rs | 1 + lightning-block-sync/src/lib.rs | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 120000 lightning-block-sync/src/async_poll.rs diff --git a/lightning-block-sync/src/async_poll.rs b/lightning-block-sync/src/async_poll.rs new file mode 120000 index 00000000000..eb85cdac697 --- /dev/null +++ b/lightning-block-sync/src/async_poll.rs @@ -0,0 +1 @@ +../../lightning/src/util/async_poll.rs \ No newline at end of file diff --git a/lightning-block-sync/src/lib.rs b/lightning-block-sync/src/lib.rs index cb4e814f9cd..c2590f0b304 100644 --- a/lightning-block-sync/src/lib.rs +++ b/lightning-block-sync/src/lib.rs @@ -16,9 +16,11 @@ #![deny(rustdoc::broken_intra_doc_links)] #![deny(rustdoc::private_intra_doc_links)] #![deny(missing_docs)] -#![deny(unsafe_code)] #![cfg_attr(docsrs, feature(doc_cfg))] +extern crate alloc; +extern crate core; + #[cfg(any(feature = "rest-client", feature = "rpc-client"))] pub mod http; @@ -42,6 +44,9 @@ mod test_utils; #[cfg(any(feature = "rest-client", feature = "rpc-client"))] mod utils; +#[allow(unused)] +mod async_poll; + use crate::poll::{ChainTip, Poll, ValidatedBlockHeader}; use bitcoin::block::{Block, Header}; From 0f130eee8395d36ed5d100737f09d225c082224e Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Sun, 7 Dec 2025 23:30:57 +0000 Subject: [PATCH 261/627] Fetch blocks from source in parallel during initial sync In `init::synchronize_listeners` we may end up spending a decent chunk of our time just fetching block data. Here we parallelize that step across up to 36 blocks at a time. On my node with bitcoind on localhost, the impact of this is somewhat muted by block deserialization being the bulk of the work, however a networked bitcoind would likely change that. Even still, fetching a batch of 36 blocks in parallel happens on my node in ~615 ms vs ~815ms in serial. --- lightning-block-sync/src/init.rs | 91 ++++++++++++++++++-------------- 1 file changed, 52 insertions(+), 39 deletions(-) diff --git a/lightning-block-sync/src/init.rs b/lightning-block-sync/src/init.rs index 3f5abf9670a..cedd11e761d 100644 --- a/lightning-block-sync/src/init.rs +++ b/lightning-block-sync/src/init.rs @@ -1,8 +1,9 @@ //! Utilities to assist in the initial sync required to initialize or reload Rust-Lightning objects //! from disk. -use crate::poll::{ChainPoller, Validate, ValidatedBlockHeader}; -use crate::{BlockSource, BlockSourceResult, ChainNotifier, HeaderCache}; +use crate::async_poll::{MultiResultFuturePoller, ResultFuture}; +use crate::poll::{ChainPoller, Poll, Validate, ValidatedBlockHeader}; +use crate::{BlockData, BlockSource, BlockSourceResult, ChainNotifier, HeaderCache}; use bitcoin::block::Header; use bitcoin::network::Network; @@ -149,7 +150,6 @@ where // Find differences and disconnect blocks for each listener individually. let mut chain_poller = ChainPoller::new(block_source, network); let mut chain_listeners_at_height = Vec::new(); - let mut most_common_ancestor = None; let mut most_connected_blocks = Vec::new(); let mut header_cache = HeaderCache::new(); for (old_best_block, chain_listener) in chain_listeners.drain(..) { @@ -170,19 +170,59 @@ where // Keep track of the most common ancestor and all blocks connected across all listeners. chain_listeners_at_height.push((common_ancestor.height, chain_listener)); if connected_blocks.len() > most_connected_blocks.len() { - most_common_ancestor = Some(common_ancestor); most_connected_blocks = connected_blocks; } } - // Connect new blocks for all listeners at once to avoid re-fetching blocks. - if let Some(common_ancestor) = most_common_ancestor { - let chain_listener = &ChainListenerSet(chain_listeners_at_height); - let mut chain_notifier = ChainNotifier { header_cache: &mut header_cache, chain_listener }; - chain_notifier - .connect_blocks(common_ancestor, most_connected_blocks, &mut chain_poller) - .await - .map_err(|(e, _)| e)?; + while !most_connected_blocks.is_empty() { + #[cfg(not(test))] + const MAX_BLOCKS_AT_ONCE: usize = 6 * 6; // Six hours of blocks, 144MiB encoded + #[cfg(test)] + const MAX_BLOCKS_AT_ONCE: usize = 2; + + let mut fetch_block_futures = + Vec::with_capacity(core::cmp::min(MAX_BLOCKS_AT_ONCE, most_connected_blocks.len())); + for header in most_connected_blocks.iter().rev().take(MAX_BLOCKS_AT_ONCE) { + let fetch_future = chain_poller.fetch_block(header); + fetch_block_futures + .push(ResultFuture::Pending(Box::pin(async move { (header, fetch_future.await) }))); + } + let results = MultiResultFuturePoller::new(fetch_block_futures).await.into_iter(); + + const NO_BLOCK: Option<(u32, crate::poll::ValidatedBlock)> = None; + let mut fetched_blocks = [NO_BLOCK; MAX_BLOCKS_AT_ONCE]; + for ((header, block_res), result) in results.into_iter().zip(fetched_blocks.iter_mut()) { + *result = Some((header.height, block_res?)); + } + debug_assert!(fetched_blocks.iter().take(most_connected_blocks.len()).all(|r| r.is_some())); + // TODO: When our MSRV is 1.82, use is_sorted_by_key + debug_assert!(fetched_blocks.windows(2).all(|blocks| { + if let (Some(a), Some(b)) = (&blocks[0], &blocks[1]) { + a.0 < b.0 + } else { + // Any non-None blocks have to come before any None entries + blocks[1].is_none() + } + })); + + for (listener_height, listener) in chain_listeners_at_height.iter() { + // Connect blocks for this listener. + for (height, block_data) in fetched_blocks.iter().flatten() { + if *height > *listener_height { + match &**block_data { + BlockData::FullBlock(block) => { + listener.block_connected(&block, *height); + }, + BlockData::HeaderOnly(header_data) => { + listener.filtered_block_connected(&header_data, &[], *height); + }, + } + } + } + } + + most_connected_blocks + .truncate(most_connected_blocks.len().saturating_sub(MAX_BLOCKS_AT_ONCE)); } Ok((header_cache, best_header)) @@ -203,33 +243,6 @@ impl<'a, L: chain::Listen + ?Sized> chain::Listen for DynamicChainListener<'a, L } } -/// A set of dynamically sized chain listeners, each paired with a starting block height. -struct ChainListenerSet<'a, L: chain::Listen + ?Sized>(Vec<(u32, &'a L)>); - -impl<'a, L: chain::Listen + ?Sized> chain::Listen for ChainListenerSet<'a, L> { - fn block_connected(&self, block: &bitcoin::Block, height: u32) { - for (starting_height, chain_listener) in self.0.iter() { - if height > *starting_height { - chain_listener.block_connected(block, height); - } - } - } - - fn filtered_block_connected( - &self, header: &Header, txdata: &chain::transaction::TransactionData, height: u32, - ) { - for (starting_height, chain_listener) in self.0.iter() { - if height > *starting_height { - chain_listener.filtered_block_connected(header, txdata, height); - } - } - } - - fn blocks_disconnected(&self, _fork_point: BestBlock) { - unreachable!() - } -} - #[cfg(test)] mod tests { use super::*; From cd1b7e78191ff9ff68bfc0d3a4d80e8651c1db08 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 8 Dec 2025 12:18:01 +0000 Subject: [PATCH 262/627] Silence "elided lifetime has a name" warnings in no-std locking --- lightning/src/sync/nostd_sync.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lightning/src/sync/nostd_sync.rs b/lightning/src/sync/nostd_sync.rs index 12070741918..18055d1ebe4 100644 --- a/lightning/src/sync/nostd_sync.rs +++ b/lightning/src/sync/nostd_sync.rs @@ -61,7 +61,7 @@ impl<'a, T: 'a> LockTestExt<'a> for Mutex { } type ExclLock = MutexGuard<'a, T>; #[inline] - fn unsafe_well_ordered_double_lock_self(&'a self) -> MutexGuard { + fn unsafe_well_ordered_double_lock_self(&'a self) -> MutexGuard<'a, T> { self.lock().unwrap() } } @@ -132,7 +132,7 @@ impl<'a, T: 'a> LockTestExt<'a> for RwLock { } type ExclLock = RwLockWriteGuard<'a, T>; #[inline] - fn unsafe_well_ordered_double_lock_self(&'a self) -> RwLockWriteGuard { + fn unsafe_well_ordered_double_lock_self(&'a self) -> RwLockWriteGuard<'a, T> { self.write().unwrap() } } From 941846aa264e3590068510f1d6f4434853d077ad Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 8 Dec 2025 14:15:47 +0000 Subject: [PATCH 263/627] Use the header cache across listeners during initial disconnect In `lightning-blocksync::init::synchronize_listeners`, we may have many listeners we want to do a chain diff on. When doing so, we should make sure we utilize our header cache, rather than querying our chain source for every header we need for each listener. Here we do so, inserting into the cache as we do chain diffs. On my node with a bitcoind on localhost, this brings the calculate-differences step of `init::synchronize_listeners` from ~500ms to under 150ms. --- lightning-block-sync/src/lib.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/lightning-block-sync/src/lib.rs b/lightning-block-sync/src/lib.rs index c2590f0b304..c5bc1d0b870 100644 --- a/lightning-block-sync/src/lib.rs +++ b/lightning-block-sync/src/lib.rs @@ -206,7 +206,6 @@ impl HeaderCache { self.headers.get(block_hash) } - /// Called when a block has been connected to the best chain to ensure it is available to be /// disconnected later if needed. pub(crate) fn block_connected( @@ -219,6 +218,19 @@ impl HeaderCache { self.headers.retain(|_, header| header.height >= cutoff_height); } + /// Inserts the given block header during a find_difference operation, implying it might not be + /// the best header. + pub(crate) fn insert_during_diff( + &mut self, block_hash: BlockHash, block_header: ValidatedBlockHeader, + ) { + self.headers.insert(block_hash, block_header); + + // Remove headers older than our newest header minus a week. + let best_height = self.headers.iter().map(|(_, header)| header.height).max().unwrap_or(0); + let cutoff_height = best_height.saturating_sub(HEADER_CACHE_LIMIT); + self.headers.retain(|_, header| header.height >= cutoff_height); + } + /// Called when blocks have been disconnected from the best chain. Only the fork point /// (best common ancestor) is provided. /// @@ -350,8 +362,11 @@ impl<'a, L: chain::Listen + ?Sized> ChainNotifier<'a, L> { /// /// First resolves `prev_best_block` to a `ValidatedBlockHeader` using the `previous_blocks` /// field as fallback if needed, then finds the common ancestor. + /// + /// Updates the header cache as it goes, tracking headers needed to find the diff to reuse for + /// other objects that might need similar headers. async fn find_difference_from_best_block( - &self, current_header: ValidatedBlockHeader, prev_best_block: BestBlock, + &mut self, current_header: ValidatedBlockHeader, prev_best_block: BestBlock, chain_poller: &mut P, ) -> BlockSourceResult { // Try to resolve the header for the previous best block. First try the block_hash, @@ -376,6 +391,7 @@ impl<'a, L: chain::Listen + ?Sized> ChainNotifier<'a, L> { )?; if let Ok(header) = chain_poller.get_header(block_hash, Some(height)).await { found_header = Some(header); + self.header_cache.insert_during_diff(*block_hash, header); break; } } From 74e1da3cc8aa87a371df4d23e222ebd851b319b7 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 27 Jan 2026 17:22:06 +0000 Subject: [PATCH 264/627] Include recent blocks in the `synchronize_listeners`-returned cache When `synchronize_listeners` runs, it returns a cache of the headers it needed when doing chain difference-finding. This allows us to ensure that when we start running normally we have all the recent headers in case we need them to reorg. Sadly, in some cases it was returning a mostly-empty cache. Because it was only being filled during block difference reconciliation it would only get a block around each listener's fork point. Worse, because we were calling `disconnect_blocks` with the cache the cache would assume we were reorging against the main chain and drop blocks we actually want. Instead, we avoid dropping blocks on `disconnect_blocks` calls and ensure we always add connected blocks to the cache. --- lightning-block-sync/src/init.rs | 35 ++++++++++++++++++++++++++++---- lightning-block-sync/src/lib.rs | 12 ++++++++--- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/lightning-block-sync/src/init.rs b/lightning-block-sync/src/init.rs index cedd11e761d..07c9f230be3 100644 --- a/lightning-block-sync/src/init.rs +++ b/lightning-block-sync/src/init.rs @@ -152,6 +152,7 @@ where let mut chain_listeners_at_height = Vec::new(); let mut most_connected_blocks = Vec::new(); let mut header_cache = HeaderCache::new(); + header_cache.retain_on_disconnect = true; for (old_best_block, chain_listener) in chain_listeners.drain(..) { // Disconnect any stale blocks, but keep them in the cache for the next iteration. let (common_ancestor, connected_blocks) = { @@ -192,7 +193,9 @@ where const NO_BLOCK: Option<(u32, crate::poll::ValidatedBlock)> = None; let mut fetched_blocks = [NO_BLOCK; MAX_BLOCKS_AT_ONCE]; for ((header, block_res), result) in results.into_iter().zip(fetched_blocks.iter_mut()) { - *result = Some((header.height, block_res?)); + let block = block_res?; + header_cache.block_connected(header.block_hash, *header); + *result = Some((header.height, block)); } debug_assert!(fetched_blocks.iter().take(most_connected_blocks.len()).all(|r| r.is_some())); // TODO: When our MSRV is 1.82, use is_sorted_by_key @@ -225,6 +228,7 @@ where .truncate(most_connected_blocks.len().saturating_sub(MAX_BLOCKS_AT_ONCE)); } + header_cache.retain_on_disconnect = false; Ok((header_cache, best_header)) } @@ -267,7 +271,13 @@ mod tests { (chain.best_block_at_height(3), &listener_3 as &dyn chain::Listen), ]; match synchronize_listeners(&chain, Network::Bitcoin, listeners).await { - Ok((_, header)) => assert_eq!(header, chain.tip()), + Ok((cache, header)) => { + assert_eq!(header, chain.tip()); + assert!(cache.look_up(&chain.at_height(1).block_hash).is_some()); + assert!(cache.look_up(&chain.at_height(2).block_hash).is_some()); + assert!(cache.look_up(&chain.at_height(3).block_hash).is_some()); + assert!(cache.look_up(&chain.at_height(4).block_hash).is_some()); + }, Err(e) => panic!("Unexpected error: {:?}", e), } } @@ -298,7 +308,15 @@ mod tests { (fork_chain_3.best_block(), &listener_3 as &dyn chain::Listen), ]; match synchronize_listeners(&main_chain, Network::Bitcoin, listeners).await { - Ok((_, header)) => assert_eq!(header, main_chain.tip()), + Ok((cache, header)) => { + assert_eq!(header, main_chain.tip()); + assert!(cache.look_up(&main_chain.at_height(1).block_hash).is_some()); + assert!(cache.look_up(&main_chain.at_height(2).block_hash).is_some()); + assert!(cache.look_up(&main_chain.at_height(3).block_hash).is_some()); + assert!(cache.look_up(&fork_chain_1.at_height(2).block_hash).is_none()); + assert!(cache.look_up(&fork_chain_2.at_height(3).block_hash).is_none()); + assert!(cache.look_up(&fork_chain_3.at_height(4).block_hash).is_none()); + }, Err(e) => panic!("Unexpected error: {:?}", e), } } @@ -332,7 +350,16 @@ mod tests { (fork_chain_3.best_block(), &listener_3 as &dyn chain::Listen), ]; match synchronize_listeners(&main_chain, Network::Bitcoin, listeners).await { - Ok((_, header)) => assert_eq!(header, main_chain.tip()), + Ok((cache, header)) => { + assert_eq!(header, main_chain.tip()); + assert!(cache.look_up(&main_chain.at_height(1).block_hash).is_some()); + assert!(cache.look_up(&main_chain.at_height(2).block_hash).is_some()); + assert!(cache.look_up(&main_chain.at_height(3).block_hash).is_some()); + assert!(cache.look_up(&main_chain.at_height(4).block_hash).is_some()); + assert!(cache.look_up(&fork_chain_1.at_height(2).block_hash).is_none()); + assert!(cache.look_up(&fork_chain_1.at_height(3).block_hash).is_none()); + assert!(cache.look_up(&fork_chain_1.at_height(4).block_hash).is_none()); + }, Err(e) => panic!("Unexpected error: {:?}", e), } } diff --git a/lightning-block-sync/src/lib.rs b/lightning-block-sync/src/lib.rs index c5bc1d0b870..8e2c5b500f6 100644 --- a/lightning-block-sync/src/lib.rs +++ b/lightning-block-sync/src/lib.rs @@ -193,12 +193,15 @@ pub const HEADER_CACHE_LIMIT: u32 = 6 * 24 * 7; /// Retains only the latest [`HEADER_CACHE_LIMIT`] block headers based on height. pub struct HeaderCache { headers: std::collections::HashMap, + /// When set, [`Self::blocks_disconnected`] will not evict headers above the fork point. + /// This is used during initial sync to retain headers across multiple listeners. + retain_on_disconnect: bool, } impl HeaderCache { /// Creates a new empty header cache. pub fn new() -> Self { - Self { headers: std::collections::HashMap::new() } + Self { headers: std::collections::HashMap::new(), retain_on_disconnect: false } } /// Retrieves the block header keyed by the given block hash. @@ -234,9 +237,12 @@ impl HeaderCache { /// Called when blocks have been disconnected from the best chain. Only the fork point /// (best common ancestor) is provided. /// - /// Once disconnected, a block's header is no longer needed and thus can be removed. + /// Once disconnected, unless [`Self::retain_on_disconnect`] is set, a block's header is no + /// longer needed and thus can be removed. pub(crate) fn blocks_disconnected(&mut self, fork_point: &ValidatedBlockHeader) { - self.headers.retain(|_, block_info| block_info.height <= fork_point.height); + if !self.retain_on_disconnect { + self.headers.retain(|_, block_info| block_info.height <= fork_point.height); + } } } From 5330f9f4802be749ed46420690497c54f658fdd5 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Mon, 23 Mar 2026 16:45:53 -0400 Subject: [PATCH 265/627] util: add default_value_vec for defaults without LengthReadable Right now, use of `default_value` requires that the struct implements `LengthReadable` itself. When trying to use `default_value` outside of LDK for `Vec`, your code will run into the orphan rule because it does not own the trait `LengthReadable` or the type `Vec`. There are various ugly workarounds for this (like using `custom`), but wanting to persist a vec with a default value seems like a common enough use case to justify the change. --- lightning/src/util/ser_macros.rs | 86 +++++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/lightning/src/util/ser_macros.rs b/lightning/src/util/ser_macros.rs index cc95fe619e8..946be54de65 100644 --- a/lightning/src/util/ser_macros.rs +++ b/lightning/src/util/ser_macros.rs @@ -21,6 +21,9 @@ macro_rules! _encode_tlv { ($stream: expr, $type: expr, $field: expr, (default_value, $default: expr) $(, $self: ident)?) => { $crate::_encode_tlv!($stream, $type, $field, required) }; + ($stream: expr, $type: expr, $field: expr, (default_value_vec, $default: expr) $(, $self: ident)?) => { + $crate::_encode_tlv!($stream, $type, $field, required_vec) + }; ($stream: expr, $type: expr, $field: expr, (static_value, $value: expr) $(, $self: ident)?) => { let _ = &$field; // Ensure we "use" the $field }; @@ -200,6 +203,9 @@ macro_rules! _get_varint_length_prefixed_tlv_length { ($len: expr, $type: expr, $field: expr, (default_value, $default: expr) $(, $self: ident)?) => { $crate::_get_varint_length_prefixed_tlv_length!($len, $type, $field, required) }; + ($len: expr, $type: expr, $field: expr, (default_value_vec, $default: expr) $(, $self: ident)?) => { + $crate::_get_varint_length_prefixed_tlv_length!($len, $type, $field, required_vec) + }; ($len: expr, $type: expr, $field: expr, (static_value, $value: expr) $(, $self: ident)?) => {}; ($len: expr, $type: expr, $field: expr, required $(, $self: ident)?) => { BigSize($type).write(&mut $len).expect("No in-memory data may fail to serialize"); @@ -301,6 +307,15 @@ macro_rules! _check_decoded_tlv_order { $field = $default.into(); } }}; + ($last_seen_type: expr, $typ: expr, $type: expr, $field: ident, (default_value_vec, $default: expr)) => {{ + $crate::_check_decoded_tlv_order!( + $last_seen_type, + $typ, + $type, + $field, + (default_value, $default) + ); + }}; ($last_seen_type: expr, $typ: expr, $type: expr, $field: ident, (static_value, $value: expr)) => {}; ($last_seen_type: expr, $typ: expr, $type: expr, $field: ident, required) => {{ // Note that $type may be 0 making the second comparison always false @@ -372,6 +387,9 @@ macro_rules! _check_missing_tlv { $field = $default.into(); } }}; + ($last_seen_type: expr, $type: expr, $field: ident, (default_value_vec, $default: expr)) => {{ + $crate::_check_missing_tlv!($last_seen_type, $type, $field, (default_value, $default)); + }}; ($last_seen_type: expr, $type: expr, $field: expr, (static_value, $value: expr)) => { $field = $value; }; @@ -440,6 +458,10 @@ macro_rules! _decode_tlv { ($outer_reader: expr, $reader: expr, $field: ident, (default_value, $default: expr)) => {{ $crate::_decode_tlv!($outer_reader, $reader, $field, required) }}; + ($outer_reader: expr, $reader: expr, $field: ident, (default_value_vec, $default: expr)) => {{ + let f: $crate::util::ser::WithoutLength> = $crate::util::ser::LengthReadable::read_from_fixed_length_buffer(&mut $reader)?; + $field = $crate::util::ser::RequiredWrapper(Some(f.0)); + }}; ($outer_reader: expr, $reader: expr, $field: ident, (static_value, $value: expr)) => {{ }}; ($outer_reader: expr, $reader: expr, $field: ident, required) => {{ @@ -854,6 +876,9 @@ macro_rules! _init_tlv_based_struct_field { ($field: ident, (default_value, $default: expr)) => { $field.0.unwrap() }; + ($field: ident, (default_value_vec, $default: expr)) => { + $crate::_init_tlv_based_struct_field!($field, (default_value, $default)) + }; ($field: ident, (static_value, $value: expr)) => { $field }; @@ -905,6 +930,9 @@ macro_rules! _init_tlv_field_var { ($field: ident, (default_value, $default: expr)) => { let mut $field = $crate::util::ser::RequiredWrapper(None); }; + ($field: ident, (default_value_vec, $default: expr)) => { + $crate::_init_tlv_field_var!($field, (default_value, $default)); + }; ($field: ident, (static_value, $value: expr)) => { let $field; }; @@ -1007,6 +1035,9 @@ macro_rules! _decode_and_build { /// /// If `$fieldty` is `required`, then `$field` is a required field that is not an [`Option`] nor a [`Vec`]. /// If `$fieldty` is `(default_value, $default)`, then `$field` will be set to `$default` if not present. +/// If `$fieldty` is `(default_value_vec, $default)`, then `$field` is a [`Vec`] which will be set to `$default` +/// if not present. Elements are serialized individually without a count prefix (like `required_vec`). +/// The TLV is always written, even if the vec is empty (matching `default_value` behavior). /// If `$fieldty` is `(static_value, $static)`, then `$field` will be set to `$static`. /// If `$fieldty` is `option`, then `$field` is optional field. /// If `$fieldty` is `upgradable_option`, then `$field` is optional and read via [`MaybeReadable`]. @@ -1019,7 +1050,7 @@ macro_rules! _decode_and_build { /// `Some`. When reading, an optional field of type `$ty` is read, and after all TLV fields are /// read, the `$read` closure is called with the `Option<&$ty>` value. The `$read` closure should /// return a `Result<(), DecodeError>`. Legacy field values can be used in later -/// `default_value` or `static_value` fields by referring to the value by name. +/// `default_value`, `default_value_vec`, or `static_value` fields by referring to the value by name. /// If `$fieldty` is `(custom, $ty, $read, $write)` then, when writing, the same behavior as /// `legacy`, above is used. When reading, if a TLV is present, it is read as `$ty` and the /// `$read` method is called with `Some(decoded_$ty_object)`. If no TLV is present, the field @@ -1956,6 +1987,59 @@ mod tests { assert_eq!(read, ExpandedField { new_field: (42, 0) }); } + #[derive(Debug, PartialEq, Eq)] + struct DefaultValueVecStruct { + items: Vec, + } + impl_writeable_tlv_based!(DefaultValueVecStruct, { + (1, items, (default_value_vec, vec![4, 5, 6])), + }); + + #[test] + fn test_default_value_vec() { + // Non-empty vec round-trips correctly. + let instance = DefaultValueVecStruct { items: vec![1, 2, 3] }; + let encoded = instance.encode(); + let decoded: DefaultValueVecStruct = Readable::read(&mut &encoded[..]).unwrap(); + assert_eq!(decoded, instance); + + // Empty TLV stream falls back to the default. + let empty_encoded = >::from_hex("00").unwrap(); // zero-length TLV stream + let decoded: DefaultValueVecStruct = Readable::read(&mut &empty_encoded[..]).unwrap(); + assert_eq!(decoded, DefaultValueVecStruct { items: vec![4, 5, 6] }); + + // Empty vec round-trips to empty vec (TLV is always written). + let empty_vec = DefaultValueVecStruct { items: vec![] }; + let encoded = empty_vec.encode(); + let decoded: DefaultValueVecStruct = Readable::read(&mut &encoded[..]).unwrap(); + assert_eq!(decoded, DefaultValueVecStruct { items: vec![] }); + } + + #[derive(Debug, PartialEq, Eq)] + struct LegacyToVecStruct { + new_items: Vec, + } + impl_writeable_tlv_based!(LegacyToVecStruct, { + (0, old_item, (legacy, u32, |_| Ok(()), + |us: &LegacyToVecStruct| us.new_items.first().copied())), + (1, new_items, (default_value_vec, + old_item.map(|v| vec![v]).unwrap_or_default())), + }); + + #[test] + fn test_default_value_vec_with_legacy_fallback() { + // New format: round-trips via the new TLV. + let instance = LegacyToVecStruct { new_items: vec![10, 20, 30] }; + let encoded = instance.encode(); + let decoded: LegacyToVecStruct = Readable::read(&mut &encoded[..]).unwrap(); + assert_eq!(decoded, instance); + + // Old format: only the legacy type-0 field is present, falls back via default expression. + let old_encoded = >::from_hex("0600040000002a").unwrap(); // TLV len 6, type 0, len 4, value 42u32 + let decoded: LegacyToVecStruct = Readable::read(&mut &old_encoded[..]).unwrap(); + assert_eq!(decoded, LegacyToVecStruct { new_items: vec![42] }); + } + #[test] fn required_vec_with_encoding() { // Ensure that serializing a required vec with a specified encoding will survive a ser round From 1ff1bb45c98304b18afd23a332951c60a44563d3 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 17 Mar 2026 13:23:16 -0500 Subject: [PATCH 266/627] Ensure minimum RBF feerate satisfies BIP125 The spec's 25/24 multiplier doesn't always satisfy BIP125's relay requirement of an absolute fee increase at low feerates, while a flat +25 sat/kwu increment falls below the spec's 25/24 rule above 600 sat/kwu. Use max(prev + 25, ceil(prev * 25/24)) for our own RBFs to satisfy both constraints, while still accepting the bare 25/24 rule from counterparties. Co-Authored-By: Claude Opus 4.6 (1M context) --- lightning/src/ln/channel.rs | 23 +++-- lightning/src/ln/funding.rs | 26 ++--- lightning/src/ln/splicing_tests.rs | 152 +++++++++++++++++++++-------- 3 files changed, 143 insertions(+), 58 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 3cc6a6b0d86..79d012ba686 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -6484,6 +6484,19 @@ fn get_v2_channel_reserve_satoshis(channel_value_satoshis: u64, dust_limit_satos cmp::min(channel_value_satoshis, cmp::max(q, dust_limit_satoshis)) } +/// Returns the minimum feerate for our own RBF attempts given a previous feerate. +/// +/// The spec (tx_init_rbf) requires the new feerate to be >= 25/24 of the previous feerate. +/// However, at low feerates that multiplier doesn't always satisfy BIP125's relay requirement of +/// an absolute fee increase, so we take the max of a flat +25 sat/kwu (0.1 sat/vB) increment +/// and the spec's multiplicative rule. We still accept the bare 25/24 rule from counterparties +/// in [`FundedChannel::validate_tx_init_rbf`]. +fn min_rbf_feerate(prev_feerate: u32) -> FeeRate { + let flat_increment = (prev_feerate as u64).saturating_add(25); + let spec_increment = ((prev_feerate as u64) * 25).div_ceil(24); + FeeRate::from_sat_per_kwu(cmp::max(flat_increment, spec_increment)) +} + /// Context for negotiating channels (dual-funded V2 open, splicing) #[derive(Debug)] pub(super) struct FundingNegotiationContext { @@ -12019,10 +12032,7 @@ where prev_feerate.is_some(), "pending_splice should have last_funding_feerate or funding_negotiation", ); - let min_rbf_feerate = prev_feerate.map(|f| { - let min_feerate_kwu = ((f as u64) * 25).div_ceil(24); - FeeRate::from_sat_per_kwu(min_feerate_kwu) - }); + let min_rbf_feerate = prev_feerate.map(min_rbf_feerate); let prior = if pending_splice.last_funding_feerate_sat_per_1000_weight.is_some() { self.build_prior_contribution() } else { @@ -12114,10 +12124,7 @@ where } match pending_splice.last_funding_feerate_sat_per_1000_weight { - Some(prev_feerate) => { - let min_feerate_kwu = ((prev_feerate as u64) * 25).div_ceil(24); - Ok(FeeRate::from_sat_per_kwu(min_feerate_kwu)) - }, + Some(prev_feerate) => Ok(min_rbf_feerate(prev_feerate)), None => Err(format!( "Channel {} has no prior feerate to compute RBF minimum", self.context.channel_id(), diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 0ba4ed188e6..c94b2806d60 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -218,8 +218,9 @@ impl PriorContribution { /// prior contribution logic internally — reusing an adjusted prior when possible, re-running /// coin selection when needed, or creating a fee-bump-only contribution. /// -/// Check [`FundingTemplate::min_rbf_feerate`] for the minimum feerate required (25/24 of -/// the previous feerate). Use [`FundingTemplate::prior_contribution`] to inspect the prior +/// Check [`FundingTemplate::min_rbf_feerate`] for the minimum feerate required (the greater of +/// the previous feerate + 25 sat/kwu and the spec's 25/24 rule). Use +/// [`FundingTemplate::prior_contribution`] to inspect the prior /// contribution's parameters (e.g., [`FundingContribution::value_added`], /// [`FundingContribution::outputs`]) before deciding whether to reuse it via the RBF methods /// or build a fresh contribution with different parameters using the splice methods above. @@ -232,8 +233,9 @@ pub struct FundingTemplate { /// transaction. shared_input: Option, - /// The minimum RBF feerate (25/24 of the previous feerate), if this template is for an - /// RBF attempt. `None` for fresh splices with no pending splice candidates. + /// The minimum RBF feerate (the greater of previous feerate + 25 sat/kwu and the spec's + /// 25/24 rule), if this template is for an RBF attempt. `None` for fresh splices with no + /// pending splice candidates. min_rbf_feerate: Option, /// The user's prior contribution from a previous splice negotiation, if available. @@ -2262,8 +2264,8 @@ mod tests { // When the caller's max_feerate is below the minimum RBF feerate, rbf_sync should // return Err(()). let prior_feerate = FeeRate::from_sat_per_kwu(2000); - let min_rbf_feerate = FeeRate::from_sat_per_kwu(5000); - let max_feerate = FeeRate::from_sat_per_kwu(3000); + let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025); + let max_feerate = FeeRate::from_sat_per_kwu(2020); let prior = FundingContribution { value_added: Amount::from_sat(50_000), @@ -2276,7 +2278,7 @@ mod tests { is_splice: true, }; - // max_feerate (3000) < min_rbf_feerate (5000). + // max_feerate (2020) < min_rbf_feerate (2025). let template = FundingTemplate::new( None, Some(min_rbf_feerate), @@ -2359,8 +2361,8 @@ mod tests { // When the prior contribution's feerate is below the minimum RBF feerate and no // holder balance is available, rbf_sync should run coin selection to add inputs that // cover the higher RBF fee. - let min_rbf_feerate = FeeRate::from_sat_per_kwu(5000); let prior_feerate = FeeRate::from_sat_per_kwu(2000); + let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025); let withdrawal = funding_output_sats(20_000); let prior = FundingContribution { @@ -2397,7 +2399,7 @@ mod tests { fn test_rbf_sync_no_prior_fee_bump_only_runs_coin_selection() { // When there is no prior contribution (e.g., acceptor), rbf_sync should run coin // selection to add inputs for a fee-bump-only contribution. - let min_rbf_feerate = FeeRate::from_sat_per_kwu(5000); + let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025); let template = FundingTemplate::new(Some(shared_input(100_000)), Some(min_rbf_feerate), None); @@ -2419,7 +2421,7 @@ mod tests { // When the prior contribution's feerate is below the minimum RBF feerate and no // holder balance is available, rbf_sync should use the caller's max_feerate (not the // prior's) for the resulting contribution. - let min_rbf_feerate = FeeRate::from_sat_per_kwu(5000); + let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025); let prior_max_feerate = FeeRate::from_sat_per_kwu(50_000); let callers_max_feerate = FeeRate::from_sat_per_kwu(10_000); let withdrawal = funding_output_sats(20_000); @@ -2458,8 +2460,8 @@ mod tests { // When splice_out_sync is called on a template with min_rbf_feerate set (user // choosing a fresh splice-out instead of rbf_sync), coin selection should NOT run. // Fees come from the channel balance. - let min_rbf_feerate = FeeRate::from_sat_per_kwu(5000); - let feerate = FeeRate::from_sat_per_kwu(5000); + let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025); + let feerate = FeeRate::from_sat_per_kwu(2025); let withdrawal = funding_output_sats(20_000); let template = diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 20339e445bf..da5b79b6017 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -4322,8 +4322,8 @@ fn test_splice_rbf_acceptor_basic() { provide_utxo_reserves(&nodes, 2, added_value * 2); // Step 3: Use splice_channel API to initiate the RBF. - // Original feerate was FEERATE_FLOOR_SATS_PER_KW (253). 253 * 25 / 24 = 263.54, so 264 works. - let rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + // Original feerate was FEERATE_FLOOR_SATS_PER_KW (253). 253 + 25 = 278. + let rbf_feerate_sat_per_kwu = FEERATE_FLOOR_SATS_PER_KW as u64 + 25; let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); let funding_contribution = do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); @@ -4357,9 +4357,73 @@ fn test_splice_rbf_acceptor_basic() { ); } +#[test] +fn test_splice_rbf_at_high_feerate() { + // Test that min_rbf_feerate satisfies the spec's 25/24 rule at high feerates (above 600 + // sat/kwu, where a flat +25 increment alone would be insufficient). + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Step 1: Complete a splice-in at floor feerate. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_first_splice_tx, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Step 2: RBF to a high feerate (1000 sat/kwu, well above the 600 crossover point). + provide_utxo_reserves(&nodes, 2, added_value * 2); + let high_feerate = FeeRate::from_sat_per_kwu(1000); + let contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, high_feerate); + complete_rbf_handshake(&nodes[0], &nodes[1]); + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + contribution, + new_funding_script.clone(), + ); + let (_, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + assert!(splice_locked.is_none()); + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Step 3: RBF again using the template's min_rbf_feerate. The counterparty must accept it. + provide_utxo_reserves(&nodes, 2, added_value * 2); + let rbf_feerate = { + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + funding_template.min_rbf_feerate().unwrap() + }; + let contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + complete_rbf_handshake(&nodes[0], &nodes[1]); + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + contribution, + new_funding_script, + ); + let (_, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + assert!(splice_locked.is_none()); + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); +} + #[test] fn test_splice_rbf_insufficient_feerate() { - // Test that splice_in_sync rejects a feerate that doesn't satisfy the 25/24 rule, and that the + // Test that splice_in_sync rejects a feerate that doesn't satisfy the +25 sat/kwu rule, and that the // acceptor also rejects tx_init_rbf with an insufficient feerate from a misbehaving peer. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); @@ -4388,8 +4452,7 @@ fn test_splice_rbf_insufficient_feerate() { // Verify that the template exposes the RBF floor. let min_rbf_feerate = funding_template.min_rbf_feerate().unwrap(); - let expected_floor = - FeeRate::from_sat_per_kwu(((FEERATE_FLOOR_SATS_PER_KW as u64) * 25).div_ceil(24)); + let expected_floor = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25); assert_eq!(min_rbf_feerate, expected_floor); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); @@ -4417,6 +4480,22 @@ fn test_splice_rbf_insufficient_feerate() { let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); assert_eq!(tx_abort.channel_id, channel_id); + + // Acceptor-side: a counterparty feerate that satisfies the spec's 25/24 rule (264) is + // accepted, even though our own RBF floor (+25 sat/kwu = 278) is higher. + // After tx_abort the channel remains quiescent, so no need to re-enter quiescence. + nodes[0].node.handle_tx_abort(node_id_1, &tx_abort); + + let rbf_feerate_25_24 = ((FEERATE_FLOOR_SATS_PER_KW as u64) * 25).div_ceil(24) as u32; + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: rbf_feerate_25_24, + funding_output_contribution: Some(added_value.to_sat() as i64), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + let _tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0); } #[test] @@ -4695,7 +4774,7 @@ fn test_splice_rbf_not_quiescence_initiator() { provide_utxo_reserves(&nodes, 2, added_value * 2); // Initiate RBF from node 0 (quiescence initiator). - let rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let rbf_feerate_sat_per_kwu = FEERATE_FLOOR_SATS_PER_KW as u64 + 25; let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); let _funding_contribution = do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); @@ -4725,7 +4804,7 @@ fn test_splice_rbf_not_quiescence_initiator() { #[test] fn test_splice_rbf_both_contribute_tiebreak() { - let min_rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let min_rbf_feerate = FEERATE_FLOOR_SATS_PER_KW as u64 + 25; let feerate = FeeRate::from_sat_per_kwu(min_rbf_feerate); let added_value = Amount::from_sat(50_000); do_test_splice_rbf_tiebreak(feerate, feerate, added_value, true); @@ -4735,7 +4814,7 @@ fn test_splice_rbf_both_contribute_tiebreak() { fn test_splice_rbf_tiebreak_higher_feerate() { // Node 0 (winner) uses a higher feerate than node 1 (loser). Node 1's change output is // adjusted (reduced) to accommodate the higher feerate. Negotiation succeeds. - let min_rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let min_rbf_feerate = FEERATE_FLOOR_SATS_PER_KW as u64 + 25; do_test_splice_rbf_tiebreak( FeeRate::from_sat_per_kwu(min_rbf_feerate * 3), FeeRate::from_sat_per_kwu(min_rbf_feerate), @@ -4749,7 +4828,7 @@ fn test_splice_rbf_tiebreak_lower_feerate() { // Node 0 (winner) uses a lower feerate than node 1 (loser). Since the initiator's feerate // is below node 1's minimum, node 1 proceeds without contribution and will retry via a new // splice at its preferred feerate after the RBF locks. - let min_rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let min_rbf_feerate = FEERATE_FLOOR_SATS_PER_KW as u64 + 25; do_test_splice_rbf_tiebreak( FeeRate::from_sat_per_kwu(min_rbf_feerate), FeeRate::from_sat_per_kwu(min_rbf_feerate * 3), @@ -4763,7 +4842,7 @@ fn test_splice_rbf_tiebreak_feerate_too_high() { // Node 0 (winner) uses a feerate high enough that node 1's (loser) contribution cannot // cover the fees. Node 1 proceeds without its contribution (QuiescentAction is preserved // for a future splice). The RBF completes with only node 0's inputs/outputs. - let min_rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let min_rbf_feerate = FEERATE_FLOOR_SATS_PER_KW as u64 + 25; do_test_splice_rbf_tiebreak( FeeRate::from_sat_per_kwu(20_000), FeeRate::from_sat_per_kwu(min_rbf_feerate), @@ -5064,7 +5143,7 @@ fn test_splice_rbf_tiebreak_feerate_too_high_rejected() { // The target (100k) far exceeds node 1's max (3k), and the fair fee at 100k exceeds // node 1's budget, triggering TooHigh. let high_feerate = FeeRate::from_sat_per_kwu(100_000); - let min_rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let min_rbf_feerate_sat_per_kwu = FEERATE_FLOOR_SATS_PER_KW as u64 + 25; let min_rbf_feerate = FeeRate::from_sat_per_kwu(min_rbf_feerate_sat_per_kwu); let node_1_max_feerate = FeeRate::from_sat_per_kwu(3_000); @@ -5194,7 +5273,7 @@ fn test_splice_rbf_acceptor_recontributes() { provide_utxo_reserves(&nodes, 2, added_value * 2); // Step 5: Only node 0 calls splice_channel + funding_contributed. - let rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let rbf_feerate_sat_per_kwu = FEERATE_FLOOR_SATS_PER_KW as u64 + 25; let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); let rbf_funding_contribution = do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); @@ -5318,8 +5397,7 @@ fn test_splice_rbf_after_counterparty_rbf_aborted() { // is adjusted to the RBF feerate via for_acceptor_at_feerate. provide_utxo_reserves(&nodes, 2, added_value * 2); - let rbf_feerate = - FeeRate::from_sat_per_kwu((FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24)); + let rbf_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25); let _rbf_funding_contribution = do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); @@ -5482,7 +5560,7 @@ fn test_splice_rbf_sequential() { // Three consecutive RBF rounds on the same splice (initial → RBF #1 → RBF #2). // Node 0 is the quiescence initiator; node 1 is the acceptor with no contribution. // Verifies: - // - Each round satisfies the 25/24 feerate rule + // - Each round satisfies the +25 sat/kwu feerate rule // - DiscardFunding events reference the correct txids from previous rounds // - The final RBF can be mined and splice_locked successfully let chanmon_cfgs = create_chanmon_cfgs(2); @@ -5505,11 +5583,11 @@ fn test_splice_rbf_sequential() { let (splice_tx_0, new_funding_script) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); - // Feerate progression: 253 → ceil(253*25/24) = 264 → ceil(264*25/24) = 275 - let feerate_1_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); // 264 - let feerate_2_sat_per_kwu = (feerate_1_sat_per_kwu * 25).div_ceil(24); + // Feerate progression: 253 → 253+25 = 278 → 278+25 = 303 + let feerate_1_sat_per_kwu = FEERATE_FLOOR_SATS_PER_KW as u64 + 25; // 278 + let feerate_2_sat_per_kwu = feerate_1_sat_per_kwu + 25; - // --- Round 1: RBF #1 at feerate 264. --- + // --- Round 1: RBF #1 at feerate 278. --- provide_utxo_reserves(&nodes, 2, added_value * 2); let rbf_feerate_1 = FeeRate::from_sat_per_kwu(feerate_1_sat_per_kwu); @@ -5529,7 +5607,7 @@ fn test_splice_rbf_sequential() { expect_splice_pending_event(&nodes[0], &node_id_1); expect_splice_pending_event(&nodes[1], &node_id_0); - // --- Round 2: RBF #2 at feerate 275. --- + // --- Round 2: RBF #2 at feerate 303. --- provide_utxo_reserves(&nodes, 2, added_value * 2); let rbf_feerate_2 = FeeRate::from_sat_per_kwu(feerate_2_sat_per_kwu); @@ -5625,7 +5703,7 @@ fn test_splice_rbf_acceptor_contributes_then_disconnects() { // --- Round 1: Node 0 initiates RBF; node 1 re-contributes via prior. --- provide_utxo_reserves(&nodes, 2, added_value * 2); - let rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let rbf_feerate_sat_per_kwu = FEERATE_FLOOR_SATS_PER_KW as u64 + 25; let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); let _rbf_funding_contribution = do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); @@ -5697,7 +5775,7 @@ fn test_splice_rbf_disconnect_filters_prior_contributions() { // Include a splice-out output with a different script_pubkey so the test can verify // selective filtering: the change output (same script_pubkey as round 0) is filtered, // while the splice-out output (different script_pubkey) survives. - let feerate_1_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let feerate_1_sat_per_kwu = FEERATE_FLOOR_SATS_PER_KW as u64 + 25; let rbf_feerate = FeeRate::from_sat_per_kwu(feerate_1_sat_per_kwu); let splice_out_output = TxOut { value: Amount::from_sat(1_000), @@ -5747,9 +5825,9 @@ fn test_splice_rbf_disconnect_filters_prior_contributions() { reconnect_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_args); - // --- Round 2: RBF at the same feerate as the failed round 1 (264). --- + // --- Round 2: RBF at the same feerate as the failed round 1 (278). --- // This should succeed because the failed round never updated the feerate floor, which - // remains at round 0's rate (253), and 264 >= ceil(253 * 25/24). + // remains at round 0's rate (253), and 278 >= 253 + 25. provide_utxo_reserves(&nodes, 1, added_value * 2); let rbf_feerate_2 = FeeRate::from_sat_per_kwu(feerate_1_sat_per_kwu); @@ -5809,8 +5887,7 @@ fn test_splice_channel_with_pending_splice_includes_rbf_floor() { // Call splice_channel again — the pending splice should cause min_rbf_feerate to be set // and the prior contribution to be available. let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); - let expected_floor = - FeeRate::from_sat_per_kwu(((FEERATE_FLOOR_SATS_PER_KW as u64) * 25).div_ceil(24)); + let expected_floor = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25); assert_eq!(funding_template.min_rbf_feerate(), Some(expected_floor)); assert!(funding_template.prior_contribution().is_some()); @@ -5859,7 +5936,7 @@ fn test_funding_contributed_adjusts_feerate_for_rbf() { splice_channel(&nodes[1], &nodes[0], channel_id, node_1_contribution); // Node 0 calls funding_contributed. The contribution's feerate (floor) is below the RBF - // floor (25/24 of floor), but funding_contributed adjusts it upward. + // floor (floor + 25 sat/kwu), but funding_contributed adjusts it upward. nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution.clone(), None).unwrap(); // STFU should be sent immediately (the adjusted feerate satisfies the RBF check). @@ -5871,8 +5948,7 @@ fn test_funding_contributed_adjusts_feerate_for_rbf() { // Verify the RBF handshake proceeds. let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); let rbf_feerate = FeeRate::from_sat_per_kwu(tx_init_rbf.feerate_sat_per_1000_weight as u64); - let expected_floor = - FeeRate::from_sat_per_kwu((FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24)); + let expected_floor = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25); assert!(rbf_feerate >= expected_floor); } @@ -5897,7 +5973,7 @@ fn test_funding_contributed_rbf_adjustment_exceeds_max_feerate() { provide_utxo_reserves(&nodes, 4, added_value * 2); // Node 0 calls splice_channel and builds contribution with max_feerate = floor_feerate. - // This means the minimum RBF feerate (25/24 of floor) will exceed max_feerate, preventing adjustment. + // This means the minimum RBF feerate (floor + 25 sat/kwu) will exceed max_feerate, preventing adjustment. let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); @@ -5972,7 +6048,8 @@ fn test_funding_contributed_rbf_adjustment_insufficient_budget() { funding_template.splice_in_sync(added_value, floor_feerate, FeeRate::MAX, &wallet).unwrap(); // Node 1 initiates a splice at a HIGH feerate (10,000 sat/kwu). The minimum RBF feerate will be - // 25/24 of 10,000 = 10,417 sat/kwu — far above what node 0's tight budget can handle. + // max(10,000 + 25, ceil(10,000 * 25/24)) = 10,417 sat/kwu — far above what node 0's tight + // budget can handle. let high_feerate = FeeRate::from_sat_per_kwu(10_000); let node_1_template = nodes[1].node.splice_channel(&channel_id, &node_id_0).unwrap(); let node_1_wallet = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); @@ -6047,7 +6124,7 @@ fn test_prior_contribution_unadjusted_when_max_feerate_too_low() { .unwrap(); let (_splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); - // Call splice_channel again — the minimum RBF feerate (25/24 of floor) exceeds the prior + // Call splice_channel again — the minimum RBF feerate (floor + 25 sat/kwu) exceeds the prior // contribution's max_feerate (floor), so adjustment fails. rbf_sync re-runs coin selection // with the caller's max_feerate. let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); @@ -6092,8 +6169,7 @@ fn test_splice_channel_during_negotiation_includes_rbf_feerate() { // Node 0 (acceptor) calls splice_channel while the negotiation is in progress. // min_rbf_feerate should be derived from the in-progress negotiation's feerate. let template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); - let expected_floor = - FeeRate::from_sat_per_kwu(((FEERATE_FLOOR_SATS_PER_KW as u64) * 25).div_ceil(24)); + let expected_floor = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25); assert_eq!(template.min_rbf_feerate(), Some(expected_floor)); // No prior contribution since there are no negotiated candidates yet. rbf_sync runs @@ -6339,7 +6415,7 @@ fn test_splice_rbf_rejects_low_feerate_after_several_attempts() { // Rounds 1-10: RBF at minimum bump. Accepted (at or below threshold). let mut prev_feerate = FEERATE_FLOOR_SATS_PER_KW as u64; for _ in 0..10 { - let feerate = (prev_feerate * 25).div_ceil(24); + let feerate = prev_feerate + 25; provide_utxo_reserves(&nodes, 2, added_value * 2); let rbf_feerate = FeeRate::from_sat_per_kwu(feerate); let contribution = @@ -6360,7 +6436,7 @@ fn test_splice_rbf_rejects_low_feerate_after_several_attempts() { } // Round 11: RBF at minimum bump. Should be rejected because feerate < fee estimator. - let next_feerate = (prev_feerate * 25).div_ceil(24); + let next_feerate = prev_feerate + 25; provide_utxo_reserves(&nodes, 2, added_value * 2); let rbf_feerate = FeeRate::from_sat_per_kwu(next_feerate); let _contribution = @@ -6410,7 +6486,7 @@ fn test_splice_rbf_rejects_own_low_feerate_after_several_attempts() { // Rounds 1-10: RBF at minimum bump. Accepted (at or below threshold). let mut prev_feerate = FEERATE_FLOOR_SATS_PER_KW as u64; for _ in 0..10 { - let feerate = (prev_feerate * 25).div_ceil(24); + let feerate = prev_feerate + 25; provide_utxo_reserves(&nodes, 2, added_value * 2); let rbf_feerate = FeeRate::from_sat_per_kwu(feerate); let contribution = @@ -6431,7 +6507,7 @@ fn test_splice_rbf_rejects_own_low_feerate_after_several_attempts() { } // Round 11: Our own RBF at minimum bump. funding_contributed should reject it. - let next_feerate = (prev_feerate * 25).div_ceil(24); + let next_feerate = prev_feerate + 25; provide_utxo_reserves(&nodes, 2, added_value * 2); let rbf_feerate = FeeRate::from_sat_per_kwu(next_feerate); let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); From 414b38d3eeaf3f7378b3fac72068080a9755701e Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Wed, 1 Apr 2026 09:55:55 +0200 Subject: [PATCH 267/627] Fix misleading comment on counterparty_commitment_txn_on_chain insert The comment claimed this insert "isn't useful yet" and was only a safety measure for a watchtower race. In practice it is also used by provide_payment_preimage to look up the commitment number when a preimage arrives after the counterparty commitment tx is confirmed. AI tools were used in preparing this commit. --- lightning/src/chain/channelmonitor.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs index 0173e988082..810de80da95 100644 --- a/lightning/src/chain/channelmonitor.rs +++ b/lightning/src/chain/channelmonitor.rs @@ -4867,13 +4867,16 @@ impl ChannelMonitorImpl { } else if let Some(per_commitment_claimable_data) = per_commitment_option { assert_eq!(funding_spent.funding_txid(), funding_txid_spent); - // While this isn't useful yet, there is a potential race where if a counterparty - // revokes a state at the same time as the commitment transaction for that state is - // confirmed, and the watchtower receives the block before the user, the user could - // upload a new ChannelMonitor with the revocation secret but the watchtower has - // already processed the block, resulting in the counterparty_commitment_txn_on_chain entry - // not being generated by the above conditional. Thus, to be safe, we go ahead and - // insert it here. + // Track that this counterparty commitment tx appeared on-chain. This is + // used by `provide_payment_preimage` to look up the commitment number + // when a preimage arrives after the commitment tx is already confirmed. + // It also handles a race where a counterparty revokes a state at the + // same time as the commitment transaction for that state is confirmed, + // and the watchtower receives the block before the user. The user could + // upload a new ChannelMonitor with the revocation secret but the + // watchtower has already processed the block, resulting in the + // counterparty_commitment_txn_on_chain entry not being generated by + // the above conditional. self.counterparty_commitment_txn_on_chain.insert(commitment_txid, commitment_number); log_info!(logger, "Got broadcast of non-revoked counterparty commitment transaction {}", commitment_txid); From b06ba3fe78a67c4425abf5b895fc947c994c8ed4 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Wed, 1 Apr 2026 21:28:03 +0000 Subject: [PATCH 268/627] Document that `Future` callbacks are not reentrant-safe Claude was complaining about this, and it seems worth documenting, but not worth (and kinda hard to) fix. --- lightning/src/util/wakers.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lightning/src/util/wakers.rs b/lightning/src/util/wakers.rs index 17edadfd822..1a0f08b5e66 100644 --- a/lightning/src/util/wakers.rs +++ b/lightning/src/util/wakers.rs @@ -165,6 +165,8 @@ impl Future { /// Registers a callback to be called upon completion of this future. If the future has already /// completed, the callback will be called immediately. /// + /// Note that callbacks *must not* reenter this [`Future`] or the corresponding [`Notifier`]. + /// /// This is not exported to bindings users, use the bindings-only `register_callback_fn` instead pub fn register_callback(&self, callback: Box) { let mut state = self.state.lock().unwrap(); @@ -182,6 +184,8 @@ impl Future { // here. /// Registers a callback to be called upon completion of this future. If the future has already /// completed, the callback will be called immediately. + /// + /// Note that callbacks *must not* reenter this [`Future`] or the corresponding [`Notifier`]. #[cfg(c_bindings)] pub fn register_callback_fn(&self, callback: F) { self.register_callback(Box::new(callback)); From f20bae33fda6c883f2d33c2ccdc5596e17e52c00 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Wed, 1 Apr 2026 10:28:44 +0200 Subject: [PATCH 269/627] Make fuzz targets deterministic Gate all SystemTime::now() and Instant::now() calls in production code with #[cfg(all(feature = "std", not(fuzzing)))] so that fuzz targets produce consistent results regardless of wall-clock time. For each location, the existing no-std fallback (highest_seen_timestamp, None, or a constant) is reused under fuzzing. Also force deterministic hashing when the fuzzing cfg is active, rather than requiring the LDK_TEST_DETERMINISTIC_HASHES env var. AI tools were used in preparing this commit. --- lightning/src/ln/channel.rs | 4 ++-- lightning/src/ln/channelmanager.rs | 12 +++++----- lightning/src/ln/outbound_payment.rs | 19 ++++++++++----- lightning/src/ln/peer_handler.rs | 2 +- lightning/src/offers/flow.rs | 18 +++++++------- lightning/src/onion_message/dns_resolution.rs | 5 ++-- lightning/src/routing/gossip.rs | 24 +++++++++---------- lightning/src/util/hash_tables.rs | 9 ++++--- 8 files changed, 52 insertions(+), 41 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 03f78dc82b4..675c53ae61e 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -16717,10 +16717,10 @@ impl<'a, 'b, 'c, ES: EntropySource, SP: SignerProvider> } fn duration_since_epoch() -> Option { - #[cfg(not(feature = "std"))] + #[cfg(any(not(feature = "std"), fuzzing))] let now = None; - #[cfg(feature = "std")] + #[cfg(all(feature = "std", not(fuzzing)))] let now = Some( std::time::SystemTime::now() .duration_since(std::time::SystemTime::UNIX_EPOCH) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 660f61f0f57..2e782701e47 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8936,11 +8936,11 @@ impl< let _ = self.handle_error(err, counterparty_node_id); } - #[cfg(feature = "std")] + #[cfg(all(feature = "std", not(fuzzing)))] let duration_since_epoch = std::time::SystemTime::now() .duration_since(std::time::SystemTime::UNIX_EPOCH) .expect("SystemTime::now() should come after SystemTime::UNIX_EPOCH"); - #[cfg(not(feature = "std"))] + #[cfg(any(not(feature = "std"), fuzzing))] let duration_since_epoch = Duration::from_secs( self.highest_seen_timestamp.load(Ordering::Acquire).saturating_sub(7200) as u64, ); @@ -14129,7 +14129,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let currency = Network::from_chain_hash(self.chain_hash).map(Into::into).unwrap_or(Currency::Bitcoin); - #[cfg(feature = "std")] + #[cfg(all(feature = "std", not(fuzzing)))] let duration_since_epoch = { use std::time::SystemTime; SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) @@ -14139,7 +14139,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ // This may be up to 2 hours in the future because of bitcoin's block time rule or about // 10-30 minutes in the past if a block hasn't been found recently. This should be fine as // the default invoice expiration is 2 hours, though shorter expirations may be problematic. - #[cfg(not(feature = "std"))] + #[cfg(any(not(feature = "std"), fuzzing))] let duration_since_epoch = Duration::from_secs(self.highest_seen_timestamp.load(Ordering::Acquire) as u64); @@ -14996,9 +14996,9 @@ impl< } pub(super) fn duration_since_epoch(&self) -> Duration { - #[cfg(not(feature = "std"))] + #[cfg(any(not(feature = "std"), fuzzing))] let now = Duration::from_secs(self.highest_seen_timestamp.load(Ordering::Acquire) as u64); - #[cfg(feature = "std")] + #[cfg(all(feature = "std", not(fuzzing)))] let now = std::time::SystemTime::now() .duration_since(std::time::SystemTime::UNIX_EPOCH) .expect("SystemTime::now() should come after SystemTime::UNIX_EPOCH"); diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index b08b0f5a886..9241e6ccf7c 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -446,14 +446,16 @@ impl Retry { (Retry::Attempts(max_retry_count), PaymentAttempts { count, .. }) => { max_retry_count > count }, - #[cfg(feature = "std")] + #[cfg(all(feature = "std", not(fuzzing)))] (Retry::Timeout(max_duration), PaymentAttempts { first_attempted_at, .. }) => *max_duration >= Instant::now().duration_since(*first_attempted_at), + #[cfg(all(feature = "std", fuzzing))] + (Retry::Timeout(_), _) => true, } } } -#[cfg(feature = "std")] +#[cfg(all(feature = "std", not(fuzzing)))] #[rustfmt::skip] pub(super) fn has_expired(route_params: &RouteParameters) -> bool { if let Some(expiry_time) = route_params.payment_params.expiry_time { @@ -464,6 +466,11 @@ pub(super) fn has_expired(route_params: &RouteParameters) -> bool { false } +#[cfg(all(feature = "std", fuzzing))] +pub(super) fn has_expired(_route_params: &RouteParameters) -> bool { + false +} + /// Storing minimal payment attempts information required for determining if a outbound payment can /// be retried. pub(crate) struct PaymentAttempts { @@ -471,7 +478,7 @@ pub(crate) struct PaymentAttempts { /// it means the result of the first attempt is not known yet. pub(crate) count: u32, /// This field is only used when retry is `Retry::Timeout` which is only build with feature std - #[cfg(feature = "std")] + #[cfg(all(feature = "std", not(fuzzing)))] first_attempted_at: Instant, } @@ -479,7 +486,7 @@ impl PaymentAttempts { pub(crate) fn new() -> Self { PaymentAttempts { count: 0, - #[cfg(feature = "std")] + #[cfg(all(feature = "std", not(fuzzing)))] first_attempted_at: Instant::now(), } } @@ -487,9 +494,9 @@ impl PaymentAttempts { impl Display for PaymentAttempts { fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { - #[cfg(not(feature = "std"))] + #[cfg(any(not(feature = "std"), fuzzing))] return write!(f, "attempts: {}", self.count); - #[cfg(feature = "std")] + #[cfg(all(feature = "std", not(fuzzing)))] return write!( f, "attempts: {}, duration: {}s", diff --git a/lightning/src/ln/peer_handler.rs b/lightning/src/ln/peer_handler.rs index 759a1e7d887..69d0815e8f0 100644 --- a/lightning/src/ln/peer_handler.rs +++ b/lightning/src/ln/peer_handler.rs @@ -2327,7 +2327,7 @@ impl< #[allow(unused_mut)] let mut should_do_full_sync = true; - #[cfg(feature = "std")] + #[cfg(all(feature = "std", not(fuzzing)))] { // Forward ad-hoc gossip if the timestamp range is less than six hours ago. // Otherwise, do a full sync. diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 6e7293cee6b..c1c3ce26aee 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -183,9 +183,9 @@ impl OffersMessageFlow { } fn duration_since_epoch(&self) -> Duration { - #[cfg(not(feature = "std"))] + #[cfg(any(not(feature = "std"), fuzzing))] let now = Duration::from_secs(self.highest_seen_timestamp.load(Ordering::Acquire) as u64); - #[cfg(feature = "std")] + #[cfg(all(feature = "std", not(fuzzing)))] let now = std::time::SystemTime::now() .duration_since(std::time::SystemTime::UNIX_EPOCH) .expect("SystemTime::now() should come after SystemTime::UNIX_EPOCH"); @@ -942,7 +942,7 @@ impl OffersMessageFlow { ) .map_err(|_| Bolt12SemanticError::MissingPaths)?; - #[cfg(feature = "std")] + #[cfg(all(feature = "std", not(fuzzing)))] let builder = refund.respond_using_derived_keys( payment_paths, payment_hash, @@ -950,9 +950,9 @@ impl OffersMessageFlow { entropy, )?; - #[cfg(not(feature = "std"))] + #[cfg(any(not(feature = "std"), fuzzing))] let created_at = Duration::from_secs(self.highest_seen_timestamp.load(Ordering::Acquire) as u64); - #[cfg(not(feature = "std"))] + #[cfg(any(not(feature = "std"), fuzzing))] let builder = refund.respond_using_derived_keys_no_std( payment_paths, payment_hash, @@ -1008,9 +1008,9 @@ impl OffersMessageFlow { ) .map_err(|_| Bolt12SemanticError::MissingPaths)?; - #[cfg(feature = "std")] + #[cfg(all(feature = "std", not(fuzzing)))] let builder = invoice_request.respond_using_derived_keys(payment_paths, payment_hash); - #[cfg(not(feature = "std"))] + #[cfg(any(not(feature = "std"), fuzzing))] let builder = invoice_request.respond_using_derived_keys_no_std( payment_paths, payment_hash, @@ -1067,9 +1067,9 @@ impl OffersMessageFlow { ) .map_err(|_| Bolt12SemanticError::MissingPaths)?; - #[cfg(feature = "std")] + #[cfg(all(feature = "std", not(fuzzing)))] let builder = invoice_request.respond_with(payment_paths, payment_hash); - #[cfg(not(feature = "std"))] + #[cfg(any(not(feature = "std"), fuzzing))] let builder = invoice_request.respond_with_no_std( payment_paths, payment_hash, diff --git a/lightning/src/onion_message/dns_resolution.rs b/lightning/src/onion_message/dns_resolution.rs index e857a359c78..5f68fa732d9 100644 --- a/lightning/src/onion_message/dns_resolution.rs +++ b/lightning/src/onion_message/dns_resolution.rs @@ -501,7 +501,7 @@ impl OMNameResolver { if let Ok(validated_rrs) = validated_rrs { #[allow(unused_assignments, unused_mut)] let mut time = self.latest_block_time.load(Ordering::Acquire) as u64; - #[cfg(feature = "std")] + #[cfg(all(feature = "std", not(fuzzing)))] { use std::time::{SystemTime, UNIX_EPOCH}; let now = SystemTime::now().duration_since(UNIX_EPOCH); @@ -512,7 +512,8 @@ impl OMNameResolver { // (we assume no more than two hours, though the actual limits are rather // complicated). // Thus, we have to let the proof times be rather fuzzy. - let max_time_offset = if cfg!(feature = "std") { 0 } else { 60 * 2 }; + let max_time_offset = + if cfg!(all(feature = "std", not(fuzzing))) { 0 } else { 60 * 2 }; if validated_rrs.valid_from > time + max_time_offset { return None; } diff --git a/lightning/src/routing/gossip.rs b/lightning/src/routing/gossip.rs index 3794c381817..adeb67a9e6c 100644 --- a/lightning/src/routing/gossip.rs +++ b/lightning/src/routing/gossip.rs @@ -843,7 +843,7 @@ impl>, U: UtxoLookup, L: Logger> BaseMessageHa let mut gossip_start_time = 0; #[allow(unused)] let should_sync = self.should_request_full_sync(); - #[cfg(feature = "std")] + #[cfg(all(feature = "std", not(fuzzing)))] { gossip_start_time = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -2195,7 +2195,7 @@ impl NetworkGraph { #[allow(unused_mut, unused_assignments)] let mut announcement_received_time = 0; - #[cfg(feature = "std")] + #[cfg(all(feature = "std", not(fuzzing)))] { announcement_received_time = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -2235,11 +2235,11 @@ impl NetworkGraph { /// /// The channel and any node for which this was their last channel are removed from the graph. pub fn channel_failed_permanent(&self, short_channel_id: u64) { - #[cfg(feature = "std")] + #[cfg(all(feature = "std", not(fuzzing)))] let current_time_unix = Some( SystemTime::now().duration_since(UNIX_EPOCH).expect("Time must be > 1970").as_secs(), ); - #[cfg(not(feature = "std"))] + #[cfg(any(not(feature = "std"), fuzzing))] let current_time_unix = None; self.channel_failed_permanent_with_time(short_channel_id, current_time_unix) @@ -2262,11 +2262,11 @@ impl NetworkGraph { /// Marks a node in the graph as permanently failed, effectively removing it and its channels /// from local storage. pub fn node_failed_permanent(&self, node_id: &PublicKey) { - #[cfg(feature = "std")] + #[cfg(all(feature = "std", not(fuzzing)))] let current_time_unix = Some( SystemTime::now().duration_since(UNIX_EPOCH).expect("Time must be > 1970").as_secs(), ); - #[cfg(not(feature = "std"))] + #[cfg(any(not(feature = "std"), fuzzing))] let current_time_unix = None; let node_id = NodeId::from_pubkey(node_id); @@ -2303,7 +2303,6 @@ impl NetworkGraph { } } - #[cfg(feature = "std")] /// Removes information about channels that we haven't heard any updates about in some time. /// This can be used regularly to prune the network graph of channels that likely no longer /// exist. @@ -2320,6 +2319,7 @@ impl NetworkGraph { /// /// This method is only available with the `std` feature. See /// [`NetworkGraph::remove_stale_channels_and_tracking_with_time`] for non-`std` use. + #[cfg(all(feature = "std", not(fuzzing)))] pub fn remove_stale_channels_and_tracking(&self) { let time = SystemTime::now().duration_since(UNIX_EPOCH).expect("Time must be > 1970").as_secs(); @@ -2403,10 +2403,10 @@ impl NetworkGraph { if let Some(time) = time { current_time_unix.saturating_sub(*time) < REMOVED_ENTRIES_TRACKING_AGE_LIMIT_SECS } else { - // NOTE: In the case of non-`std`, we won't have access to the current UNIX time at the time of removal, - // so we'll just set the removal time here to the current UNIX time on the very next invocation - // of this function. - #[cfg(not(feature = "std"))] + // NOTE: In the case of non-`std` or fuzzing, we won't have access to the current UNIX + // time at the time of removal, so we'll just set the removal time here to the current + // UNIX time on the very next invocation of this function. + #[cfg(any(not(feature = "std"), fuzzing))] { let mut tracked_time = Some(current_time_unix); core::mem::swap(time, &mut tracked_time); @@ -2476,7 +2476,7 @@ impl NetworkGraph { }); } - #[cfg(all(feature = "std", not(test), not(feature = "_test_utils")))] + #[cfg(all(feature = "std", not(test), not(feature = "_test_utils"), not(fuzzing)))] { // Note that many tests rely on being able to set arbitrarily old timestamps, thus we // disable this check during tests! diff --git a/lightning/src/util/hash_tables.rs b/lightning/src/util/hash_tables.rs index b6555975191..545b034a5ae 100644 --- a/lightning/src/util/hash_tables.rs +++ b/lightning/src/util/hash_tables.rs @@ -6,11 +6,11 @@ pub use hashbrown::hash_map; mod hashbrown_tables { - #[cfg(all(feature = "std", not(test)))] + #[cfg(all(feature = "std", not(test), not(fuzzing)))] mod hasher { pub use std::collections::hash_map::RandomState; } - #[cfg(all(feature = "std", test))] + #[cfg(all(feature = "std", any(test, fuzzing)))] mod hasher { #![allow(deprecated)] // hash::SipHasher was deprecated in favor of something only in std. use core::hash::{BuildHasher, Hasher}; @@ -27,7 +27,10 @@ mod hashbrown_tables { impl RandomState { pub fn new() -> RandomState { - if std::env::var("LDK_TEST_DETERMINISTIC_HASHES").map(|v| v == "1").unwrap_or(false) + if cfg!(fuzzing) + || std::env::var("LDK_TEST_DETERMINISTIC_HASHES") + .map(|v| v == "1") + .unwrap_or(false) { RandomState::Deterministic } else { From f14b4b2fd5889da3265be19db0891adcbc67068b Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 2 Apr 2026 16:38:15 +0000 Subject: [PATCH 270/627] Wipe empty entries from `actions_blocking_raa_monitor_updates` In a very specific case, forgetting to do so can lead to a debug assertion failure when we see a double-claim of an HTLC (see the included test). Found by @joostjager's work on growing the chanmon_consistency fuzzer. --- lightning/src/ln/channelmanager.rs | 21 +++++---- lightning/src/ln/functional_tests.rs | 66 ++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 2e782701e47..5737c496587 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -9818,12 +9818,12 @@ impl< { if let Some(peer_state_mtx) = per_peer_state.get(&node_id) { let mut peer_state = peer_state_mtx.lock().unwrap(); - if let Some(blockers) = peer_state + let entry = peer_state .actions_blocking_raa_monitor_updates - .get_mut(&channel_id) - { + .entry(channel_id); + if let btree_map::Entry::Occupied(mut entry) = entry { let mut found_blocker = false; - blockers.retain(|iter| { + entry.get_mut().retain(|iter| { // Note that we could actually be blocked, in // which case we need to only remove the one // blocker which was added duplicatively. @@ -9833,6 +9833,9 @@ impl< } *iter != blocker || !first_blocker }); + if entry.get().is_empty() { + entry.remove(); + } debug_assert!(found_blocker); } } else { @@ -15251,10 +15254,12 @@ impl< let peer_state = &mut *peer_state_lck; if let Some(blocker) = completed_blocker.take() { // Only do this on the first iteration of the loop. - if let Some(blockers) = peer_state.actions_blocking_raa_monitor_updates - .get_mut(&channel_id) - { - blockers.retain(|iter| iter != &blocker); + let entry = peer_state.actions_blocking_raa_monitor_updates.entry(channel_id); + if let btree_map::Entry::Occupied(mut entry) = entry { + entry.get_mut().retain(|iter| iter != &blocker); + if entry.get().is_empty() { + entry.remove(); + } } } diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index 7ed46922d8a..1fd3daf3ce3 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -10168,3 +10168,69 @@ pub fn test_dust_exposure_holding_cell_assertion() { // Now that everything has settled, make sure the channels still work with a simple claim. claim_payment(&nodes[2], &[&nodes[1]], payment_preimage_cb); } + +#[test] +fn test_dup_htlc_claim_onchain_and_offchain() { + // Tests what happens if we receive a claim first offchain, then see a counterparty broadcast + // their commitment transaction and re-claim the same HTLC on-chain. This was never broken, but + // the very specific ordering in this test did hit a debug assertion failure. + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let legacy_cfg = test_legacy_channel_config(); + let node_chanmgrs = create_node_chanmgrs( + 3, + &node_cfgs, + &[Some(legacy_cfg.clone()), Some(legacy_cfg.clone()), Some(legacy_cfg)], + ); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + + let node_b_id = nodes[1].node.get_our_node_id(); + let node_c_id = nodes[2].node.get_our_node_id(); + + create_announced_chan_between_nodes(&nodes, 0, 1); + let chan_bc = create_announced_chan_between_nodes(&nodes, 1, 2); + + // Route payment A -> B -> C. + let (payment_preimage, payment_hash, _, _) = + route_payment(&nodes[0], &[&nodes[1], &nodes[2]], 1_000_000); + + // C claims the payment. + nodes[2].node.claim_funds(payment_preimage); + expect_payment_claimed!(nodes[2], payment_hash, 1_000_000); + check_added_monitors(&nodes[2], 1); + + // Deliver only C's update_fulfill_htlc to B (NOT the commitment_signed). B learns + // the preimage and claims from A (adding an RAA blocker on B-C via + // internal_update_fulfill_htlc, then removing it when the A-B monitor update completes + // and the EmitEventOptionAndFreeOtherChannel action runs). + let cs_updates = get_htlc_update_msgs(&nodes[2], &node_b_id); + nodes[1].node.handle_update_fulfill_htlc(node_c_id, cs_updates.update_fulfill_htlcs[0].clone()); + check_added_monitors(&nodes[1], 1); + + // Ignore B's attempts to claim the HTLC from A. + nodes[1].node.get_and_clear_pending_msg_events(); + + // Get C's commitment transactions. C's commitment includes the HTLC and C has + // an HTLC-success transaction (claiming with preimage). Mine both on B. + let cs_txn = get_local_commitment_txn!(nodes[2], chan_bc.2); + assert!(cs_txn.len() >= 2, "Expected commitment + HTLC-success tx, got {}", cs_txn.len()); + + // Mine C's commitment on B. B sees the counterparty commitment on-chain. + mine_transaction(&nodes[1], &cs_txn[0]); + check_closed_broadcast(&nodes[1], 1, true); + check_added_monitors(&nodes[1], 1); + let events = nodes[1].node.get_and_clear_pending_events(); + assert!( + events.iter().any(|e| matches!(e, Event::ChannelClosed { .. })), + "Expected ChannelClosed event" + ); + + // Mine C's HTLC-success transaction. B's monitor sees the preimage being used on-chain + // and generates an HTLCEvent with the preimage. + mine_transaction(&nodes[1], &cs_txn[1]); + + // Advance past ANTI_REORG_DELAY so the on-chain HTLC resolution matures. This triggers + // the monitor to generate an HTLCEvent with the preimage via process_pending_monitor_events, + // which calls claim_funds_internal a second time. + connect_blocks(&nodes[1], ANTI_REORG_DELAY); +} From 2867d5c1a4d2c931f11cc0e713e8878419ca481b Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 1 Apr 2026 01:01:58 +0000 Subject: [PATCH 271/627] Add `unannounced_channel_max_inbound_htlc_value_in_flight_percentage` Users can now configure two different max percentages for the channel value that can be allocated to inbound HTLCs, one for announced channels, and another for unannounced channels. We also bump the default maximums to 25% for announced channels, and 100% for unannounced channels, to bring them closer to what people would expect. --- lightning/src/ln/async_payments_tests.rs | 7 ++ lightning/src/ln/blinded_payment_tests.rs | 12 +- lightning/src/ln/chanmon_update_fail_tests.rs | 7 +- lightning/src/ln/channel.rs | 104 +++++++++++++----- lightning/src/ln/channel_open_tests.rs | 6 +- lightning/src/ln/functional_tests.rs | 28 +++-- lightning/src/ln/htlc_reserve_unit_tests.rs | 21 ++-- lightning/src/ln/monitor_tests.rs | 2 + lightning/src/ln/payment_tests.rs | 82 +++++++++----- lightning/src/ln/reload_tests.rs | 12 +- lightning/src/ln/splicing_tests.rs | 24 ++-- lightning/src/ln/update_fee_tests.rs | 7 +- lightning/src/util/config.rs | 77 ++++++++++--- 13 files changed, 288 insertions(+), 101 deletions(-) diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index 25522346d9c..341bd5d7269 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -504,6 +504,9 @@ fn often_offline_node_cfg() -> UserConfig { cfg.channel_handshake_config.announce_for_forwarding = false; cfg.channel_handshake_limits.force_announced_channel_preference = true; cfg.hold_outbound_htlcs_at_next_hop = true; + // Use the setting that matches the default at the time these tests were written + cfg.channel_handshake_config.unannounced_channel_max_inbound_htlc_value_in_flight_percentage = + 10; cfg } @@ -1310,6 +1313,10 @@ fn async_receive_mpp() { let mut allow_priv_chan_fwds_cfg = test_default_channel_config(); allow_priv_chan_fwds_cfg.accept_forwards_to_priv_channels = true; + // Set the percentage to the default value at the time this test was written + allow_priv_chan_fwds_cfg + .channel_handshake_config + .announced_channel_max_inbound_htlc_value_in_flight_percentage = 10; let node_chanmgrs = create_node_chanmgrs( 4, diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index e148ce2c474..d62f79957eb 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -269,7 +269,11 @@ fn one_hop_blinded_path_with_dummy_hops() { fn mpp_to_one_hop_blinded_path() { let chanmon_cfgs = create_chanmon_cfgs(4); let node_cfgs = create_node_cfgs(4, &chanmon_cfgs); - let node_chanmgrs = create_node_chanmgrs(4, &node_cfgs, &[None, None, None, None]); + let mut config = test_default_channel_config(); + // Set the percentage to the default value at the time this test was written + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = 10; + let configs: [Option; 4] = core::array::from_fn(|_| Some(config.clone())); + let node_chanmgrs = create_node_chanmgrs(4, &node_cfgs, &configs); let nodes = create_network(4, &node_cfgs, &node_chanmgrs); let mut secp_ctx = Secp256k1::new(); @@ -349,7 +353,11 @@ fn mpp_to_one_hop_blinded_path() { fn mpp_to_three_hop_blinded_paths() { let chanmon_cfgs = create_chanmon_cfgs(6); let node_cfgs = create_node_cfgs(6, &chanmon_cfgs); - let node_chanmgrs = create_node_chanmgrs(6, &node_cfgs, &[None, None, None, None, None, None]); + let mut config = test_default_channel_config(); + // Set the percentage to the default value at the time this test was written + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = 10; + let configs: [Option; 6] = core::array::from_fn(|_| Some(config.clone())); + let node_chanmgrs = create_node_chanmgrs(6, &node_cfgs, &configs); let nodes = create_network(6, &node_cfgs, &node_chanmgrs); // Create this network topology so node 0 MPP's over 2 3-hop blinded paths: diff --git a/lightning/src/ln/chanmon_update_fail_tests.rs b/lightning/src/ln/chanmon_update_fail_tests.rs index dd799c2c27c..af4d1569d0c 100644 --- a/lightning/src/ln/chanmon_update_fail_tests.rs +++ b/lightning/src/ln/chanmon_update_fail_tests.rs @@ -4656,6 +4656,7 @@ fn test_claim_to_closed_channel_blocks_claimed_event() { #[test] #[cfg(all(feature = "std", not(target_os = "windows")))] fn test_single_channel_multiple_mpp() { + use crate::util::config::UserConfig; use std::sync::atomic::{AtomicBool, Ordering}; // Test what happens when we attempt to claim an MPP with many parts that came to us through @@ -4667,7 +4668,11 @@ fn test_single_channel_multiple_mpp() { // for more info. let chanmon_cfgs = create_chanmon_cfgs(9); let node_cfgs = create_node_cfgs(9, &chanmon_cfgs); - let configs = [None, None, None, None, None, None, None, None, None]; + let mut config = test_default_channel_config(); + // Set the percentage to the default value at the time this test was written + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 10; + let configs: [Option; 9] = core::array::from_fn(|_| Some(config.clone())); let node_chanmgrs = create_node_chanmgrs(9, &node_cfgs, &configs); let mut nodes = create_network(9, &node_cfgs, &node_chanmgrs); diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 03f78dc82b4..5579c119e05 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -4102,6 +4102,7 @@ impl ChannelContext { ), holder_max_htlc_value_in_flight_msat: get_holder_max_htlc_value_in_flight_msat( channel_value_satoshis, + announce_for_forwarding, &config.channel_handshake_config, ), counterparty_htlc_minimum_msat: open_channel_fields.htlc_minimum_msat, @@ -4405,6 +4406,7 @@ impl ChannelContext { // receive `accept_channel2`. holder_max_htlc_value_in_flight_msat: get_holder_max_htlc_value_in_flight_msat( channel_value_satoshis, + config.channel_handshake_config.announce_for_forwarding, &config.channel_handshake_config, ), counterparty_htlc_minimum_msat: 0, @@ -6678,24 +6680,41 @@ impl ChannelContext { /// Returns the value to use for `holder_max_htlc_value_in_flight_msat` as a percentage of the /// `channel_value_satoshis` in msat, set through -/// [`ChannelHandshakeConfig::max_inbound_htlc_value_in_flight_percent_of_channel`] +/// [`ChannelHandshakeConfig::announced_channel_max_inbound_htlc_value_in_flight_percentage`] +/// or [`ChannelHandshakeConfig::unannounced_channel_max_inbound_htlc_value_in_flight_percentage`] +/// depending on the value of [`ChannelHandshakeConfig::announce_for_forwarding`]. /// /// The effective percentage is lower bounded by 1% and upper bounded by 100%. /// -/// [`ChannelHandshakeConfig::max_inbound_htlc_value_in_flight_percent_of_channel`]: crate::util::config::ChannelHandshakeConfig::max_inbound_htlc_value_in_flight_percent_of_channel +/// [`ChannelHandshakeConfig::announced_channel_max_inbound_htlc_value_in_flight_percentage`]: crate::util::config::ChannelHandshakeConfig::announced_channel_max_inbound_htlc_value_in_flight_percentage +/// [`ChannelHandshakeConfig::unannounced_channel_max_inbound_htlc_value_in_flight_percentage`]: crate::util::config::ChannelHandshakeConfig::unannounced_channel_max_inbound_htlc_value_in_flight_percentage +/// [`ChannelHandshakeConfig::announce_for_forwarding`]: crate::util::config::ChannelHandshakeConfig::announce_for_forwarding fn get_holder_max_htlc_value_in_flight_msat( - channel_value_satoshis: u64, config: &ChannelHandshakeConfig, + channel_value_satoshis: u64, is_announced_channel: bool, config: &ChannelHandshakeConfig, ) -> u64 { - let configured_percent = if config.max_inbound_htlc_value_in_flight_percent_of_channel < 1 { + let config_setting = if is_announced_channel { + config.announced_channel_max_inbound_htlc_value_in_flight_percentage + } else { + config.unannounced_channel_max_inbound_htlc_value_in_flight_percentage + }; + let configured_percent = if config_setting < 1 { 1 - } else if config.max_inbound_htlc_value_in_flight_percent_of_channel > 100 { + } else if config_setting > 100 { 100 } else { - config.max_inbound_htlc_value_in_flight_percent_of_channel as u64 + config_setting as u64 }; channel_value_satoshis * 10 * configured_percent } +/// This is for legacy reasons, present for forward-compatibility. +/// LDK versions older than 0.0.104 don't know how read/handle values other than the legacy +/// percentage from storage. Hence, we use this function to not persist legacy values of +/// `holder_max_htlc_value_in_flight_msat` for channels into storage. +fn get_legacy_default_holder_max_htlc_value_in_flight_msat(channel_value_satoshis: u64) -> u64 { + channel_value_satoshis * 10 * MAX_IN_FLIGHT_PERCENT_LEGACY as u64 +} + /// Returns a minimum channel reserve value the remote needs to maintain, /// required by us according to the configured or default /// [`ChannelHandshakeConfig::their_channel_reserve_proportional_millionths`] @@ -15691,15 +15710,11 @@ impl Writeable for FundedChannel { None }; - let mut old_max_in_flight_percent_config = UserConfig::default().channel_handshake_config; - old_max_in_flight_percent_config.max_inbound_htlc_value_in_flight_percent_of_channel = - MAX_IN_FLIGHT_PERCENT_LEGACY; - let max_in_flight_msat = get_holder_max_htlc_value_in_flight_msat( + let legacy_max_in_flight_msat = get_legacy_default_holder_max_htlc_value_in_flight_msat( self.funding.get_value_satoshis(), - &old_max_in_flight_percent_config, ); let serialized_holder_htlc_max_in_flight = - if self.context.holder_max_htlc_value_in_flight_msat != max_in_flight_msat { + if self.context.holder_max_htlc_value_in_flight_msat != legacy_max_in_flight_msat { Some(self.context.holder_max_htlc_value_in_flight_msat) } else { None @@ -16131,11 +16146,9 @@ impl<'a, 'b, 'c, ES: EntropySource, SP: SignerProvider> let mut holder_selected_channel_reserve_satoshis = Some( get_legacy_default_holder_selected_channel_reserve_satoshis(channel_value_satoshis), ); + let mut holder_max_htlc_value_in_flight_msat = - Some(get_holder_max_htlc_value_in_flight_msat( - channel_value_satoshis, - &UserConfig::default().channel_handshake_config, - )); + Some(get_legacy_default_holder_max_htlc_value_in_flight_msat(channel_value_satoshis)); // Prior to supporting channel type negotiation, all of our channels were static_remotekey // only, so we default to that if none was written. let mut channel_type = Some(ChannelTypeFeatures::only_static_remote_key()); @@ -17141,8 +17154,13 @@ mod tests { } #[test] - #[rustfmt::skip] fn test_configured_holder_max_htlc_value_in_flight() { + do_test_configured_holder_max_htlc_value_in_flight(true); + do_test_configured_holder_max_htlc_value_in_flight(false); + } + + #[rustfmt::skip] + fn do_test_configured_holder_max_htlc_value_in_flight(announce_channel: bool) { let test_est = TestFeeEstimator::new(15000); let feeest = LowerBoundedFeeEstimator::new(&test_est); let logger = TestLogger::new(); @@ -17154,13 +17172,49 @@ mod tests { let inbound_node_id = PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[7; 32]).unwrap()); let mut config_2_percent = UserConfig::default(); - config_2_percent.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 2; + config_2_percent.channel_handshake_config.announce_for_forwarding = announce_channel; + if announce_channel { + config_2_percent + .channel_handshake_config + .announced_channel_max_inbound_htlc_value_in_flight_percentage = 2; + } else { + config_2_percent + .channel_handshake_config + .unannounced_channel_max_inbound_htlc_value_in_flight_percentage = 2; + } let mut config_99_percent = UserConfig::default(); - config_99_percent.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 99; + config_99_percent.channel_handshake_config.announce_for_forwarding = announce_channel; + if announce_channel { + config_99_percent + .channel_handshake_config + .announced_channel_max_inbound_htlc_value_in_flight_percentage = 99; + } else { + config_99_percent + .channel_handshake_config + .unannounced_channel_max_inbound_htlc_value_in_flight_percentage = 99; + } let mut config_0_percent = UserConfig::default(); - config_0_percent.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 0; + config_0_percent.channel_handshake_config.announce_for_forwarding = announce_channel; + if announce_channel { + config_0_percent + .channel_handshake_config + .announced_channel_max_inbound_htlc_value_in_flight_percentage = 0; + } else { + config_0_percent + .channel_handshake_config + .unannounced_channel_max_inbound_htlc_value_in_flight_percentage = 0; + } let mut config_101_percent = UserConfig::default(); - config_101_percent.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 101; + config_101_percent.channel_handshake_config.announce_for_forwarding = announce_channel; + if announce_channel { + config_101_percent + .channel_handshake_config + .announced_channel_max_inbound_htlc_value_in_flight_percentage = 101; + } else { + config_101_percent + .channel_handshake_config + .unannounced_channel_max_inbound_htlc_value_in_flight_percentage = 101; + } // Test that `OutboundV1Channel::new` creates a channel with the correct value for // `holder_max_htlc_value_in_flight_msat`, when configured with a valid percentage value, @@ -17189,26 +17243,26 @@ mod tests { assert_eq!(chan_4.context.holder_max_htlc_value_in_flight_msat, (chan_4_value_msat as f64 * 0.99) as u64); // Test that `OutboundV1Channel::new` uses the lower bound of the configurable percentage values (1%) - // if `max_inbound_htlc_value_in_flight_percent_of_channel` is set to a value less than 1. + // if `(un)announced_channel_max_inbound_htlc_value_in_flight_percentage` is set to a value less than 1. let chan_5 = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, outbound_node_id, &channelmanager::provided_init_features(&config_0_percent), 10000000, 100000, 42, &config_0_percent, 0, 42, None, &logger, None).unwrap(); let chan_5_value_msat = chan_5.funding.get_value_satoshis() * 1000; assert_eq!(chan_5.context.holder_max_htlc_value_in_flight_msat, (chan_5_value_msat as f64 * 0.01) as u64); // Test that `OutboundV1Channel::new` uses the upper bound of the configurable percentage values - // (100%) if `max_inbound_htlc_value_in_flight_percent_of_channel` is set to a larger value + // (100%) if `(un)announced_channel_max_inbound_htlc_value_in_flight_percentage` is set to a larger value // than 100. let chan_6 = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, outbound_node_id, &channelmanager::provided_init_features(&config_101_percent), 10000000, 100000, 42, &config_101_percent, 0, 42, None, &logger, None).unwrap(); let chan_6_value_msat = chan_6.funding.get_value_satoshis() * 1000; assert_eq!(chan_6.context.holder_max_htlc_value_in_flight_msat, chan_6_value_msat); // Test that `InboundV1Channel::new` uses the lower bound of the configurable percentage values (1%) - // if `max_inbound_htlc_value_in_flight_percent_of_channel` is set to a value less than 1. + // if `(un)announced_channel_max_inbound_htlc_value_in_flight_percentage` is set to a value less than 1. let chan_7 = InboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, inbound_node_id, &channelmanager::provided_channel_type_features(&config_0_percent), &channelmanager::provided_init_features(&config_0_percent), &chan_1_open_channel_msg, 7, &config_0_percent, 0, &&logger, None).unwrap(); let chan_7_value_msat = chan_7.funding.get_value_satoshis() * 1000; assert_eq!(chan_7.context.holder_max_htlc_value_in_flight_msat, (chan_7_value_msat as f64 * 0.01) as u64); // Test that `InboundV1Channel::new` uses the upper bound of the configurable percentage values - // (100%) if `max_inbound_htlc_value_in_flight_percent_of_channel` is set to a larger value + // (100%) if `(un)announced_channel_max_inbound_htlc_value_in_flight_percentage` is set to a larger value // than 100. let chan_8 = InboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, inbound_node_id, &channelmanager::provided_channel_type_features(&config_101_percent), &channelmanager::provided_init_features(&config_101_percent), &chan_1_open_channel_msg, 7, &config_101_percent, 0, &&logger, None).unwrap(); let chan_8_value_msat = chan_8.funding.get_value_satoshis() * 1000; diff --git a/lightning/src/ln/channel_open_tests.rs b/lightning/src/ln/channel_open_tests.rs index d28d157488d..ac4a1b67994 100644 --- a/lightning/src/ln/channel_open_tests.rs +++ b/lightning/src/ln/channel_open_tests.rs @@ -182,7 +182,8 @@ fn test_inbound_anchors_manual_acceptance() { fn test_inbound_anchors_config_overridden() { let overrides = ChannelConfigOverrides { handshake_overrides: Some(ChannelHandshakeConfigUpdate { - max_inbound_htlc_value_in_flight_percent_of_channel: Some(5), + announced_channel_max_inbound_htlc_value_in_flight_percentage: Some(5), + unannounced_channel_max_inbound_htlc_value_in_flight_percentage: None, htlc_minimum_msat: Some(1000), minimum_depth: Some(2), to_self_delay: Some(200), @@ -1070,7 +1071,8 @@ pub fn test_accept_inbound_channel_config_override() { let config_overrides = ChannelConfigOverrides { handshake_overrides: Some(ChannelHandshakeConfigUpdate { - max_inbound_htlc_value_in_flight_percent_of_channel: None, + announced_channel_max_inbound_htlc_value_in_flight_percentage: None, + unannounced_channel_max_inbound_htlc_value_in_flight_percentage: None, htlc_minimum_msat: None, minimum_depth: None, to_self_delay: None, diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index 7ed46922d8a..32a07be4d2b 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -6903,22 +6903,22 @@ pub fn test_channel_update_has_correct_htlc_maximum_msat() { config_30_percent.channel_handshake_config.announce_for_forwarding = true; config_30_percent .channel_handshake_config - .max_inbound_htlc_value_in_flight_percent_of_channel = 30; + .announced_channel_max_inbound_htlc_value_in_flight_percentage = 30; let mut config_50_percent = UserConfig::default(); config_50_percent.channel_handshake_config.announce_for_forwarding = true; config_50_percent .channel_handshake_config - .max_inbound_htlc_value_in_flight_percent_of_channel = 50; + .announced_channel_max_inbound_htlc_value_in_flight_percentage = 50; let mut config_95_percent = UserConfig::default(); config_95_percent.channel_handshake_config.announce_for_forwarding = true; config_95_percent .channel_handshake_config - .max_inbound_htlc_value_in_flight_percent_of_channel = 95; + .announced_channel_max_inbound_htlc_value_in_flight_percentage = 95; let mut config_100_percent = UserConfig::default(); config_100_percent.channel_handshake_config.announce_for_forwarding = true; config_100_percent .channel_handshake_config - .max_inbound_htlc_value_in_flight_percent_of_channel = 100; + .announced_channel_max_inbound_htlc_value_in_flight_percentage = 100; let chanmon_cfgs = create_chanmon_cfgs(4); let node_cfgs = create_node_cfgs(4, &chanmon_cfgs); @@ -8436,7 +8436,12 @@ pub fn test_inconsistent_mpp_params() { // such HTLC and allow the second to stay. let chanmon_cfgs = create_chanmon_cfgs(4); let node_cfgs = create_node_cfgs(4, &chanmon_cfgs); - let node_chanmgrs = create_node_chanmgrs(4, &node_cfgs, &[None, None, None, None]); + let mut config = test_default_channel_config(); + // Set the percentage to the default value at the time this test was written + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 10; + let configs: [Option; 4] = core::array::from_fn(|_| Some(config.clone())); + let node_chanmgrs = create_node_chanmgrs(4, &node_cfgs, &configs); let nodes = create_network(4, &node_cfgs, &node_chanmgrs); let node_a_id = nodes[0].node.get_our_node_id(); @@ -8580,7 +8585,12 @@ pub fn test_double_partial_claim() { // amount. let chanmon_cfgs = create_chanmon_cfgs(4); let node_cfgs = create_node_cfgs(4, &chanmon_cfgs); - let node_chanmgrs = create_node_chanmgrs(4, &node_cfgs, &[None, None, None, None]); + let mut config = test_default_channel_config(); + // Set the percentage to the default value at the time this test was written + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 10; + let configs: [Option; 4] = core::array::from_fn(|_| Some(config.clone())); + let node_chanmgrs = create_node_chanmgrs(4, &node_cfgs, &configs); let nodes = create_network(4, &node_cfgs, &node_chanmgrs); let node_b_id = nodes[1].node.get_our_node_id(); @@ -9067,7 +9077,8 @@ pub fn test_nondust_htlc_excess_fees_are_dust() { config.channel_handshake_limits.min_max_accepted_htlcs = chan_utils::max_htlcs(&chan_ty); config.channel_handshake_config.our_max_accepted_htlcs = chan_utils::max_htlcs(&chan_ty); config.channel_handshake_config.our_htlc_minimum_msat = 1; - config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; let node_chanmgrs = create_node_chanmgrs( 3, @@ -10039,7 +10050,8 @@ pub fn test_dust_exposure_holding_cell_assertion() { // Use a fixed dust exposure limit to make the test simpler const DUST_HTLC_VALUE_MSAT: u64 = 500_000; config.channel_config.max_dust_htlc_exposure = MaxDustHTLCExposure::FixedLimitMsat(5_000_000); - config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; let configs = [Some(config.clone()), Some(config.clone()), Some(config.clone())]; let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &configs); diff --git a/lightning/src/ln/htlc_reserve_unit_tests.rs b/lightning/src/ln/htlc_reserve_unit_tests.rs index 608ac143c8d..aaf81b87be7 100644 --- a/lightning/src/ln/htlc_reserve_unit_tests.rs +++ b/lightning/src/ln/htlc_reserve_unit_tests.rs @@ -2376,7 +2376,8 @@ fn test_create_channel_to_trusted_peer_0reserve() { fn do_test_create_channel_to_trusted_peer_0reserve(mut config: UserConfig) -> ChannelTypeFeatures { let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); @@ -2465,7 +2466,8 @@ fn do_test_accept_inbound_channel_from_trusted_peer_0reserve( ) -> ChannelTypeFeatures { let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); @@ -2679,7 +2681,8 @@ fn do_test_0reserve_no_outputs_legacy(no_outputs_case: LegacyChannelsNoOutputs) let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; let channel_type = ChannelTypeFeatures::only_static_remote_key(); @@ -2979,7 +2982,8 @@ fn do_test_0reserve_no_outputs_keyed_anchors(payment_success: bool) { let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; let channel_type = ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies(); @@ -3112,7 +3116,8 @@ fn do_test_0reserve_no_outputs_p2a_anchor() { let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; let channel_type = ChannelTypeFeatures::anchors_zero_fee_commitments(); @@ -3170,7 +3175,8 @@ fn do_test_0reserve_force_close_with_single_p2a_output(high_feerate: bool) { *feerate_lock = 2500; } let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; let channel_type = ChannelTypeFeatures::anchors_zero_fee_commitments(); @@ -3276,7 +3282,8 @@ fn test_0reserve_zero_conf_combined() { let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let mut config = test_default_channel_config(); - config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); diff --git a/lightning/src/ln/monitor_tests.rs b/lightning/src/ln/monitor_tests.rs index efd2084a38e..f52f093917b 100644 --- a/lightning/src/ln/monitor_tests.rs +++ b/lightning/src/ln/monitor_tests.rs @@ -2724,6 +2724,8 @@ fn do_test_anchors_aggregated_revoked_htlc_tx(p2a_anchor: bool) { anchors_config.channel_handshake_config.announce_for_forwarding = true; anchors_config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; anchors_config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = p2a_anchor; + // Set the percentage to the default value at the time this test was written + anchors_config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = 10; let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(anchors_config.clone()), Some(anchors_config.clone())]); let bob_deserialized; diff --git a/lightning/src/ln/payment_tests.rs b/lightning/src/ln/payment_tests.rs index be52459a872..807d1a1af39 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -45,7 +45,7 @@ use crate::sign::EntropySource; use crate::types::features::{Bolt11InvoiceFeatures, ChannelTypeFeatures}; use crate::types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; use crate::types::string::UntrustedString; -use crate::util::config::HTLCInterceptionFlags; +use crate::util::config::{HTLCInterceptionFlags, UserConfig}; use crate::util::errors::APIError; use crate::util::ser::Writeable; use bitcoin::hashes::sha256::Hash as Sha256; @@ -212,7 +212,9 @@ fn mpp_retry_overpay() { let node_cfgs = create_node_cfgs(4, &chanmon_cfgs); let mut user_config = test_legacy_channel_config(); - user_config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + user_config + .channel_handshake_config + .announced_channel_max_inbound_htlc_value_in_flight_percentage = 100; let mut limited_1 = user_config.clone(); limited_1.channel_handshake_config.our_htlc_minimum_msat = 35_000_000; let mut limited_2 = user_config.clone(); @@ -487,7 +489,12 @@ fn do_test_keysend_payments(public_node: bool) { fn test_mpp_keysend() { let chanmon_cfgs = create_chanmon_cfgs(4); let node_cfgs = create_node_cfgs(4, &chanmon_cfgs); - let node_chanmgrs = create_node_chanmgrs(4, &node_cfgs, &[None, None, None, None]); + let mut config = test_default_channel_config(); + // Set the percentage to the default value at the time this test was written + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 10; + let configs: [Option; 4] = core::array::from_fn(|_| Some(config.clone())); + let node_chanmgrs = create_node_chanmgrs(4, &node_cfgs, &configs); let nodes = create_network(4, &node_cfgs, &node_chanmgrs); let node_b_id = nodes[1].node.get_our_node_id(); @@ -1702,7 +1709,8 @@ fn preflight_probes_yield_event_skip_private_hop() { // We alleviate the HTLC max-in-flight limit, as otherwise we'd always be limited through that. let mut config = test_default_channel_config(); - config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; let config = Some(config); let configs = [config.clone(), config.clone(), config.clone(), config.clone(), config]; @@ -1749,7 +1757,8 @@ fn preflight_probes_yield_event() { // We alleviate the HTLC max-in-flight limit, as otherwise we'd always be limited through that. let mut config = test_default_channel_config(); - config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; let config = Some(config); let configs = [config.clone(), config.clone(), config.clone(), config]; @@ -1800,7 +1809,8 @@ fn preflight_probes_yield_event_and_skip() { // We alleviate the HTLC max-in-flight limit, as otherwise we'd always be limited through that. let mut config = test_default_channel_config(); - config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; let config = Some(config); let configs = @@ -2429,11 +2439,12 @@ fn do_accept_underpaying_htlcs_config(num_mpp_parts: usize) { HTLCInterceptionFlags::ToInterceptSCIDs as u8; intercept_forwards_config .channel_handshake_config - .max_inbound_htlc_value_in_flight_percent_of_channel = max_in_flight_percent; + .announced_channel_max_inbound_htlc_value_in_flight_percentage = max_in_flight_percent; let mut underpay_config = test_default_channel_config(); underpay_config.channel_config.accept_underpaying_htlcs = true; - underpay_config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = - max_in_flight_percent; + underpay_config + .channel_handshake_config + .unannounced_channel_max_inbound_htlc_value_in_flight_percentage = max_in_flight_percent; let configs = [None, Some(intercept_forwards_config), Some(underpay_config)]; let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &configs); @@ -3220,7 +3231,12 @@ fn retry_multi_path_single_failed_payment() { // Tests that we can/will retry after a single path of an MPP payment failed immediately let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None, None]); + let mut config = test_default_channel_config(); + // Set the percentage to the default value at the time this test was written + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 10; + let node_chanmgrs = + create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config.clone())]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let node_b_id = nodes[1].node.get_our_node_id(); @@ -3339,7 +3355,12 @@ fn immediate_retry_on_failure() { // Tests that we can/will retry immediately after a failure let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None, None]); + let mut config = test_default_channel_config(); + // Set the percentage to the default value at the time this test was written + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 10; + let node_chanmgrs = + create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config.clone())]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let node_b_id = nodes[1].node.get_our_node_id(); @@ -4240,17 +4261,13 @@ fn do_claim_from_closed_chan(fail_payment: bool) { // CLTVs on the paths to different value resulting in a different claim deadline. let chanmon_cfgs = create_chanmon_cfgs(4); let node_cfgs = create_node_cfgs(4, &chanmon_cfgs); - let legacy_cfg = test_legacy_channel_config(); - let node_chanmgrs = create_node_chanmgrs( - 4, - &node_cfgs, - &[ - Some(legacy_cfg.clone()), - Some(legacy_cfg.clone()), - Some(legacy_cfg.clone()), - Some(legacy_cfg), - ], - ); + let mut legacy_cfg = test_legacy_channel_config(); + // Set the percentage to the default value at the time this test was written + legacy_cfg + .channel_handshake_config + .announced_channel_max_inbound_htlc_value_in_flight_percentage = 10; + let configs: [Option; 4] = core::array::from_fn(|_| Some(legacy_cfg.clone())); + let node_chanmgrs = create_node_chanmgrs(4, &node_cfgs, &configs); let mut nodes = create_network(4, &node_cfgs, &node_chanmgrs); let node_a_id = nodes[0].node.get_our_node_id(); @@ -4635,7 +4652,12 @@ fn do_test_custom_tlvs_consistency( ) { let chanmon_cfgs = create_chanmon_cfgs(4); let node_cfgs = create_node_cfgs(4, &chanmon_cfgs); - let node_chanmgrs = create_node_chanmgrs(4, &node_cfgs, &[None, None, None, None]); + let mut config = test_default_channel_config(); + // Set the percentage to the default value at the time this test was written + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 10; + let configs: [Option; 4] = core::array::from_fn(|_| Some(config.clone())); + let node_chanmgrs = create_node_chanmgrs(4, &node_cfgs, &configs); let nodes = create_network(4, &node_cfgs, &node_chanmgrs); let node_a_id = nodes[0].node.get_our_node_id(); @@ -4788,7 +4810,8 @@ fn do_test_payment_metadata_consistency(do_reload: bool, do_modify: bool) { let chain_mon; let mut config = test_default_channel_config(); - config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 50; + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 50; let configs = [None, Some(config.clone()), Some(config.clone()), Some(config.clone())]; let node_chanmgrs = create_node_chanmgrs(4, &node_cfgs, &configs); let node_d_reload; @@ -5147,7 +5170,8 @@ fn test_non_strict_forwarding() { let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); let mut config = test_legacy_channel_config(); - config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; let configs = [Some(config.clone()), Some(config.clone()), Some(config)]; let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &configs); @@ -5386,11 +5410,15 @@ fn max_out_mpp_path() { let mut user_cfg = test_default_channel_config(); user_cfg.channel_config.forwarding_fee_base_msat = 0; - user_cfg.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + user_cfg + .channel_handshake_config + .unannounced_channel_max_inbound_htlc_value_in_flight_percentage = 100; let mut lsp_cfg = test_default_channel_config(); lsp_cfg.channel_config.forwarding_fee_base_msat = 0; lsp_cfg.channel_config.forwarding_fee_proportional_millionths = 3000; - lsp_cfg.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + lsp_cfg + .channel_handshake_config + .unannounced_channel_max_inbound_htlc_value_in_flight_percentage = 100; let chanmon_cfgs = create_chanmon_cfgs(3); let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); diff --git a/lightning/src/ln/reload_tests.rs b/lightning/src/ln/reload_tests.rs index 892a6c62d8f..9e992467ecd 100644 --- a/lightning/src/ln/reload_tests.rs +++ b/lightning/src/ln/reload_tests.rs @@ -745,7 +745,11 @@ fn do_test_partial_claim_before_restart(persist_both_monitors: bool, double_rest let (persist_d_1, persist_d_2); let (chain_d_1, chain_d_2); - let node_chanmgrs = create_node_chanmgrs(4, &node_cfgs, &[None, None, None, None]); + let mut config = test_default_channel_config(); + // Set the percentage to the default value at the time this test was written + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = 10; + let configs: [Option; 4] = core::array::from_fn(|_| Some(config.clone())); + let node_chanmgrs = create_node_chanmgrs(4, &node_cfgs, &configs); let (node_d_1, node_d_2); let mut nodes = create_network(4, &node_cfgs, &node_chanmgrs); @@ -2107,7 +2111,11 @@ fn test_reload_with_mpp_claims_on_same_channel() { let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); let persister; let new_chain_monitor; - let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]); + let mut config = test_default_channel_config(); + // Set the percentage to the default value at the time this test was written + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = 10; + let configs: [Option; 3] = core::array::from_fn(|_| Some(config.clone())); + let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &configs); let nodes_1_deserialized; let mut nodes = create_network(3, &node_cfgs, &node_chanmgrs); diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 5279f2dfcc0..e4c4c10dbbd 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -1122,7 +1122,8 @@ fn test_splice_in() { let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let mut config = test_default_channel_config(); - config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, Some(config)]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); @@ -1172,7 +1173,8 @@ fn test_splice_out() { let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let mut config = test_default_channel_config(); - config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, Some(config)]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); @@ -1215,7 +1217,8 @@ fn test_splice_in_and_out() { let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let mut config = test_default_channel_config(); - config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, Some(config)]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); @@ -3546,7 +3549,8 @@ fn test_splice_balance_falls_below_reserve() { let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let mut config = test_default_channel_config(); - config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); @@ -4066,19 +4070,22 @@ fn test_funding_contributed_unfunded_channel() { #[test] fn test_splice_pending_htlcs() { let mut config = test_default_channel_config(); - config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; do_test_splice_pending_htlcs(config); let mut config = test_default_channel_config(); - config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; do_test_splice_pending_htlcs(config); let mut config = test_default_channel_config(); - config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = true; do_test_splice_pending_htlcs(config); @@ -6298,7 +6305,8 @@ fn test_splice_revalidation_at_quiescence() { let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let mut config = test_default_channel_config(); - config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); diff --git a/lightning/src/ln/update_fee_tests.rs b/lightning/src/ln/update_fee_tests.rs index fc80059bbd3..b1f8257088e 100644 --- a/lightning/src/ln/update_fee_tests.rs +++ b/lightning/src/ln/update_fee_tests.rs @@ -1031,7 +1031,8 @@ pub fn do_cannot_afford_on_holding_cell_release( let chanmon_cfgs = create_chanmon_cfgs(2); let mut cfg = test_legacy_channel_config(); - cfg.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + cfg.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; if channel_type_features.supports_anchors_zero_fee_htlc_tx() { cfg.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; } @@ -1225,7 +1226,9 @@ pub fn do_can_afford_given_trimmed_htlcs(inequality_regions: core::cmp::Ordering let chanmon_cfgs = create_chanmon_cfgs(2); let mut legacy_cfg = test_legacy_channel_config(); - legacy_cfg.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + legacy_cfg + .channel_handshake_config + .announced_channel_max_inbound_htlc_value_in_flight_percentage = 100; let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = diff --git a/lightning/src/util/config.rs b/lightning/src/util/config.rs index 14c507184ac..ebef8c27bca 100644 --- a/lightning/src/util/config.rs +++ b/lightning/src/util/config.rs @@ -62,17 +62,35 @@ pub struct ChannelHandshakeConfig { /// Default value: `1` (If the value is less than `1`, it is ignored and set to `1`, as is /// required by the protocol. pub our_htlc_minimum_msat: u64, - /// Sets the percentage of the channel value we will cap the total value of outstanding inbound - /// HTLCs to. + /// Sets the maximum percentage of the total channel value that can be allocated to inbound + /// HTLCs in announced channels. /// /// This can be set to a value between 1-100, where the value corresponds to the percent of the /// channel value in whole percentages. /// /// Note that: - /// * If configured to another value than the default value `10`, any new channels created with - /// the non default value will cause versions of LDK prior to 0.0.104 to refuse to read the - /// `ChannelManager`. + /// * This caps the total value for inbound HTLCs in-flight only, and there's currently + /// no way to configure the cap for the total value of outbound HTLCs in-flight. + /// + /// * The requirements for your node being online to ensure the safety of HTLC-encumbered funds + /// are different from the non-HTLC-encumbered funds. This makes this an important knob to + /// restrict exposure to loss due to being offline for too long. + /// See [`ChannelHandshakeConfig::our_to_self_delay`] and [`ChannelConfig::cltv_expiry_delta`] + /// for more information. + /// + /// Default value: `25` /// + /// Minimum value: `1` (Any values less will be treated as `1` instead.) + /// + /// Maximum value: `100` (Any values larger will be treated as `100` instead.) + pub announced_channel_max_inbound_htlc_value_in_flight_percentage: u8, + /// Sets the maximum percentage of the total channel value that can be allocated to inbound + /// HTLCs in unannounced channels. + /// + /// This can be set to a value between 1-100, where the value corresponds to the percent of the + /// channel value in whole percentages. + /// + /// Note that: /// * This caps the total value for inbound HTLCs in-flight only, and there's currently /// no way to configure the cap for the total value of outbound HTLCs in-flight. /// @@ -82,12 +100,12 @@ pub struct ChannelHandshakeConfig { /// See [`ChannelHandshakeConfig::our_to_self_delay`] and [`ChannelConfig::cltv_expiry_delta`] /// for more information. /// - /// Default value: `10` + /// Default value: `100` /// /// Minimum value: `1` (Any values less will be treated as `1` instead.) /// /// Maximum value: `100` (Any values larger will be treated as `100` instead.) - pub max_inbound_htlc_value_in_flight_percent_of_channel: u8, + pub unannounced_channel_max_inbound_htlc_value_in_flight_percentage: u8, /// If set, we attempt to negotiate the `scid_privacy` (referred to as `scid_alias` in the /// BOLTs) option for outbound private channels. This provides better privacy by not including /// our real on-chain channel UTXO in each invoice and requiring that our counterparty only @@ -246,7 +264,8 @@ impl Default for ChannelHandshakeConfig { minimum_depth: 6, our_to_self_delay: BREAKDOWN_TIMEOUT, our_htlc_minimum_msat: 1, - max_inbound_htlc_value_in_flight_percent_of_channel: 10, + announced_channel_max_inbound_htlc_value_in_flight_percentage: 25, + unannounced_channel_max_inbound_htlc_value_in_flight_percentage: 100, negotiate_scid_privacy: false, announce_for_forwarding: false, commit_upfront_shutdown_pubkey: true, @@ -264,11 +283,21 @@ impl Default for ChannelHandshakeConfig { #[cfg(fuzzing)] impl Readable for ChannelHandshakeConfig { fn read(reader: &mut R) -> Result { + let minimum_depth = Readable::read(reader)?; + let our_to_self_delay = Readable::read(reader)?; + let our_htlc_minimum_msat = Readable::read(reader)?; + // Apply the same byte to both the announced and the unannounced maximums so as to + // not invalidate the existing fuzz corpus + let max_inbound_htlc_value_in_flight_percentage = Readable::read(reader)?; + Ok(Self { - minimum_depth: Readable::read(reader)?, - our_to_self_delay: Readable::read(reader)?, - our_htlc_minimum_msat: Readable::read(reader)?, - max_inbound_htlc_value_in_flight_percent_of_channel: Readable::read(reader)?, + minimum_depth, + our_to_self_delay, + our_htlc_minimum_msat, + announced_channel_max_inbound_htlc_value_in_flight_percentage: + max_inbound_htlc_value_in_flight_percentage, + unannounced_channel_max_inbound_htlc_value_in_flight_percentage: + max_inbound_htlc_value_in_flight_percentage, negotiate_scid_privacy: Readable::read(reader)?, announce_for_forwarding: Readable::read(reader)?, commit_upfront_shutdown_pubkey: Readable::read(reader)?, @@ -1171,9 +1200,15 @@ impl UserConfig { /// Config structure for overriding channel handshake parameters. #[derive(Default)] pub struct ChannelHandshakeConfigUpdate { - /// Overrides the percentage of the channel value we will cap the total value of outstanding inbound HTLCs to. See - /// [`ChannelHandshakeConfig::max_inbound_htlc_value_in_flight_percent_of_channel`]. - pub max_inbound_htlc_value_in_flight_percent_of_channel: Option, + /// Overrides the maximum percentage of the total channel value that can be allocated to inbound + /// HTLCs in announced channels. See + /// [`ChannelHandshakeConfig::announced_channel_max_inbound_htlc_value_in_flight_percentage`]. + pub announced_channel_max_inbound_htlc_value_in_flight_percentage: Option, + + /// Overrides the maximum percentage of the total channel value that can be allocated to inbound + /// HTLCs in unannounced channels. See + /// [`ChannelHandshakeConfig::unannounced_channel_max_inbound_htlc_value_in_flight_percentage`]. + pub unannounced_channel_max_inbound_htlc_value_in_flight_percentage: Option, /// Overrides the smallest value HTLC we will accept to process. See [`ChannelHandshakeConfig::our_htlc_minimum_msat`]. pub htlc_minimum_msat: Option, @@ -1199,9 +1234,17 @@ impl ChannelHandshakeConfig { /// Applies the provided handshake config update. pub fn apply(&mut self, config: &ChannelHandshakeConfigUpdate) { if let Some(max_in_flight_percent) = - config.max_inbound_htlc_value_in_flight_percent_of_channel + config.announced_channel_max_inbound_htlc_value_in_flight_percentage + { + self.announced_channel_max_inbound_htlc_value_in_flight_percentage = + max_in_flight_percent; + } + + if let Some(max_in_flight_percent) = + config.unannounced_channel_max_inbound_htlc_value_in_flight_percentage { - self.max_inbound_htlc_value_in_flight_percent_of_channel = max_in_flight_percent; + self.unannounced_channel_max_inbound_htlc_value_in_flight_percentage = + max_in_flight_percent; } if let Some(htlc_minimum_msat) = config.htlc_minimum_msat { From c1c98049ed96e0b4bc3ed96986c3de02d5dced18 Mon Sep 17 00:00:00 2001 From: Philip Kannegaard Hayes Date: Thu, 2 Apr 2026 12:31:44 -0700 Subject: [PATCH 272/627] chainmon: fixup stale docs mentioning removed funding_txo params --- lightning/src/chain/chainmonitor.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lightning/src/chain/chainmonitor.rs b/lightning/src/chain/chainmonitor.rs index 125f206bbea..8644301ceb4 100644 --- a/lightning/src/chain/chainmonitor.rs +++ b/lightning/src/chain/chainmonitor.rs @@ -691,7 +691,7 @@ where ret } - /// Gets the [`LockedChannelMonitor`] for a given funding outpoint, returning an `Err` if no + /// Gets the [`LockedChannelMonitor`] for a given channel ID, returning an `Err` if no /// such [`ChannelMonitor`] is currently being monitored for. /// /// Note that the result holds a mutex over our monitor set, and should not be held @@ -707,7 +707,7 @@ where } } - /// Lists the funding outpoint and channel ID of each [`ChannelMonitor`] being monitored. + /// Lists the channel ID of each [`ChannelMonitor`] being monitored. /// /// Note that [`ChannelMonitor`]s are not removed when a channel is closed as they are always /// monitoring for on-chain state resolutions. @@ -764,7 +764,7 @@ where /// Note that we don't care about calls to [`Persist::update_persisted_channel`] where no /// [`ChannelMonitorUpdate`] was provided. /// - /// Returns an [`APIError::APIMisuseError`] if `funding_txo` does not match any currently + /// Returns an [`APIError::APIMisuseError`] if `channel_id` does not match any currently /// registered [`ChannelMonitor`]s. pub fn channel_monitor_updated( &self, channel_id: ChannelId, completed_update_id: u64, From 884158d0914c64df4807c394e3155646dead478d Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 2 Apr 2026 22:25:53 +0000 Subject: [PATCH 273/627] Drop `ChannelManager`-built-in BIP 353 resolution logic Since we shipped the `bitcoin-payment-instructions` crate we generally expect downstream code to use that rather than doing BIP 353 DNS resolutions over onion messages directly from the `ChannelManager`. Thus, in 0.2 we marked `pay_for_offer_from_human_readable_name` deprecated and here remove it in favor of explicit references to the `bitcoin-payment-instructions` crate in documentation. --- lightning-dns-resolver/src/lib.rs | 164 +-------------------- lightning/src/ln/channelmanager.rs | 167 ++-------------------- lightning/src/ln/functional_test_utils.rs | 27 ---- lightning/src/ln/outbound_payment.rs | 61 +------- lightning/src/onion_message/messenger.rs | 45 ------ 5 files changed, 17 insertions(+), 447 deletions(-) diff --git a/lightning-dns-resolver/src/lib.rs b/lightning-dns-resolver/src/lib.rs index e9578844cf8..c6d583ab745 100644 --- a/lightning-dns-resolver/src/lib.rs +++ b/lightning-dns-resolver/src/lib.rs @@ -147,32 +147,21 @@ mod test { use super::*; use bitcoin::secp256k1::{self, PublicKey, Secp256k1}; - use bitcoin::Block; use lightning::blinded_path::message::{ BlindedMessagePath, MessageContext, MessageForwardNode, }; use lightning::blinded_path::NodeIdLookUp; - use lightning::events::{Event, PaymentPurpose}; - use lightning::ln::channelmanager::{OptionalOfferPaymentParams, PaymentId}; - use lightning::ln::functional_test_utils::*; - use lightning::ln::msgs::{ - BaseMessageHandler, ChannelMessageHandler, Init, OnionMessageHandler, - }; - use lightning::offers::offer::Offer; + use lightning::ln::channelmanager::PaymentId; + use lightning::ln::msgs::{BaseMessageHandler, Init, OnionMessageHandler}; use lightning::onion_message::dns_resolution::{HumanReadableName, OMNameResolver}; use lightning::onion_message::messenger::{ AOnionMessenger, Destination, MessageRouter, OnionMessagePath, OnionMessenger, }; - use lightning::routing::router::DEFAULT_PAYMENT_DUMMY_HOPS; use lightning::sign::{KeysManager, NodeSigner, ReceiveAuthKey, Recipient}; use lightning::types::features::InitFeatures; - use lightning::types::payment::PaymentHash; use lightning::util::logger::Logger; - use lightning::expect_payment_claimed; - use lightning_types::string::UntrustedString; - use std::sync::Mutex; use std::time::{Duration, Instant, SystemTime}; @@ -348,153 +337,4 @@ mod test { assert_eq!(resolution.1, payment_id); assert!(resolution.2[.."bitcoin:".len()].eq_ignore_ascii_case("bitcoin:")); } - - async fn pay_offer_flow<'a, 'b, 'c>( - nodes: &[Node<'a, 'b, 'c>], resolver_messenger: &impl AOnionMessenger, - resolver_id: PublicKey, payer_id: PublicKey, payee_id: PublicKey, offer: Offer, - name: HumanReadableName, payment_id: PaymentId, payer_note: Option, - resolvers: Vec, - ) { - // Override contents to offer provided - let proof_override = &nodes[0].node.testing_dnssec_proof_offer_resolution_override; - proof_override.lock().unwrap().insert(name.clone(), offer); - let amt = 42_000; - let mut opts = OptionalOfferPaymentParams::default(); - opts.payer_note = payer_note.clone(); - #[allow(deprecated)] - nodes[0] - .node - .pay_for_offer_from_human_readable_name(name, amt, payment_id, opts, resolvers) - .unwrap(); - - let query = nodes[0].onion_messenger.next_onion_message_for_peer(resolver_id).unwrap(); - resolver_messenger.get_om().handle_onion_message(payer_id, &query); - - assert!(resolver_messenger.get_om().next_onion_message_for_peer(payer_id).is_none()); - let start = Instant::now(); - let response = loop { - tokio::time::sleep(Duration::from_millis(10)).await; - if let Some(msg) = resolver_messenger.get_om().next_onion_message_for_peer(payer_id) { - break msg; - } - assert!(start.elapsed() < Duration::from_secs(10), "Resolution took too long"); - }; - - nodes[0].onion_messenger.handle_onion_message(resolver_id, &response); - - let invreq = nodes[0].onion_messenger.next_onion_message_for_peer(payee_id).unwrap(); - nodes[1].onion_messenger.handle_onion_message(payer_id, &invreq); - - let inv = nodes[1].onion_messenger.next_onion_message_for_peer(payer_id).unwrap(); - nodes[0].onion_messenger.handle_onion_message(payee_id, &inv); - - check_added_monitors(&nodes[0], 1); - let updates = get_htlc_update_msgs(&nodes[0], &payee_id); - nodes[1].node.handle_update_add_htlc(payer_id, &updates.update_add_htlcs[0]); - do_commitment_signed_dance(&nodes[1], &nodes[0], &updates.commitment_signed, false, false); - - for _ in 0..DEFAULT_PAYMENT_DUMMY_HOPS { - assert!(nodes[1].node.needs_pending_htlc_processing()); - nodes[1].node.process_pending_htlc_forwards(); - } - - expect_and_process_pending_htlcs(&nodes[1], false); - - let claimable_events = nodes[1].node.get_and_clear_pending_events(); - assert_eq!(claimable_events.len(), 1); - let our_payment_preimage; - if let Event::PaymentClaimable { purpose, amount_msat, .. } = &claimable_events[0] { - assert_eq!(*amount_msat, amt); - if let PaymentPurpose::Bolt12OfferPayment { - payment_preimage, payment_context, .. - } = purpose - { - our_payment_preimage = payment_preimage.unwrap(); - nodes[1].node.claim_funds(our_payment_preimage); - let payment_hash: PaymentHash = our_payment_preimage.into(); - expect_payment_claimed!(nodes[1], payment_hash, amt); - if let Some(note) = payer_note { - assert_eq!( - payment_context.invoice_request.payer_note_truncated, - Some(UntrustedString(note.into())) - ); - } else { - assert_eq!(payment_context.invoice_request.payer_note_truncated, None); - } - } else { - panic!(); - } - } else { - panic!(); - } - - check_added_monitors(&nodes[1], 1); - let mut updates = get_htlc_update_msgs(&nodes[1], &payer_id); - nodes[0].node.handle_update_fulfill_htlc(payee_id, updates.update_fulfill_htlcs.remove(0)); - do_commitment_signed_dance(&nodes[0], &nodes[1], &updates.commitment_signed, false, false); - - expect_payment_sent(&nodes[0], our_payment_preimage, None, true, true); - } - - #[tokio::test] - async fn end_to_end_test() { - let chanmon_cfgs = create_chanmon_cfgs(2); - let node_cfgs = create_node_cfgs_with_node_id_message_router(2, &chanmon_cfgs); - let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); - let nodes = create_network(2, &node_cfgs, &node_chanmgrs); - - create_announced_chan_between_nodes(&nodes, 0, 1); - - // The DNSSEC validation will only work with the current time, so set the time on the - // resolver. - let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs(); - let block = Block { - header: create_dummy_header(nodes[0].best_block_hash(), now as u32), - txdata: Vec::new(), - }; - connect_block(&nodes[0], &block); - connect_block(&nodes[1], &block); - - let payer_id = nodes[0].node.get_our_node_id(); - let payee_id = nodes[1].node.get_our_node_id(); - - let (resolver_messenger, resolver_id) = create_resolver(); - let init_msg = get_om_init(); - nodes[0].onion_messenger.peer_connected(resolver_id, &init_msg, true).unwrap(); - resolver_messenger.get_om().peer_connected(payer_id, &init_msg, false).unwrap(); - - let name = HumanReadableName::from_encoded("matt@mattcorallo.com").unwrap(); - - let bs_offer = nodes[1].node.create_offer_builder().unwrap().build().unwrap(); - let resolvers = vec![Destination::Node(resolver_id)]; - - pay_offer_flow( - &nodes, - &resolver_messenger, - resolver_id, - payer_id, - payee_id, - bs_offer.clone(), - name.clone(), - PaymentId([42; 32]), - None, - resolvers.clone(), - ) - .await; - - // Pay offer with payer_note - pay_offer_flow( - &nodes, - &resolver_messenger, - resolver_id, - payer_id, - payee_id, - bs_offer, - name, - PaymentId([21; 32]), - Some("foo".into()), - resolvers, - ) - .await; - } } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 2e782701e47..660875400c7 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -142,15 +142,6 @@ use crate::util::wakers::{Future, Notifier}; #[cfg(test)] use crate::blinded_path::payment::BlindedPaymentPath; -#[cfg(feature = "dnssec")] -use { - crate::blinded_path::message::DNSResolverContext, - crate::onion_message::dns_resolution::{ - DNSResolverMessage, DNSResolverMessageHandler, DNSSECProof, DNSSECQuery, - }, - crate::onion_message::messenger::Destination, -}; - #[cfg(c_bindings)] use { crate::offers::offer::OfferWithDerivedMetadataBuilder, @@ -715,12 +706,7 @@ impl Default for OptionalBolt11PaymentParams { } } -/// Optional arguments to [`ChannelManager::pay_for_offer`] -#[cfg_attr( - feature = "dnssec", - doc = "and [`ChannelManager::pay_for_offer_from_human_readable_name`]" -)] -/// . +/// Optional arguments to [`ChannelManager::pay_for_offer`]. /// /// These fields will often not need to be set, and the provided [`Self::default`] can be used. pub struct OptionalOfferPaymentParams { @@ -2942,14 +2928,6 @@ pub struct ChannelManager< /// [`ConfirmationTarget::MinAllowedNonAnchorChannelRemoteFee`] estimate. last_days_feerates: Mutex>, - #[cfg(feature = "_test_utils")] - /// In testing, it is useful be able to forge a name -> offer mapping so that we can pay an - /// offer generated in the test. - /// - /// This allows for doing so, validating proofs as normal, but, if they pass, replacing the - /// offer they resolve to to the given one. - pub testing_dnssec_proof_offer_resolution_override: Mutex>, - #[cfg(test)] pub(super) entropy_source: ES, #[cfg(not(test))] @@ -3680,9 +3658,6 @@ impl< signer_provider, logger, - - #[cfg(feature = "_test_utils")] - testing_dnssec_proof_offer_resolution_override: Mutex::new(new_hash_map()), } } @@ -5704,6 +5679,12 @@ impl< /// # Custom Routing Parameters /// Users can customize routing parameters via [`RouteParametersConfig`]. /// To use default settings, call the function with [`RouteParametersConfig::default`]. + /// + /// In general, you should use the + /// [`bitcoin-payment-instructions` crate](https://docs.rs/bitcoin-payment-instructions/) to + /// resolve payment instructions strings (e.g. from QR codes, link opens, pasted instructions, + /// or typed instructions) into payment instructions and use this when the instructions resolve + /// to a BOLT 11 invoice. pub fn pay_for_bolt11_invoice( &self, invoice: &Bolt11Invoice, payment_id: PaymentId, amount_msats: Option, optional_params: OptionalBolt11PaymentParams, @@ -14588,6 +14569,12 @@ impl< /// - the parameterized [`Router`] is unable to create a blinded reply path for the invoice /// request. /// + /// In general, you should use the + /// [`bitcoin-payment-instructions` crate](https://docs.rs/bitcoin-payment-instructions/) to + /// resolve payment instructions strings (e.g. from QR codes, link opens, pasted instructions, + /// or typed instructions) into payment instructions and use this when the instructions resolve + /// to a BOLT 12 offer. + /// /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest /// [`InvoiceRequestBuilder`]: crate::offers::invoice_request::InvoiceRequestBuilder /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice @@ -14787,72 +14774,6 @@ impl< Ok(invoice) } - /// Pays for an [`Offer`] looked up using [BIP 353] Human Readable Names resolved by the DNS - /// resolver(s) at `dns_resolvers` which resolve names according to [bLIP 32]. - /// - /// Because most wallets support on-chain or other payment schemes beyond only offers, this is - /// deprecated in favor of the [`bitcoin-payment-instructions`] crate, which can be used to - /// build an [`OfferFromHrn`] and call [`Self::pay_for_offer_from_hrn`]. Thus, this method is - /// deprecated. - /// - /// # Payment - /// - /// The provided `payment_id` is used to ensure that only one invoice is paid for the request - /// when received. See [Avoiding Duplicate Payments] for other requirements once the payment has - /// been sent. - /// - /// To revoke the request, use [`ChannelManager::abandon_payment`] prior to receiving the - /// invoice. If abandoned, or an invoice isn't received in a reasonable amount of time, the - /// payment will fail with an [`PaymentFailureReason::UserAbandoned`] or - /// [`PaymentFailureReason::InvoiceRequestExpired`], respectively. - /// - /// # Privacy - /// - /// For payer privacy, uses a derived payer id and uses [`MessageRouter::create_blinded_paths`] - /// to construct a [`BlindedMessagePath`] for the reply path. - /// - /// # Errors - /// - /// Errors if a duplicate `payment_id` is provided given the caveats in the aforementioned link. - /// - /// [BIP 353]: https://github.com/bitcoin/bips/blob/master/bip-0353.mediawiki - /// [bLIP 32]: https://github.com/lightning/blips/blob/master/blip-0032.md - /// [`OMNameResolver::resolve_name`]: crate::onion_message::dns_resolution::OMNameResolver::resolve_name - /// [`OMNameResolver::handle_dnssec_proof_for_uri`]: crate::onion_message::dns_resolution::OMNameResolver::handle_dnssec_proof_for_uri - /// [`bitcoin-payment-instructions`]: https://docs.rs/bitcoin-payment-instructions/ - /// [Avoiding Duplicate Payments]: #avoiding-duplicate-payments - /// [`BlindedMessagePath`]: crate::blinded_path::message::BlindedMessagePath - /// [`PaymentFailureReason::UserAbandoned`]: crate::events::PaymentFailureReason::UserAbandoned - /// [`PaymentFailureReason::InvoiceRequestRejected`]: crate::events::PaymentFailureReason::InvoiceRequestRejected - #[cfg(feature = "dnssec")] - #[deprecated(note = "Use bitcoin-payment-instructions and pay_for_offer_from_hrn instead")] - pub fn pay_for_offer_from_human_readable_name( - &self, name: HumanReadableName, amount_msats: u64, payment_id: PaymentId, - optional_params: OptionalOfferPaymentParams, dns_resolvers: Vec, - ) -> Result<(), ()> { - let (onion_message, context) = - self.flow.hrn_resolver.resolve_name(payment_id, name, &self.entropy_source)?; - - let expiration = StaleExpiration::TimerTicks(1); - self.pending_outbound_payments.add_new_awaiting_offer( - payment_id, - expiration, - optional_params.retry_strategy, - optional_params.route_params_config, - amount_msats, - optional_params.payer_note, - )?; - - self.flow - .enqueue_dns_onion_message( - onion_message, - context, - dns_resolvers, - self.get_peers_for_blinded_path(), - ) - .map_err(|_| ()) - } - /// Gets a payment secret and payment hash for use in an invoice given to a third party wishing /// to pay us. /// @@ -17398,65 +17319,6 @@ impl< } } -#[cfg(feature = "dnssec")] -impl< - M: chain::Watch, - T: BroadcasterInterface, - ES: EntropySource, - NS: NodeSigner, - SP: SignerProvider, - F: FeeEstimator, - R: Router, - MR: MessageRouter, - L: Logger, - > DNSResolverMessageHandler for ChannelManager -{ - fn handle_dnssec_query( - &self, _message: DNSSECQuery, _responder: Option, - ) -> Option<(DNSResolverMessage, ResponseInstruction)> { - None - } - - #[rustfmt::skip] - fn handle_dnssec_proof(&self, message: DNSSECProof, context: DNSResolverContext) { - let offer_opt = self.flow.hrn_resolver.handle_dnssec_proof_for_offer(message, context); - #[cfg_attr(not(feature = "_test_utils"), allow(unused_mut))] - if let Some((completed_requests, mut offer)) = offer_opt { - for (name, payment_id) in completed_requests { - #[cfg(feature = "_test_utils")] - if let Some(replacement_offer) = self.testing_dnssec_proof_offer_resolution_override.lock().unwrap().remove(&name) { - // If we have multiple pending requests we may end up over-using the override - // offer, but tests can deal with that. - offer = replacement_offer; - } - if let Ok((amt_msats, payer_note)) = self.pending_outbound_payments.params_for_payment_awaiting_offer(payment_id) { - let offer_pay_res = - self.pay_for_offer_intern(&offer, None, Some(amt_msats), payer_note, payment_id, Some(name), - |retryable_invoice_request| { - self.pending_outbound_payments - .received_offer(payment_id, Some(retryable_invoice_request)) - .map_err(|_| Bolt12SemanticError::DuplicatePaymentId) - }); - if offer_pay_res.is_err() { - // The offer we tried to pay is the canonical current offer for the name we - // wanted to pay. If we can't pay it, there's no way to recover so fail the - // payment. - // Note that the PaymentFailureReason should be ignored for an - // AwaitingInvoice payment. - self.pending_outbound_payments.abandon_payment( - payment_id, PaymentFailureReason::RouteNotFound, &self.pending_events, - ); - } - } - } - } - } - - fn release_pending_messages(&self) -> Vec<(DNSResolverMessage, MessageSendInstructions)> { - self.flow.release_pending_dns_messages() - } -} - impl< M: chain::Watch, T: BroadcasterInterface, @@ -20233,9 +20095,6 @@ impl< logger: args.logger, config: RwLock::new(args.config), - - #[cfg(feature = "_test_utils")] - testing_dnssec_proof_offer_resolution_override: Mutex::new(new_hash_map()), }; let mut processed_claims: HashSet> = new_hash_set(); diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 84cdf785da5..d39cee78b0f 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -521,7 +521,6 @@ pub type TestChannelManager<'node_cfg, 'chan_mon_cfg> = ChannelManager< &'chan_mon_cfg test_utils::TestLogger, >; -#[cfg(not(feature = "dnssec"))] type TestOnionMessenger<'chan_man, 'node_cfg, 'chan_mon_cfg> = OnionMessenger< DedicatedEntropy, &'node_cfg test_utils::TestKeysInterface, @@ -534,19 +533,6 @@ type TestOnionMessenger<'chan_man, 'node_cfg, 'chan_mon_cfg> = OnionMessenger< IgnoringMessageHandler, >; -#[cfg(feature = "dnssec")] -type TestOnionMessenger<'chan_man, 'node_cfg, 'chan_mon_cfg> = OnionMessenger< - DedicatedEntropy, - &'node_cfg test_utils::TestKeysInterface, - &'chan_mon_cfg test_utils::TestLogger, - &'chan_man TestChannelManager<'node_cfg, 'chan_mon_cfg>, - &'node_cfg test_utils::TestMessageRouter<'chan_mon_cfg>, - &'chan_man TestChannelManager<'node_cfg, 'chan_mon_cfg>, - &'chan_man TestChannelManager<'node_cfg, 'chan_mon_cfg>, - &'chan_man TestChannelManager<'node_cfg, 'chan_mon_cfg>, - IgnoringMessageHandler, ->; - /// For use with [`OnionMessenger`] otherwise `test_restored_packages_retry` will fail. This is /// because that test uses older serialized data produced by calling [`EntropySource`] in a specific /// manner. Using the same [`EntropySource`] with [`OnionMessenger`] would introduce another call, @@ -4797,19 +4783,6 @@ pub fn create_network<'a, 'b: 'a, 'c: 'b>( for i in 0..node_count { let dedicated_entropy = DedicatedEntropy(RandomBytes::new([i as u8; 32])); - #[cfg(feature = "dnssec")] - let onion_messenger = OnionMessenger::new_with_offline_peer_interception( - dedicated_entropy, - cfgs[i].keys_manager, - cfgs[i].logger, - &chan_mgrs[i], - &cfgs[i].message_router, - &chan_mgrs[i], - &chan_mgrs[i], - &chan_mgrs[i], - IgnoringMessageHandler {}, - ); - #[cfg(not(feature = "dnssec"))] let onion_messenger = OnionMessenger::new_with_offline_peer_interception( dedicated_entropy, cfgs[i].keys_manager, diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 9241e6ccf7c..7259f60796f 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -2033,65 +2033,6 @@ impl OutboundPayments { (payment, onion_session_privs) } - #[cfg(feature = "dnssec")] - pub(super) fn add_new_awaiting_offer( - &self, payment_id: PaymentId, expiration: StaleExpiration, retry_strategy: Retry, - route_params_config: RouteParametersConfig, amount_msats: u64, payer_note: Option, - ) -> Result<(), ()> { - let mut pending_outbounds = self.pending_outbound_payments.lock().unwrap(); - match pending_outbounds.entry(payment_id) { - hash_map::Entry::Occupied(_) => Err(()), - hash_map::Entry::Vacant(entry) => { - entry.insert(PendingOutboundPayment::AwaitingOffer { - expiration, - retry_strategy, - route_params_config, - amount_msats, - payer_note, - }); - - Ok(()) - }, - } - } - - #[cfg(feature = "dnssec")] - #[rustfmt::skip] - pub(super) fn params_for_payment_awaiting_offer(&self, payment_id: PaymentId) -> Result<(u64, Option), ()> { - match self.pending_outbound_payments.lock().unwrap().entry(payment_id) { - hash_map::Entry::Occupied(entry) => match entry.get() { - PendingOutboundPayment::AwaitingOffer { amount_msats, payer_note, .. } => Ok((*amount_msats, payer_note.clone())), - _ => Err(()), - }, - _ => Err(()), - } - } - - #[cfg(feature = "dnssec")] - #[rustfmt::skip] - pub(super) fn received_offer( - &self, payment_id: PaymentId, retryable_invoice_request: Option, - ) -> Result<(), ()> { - match self.pending_outbound_payments.lock().unwrap().entry(payment_id) { - hash_map::Entry::Occupied(entry) => match entry.get() { - PendingOutboundPayment::AwaitingOffer { - expiration, retry_strategy, route_params_config, .. - } => { - let mut new_val = PendingOutboundPayment::AwaitingInvoice { - expiration: *expiration, - retry_strategy: *retry_strategy, - route_params_config: *route_params_config, - retryable_invoice_request, - }; - core::mem::swap(&mut new_val, entry.into_mut()); - Ok(()) - }, - _ => Err(()), - }, - hash_map::Entry::Vacant(_) => Err(()), - } - } - pub(super) fn add_new_awaiting_invoice( &self, payment_id: PaymentId, expiration: StaleExpiration, retry_strategy: Retry, route_params_config: RouteParametersConfig, @@ -2886,6 +2827,8 @@ impl_writeable_tlv_based_enum_upgradable!(PendingOutboundPayment, }, // Added in 0.1. Prior versions will drop these outbounds on downgrade, which is safe because // no HTLCs are in-flight. + // No longer created in 0.3 as we now expect BIP 353 to happen before a payment makes it into + // the `lightning` crate. (11, AwaitingOffer) => { (0, expiration, required), (2, retry_strategy, required), diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index f94eb7877f5..7ef4e4a66a8 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -2340,28 +2340,6 @@ impl< /// [`SimpleArcChannelManager`]: crate::ln::channelmanager::SimpleArcChannelManager /// [`SimpleArcPeerManager`]: crate::ln::peer_handler::SimpleArcPeerManager #[cfg(not(c_bindings))] -#[cfg(feature = "dnssec")] -pub type SimpleArcOnionMessenger = OnionMessenger< - Arc, - Arc, - Arc, - Arc>, - Arc>>, Arc, Arc>>, - Arc>, - Arc>, - Arc>, - IgnoringMessageHandler, ->; - -/// Useful for simplifying the parameters of [`SimpleArcChannelManager`] and -/// [`SimpleArcPeerManager`]. See their docs for more details. -/// -/// This is not exported to bindings users as type aliases aren't supported in most languages. -/// -/// [`SimpleArcChannelManager`]: crate::ln::channelmanager::SimpleArcChannelManager -/// [`SimpleArcPeerManager`]: crate::ln::peer_handler::SimpleArcPeerManager -#[cfg(not(c_bindings))] -#[cfg(not(feature = "dnssec"))] pub type SimpleArcOnionMessenger = OnionMessenger< Arc, Arc, @@ -2382,29 +2360,6 @@ pub type SimpleArcOnionMessenger = OnionMessenger< /// [`SimpleRefChannelManager`]: crate::ln::channelmanager::SimpleRefChannelManager /// [`SimpleRefPeerManager`]: crate::ln::peer_handler::SimpleRefPeerManager #[cfg(not(c_bindings))] -#[cfg(feature = "dnssec")] -pub type SimpleRefOnionMessenger<'a, 'b, 'c, 'd, 'e, 'f, 'g, 'h, 'i, 'j, M, T, F, L> = - OnionMessenger< - &'a KeysManager, - &'a KeysManager, - &'b L, - &'j SimpleRefChannelManager<'a, 'b, 'c, 'd, 'e, 'f, 'g, 'h, 'i, M, T, F, L>, - &'i DefaultMessageRouter<&'g NetworkGraph<&'b L>, &'b L, &'a KeysManager>, - &'j SimpleRefChannelManager<'a, 'b, 'c, 'd, 'e, 'f, 'g, 'h, 'i, M, T, F, L>, - &'j SimpleRefChannelManager<'a, 'b, 'c, 'd, 'e, 'f, 'g, 'h, 'i, M, T, F, L>, - &'j SimpleRefChannelManager<'a, 'b, 'c, 'd, 'e, 'f, 'g, 'h, 'i, M, T, F, L>, - IgnoringMessageHandler, - >; - -/// Useful for simplifying the parameters of [`SimpleRefChannelManager`] and -/// [`SimpleRefPeerManager`]. See their docs for more details. -/// -/// This is not exported to bindings users as type aliases aren't supported in most languages. -/// -/// [`SimpleRefChannelManager`]: crate::ln::channelmanager::SimpleRefChannelManager -/// [`SimpleRefPeerManager`]: crate::ln::peer_handler::SimpleRefPeerManager -#[cfg(not(c_bindings))] -#[cfg(not(feature = "dnssec"))] pub type SimpleRefOnionMessenger<'a, 'b, 'c, 'd, 'e, 'f, 'g, 'h, 'i, 'j, M, T, F, L> = OnionMessenger< &'a KeysManager, From 39029081ffb0cc00be9d391eb7dbb4ca20f525ee Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Fri, 3 Apr 2026 11:16:05 +0000 Subject: [PATCH 274/627] Correctly refer to `pay_for_offer_from_hrn` in `pay_for_offer` docs If a `pay_for_offer` call comes in that was for an HRN, downstream code should instead call `pay_for_offer_from_hrn`, not `pay_for_offer`. --- lightning/src/ln/channelmanager.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 660875400c7..9d93f443e32 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -14573,7 +14573,8 @@ impl< /// [`bitcoin-payment-instructions` crate](https://docs.rs/bitcoin-payment-instructions/) to /// resolve payment instructions strings (e.g. from QR codes, link opens, pasted instructions, /// or typed instructions) into payment instructions and use this when the instructions resolve - /// to a BOLT 12 offer. + /// to a BOLT 12 offer or [`Self::pay_for_offer_from_hrn`] when they resolve to a BOLT 12 offer + /// via a human-readable name. /// /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest /// [`InvoiceRequestBuilder`]: crate::offers::invoice_request::InvoiceRequestBuilder From dc06afa0f0c0ca185d1b592b1f9a4e669ab38f11 Mon Sep 17 00:00:00 2001 From: elnosh Date: Fri, 3 Apr 2026 13:04:49 -0400 Subject: [PATCH 275/627] Fix cltv_expiry_delta comment Fix incorrect comment about cltv_expiry_delta. The cltv_expiry on the outgoing HTLC must be less than the one in the incoming HTLC. --- lightning/src/ln/msgs.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/msgs.rs b/lightning/src/ln/msgs.rs index d49c57388a5..6210d26893a 100644 --- a/lightning/src/ln/msgs.rs +++ b/lightning/src/ln/msgs.rs @@ -1491,9 +1491,9 @@ pub struct UnsignedChannelUpdate { /// The number of blocks such that if: /// `incoming_htlc.cltv_expiry < outgoing_htlc.cltv_expiry + cltv_expiry_delta` /// then we need to fail the HTLC backwards. When forwarding an HTLC, `cltv_expiry_delta` determines - /// the outgoing HTLC's minimum `cltv_expiry` value -- so, if an incoming HTLC comes in with a + /// the outgoing HTLC's maximum `cltv_expiry` value -- so, if an incoming HTLC comes in with a /// `cltv_expiry` of 100000, and the node we're forwarding to has a `cltv_expiry_delta` value of 10, - /// then we'll check that the outgoing HTLC's `cltv_expiry` value is at least 100010 before + /// then we'll check that the outgoing HTLC's `cltv_expiry` value is at most 99990 before /// forwarding. Note that the HTLC sender is the one who originally sets this value when /// constructing the route. pub cltv_expiry_delta: u16, From 2637b387ad851e7763fafc16efce3caa71ca0618 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 7 Apr 2026 01:14:56 +0000 Subject: [PATCH 276/627] Drop stale DNSSec resolution logic in `OffersMessageFlow` In 884158d0914c64df4807c394e3155646dead478d we dropped built-in BIP 353 resolution logic in favor of the `bitcoin-payment-instructions` crate but forgot to do so in the `OffersMessageFlow`. Here we do so. --- lightning/src/offers/flow.rs | 65 ------------------------------------ 1 file changed, 65 deletions(-) diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index c1c3ce26aee..79e23321fd9 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -62,12 +62,6 @@ use crate::types::payment::{PaymentHash, PaymentSecret}; use crate::util::logger::Logger; use crate::util::ser::Writeable; -#[cfg(feature = "dnssec")] -use { - crate::blinded_path::message::DNSResolverContext, - crate::onion_message::dns_resolution::{DNSResolverMessage, DNSSECQuery, OMNameResolver}, -}; - /// A BOLT12 offers code and flow utility provider, which facilitates /// BOLT12 builder generation and onion message handling. /// @@ -94,11 +88,6 @@ pub struct OffersMessageFlow { pending_async_payments_messages: Mutex>, async_receive_offer_cache: Mutex, - #[cfg(feature = "dnssec")] - pub(crate) hrn_resolver: OMNameResolver, - #[cfg(feature = "dnssec")] - pending_dns_onion_messages: Mutex>, - logger: L, } @@ -126,11 +115,6 @@ impl OffersMessageFlow { pending_offers_messages: Mutex::new(Vec::new()), pending_async_payments_messages: Mutex::new(Vec::new()), - #[cfg(feature = "dnssec")] - hrn_resolver: OMNameResolver::new(current_timestamp, best_block.height), - #[cfg(feature = "dnssec")] - pending_dns_onion_messages: Mutex::new(Vec::new()), - async_receive_offer_cache: Mutex::new(AsyncReceiveOfferCache::new()), logger, @@ -220,12 +204,6 @@ impl OffersMessageFlow { break; } } - - #[cfg(feature = "dnssec")] - { - let updated_time = timestamp.load(Ordering::Acquire) as u32; - self.hrn_resolver.new_best_block(_height, updated_time); - } } } @@ -1306,41 +1284,6 @@ impl OffersMessageFlow { ) } - /// Enqueues the created [`DNSSECQuery`] to be sent to the counterparty. - /// - /// # Peers - /// - /// The user must provide a list of [`MessageForwardNode`] that will be used to generate - /// valid reply paths for the counterparty to send back the corresponding response for - /// the [`DNSSECQuery`] message. - /// - /// [`supports_onion_messages`]: crate::types::features::Features::supports_onion_messages - #[cfg(feature = "dnssec")] - pub fn enqueue_dns_onion_message( - &self, message: DNSSECQuery, context: DNSResolverContext, dns_resolvers: Vec, - peers: Vec, - ) -> Result<(), Bolt12SemanticError> { - let reply_paths = self - .create_blinded_paths(peers, MessageContext::DNSResolver(context)) - .map_err(|_| Bolt12SemanticError::MissingPaths)?; - - let message_params = dns_resolvers - .iter() - .flat_map(|destination| reply_paths.iter().map(move |path| (path, destination))) - .take(OFFERS_MESSAGE_REQUEST_LIMIT); - for (reply_path, destination) in message_params { - self.pending_dns_onion_messages.lock().unwrap().push(( - DNSResolverMessage::DNSSECQuery(message.clone()), - MessageSendInstructions::WithSpecifiedReplyPath { - destination: destination.clone(), - reply_path: reply_path.clone(), - }, - )); - } - - Ok(()) - } - /// Gets the enqueued [`OffersMessage`] with their corresponding [`MessageSendInstructions`]. pub fn release_pending_offers_messages(&self) -> Vec<(OffersMessage, MessageSendInstructions)> { core::mem::take(&mut self.pending_offers_messages.lock().unwrap()) @@ -1353,14 +1296,6 @@ impl OffersMessageFlow { core::mem::take(&mut self.pending_async_payments_messages.lock().unwrap()) } - /// Gets the enqueued [`DNSResolverMessage`] with their corresponding [`MessageSendInstructions`]. - #[cfg(feature = "dnssec")] - pub fn release_pending_dns_messages( - &self, - ) -> Vec<(DNSResolverMessage, MessageSendInstructions)> { - core::mem::take(&mut self.pending_dns_onion_messages.lock().unwrap()) - } - /// Retrieve an [`Offer`] for receiving async payments as an often-offline recipient. Will only /// return an offer if [`Self::set_paths_to_static_invoice_server`] was called and we succeeded in /// interactively building a [`StaticInvoice`] with the static invoice server. From 417b06585cf78728e3d0659e078b24c096d78bca Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 7 Apr 2026 01:19:01 +0000 Subject: [PATCH 277/627] Add missing `OffersMessageHandler::best_block` updating It seems we forgot to ensure `OffersMessageHandler::best_block` is consistently updated, leading to us building invalid blinded payment paths for short-lived payment paths after two weeks without restart. --- lightning/src/offers/flow.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 79e23321fd9..901866f1e8f 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -183,10 +183,12 @@ impl OffersMessageFlow { /// /// Must be called whenever a new chain tip becomes available. May be skipped /// for intermediary blocks. - pub fn best_block_updated(&self, header: &Header, _height: u32) { + pub fn best_block_updated(&self, header: &Header, height: u32) { let timestamp = &self.highest_seen_timestamp; let block_time = header.time as usize; + *self.best_block.write().unwrap() = BestBlock::new(header.block_hash(), height); + loop { // Update timestamp to be the max of its current value and the block // timestamp. This should keep us close to the current time without relying on From 6bf2352dc9eb7aaeafacd418a95e9d28d139f2d0 Mon Sep 17 00:00:00 2001 From: Swagmuffin Date: Mon, 6 Apr 2026 19:14:45 -0700 Subject: [PATCH 278/627] Bypass channel monitor sync requests when no partition key given --- lightning/src/chain/chainmonitor.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lightning/src/chain/chainmonitor.rs b/lightning/src/chain/chainmonitor.rs index 8644301ceb4..0a790b3e1f5 100644 --- a/lightning/src/chain/chainmonitor.rs +++ b/lightning/src/chain/chainmonitor.rs @@ -555,7 +555,7 @@ where channel_id_bytes[2], channel_id_bytes[3], ]); - channel_id_u32.wrapping_add(best_height.unwrap_or_default()) + best_height.map(|height| channel_id_u32.wrapping_add(height)) }; let partition_factor = if channel_count < 15 { @@ -565,7 +565,7 @@ where }; let has_pending_claims = monitor_state.monitor.has_pending_claims(); - if has_pending_claims || get_partition_key(channel_id) % partition_factor == 0 { + if has_pending_claims || get_partition_key(channel_id).is_some_and(|key| key % partition_factor == 0) { log_trace!(logger, "Syncing Channel Monitor"); // Even though we don't track monitor updates from chain-sync as pending, we still want // updates per-channel to be well-ordered so that users don't see a From 2b181dc463cb04c09ecdbc5aa77ef4af8667df4a Mon Sep 17 00:00:00 2001 From: Swagmuffin Date: Mon, 6 Apr 2026 20:07:51 -0700 Subject: [PATCH 279/627] cargo fmt --- lightning/src/chain/chainmonitor.rs | 4 +- lightning/src/ln/funding.rs | 60 ++++++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/lightning/src/chain/chainmonitor.rs b/lightning/src/chain/chainmonitor.rs index 0a790b3e1f5..ca01e95c054 100644 --- a/lightning/src/chain/chainmonitor.rs +++ b/lightning/src/chain/chainmonitor.rs @@ -565,7 +565,9 @@ where }; let has_pending_claims = monitor_state.monitor.has_pending_claims(); - if has_pending_claims || get_partition_key(channel_id).is_some_and(|key| key % partition_factor == 0) { + if has_pending_claims + || get_partition_key(channel_id).is_some_and(|key| key % partition_factor == 0) + { log_trace!(logger, "Syncing Channel Monitor"); // Even though we don't track monitor updates from chain-sync as pending, we still want // updates per-channel to be well-ordered so that users don't see a diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index c08a0a9f471..0a5fb647de1 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -386,7 +386,17 @@ impl FundingTemplate { return Err(FundingContributionError::InvalidSpliceValue); } let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; - build_funding_contribution!(value_added, vec![], shared_input, min_rbf_feerate, min_feerate, max_feerate, false, wallet, await) + build_funding_contribution!( + value_added, + vec![], + shared_input, + min_rbf_feerate, + min_feerate, + max_feerate, + false, + wallet, + await + ) } /// Creates a [`FundingContribution`] for adding funds to a channel using `wallet` to perform @@ -426,7 +436,17 @@ impl FundingTemplate { return Err(FundingContributionError::InvalidSpliceValue); } let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; - build_funding_contribution!(Amount::ZERO, outputs, shared_input, min_rbf_feerate, min_feerate, max_feerate, false, wallet, await) + build_funding_contribution!( + Amount::ZERO, + outputs, + shared_input, + min_rbf_feerate, + min_feerate, + max_feerate, + false, + wallet, + await + ) } /// Creates a [`FundingContribution`] for removing funds from a channel using `wallet` to @@ -467,7 +487,17 @@ impl FundingTemplate { return Err(FundingContributionError::InvalidSpliceValue); } let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; - build_funding_contribution!(value_added, outputs, shared_input, min_rbf_feerate, min_feerate, max_feerate, false, wallet, await) + build_funding_contribution!( + value_added, + outputs, + shared_input, + min_rbf_feerate, + min_feerate, + max_feerate, + false, + wallet, + await + ) } /// Creates a [`FundingContribution`] for both adding and removing funds from a channel using @@ -550,10 +580,30 @@ impl FundingTemplate { return Ok(adjusted); } } - build_funding_contribution!(contribution.value_added, contribution.outputs, shared_input, min_rbf_feerate, rbf_feerate, max_feerate, true, wallet, await) + build_funding_contribution!( + contribution.value_added, + contribution.outputs, + shared_input, + min_rbf_feerate, + rbf_feerate, + max_feerate, + true, + wallet, + await + ) }, None => { - build_funding_contribution!(Amount::ZERO, vec![], shared_input, min_rbf_feerate, rbf_feerate, max_feerate, true, wallet, await) + build_funding_contribution!( + Amount::ZERO, + vec![], + shared_input, + min_rbf_feerate, + rbf_feerate, + max_feerate, + true, + wallet, + await + ) }, } } From 5704e8e75a18f0d9946ab734ea53046818c814f3 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 7 Apr 2026 11:07:12 +0000 Subject: [PATCH 280/627] Note why we don't use `update_for_new_tip` in offers flow block upd --- lightning/src/offers/flow.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 901866f1e8f..2edcbc8aba8 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -187,6 +187,8 @@ impl OffersMessageFlow { let timestamp = &self.highest_seen_timestamp; let block_time = header.time as usize; + // Note that we deliberately don't use `update_for_new_tip` as we dont rely on receiving + // disconnection information instead expecting to simply "jump" to the new tip. *self.best_block.write().unwrap() = BestBlock::new(header.block_hash(), height); loop { From d0f1f39c6d9986e236dd89fbfe20b15d265c2e82 Mon Sep 17 00:00:00 2001 From: Nishant Bansal Date: Sat, 21 Mar 2026 00:17:31 +0530 Subject: [PATCH 281/627] fuzz: add fuzz target for P2PGossipSync gossip message handling Signed-off-by: Nishant Bansal --- fuzz/src/bin/gen_target.sh | 1 + fuzz/src/bin/gossip_discovery_target.rs | 133 ++++++++++++ fuzz/src/gossip_discovery.rs | 265 ++++++++++++++++++++++++ fuzz/src/lib.rs | 1 + fuzz/targets.h | 1 + 5 files changed, 401 insertions(+) create mode 100644 fuzz/src/bin/gossip_discovery_target.rs create mode 100644 fuzz/src/gossip_discovery.rs diff --git a/fuzz/src/bin/gen_target.sh b/fuzz/src/bin/gen_target.sh index b4f0c7a12b9..fd308a1f10e 100755 --- a/fuzz/src/bin/gen_target.sh +++ b/fuzz/src/bin/gen_target.sh @@ -29,6 +29,7 @@ GEN_TEST fromstr_to_netaddress GEN_TEST feature_flags GEN_TEST lsps_message GEN_TEST fs_store +GEN_TEST gossip_discovery GEN_TEST msg_accept_channel msg_targets:: GEN_TEST msg_announcement_signatures msg_targets:: diff --git a/fuzz/src/bin/gossip_discovery_target.rs b/fuzz/src/bin/gossip_discovery_target.rs new file mode 100644 index 00000000000..960ba80ec8c --- /dev/null +++ b/fuzz/src/bin/gossip_discovery_target.rs @@ -0,0 +1,133 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +// This file is auto-generated by gen_target.sh based on target_template.txt +// To modify it, modify target_template.txt and run gen_target.sh instead. + +#![cfg_attr(feature = "libfuzzer_fuzz", no_main)] +#![cfg_attr(rustfmt, rustfmt_skip)] + +#[cfg(not(fuzzing))] +compile_error!("Fuzz targets need cfg=fuzzing"); + +#[cfg(not(hashes_fuzz))] +compile_error!("Fuzz targets need cfg=hashes_fuzz"); + +#[cfg(not(secp256k1_fuzz))] +compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); + +extern crate lightning_fuzz; +use lightning_fuzz::gossip_discovery::*; +use lightning_fuzz::utils::test_logger; + +#[cfg(feature = "afl")] +#[macro_use] extern crate afl; +#[cfg(feature = "afl")] +fn main() { + fuzz!(|data| { + gossip_discovery_test(&data, test_logger::DevNull {}); + }); +} + +#[cfg(feature = "honggfuzz")] +#[macro_use] extern crate honggfuzz; +#[cfg(feature = "honggfuzz")] +fn main() { + loop { + fuzz!(|data| { + gossip_discovery_test(&data, test_logger::DevNull {}); + }); + } +} + +#[cfg(feature = "libfuzzer_fuzz")] +#[macro_use] extern crate libfuzzer_sys; +#[cfg(feature = "libfuzzer_fuzz")] +fuzz_target!(|data: &[u8]| { + gossip_discovery_test(data, test_logger::DevNull {}); +}); + +#[cfg(feature = "stdin_fuzz")] +fn main() { + use std::io::Read; + + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + + let mut data = Vec::with_capacity(8192); + std::io::stdin().read_to_end(&mut data).unwrap(); + gossip_discovery_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); +} + +#[test] +fn run_test_cases() { + use std::fs; + use std::io::Read; + use lightning_fuzz::utils::test_logger::StringBuffer; + + use std::sync::{atomic, Arc}; + { + let data: Vec = vec![0]; + gossip_discovery_test(&data, test_logger::DevNull {}); + } + let mut threads = Vec::new(); + let threads_running = Arc::new(atomic::AtomicUsize::new(0)); + if let Ok(tests) = fs::read_dir("test_cases/gossip_discovery") { + for test in tests { + let mut data: Vec = Vec::new(); + let path = test.unwrap().path(); + fs::File::open(&path).unwrap().read_to_end(&mut data).unwrap(); + threads_running.fetch_add(1, atomic::Ordering::AcqRel); + + let thread_count_ref = Arc::clone(&threads_running); + let main_thread_ref = std::thread::current(); + threads.push((path.file_name().unwrap().to_str().unwrap().to_string(), + std::thread::spawn(move || { + let string_logger = StringBuffer::new(); + + let panic_logger = string_logger.clone(); + let res = if ::std::panic::catch_unwind(move || { + gossip_discovery_test(&data, panic_logger); + }).is_err() { + Some(string_logger.into_string()) + } else { None }; + thread_count_ref.fetch_sub(1, atomic::Ordering::AcqRel); + main_thread_ref.unpark(); + res + }) + )); + while threads_running.load(atomic::Ordering::Acquire) > 32 { + std::thread::park(); + } + } + } + let mut failed_outputs = Vec::new(); + for (test, thread) in threads.drain(..) { + if let Some(output) = thread.join().unwrap() { + println!("\nOutput of {}:\n{}\n", test, output); + failed_outputs.push(test); + } + } + if !failed_outputs.is_empty() { + println!("Test cases which failed: "); + for case in failed_outputs { + println!("{}", case); + } + panic!(); + } +} diff --git a/fuzz/src/gossip_discovery.rs b/fuzz/src/gossip_discovery.rs new file mode 100644 index 00000000000..8eee8dc482b --- /dev/null +++ b/fuzz/src/gossip_discovery.rs @@ -0,0 +1,265 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Test that no series of gossip messages received from peers can result in a crash. We do this +//! by standing up a `P2PGossipSync` with a `NetworkGraph` and a mock UTXO lookup, then reading +//! bytes from the fuzz input to denote actions such as feeding channel announcements, node +//! announcements, channel updates, query messages, and pruning channels and nodes. Both valid +//! and malformed messages are generated to exercise error paths. + +use bitcoin::amount::Amount; +use bitcoin::constants::ChainHash; +use bitcoin::network::Network; +use bitcoin::secp256k1::PublicKey; +use bitcoin::TxOut; + +use lightning::ln::chan_utils::make_funding_redeemscript; +use lightning::ln::msgs::{self, BaseMessageHandler, MessageSendEvent, RoutingMessageHandler}; +use lightning::routing::gossip::{NetworkGraph, NetworkUpdate, NodeId, P2PGossipSync}; +use lightning::routing::utxo::{UtxoLookup, UtxoLookupError, UtxoResult}; +use lightning::util::ser::LengthReadable; +use lightning::util::wakers::Notifier; + +use crate::utils::test_logger; + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +struct FuzzUtxoLookup { + utxos: Mutex>, +} + +impl FuzzUtxoLookup { + fn new() -> Arc { + Arc::new(Self { utxos: Mutex::new(HashMap::new()) }) + } + + fn register(&self, scid: u64, txout: TxOut) { + self.utxos.lock().unwrap().insert(scid, txout); + } +} + +impl UtxoLookup for FuzzUtxoLookup { + fn get_utxo( + &self, _chain_hash: &ChainHash, short_channel_id: u64, + _async_completion_notifier: Arc, + ) -> UtxoResult { + let utxos = self.utxos.lock().unwrap(); + match utxos.get(&short_channel_id) { + Some(txout) => UtxoResult::Sync(Ok(txout.clone())), + None => UtxoResult::Sync(Err(UtxoLookupError::UnknownTx)), + } + } +} + +#[inline] +fn do_test(data: &[u8], out: Out) { + let logger = Arc::new(test_logger::TestLogger::new("".to_owned(), out)); + + let network = Network::Bitcoin; + let network_graph = Arc::new(NetworkGraph::new(network, Arc::clone(&logger))); + let utxo_lookup = FuzzUtxoLookup::new(); + let gossip = Arc::new(P2PGossipSync::new( + Arc::clone(&network_graph), + Some(Arc::clone(&utxo_lookup)), + Arc::clone(&logger), + )); + + let mut read_pos = 0; + macro_rules! get_slice { + ($len: expr) => {{ + let slice_len = $len as usize; + if data.len() < read_pos + slice_len { + return; + } + read_pos += slice_len; + &data[read_pos - slice_len..read_pos] + }}; + } + + macro_rules! get_pubkey { + () => { + match PublicKey::from_slice(get_slice!(33)) { + Ok(key) => key, + Err(_) => continue, + } + }; + } + + macro_rules! decode_msg { + ($MsgType: path) => {{ + let len_bytes = get_slice!(2); + let msg_len = u16::from_be_bytes(len_bytes.try_into().unwrap()) as usize; + if msg_len == 0 { + continue; + } + let msg_data = get_slice!(msg_len); + let mut reader = &msg_data[..]; + match <$MsgType>::read_from_fixed_length_buffer(&mut reader) { + Ok(msg) => { + assert!(reader.is_empty()); + msg + }, + Err(e) => match e { + msgs::DecodeError::UnknownVersion => continue, + msgs::DecodeError::UnknownRequiredFeature => continue, + msgs::DecodeError::InvalidValue => continue, + msgs::DecodeError::BadLengthDescriptor => continue, + msgs::DecodeError::ShortRead => continue, + msgs::DecodeError::Io(e) => panic!("{:?}", e), + msgs::DecodeError::UnsupportedCompression => continue, + msgs::DecodeError::DangerousValue => continue, + }, + } + }}; + } + + loop { + match get_slice!(1)[0] % 7 { + // Handle a node announcement. + 0 => { + let node_ann = decode_msg!(msgs::NodeAnnouncement); + let Ok(peer_node_id) = node_ann.contents.node_id.as_pubkey() else { + continue; + }; + + match gossip.handle_node_announcement(Some(peer_node_id), &node_ann) { + Ok(_) => { + let graph = network_graph.read_only(); + let node = graph.node(&node_ann.contents.node_id).unwrap(); + let info = node.announcement_info.as_ref().unwrap(); + assert_eq!(info.last_update(), node_ann.contents.timestamp); + }, + Err(_) => {}, + } + }, + // Handle a channel announcement. + 1 => { + let chan_ann = decode_msg!(msgs::ChannelAnnouncement); + let scid = chan_ann.contents.short_channel_id; + let Ok(peer_node_id) = chan_ann.contents.node_id_1.as_pubkey() else { + continue; + }; + let Ok(btc_key1) = chan_ann.contents.bitcoin_key_1.as_pubkey() else { + continue; + }; + let Ok(btc_key2) = chan_ann.contents.bitcoin_key_2.as_pubkey() else { + continue; + }; + + // We conditionally register the funding script in the UTXO set so that valid funding + // script cases are also validated. + if (get_slice!(1)[0] & 1) != 0 { + let script_pubkey = make_funding_redeemscript(&btc_key1, &btc_key2).to_p2wsh(); + utxo_lookup.register( + scid, + TxOut { value: Amount::from_sat(1_000_000), script_pubkey }, + ); + } + + match gossip.handle_channel_announcement(Some(peer_node_id), &chan_ann) { + Ok(_) => { + let graph = network_graph.read_only(); + let chan = graph.channel(scid).unwrap(); + assert_eq!(chan.node_one, chan_ann.contents.node_id_1); + assert_eq!(chan.node_two, chan_ann.contents.node_id_2); + + assert!(graph.node(&chan_ann.contents.node_id_1).is_some()); + assert!(graph.node(&chan_ann.contents.node_id_2).is_some()); + }, + Err(_) => {}, + } + }, + // Handle a channel update. + 2 => { + let chan_upd = decode_msg!(msgs::ChannelUpdate); + let peer_node_id = get_pubkey!(); + + match gossip.handle_channel_update(Some(peer_node_id), &chan_upd) { + Ok(_) => { + let graph = network_graph.read_only(); + let chan = graph.channel(chan_upd.contents.short_channel_id).unwrap(); + let info = + chan.get_directional_info(chan_upd.contents.channel_flags).unwrap(); + assert_eq!(info.last_update, chan_upd.contents.timestamp); + }, + Err(_) => {}, + } + }, + // Handle query channel range. + 3 => { + let query = decode_msg!(msgs::QueryChannelRange); + let peer_node_id = get_pubkey!(); + + let _ = gossip.handle_query_channel_range(peer_node_id, query); + + // handle_query_channel_range always enqueues at least one + // SendReplyChannelRange event regardless of success or failure. + let events = gossip.get_and_clear_pending_msg_events(); + assert!(!events.is_empty()); + for event in &events { + match event { + MessageSendEvent::SendReplyChannelRange { node_id, msg } => { + assert_eq!(*node_id, peer_node_id); + assert!(msg.sync_complete || events.len() > 1); + }, + _ => panic!("Expected SendReplyChannelRange event"), + } + } + // The last reply must have sync_complete set. + match events.last().unwrap() { + MessageSendEvent::SendReplyChannelRange { msg, .. } => { + assert!(msg.sync_complete); + }, + _ => panic!("Expected SendReplyChannelRange event"), + } + }, + // Handle channel failure network update. + 4 => { + let scid = u64::from_be_bytes(get_slice!(8).try_into().unwrap()); + + network_graph.handle_network_update(&NetworkUpdate::ChannelFailure { + short_channel_id: scid, + is_permanent: true, + }); + + assert!(network_graph.read_only().channel(scid).is_none()); + }, + // Handle node failure network update. + 5 => { + let peer_node_id = get_pubkey!(); + + network_graph.handle_network_update(&NetworkUpdate::NodeFailure { + node_id: peer_node_id, + is_permanent: true, + }); + + assert!(network_graph + .read_only() + .node(&NodeId::from_pubkey(&peer_node_id)) + .is_none()); + }, + // Remove stale channels and tracking. + 6 => { + let time_unix = u64::from_be_bytes(get_slice!(8).try_into().unwrap()); + network_graph.remove_stale_channels_and_tracking_with_time(time_unix); + }, + _ => unreachable!(), + } + } +} + +pub fn gossip_discovery_test(data: &[u8], out: Out) { + do_test(data, out); +} + +#[no_mangle] +pub extern "C" fn gossip_discovery_run(data: *const u8, datalen: usize) { + do_test(unsafe { std::slice::from_raw_parts(data, datalen) }, test_logger::DevNull {}); +} diff --git a/fuzz/src/lib.rs b/fuzz/src/lib.rs index 582fa346c54..5f429ea2c3b 100644 --- a/fuzz/src/lib.rs +++ b/fuzz/src/lib.rs @@ -31,6 +31,7 @@ pub mod chanmon_deser; pub mod feature_flags; pub mod fromstr_to_netaddress; pub mod full_stack; +pub mod gossip_discovery; pub mod indexedmap; pub mod invoice_deser; pub mod invoice_request_deser; diff --git a/fuzz/targets.h b/fuzz/targets.h index 921439836af..ef8e899b178 100644 --- a/fuzz/targets.h +++ b/fuzz/targets.h @@ -22,6 +22,7 @@ void fromstr_to_netaddress_run(const unsigned char* data, size_t data_len); void feature_flags_run(const unsigned char* data, size_t data_len); void lsps_message_run(const unsigned char* data, size_t data_len); void fs_store_run(const unsigned char* data, size_t data_len); +void gossip_discovery_run(const unsigned char* data, size_t data_len); void msg_accept_channel_run(const unsigned char* data, size_t data_len); void msg_announcement_signatures_run(const unsigned char* data, size_t data_len); void msg_channel_reestablish_run(const unsigned char* data, size_t data_len); From 484ecb89b7c6f0c8061e199d3bdffc7af6b06c07 Mon Sep 17 00:00:00 2001 From: Philip Kannegaard Hayes Date: Tue, 7 Apr 2026 16:30:54 -0700 Subject: [PATCH 282/627] util: add helper ChannelHandshakeConfig -> ChannelHandshakeConfigUpdate Mirror the ChannelConfig -> ChannelConfigUpdate helper --- lightning/src/util/config.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/lightning/src/util/config.rs b/lightning/src/util/config.rs index ebef8c27bca..c83eb697461 100644 --- a/lightning/src/util/config.rs +++ b/lightning/src/util/config.rs @@ -1230,6 +1230,26 @@ pub struct ChannelHandshakeConfigUpdate { pub channel_reserve_proportional_millionths: Option, } +impl From for ChannelHandshakeConfigUpdate { + fn from(config: ChannelHandshakeConfig) -> Self { + Self { + announced_channel_max_inbound_htlc_value_in_flight_percentage: Some( + config.announced_channel_max_inbound_htlc_value_in_flight_percentage, + ), + unannounced_channel_max_inbound_htlc_value_in_flight_percentage: Some( + config.unannounced_channel_max_inbound_htlc_value_in_flight_percentage, + ), + htlc_minimum_msat: Some(config.our_htlc_minimum_msat), + minimum_depth: Some(config.minimum_depth), + to_self_delay: Some(config.our_to_self_delay), + max_accepted_htlcs: Some(config.our_max_accepted_htlcs), + channel_reserve_proportional_millionths: Some( + config.their_channel_reserve_proportional_millionths, + ), + } + } +} + impl ChannelHandshakeConfig { /// Applies the provided handshake config update. pub fn apply(&mut self, config: &ChannelHandshakeConfigUpdate) { From b4b3bfb5bf4732dc4b8709ddb98c9c424a2d0775 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Thu, 19 Mar 2026 14:52:08 -0700 Subject: [PATCH 283/627] Remove wallet argument from FundingTemplate::splice_out It does not require coin selection, so the wallet argument is not necessary. --- fuzz/src/chanmon_consistency.rs | 20 +--- fuzz/src/full_stack.rs | 10 +- lightning/src/ln/funding.rs | 180 ++++++++++++++++------------- lightning/src/ln/splicing_tests.rs | 11 +- 4 files changed, 112 insertions(+), 109 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index f20f93c789c..07aae78f22f 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1504,7 +1504,6 @@ pub fn do_test(data: &[u8], out: Out) { counterparty_node_id: &PublicKey, channel_id: &ChannelId, wallet: &TestWalletSource, - logger: Arc, funding_feerate_sat_per_kw: FeeRate| { // We conditionally splice out `MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS` only when the node // has double the balance required to send a payment upon a `0xff` byte. We do this to @@ -1524,12 +1523,7 @@ pub fn do_test(data: &[u8], out: Out) { value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), script_pubkey: wallet.get_change_script().unwrap(), }]; - funding_template.splice_out_sync( - outputs, - feerate, - FeeRate::MAX, - &WalletSync::new(wallet, logger.clone()), - ) + funding_template.splice_out(outputs, feerate, FeeRate::MAX) }); }; @@ -2450,30 +2444,26 @@ pub fn do_test(data: &[u8], out: Out) { 0xa4 => { let cp_node_id = nodes[1].get_our_node_id(); let wallet = &wallets[0]; - let logger = Arc::clone(&loggers[0]); let feerate_sat_per_kw = fee_estimators[0].feerate_sat_per_kw(); - splice_out(&nodes[0], &cp_node_id, &chan_a_id, wallet, logger, feerate_sat_per_kw); + splice_out(&nodes[0], &cp_node_id, &chan_a_id, wallet, feerate_sat_per_kw); }, 0xa5 => { let cp_node_id = nodes[0].get_our_node_id(); let wallet = &wallets[1]; - let logger = Arc::clone(&loggers[1]); let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw(); - splice_out(&nodes[1], &cp_node_id, &chan_a_id, wallet, logger, feerate_sat_per_kw); + splice_out(&nodes[1], &cp_node_id, &chan_a_id, wallet, feerate_sat_per_kw); }, 0xa6 => { let cp_node_id = nodes[2].get_our_node_id(); let wallet = &wallets[1]; - let logger = Arc::clone(&loggers[1]); let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw(); - splice_out(&nodes[1], &cp_node_id, &chan_b_id, wallet, logger, feerate_sat_per_kw); + splice_out(&nodes[1], &cp_node_id, &chan_b_id, wallet, feerate_sat_per_kw); }, 0xa7 => { let cp_node_id = nodes[1].get_our_node_id(); let wallet = &wallets[2]; - let logger = Arc::clone(&loggers[2]); let feerate_sat_per_kw = fee_estimators[2].feerate_sat_per_kw(); - splice_out(&nodes[2], &cp_node_id, &chan_b_id, wallet, logger, feerate_sat_per_kw); + splice_out(&nodes[2], &cp_node_id, &chan_b_id, wallet, feerate_sat_per_kw); }, // Sync node by 1 block to cover confirmation of a transaction. diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index c1d7982e5e4..f300ded4fb7 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -1083,13 +1083,9 @@ pub fn do_test(mut data: &[u8], logger: &Arc value: Amount::from_sat(splice_out_sats), script_pubkey: wallet.get_change_script().unwrap(), }]; - let wallet_sync = WalletSync::new(&wallet, Arc::clone(&logger)); - if let Ok(contribution) = funding_template.splice_out_sync( - outputs, - feerate, - FeeRate::MAX, - &wallet_sync, - ) { + if let Ok(contribution) = + funding_template.splice_out(outputs, feerate, FeeRate::MAX) + { let _ = channelmanager.funding_contributed( &chan_id, &counterparty, diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 0a5fb647de1..470e8bc71f1 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -206,10 +206,12 @@ impl PriorContribution { /// For a fresh splice (no pending splice to replace), build a new contribution using one of /// the splice methods: /// - [`FundingTemplate::splice_in_sync`] to add funds to the channel -/// - [`FundingTemplate::splice_out_sync`] to remove funds from the channel +/// - [`FundingTemplate::splice_out`] to remove funds from the channel /// - [`FundingTemplate::splice_in_and_out_sync`] to do both /// -/// These perform coin selection and require `min_feerate` and `max_feerate` parameters. +/// These require `min_feerate` and `max_feerate` parameters. The splice-in variants perform +/// coin selection when wallet inputs are needed, while splice-out spends only from the channel +/// balance. /// /// # Replace By Fee (RBF) /// @@ -287,31 +289,13 @@ macro_rules! build_funding_contribution { let max_feerate: FeeRate = $max_feerate; let force_coin_selection: bool = $force_coin_selection; - if feerate > max_feerate { - return Err(FundingContributionError::FeeRateExceedsMaximum { feerate, max_feerate }); - } - - if let Some(min_rbf_feerate) = min_rbf_feerate { - if feerate < min_rbf_feerate { - return Err(FundingContributionError::FeeRateBelowRbfMinimum { feerate, min_rbf_feerate }); - } - } - - // Validate user-provided amounts are within MAX_MONEY before coin selection to - // ensure FundingContribution::net_value() arithmetic cannot overflow. With all - // amounts bounded by MAX_MONEY (~2.1e15 sat), the worst-case net_value() - // computation is -2 * MAX_MONEY (~-4.2e15), well within i64::MIN (~-9.2e18). - if value_added > Amount::MAX_MONEY { - return Err(FundingContributionError::InvalidSpliceValue); - } - - let mut value_removed = Amount::ZERO; - for txout in outputs.iter() { - value_removed = match value_removed.checked_add(txout.value) { - Some(sum) if sum <= Amount::MAX_MONEY => sum, - _ => return Err(FundingContributionError::InvalidSpliceValue), - }; - } + let value_removed = validate_funding_contribution_params( + value_added, + &outputs, + min_rbf_feerate, + feerate, + max_feerate, + )?; let is_splice = shared_input.is_some(); @@ -350,25 +334,52 @@ macro_rules! build_funding_contribution { let CoinSelection { confirmed_utxos: inputs, change_output } = coin_selection; - // The caller creating a FundingContribution is always the initiator for fee estimation - // purposes — this is conservative, overestimating rather than underestimating fees if - // the node ends up as the acceptor. - let estimated_fee = estimate_transaction_fee(&inputs, &outputs, change_output.as_ref(), true, is_splice, feerate); - debug_assert!(estimated_fee <= Amount::MAX_MONEY); - - let contribution = FundingContribution { + Ok(FundingContribution::new( value_added, - estimated_fee, - inputs, outputs, + inputs, change_output, feerate, max_feerate, is_splice, + )) + }}; +} + +fn validate_funding_contribution_params( + value_added: Amount, outputs: &[TxOut], min_rbf_feerate: Option, feerate: FeeRate, + max_feerate: FeeRate, +) -> Result { + if feerate > max_feerate { + return Err(FundingContributionError::FeeRateExceedsMaximum { feerate, max_feerate }); + } + + if let Some(min_rbf_feerate) = min_rbf_feerate { + if feerate < min_rbf_feerate { + return Err(FundingContributionError::FeeRateBelowRbfMinimum { + feerate, + min_rbf_feerate, + }); + } + } + + // Validate user-provided amounts are within MAX_MONEY before coin selection to + // ensure FundingContribution::net_value() arithmetic cannot overflow. With all + // amounts bounded by MAX_MONEY (~2.1e15 sat), the worst-case net_value() + // computation is -2 * MAX_MONEY (~-4.2e15), well within i64::MIN (~-9.2e18). + if value_added > Amount::MAX_MONEY { + return Err(FundingContributionError::InvalidSpliceValue); + } + + let mut value_removed = Amount::ZERO; + for txout in outputs.iter() { + value_removed = match value_removed.checked_add(txout.value) { + Some(sum) if sum <= Amount::MAX_MONEY => sum, + _ => return Err(FundingContributionError::InvalidSpliceValue), }; + } - Ok(contribution) - }}; + Ok(value_removed) } impl FundingTemplate { @@ -422,54 +433,37 @@ impl FundingTemplate { ) } - /// Creates a [`FundingContribution`] for removing funds from a channel using `wallet` to - /// perform coin selection. + /// Creates a [`FundingContribution`] for removing funds from a channel. + /// + /// Fees are paid from the channel balance, so this does not perform coin selection or spend + /// wallet inputs. /// /// `outputs` are the complete set of withdrawal outputs for this contribution. When /// replacing a prior contribution via RBF, use [`FundingTemplate::prior_contribution`] to /// inspect the prior parameters. To keep existing withdrawals and add new ones, include the /// prior's outputs: combine [`FundingContribution::outputs`] with the new outputs. - pub async fn splice_out( - self, outputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, + pub fn splice_out( + self, outputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, ) -> Result { if outputs.is_empty() { return Err(FundingContributionError::InvalidSpliceValue); } - let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; - build_funding_contribution!( + validate_funding_contribution_params( Amount::ZERO, - outputs, - shared_input, - min_rbf_feerate, + &outputs, + self.min_rbf_feerate, min_feerate, max_feerate, - false, - wallet, - await - ) - } - - /// Creates a [`FundingContribution`] for removing funds from a channel using `wallet` to - /// perform coin selection. - /// - /// See [`FundingTemplate::splice_out`] for details. - pub fn splice_out_sync( - self, outputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, - ) -> Result { - if outputs.is_empty() { - return Err(FundingContributionError::InvalidSpliceValue); - } - let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; - build_funding_contribution!( + )?; + Ok(FundingContribution::new( Amount::ZERO, outputs, - shared_input, - min_rbf_feerate, + vec![], + None, min_feerate, max_feerate, - false, - wallet, - ) + self.shared_input.is_some(), + )) } /// Creates a [`FundingContribution`] for both adding and removing funds from a channel using @@ -760,6 +754,35 @@ impl_writeable_tlv_based!(FundingContribution, { }); impl FundingContribution { + fn new( + value_added: Amount, outputs: Vec, inputs: Vec, + change_output: Option, feerate: FeeRate, max_feerate: FeeRate, is_splice: bool, + ) -> Self { + // The caller creating a FundingContribution is always the initiator for fee estimation + // purposes — this is conservative, overestimating rather than underestimating fees if the + // node ends up as the acceptor. + let estimated_fee = estimate_transaction_fee( + &inputs, + &outputs, + change_output.as_ref(), + true, + is_splice, + feerate, + ); + debug_assert!(estimated_fee <= Amount::MAX_MONEY); + + Self { + value_added, + estimated_fee, + inputs, + outputs, + change_output, + feerate, + max_feerate, + is_splice, + } + } + pub(super) fn feerate(&self) -> FeeRate { self.feerate } @@ -1492,17 +1515,17 @@ mod tests { )); } - // splice_out_sync with single output value > MAX_MONEY + // splice_out with single output value > MAX_MONEY { let template = FundingTemplate::new(None, None, None); let outputs = vec![funding_output_sats(over_max.to_sat())]; assert!(matches!( - template.splice_out_sync(outputs, feerate, feerate, UnreachableWallet), + template.splice_out(outputs, feerate, feerate), Err(FundingContributionError::InvalidSpliceValue), )); } - // splice_out_sync with multiple outputs summing > MAX_MONEY + // splice_out with multiple outputs summing > MAX_MONEY { let template = FundingTemplate::new(None, None, None); let half_over = Amount::MAX_MONEY / 2 + Amount::from_sat(1); @@ -1511,7 +1534,7 @@ mod tests { funding_output_sats(half_over.to_sat()), ]; assert!(matches!( - template.splice_out_sync(outputs, feerate, feerate, UnreachableWallet), + template.splice_out(outputs, feerate, feerate), Err(FundingContributionError::InvalidSpliceValue), )); } @@ -2506,7 +2529,7 @@ mod tests { } #[test] - fn test_splice_out_sync_skips_coin_selection_during_rbf() { + fn test_splice_out_skips_coin_selection_during_rbf() { // When splice_out_sync is called on a template with min_rbf_feerate set (user // choosing a fresh splice-out instead of rbf_sync), coin selection should NOT run. // Fees come from the channel balance. @@ -2517,12 +2540,11 @@ mod tests { let template = FundingTemplate::new(Some(shared_input(100_000)), Some(min_rbf_feerate), None); - // UnreachableWallet panics if coin selection runs — verifying it is skipped. - let contribution = template - .splice_out_sync(vec![withdrawal.clone()], feerate, FeeRate::MAX, UnreachableWallet) - .unwrap(); + let contribution = + template.splice_out(vec![withdrawal.clone()], feerate, FeeRate::MAX).unwrap(); assert_eq!(contribution.value_added, Amount::ZERO); assert!(contribution.inputs.is_empty()); + assert!(contribution.change_output.is_none()); assert_eq!(contribution.outputs, vec![withdrawal]); } } diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 9adccd17627..54929214ab6 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -271,9 +271,7 @@ pub fn initiate_splice_out<'a, 'b, 'c, 'd>( let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); let funding_template = initiator.node.splice_channel(&channel_id, &node_id_acceptor).unwrap(); let feerate = funding_template.min_rbf_feerate().unwrap_or(floor_feerate); - let wallet = WalletSync::new(Arc::clone(&initiator.wallet_source), initiator.logger); - let funding_contribution = - funding_template.splice_out_sync(outputs, feerate, FeeRate::MAX, &wallet).unwrap(); + let funding_contribution = funding_template.splice_out(outputs, feerate, FeeRate::MAX).unwrap(); match initiator.node.funding_contributed( &channel_id, &node_id_acceptor, @@ -1370,9 +1368,8 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); let funding_template = nodes[0].node.splice_channel(&channel_id, &node_1_id).unwrap(); - let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); let funding_contribution = - funding_template.splice_out_sync(outputs.clone(), feerate, FeeRate::MAX, &wallet).unwrap(); + funding_template.splice_out(outputs.clone(), feerate, FeeRate::MAX).unwrap(); nodes[0] .node .funding_contributed(&channel_id, &node_1_id, funding_contribution.clone(), None) @@ -6420,9 +6417,7 @@ fn test_splice_revalidation_at_quiescence() { let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); - let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let contribution = - funding_template.splice_out_sync(outputs, feerate, FeeRate::MAX, &wallet).unwrap(); + let contribution = funding_template.splice_out(outputs, feerate, FeeRate::MAX).unwrap(); nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution.clone(), None).unwrap(); assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty(), "stfu should be delayed"); From 2bd09e4ee6fa416bf84c30015b45ec67088cd9c3 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Thu, 9 Apr 2026 19:03:58 -0400 Subject: [PATCH 284/627] Hold peer lock when pushing to decode_update_adds This avoids race conditions where we're unable to properly update an HTLC's state because we need to update its state in the ChannelManager, but the HTLC is stuck in transit from the Channel to ChannelManager::decode_update_add_htlcs. Now the HTLC will atomically go from the Channel to the ChannelManager decode queue under the same lock. --- lightning/src/ln/channelmanager.rs | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 2c97e4adaa1..4aad2a5381c 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -1516,7 +1516,6 @@ enum PostMonitorUpdateChanResume { unbroadcasted_batch_funding_txid: Option, update_actions: Vec, htlc_forwards: Vec, - decode_update_add_htlcs: Option<(u64, Vec)>, finalized_claimed_htlcs: Vec<(HTLCSource, Option)>, failed_htlcs: Vec<(HTLCSource, PaymentHash, HTLCFailReason)>, committed_outbound_htlc_sources: Vec<(HTLCPreviousHopData, u64)>, @@ -10116,7 +10115,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ &self, channel_id: ChannelId, counterparty_node_id: PublicKey, funding_txo: OutPoint, user_channel_id: u128, unbroadcasted_batch_funding_txid: Option, update_actions: Vec, htlc_forwards: Vec, - decode_update_add_htlcs: Option<(u64, Vec)>, finalized_claimed_htlcs: Vec<(HTLCSource, Option)>, failed_htlcs: Vec<(HTLCSource, PaymentHash, HTLCFailReason)>, committed_outbound_htlc_sources: Vec<(HTLCPreviousHopData, u64)>, @@ -10177,9 +10175,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ self.handle_monitor_update_completion_actions(update_actions); self.forward_htlcs(htlc_forwards); - if let Some(decode) = decode_update_add_htlcs { - self.push_decode_update_add_htlcs(decode); - } self.finalize_claims(finalized_claimed_htlcs); for failure in failed_htlcs { let failure_type = failure.0.failure_type(counterparty_node_id, channel_id); @@ -10667,6 +10662,10 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ pending_msg_events.push(upd); } + if let Some(update_adds) = decode_update_add_htlcs { + self.push_decode_update_add_htlcs(update_adds); + } + let unbroadcasted_batch_funding_txid = chan.context.unbroadcasted_batch_funding_txid(&chan.funding); @@ -10678,7 +10677,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ unbroadcasted_batch_funding_txid, update_actions, htlc_forwards, - decode_update_add_htlcs, finalized_claimed_htlcs: updates.finalized_claimed_htlcs, failed_htlcs: updates.failed_htlcs, committed_outbound_htlc_sources: updates.committed_outbound_htlc_sources, @@ -10780,7 +10778,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ unbroadcasted_batch_funding_txid, update_actions, htlc_forwards, - decode_update_add_htlcs, finalized_claimed_htlcs, failed_htlcs, committed_outbound_htlc_sources, @@ -10793,7 +10790,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ unbroadcasted_batch_funding_txid, update_actions, htlc_forwards, - decode_update_add_htlcs, finalized_claimed_htlcs, failed_htlcs, committed_outbound_htlc_sources, @@ -17920,12 +17916,6 @@ impl< } } - let mut decode_update_add_htlcs_opt = None; - let decode_update_add_htlcs = self.decode_update_add_htlcs.lock().unwrap(); - if !decode_update_add_htlcs.is_empty() { - decode_update_add_htlcs_opt = Some(decode_update_add_htlcs); - } - let claimable_payments = self.claimable_payments.lock().unwrap(); let pending_outbound_payments = self.pending_outbound_payments.pending_outbound_payments.lock().unwrap(); @@ -17951,6 +17941,14 @@ impl< peer_states.push(peer_state_mutex.unsafe_well_ordered_double_lock_self()); } + let mut decode_update_add_htlcs_opt = None; + { + let decode_update_add_htlcs = self.decode_update_add_htlcs.lock().unwrap(); + if !decode_update_add_htlcs.is_empty() { + decode_update_add_htlcs_opt = Some(decode_update_add_htlcs); + } + } + let mut peer_storage_dir: Vec<(&PublicKey, &Vec)> = Vec::new(); (serializable_peer_count).write(writer)?; From 2ebc372fbce1763201ff9f66b1490d59192de8bf Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Tue, 7 Apr 2026 13:34:53 -0400 Subject: [PATCH 285/627] Fix async release before HTLC decode Handle `ReleaseHeldHtlc` messages that arrive before the sender-side LSP has even queued the held HTLC for onion decoding. Unlike lightningdevkit#4106, which covers releases arriving after the HTLC is in `decode_update_add_htlcs` but before it reaches `pending_intercepted_htlcs`, this preserves releases that arrive one step earlier and would otherwise be dropped as HTLC not found. Co-Authored-By: HAL 9000 Co-Authored-By: Elias Rohrer --- lightning/src/ln/async_payments_tests.rs | 164 +++++++++++++++++++++++ lightning/src/ln/channel.rs | 37 +++++ lightning/src/ln/channelmanager.rs | 12 ++ 3 files changed, 213 insertions(+) diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index 341bd5d7269..60632b13f7a 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -3458,3 +3458,167 @@ fn release_htlc_races_htlc_onion_decode() { claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage)); assert_eq!(res, Some(PaidBolt12Invoice::StaticInvoice(static_invoice))); } + +#[test] +fn async_payment_e2e_release_before_hold_registered() { + // Tests that an LSP will release a held htlc if the `ReleaseHeldHtlc` message was received + // before the HTLC was fully committed to the channel, which was previously broken. + let chanmon_cfgs = create_chanmon_cfgs(4); + let node_cfgs = create_node_cfgs(4, &chanmon_cfgs); + + let (sender_cfg, recipient_cfg) = (often_offline_node_cfg(), often_offline_node_cfg()); + let mut sender_lsp_cfg = test_default_channel_config(); + sender_lsp_cfg.enable_htlc_hold = true; + let mut invoice_server_cfg = test_default_channel_config(); + invoice_server_cfg.accept_forwards_to_priv_channels = true; + + let node_chanmgrs = create_node_chanmgrs( + 4, + &node_cfgs, + &[Some(sender_cfg), Some(sender_lsp_cfg), Some(invoice_server_cfg), Some(recipient_cfg)], + ); + let nodes = create_network(4, &node_cfgs, &node_chanmgrs); + create_unannounced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); + create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0); + create_unannounced_chan_between_nodes_with_value(&nodes, 2, 3, 1_000_000, 0); + unify_blockheight_across_nodes(&nodes); + let sender = &nodes[0]; + let sender_lsp = &nodes[1]; + let invoice_server = &nodes[2]; + let recipient = &nodes[3]; + + let recipient_id = vec![42; 32]; + let inv_server_paths = + invoice_server.node.blinded_paths_for_async_recipient(recipient_id.clone(), None).unwrap(); + recipient.node.set_paths_to_static_invoice_server(inv_server_paths).unwrap(); + expect_offer_paths_requests(recipient, &[invoice_server, sender_lsp]); + let invoice_flow_res = + pass_static_invoice_server_messages(invoice_server, recipient, recipient_id.clone()); + let invoice = invoice_flow_res.invoice; + let invreq_path = invoice_flow_res.invoice_request_path; + + let offer = recipient.node.get_async_receive_offer().unwrap(); + recipient.node.peer_disconnected(invoice_server.node.get_our_node_id()); + recipient.onion_messenger.peer_disconnected(invoice_server.node.get_our_node_id()); + invoice_server.node.peer_disconnected(recipient.node.get_our_node_id()); + invoice_server.onion_messenger.peer_disconnected(recipient.node.get_our_node_id()); + + let amt_msat = 5000; + let payment_id = PaymentId([1; 32]); + sender.node.pay_for_offer(&offer, Some(amt_msat), payment_id, Default::default()).unwrap(); + + let (peer_id, invreq_om) = extract_invoice_request_om(sender, &[sender_lsp, invoice_server]); + invoice_server.onion_messenger.handle_onion_message(peer_id, &invreq_om); + + let mut events = invoice_server.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + let (reply_path, invreq) = match events.pop().unwrap() { + Event::StaticInvoiceRequested { + recipient_id: ev_id, reply_path, invoice_request, .. + } => { + assert_eq!(recipient_id, ev_id); + (reply_path, invoice_request) + }, + _ => panic!(), + }; + + invoice_server + .node + .respond_to_static_invoice_request(invoice, reply_path, invreq, invreq_path) + .unwrap(); + let (peer_node_id, static_invoice_om, static_invoice) = + extract_static_invoice_om(invoice_server, &[sender_lsp, sender]); + + // Lock the HTLC in with the sender LSP, but stop before the sender's revoke_and_ack is handed + // back to the sender LSP. This reproduces the real LSPS2 timing where ReleaseHeldHtlc can + // arrive before the held HTLC is queued for decode on the sender LSP. + sender.onion_messenger.handle_onion_message(peer_node_id, &static_invoice_om); + check_added_monitors(sender, 1); + let commitment_update = get_htlc_update_msgs(&sender, &sender_lsp.node.get_our_node_id()); + let update_add = commitment_update.update_add_htlcs[0].clone(); + let payment_hash = update_add.payment_hash; + assert!(update_add.hold_htlc.is_some()); + sender_lsp.node.handle_update_add_htlc(sender.node.get_our_node_id(), &update_add); + sender_lsp.node.handle_commitment_signed_batch_test( + sender.node.get_our_node_id(), + &commitment_update.commitment_signed, + ); + check_added_monitors(sender_lsp, 1); + let (_extra_msg_option, sender_raa, sender_holding_cell_htlcs) = + do_main_commitment_signed_dance(sender_lsp, sender, false); + assert!(sender_holding_cell_htlcs.is_empty()); + + let held_htlc_om_to_inv_server = sender + .onion_messenger + .next_onion_message_for_peer(invoice_server.node.get_our_node_id()) + .unwrap(); + invoice_server + .onion_messenger + .handle_onion_message(sender_lsp.node.get_our_node_id(), &held_htlc_om_to_inv_server); + + let mut events_rc = core::cell::RefCell::new(Vec::new()); + invoice_server.onion_messenger.process_pending_events(&|e| Ok(events_rc.borrow_mut().push(e))); + let events = events_rc.into_inner(); + let held_htlc_om = events + .into_iter() + .find_map(|ev| { + if let Event::OnionMessageIntercepted { message, .. } = ev { + let peeled_onion = recipient.onion_messenger.peel_onion_message(&message).unwrap(); + if matches!( + peeled_onion, + PeeledOnion::Offers(OffersMessage::InvoiceRequest { .. }, _, _) + ) { + return None; + } + + assert!(matches!( + peeled_onion, + PeeledOnion::AsyncPayments(AsyncPaymentsMessage::HeldHtlcAvailable(_), _, _) + )); + Some(message) + } else { + None + } + }) + .unwrap(); + + let mut reconnect_args = ReconnectArgs::new(invoice_server, recipient); + reconnect_args.send_channel_ready = (true, true); + reconnect_nodes(reconnect_args); + + let events = core::cell::RefCell::new(Vec::new()); + invoice_server.onion_messenger.process_pending_events(&|e| Ok(events.borrow_mut().push(e))); + assert_eq!(events.borrow().len(), 1); + assert!(matches!(events.into_inner().pop().unwrap(), Event::OnionMessagePeerConnected { .. })); + expect_offer_paths_requests(recipient, &[invoice_server]); + + recipient + .onion_messenger + .handle_onion_message(invoice_server.node.get_our_node_id(), &held_htlc_om); + let (peer_id, release_htlc_om) = + extract_release_htlc_oms(recipient, &[sender, sender_lsp, invoice_server]).pop().unwrap(); + sender_lsp.onion_messenger.handle_onion_message(peer_id, &release_htlc_om); + + // Now let the sender LSP receive the sender's revoke_and_ack and continue processing the held + // HTLC, which previously would've resulted in holding the HTLC even though the release message + // was already received. + sender_lsp.node.handle_revoke_and_ack(sender.node.get_our_node_id(), &sender_raa); + check_added_monitors(sender_lsp, 1); + assert!(sender_lsp.node.get_and_clear_pending_msg_events().is_empty()); + sender_lsp.node.process_pending_htlc_forwards(); + let mut events = sender_lsp.node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + let ev = remove_first_msg_event_to_node(&invoice_server.node.get_our_node_id(), &mut events); + check_added_monitors(&sender_lsp, 1); + + let path: &[&Node] = &[invoice_server, recipient]; + let args = PassAlongPathArgs::new(sender_lsp, path, amt_msat, payment_hash, ev) + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); + let claimable_ev = do_pass_along_path(args).unwrap(); + + let route: &[&[&Node]] = &[&[sender_lsp, invoice_server, recipient]]; + let keysend_preimage = extract_payment_preimage(&claimable_ev); + let (res, _) = + claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage)); + assert_eq!(res, Some(PaidBolt12Invoice::StaticInvoice(static_invoice))); +} diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 32c0e94bdc8..55d4a84eb91 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -8108,6 +8108,43 @@ where debug_assert!(false, "If we go to prune an inbound HTLC it should be present") } + /// Clears the `hold_htlc` flag for a pending inbound HTLC, returning `true` if the HTLC was + /// successfully released. Useful when a [`ReleaseHeldHtlc`] onion message arrives before the + /// HTLC has been fully committed. + /// + /// [`ReleaseHeldHtlc`]: crate::onion_message::async_payments::ReleaseHeldHtlc + pub(super) fn release_pending_inbound_held_htlc(&mut self, htlc_id: u64) -> bool { + for update_add in self.context.monitor_pending_update_adds.iter_mut() { + if update_add.htlc_id == htlc_id { + update_add.hold_htlc.take(); + return true; + } + } + for htlc in self.context.pending_inbound_htlcs.iter_mut() { + if htlc.htlc_id != htlc_id { + continue; + } + match &mut htlc.state { + // Clearing `hold_htlc` here directly affects the copy that will be cloned into the decode + // pipeline when RAA promotes the HTLC. + InboundHTLCState::RemoteAnnounced(InboundHTLCResolution::Pending { + update_add_htlc, + }) + | InboundHTLCState::AwaitingRemoteRevokeToAnnounce( + InboundHTLCResolution::Pending { update_add_htlc }, + ) + | InboundHTLCState::AwaitingAnnouncedRemoteRevoke( + InboundHTLCResolution::Pending { update_add_htlc }, + ) => { + update_add_htlc.hold_htlc.take(); + return true; + }, + _ => return false, + } + } + false + } + /// Useful for testing crash scenarios where the holding cell is not persisted. #[cfg(test)] pub(super) fn test_clear_holding_cell(&mut self) { diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 4aad2a5381c..b08864af737 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -17189,6 +17189,18 @@ impl< htlc_id, } => { let _serialize_guard = PersistenceNotifierGuard::notify_on_drop(self); + // It's possible the release_held_htlc message raced ahead of us fully committing to the + // HTLC. If that's the case, update the pending update_add to indicate that the HTLC should + // be released immediately. + let released_pre_commitment_htlc = self + .do_funded_channel_callback(prev_outbound_scid_alias, |chan| { + chan.release_pending_inbound_held_htlc(htlc_id) + }) + .unwrap_or(false); + if released_pre_commitment_htlc { + return; + } + // It's possible the release_held_htlc message raced ahead of us transitioning the pending // update_add to `Self::pending_intercept_htlcs`. If that's the case, update the pending // update_add to indicate that the HTLC should be released immediately. From 8d8313de9e5cd72bd983ed3383bd67b9b53dd9bc Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 13 Apr 2026 00:40:31 +0000 Subject: [PATCH 286/627] Correct blinded path forwarding CLTV expiry check The `PaymentConstraints::max_cltv_expiry` field exists to ensure a blinded path expires across the entire path at once - once the path is expired it will be rejected by the introduction node rather than traversing the entire path and failing at the destination. This was broken by the fact that we were checking the outgoing CLTV value rather than the incoming one, which admittedly isn't clear in the spec but is somewhat implied. Here we fix this, updating a test which was actually (kinda) exploiting this privacy loss rather than allowing the HTLC to fail at the introduction node. This, of course, does not risk funds loss as our own CLTV policy is still enforced on top. The only impact it could have is a recipient which was relying on blinded path expiry to avoid some cost (e.g. LSPS5 node wakeup cost) involved in receiving an HTLC they ultimately fail, though I'm not aware of any practical deployment where that is a concern. Reported by Jordan Mecom of Block's Security Team --- lightning/src/ln/async_payments_tests.rs | 27 ++++++++++++------------ lightning/src/ln/onion_payment.rs | 2 +- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index 60632b13f7a..bd07d13c13d 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -1886,8 +1886,9 @@ fn expired_static_invoice_payment_path() { } }; - // Mine a bunch of blocks so the hardcoded path's `max_cltv_expiry` is expired at the recipient's - // end by the time the payment arrives. + // Mine a bunch of blocks on the sender so the hardcoded path's `max_cltv_expiry` is expired. + // Note that the path expires "all at once" and will be invalid at the intro point so will be + // rejected before it reaches the destination. let min_cltv_expiry_delta = test_default_channel_config().channel_config.cltv_expiry_delta; connect_blocks( &nodes[0], @@ -1902,7 +1903,6 @@ fn expired_static_invoice_payment_path() { &nodes[1], final_max_cltv_expiry - nodes[1].best_block_info().1 - // Don't expire the path for nodes[1] - min_cltv_expiry_delta as u32 - HTLC_FAIL_BACK_BUFFER - LATENCY_GRACE_PERIOD_BLOCKS @@ -1939,18 +1939,17 @@ fn expired_static_invoice_payment_path() { let payment_hash = extract_payment_hash(&ev); check_added_monitors(&nodes[0], 1); - let route: &[&[&Node]] = &[&[&nodes[1], &nodes[2]]]; - let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev) - .without_claimable_event() - .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }) - .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); - do_pass_along_path(args); - fail_blinded_htlc_backwards(payment_hash, 1, &[&nodes[0], &nodes[1], &nodes[2]], false); - nodes[2].logger.assert_log_contains( - "lightning::ln::channelmanager", - "violated blinded payment constraints", - 1, + let payment_event = SendEvent::from_event(ev); + nodes[1].node.handle_update_add_htlc(nodes[0].node.get_our_node_id(), &payment_event.msgs[0]); + check_added_monitors(&nodes[1], 0); + do_commitment_signed_dance(&nodes[1], &nodes[0], &payment_event.commitment_msg, false, true); + expect_and_process_pending_htlcs(&nodes[1], false); + expect_htlc_handling_failed_destinations!( + nodes[1].node.get_and_clear_pending_events(), + &[HTLCHandlingFailureType::InvalidOnion] ); + check_added_monitors(&nodes[1], 1); + fail_blinded_htlc_backwards(payment_hash, 1, &[&nodes[0], &nodes[1]], false); } #[cfg_attr(feature = "std", ignore)] diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index 5111f6982fe..bd06bfc5089 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -66,7 +66,7 @@ fn check_blinded_forward( let outgoing_cltv_value = inbound_cltv_expiry.checked_sub( payment_relay.cltv_expiry_delta as u32 ).ok_or(())?; - check_blinded_payment_constraints(inbound_amt_msat, outgoing_cltv_value, payment_constraints)?; + check_blinded_payment_constraints(inbound_amt_msat, inbound_cltv_expiry, payment_constraints)?; if features.requires_unknown_bits_from(&BlindedHopFeatures::empty()) { return Err(()) } Ok((amt_to_forward, outgoing_cltv_value)) From b98d7b8a6253a71e01ed854aeaa7cb33903087de Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Sun, 12 Apr 2026 23:25:03 +0000 Subject: [PATCH 287/627] Use `saturating_mul` when multiplying feerates by the fee spike buf In theory a channel's feerate could be set to some absurd value (millions of satoshis per vB) and we'd overflow the fee spike buffer, accepting the absurd fee and ignoring our fee spike buffer check. This is harmless - the counterparty has much easier ways of bricking the channel if they want, and paying several BTC in fees is probably not the best way. Our commitment transaction and dust fee exposure logic all correctly map the `u32` to a `u64` before multiplying, making them overflow-safe. Still, its good to fix overflows because it is a remotely-reachable crash in debug builds. Reported by Jordan Mecom of Block's Security Team --- lightning/src/ln/channel.rs | 5 +++-- lightning/src/sign/tx_builder.rs | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 55d4a84eb91..b80ea76cf10 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -5847,7 +5847,7 @@ impl ChannelContext { 1 }; // Note that the feerate is 0 in zero-fee commitment channels, so this statement is a noop - let spiked_feerate = feerate * fee_spike_multiple; + let spiked_feerate = feerate.saturating_mul(fee_spike_multiple); let (remote_stats, _remote_htlcs) = self .get_next_remote_commitment_stats( funding, @@ -13333,7 +13333,8 @@ where let feerate_per_kw = if !funding.get_channel_type().supports_anchors_zero_fee_htlc_tx() { // Similar to HTLC additions, require the funder to have enough funds reserved for // fees such that the feerate can jump without rendering the channel useless. - self.context.feerate_per_kw * FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 + let spike_mul = FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32; + self.context.feerate_per_kw.saturating_mul(spike_mul) } else { self.context.feerate_per_kw }; diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index a54f8f70f8d..f51759db5e9 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -336,12 +336,13 @@ fn get_available_balances( if channel_type.supports_anchor_zero_fee_commitments() { 0 } else { 1 }; // Note that the feerate is 0 in zero-fee commitment channels, so this statement is a noop - let spiked_feerate = feerate_per_kw - * if is_outbound_from_holder && !channel_type.supports_anchors_zero_fee_htlc_tx() { + let spiked_feerate = feerate_per_kw.saturating_mul( + if is_outbound_from_holder && !channel_type.supports_anchors_zero_fee_htlc_tx() { crate::ln::channel::FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 } else { 1 - }; + }, + ); let local_nondust_htlc_count = pending_htlcs .iter() From 4c398bb109f57bb22c56230c1bec768545331231 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 13 Apr 2026 00:56:51 +0000 Subject: [PATCH 288/627] Avoid `Vec::with_capacity(huge)` on empty `Route`s In generally we consider empty `Route`s bogus garbage and don't always handle them super carefully, but ideally we shouldn't allocate a huge buffer just because someone passes a bogus `Route` to an onion-building utility method. Reported by Jordan Mecom of Block's Security Team Test by Claude Opus 4.6 --- .../src/ln/max_payment_path_len_tests.rs | 4 +- lightning/src/ln/onion_utils.rs | 37 ++++++++++++++++++- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/lightning/src/ln/max_payment_path_len_tests.rs b/lightning/src/ln/max_payment_path_len_tests.rs index 45640d3486d..0515a5290d7 100644 --- a/lightning/src/ln/max_payment_path_len_tests.rs +++ b/lightning/src/ln/max_payment_path_len_tests.rs @@ -149,7 +149,7 @@ fn large_payment_metadata() { .unwrap_err(); match err { APIError::InvalidRoute { err } => { - assert_eq!(err, "Route size too large considering onion data"); + assert_eq!(err, "Route size too large (or empty) considering onion data"); }, _ => panic!(), } @@ -441,7 +441,7 @@ fn blinded_path_with_custom_tlv() { .unwrap_err(); match err { APIError::InvalidRoute { err } => { - assert_eq!(err, "Route size too large considering onion data"); + assert_eq!(err, "Route size too large (or empty) considering onion data"); }, _ => panic!(), } diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 9b1b009e93a..602d731bac6 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -832,6 +832,10 @@ fn construct_onion_packet_with_init_noise( mut payloads: Vec, onion_keys: Vec, mut packet_data: P::Data, associated_data: Option<&PaymentHash>, ) -> Result { + if payloads.is_empty() { + return Err(()); + } + let filler = { let packet_data = packet_data.as_mut(); const ONION_HOP_DATA_LEN: usize = 65; // We may decrease this eventually after TLV is common @@ -2682,7 +2686,7 @@ pub(crate) fn create_payment_onion_internal( None, ) .map_err(|_| APIError::InvalidRoute { - err: "Route size too large considering onion data".to_owned(), + err: "Route size too large (or empty) considering onion data".to_owned(), })?; (&trampoline_outer_onion, Some(trampoline_packet)) @@ -2706,7 +2710,7 @@ pub(crate) fn create_payment_onion_internal( let onion_keys = construct_onion_keys(&secp_ctx, &path, session_priv); let onion_packet = construct_onion_packet(onion_payloads, onion_keys, prng_seed, payment_hash) .map_err(|_| APIError::InvalidRoute { - err: "Route size too large considering onion data".to_owned(), + err: "Route size too large (or empty) considering onion data".to_owned(), })?; Ok((onion_packet, htlc_msat, htlc_cltv)) } @@ -4104,4 +4108,33 @@ mod tests { assert_eq!(buffer.len(), 65535); } + + #[test] + fn create_payment_onion_fails_for_empty_route() { + let secp_ctx = Secp256k1::new(); + let session_priv = get_test_session_key(); + let recipient_onion = RecipientOnionFields::spontaneous_empty(1000); + let payment_hash = PaymentHash([0; 32]); + let empty_path = Path { hops: vec![], blinded_tail: None }; + + let err = super::create_payment_onion( + &secp_ctx, + &empty_path, + &session_priv, + &recipient_onion, + 100, + &payment_hash, + &None, + None, + [0; 32], + ) + .unwrap_err(); + + match err { + APIError::InvalidRoute { err } => { + assert_eq!(err, "Route size too large (or empty) considering onion data"); + }, + _ => panic!("Expected InvalidRoute error, got {:?}", err), + } + } } From f6dac1a2a1788696c3b2fa62d9f44179244f6289 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 13 Apr 2026 02:33:01 +0000 Subject: [PATCH 289/627] Document that LSPS5 services should double-check the destination It would be easy to implement an LSPS5 service and forget that the webhook calls are going out based on a URI and headers provided by an untrusted client, so such implementations need to make sure to check if the destination is some internal resource before sending. Reported by Jordan Mecom of Block's Security Team --- lightning-liquidity/src/lsps5/event.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lightning-liquidity/src/lsps5/event.rs b/lightning-liquidity/src/lsps5/event.rs index 30e3aea5687..f6ad6e17b02 100644 --- a/lightning-liquidity/src/lsps5/event.rs +++ b/lightning-liquidity/src/lsps5/event.rs @@ -56,6 +56,9 @@ pub enum LSPS5ServiceEvent { /// /// This is the [`webhook URL`] provided by the client during registration. /// + /// Obviously as the URL provided here is untrusted you should check whether it would + /// access any internal or private resources and decline to send the request if it is. + /// /// [`webhook URL`]: super::msgs::LSPS5WebhookUrl url: LSPS5WebhookUrl, /// Notification method with its parameters. From 78df66df2f541a9c33e05c9d57596e920f207dd6 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Mon, 13 Apr 2026 21:04:15 +0200 Subject: [PATCH 290/627] Validate HTTPS scheme in LSPS5 URL Readable deserialization The `Readable` implementations for `LSPSUrl` and `LSPS5WebhookUrl` were bypassing URL validation, allowing non-HTTPS URLs (e.g., http://, ftp://) to be deserialized from the wire protocol without rejection. Only the serde `Deserialize` and `new()`/`parse()` paths were correctly validating the HTTPS scheme. Route `LSPSUrl::Readable` through `LSPSUrl::parse()` and add a length check to `LSPS5WebhookUrl::Readable` so that wire-deserialized URLs receive the same validation as JSON-deserialized ones. Fixes #4559 Reported-by: Thomas Kilbride of Block Security Co-Authored-By: Claude Opus 4.6 (1M context) --- lightning-liquidity/src/lsps5/msgs.rs | 58 +++++++++++++++++++++- lightning-liquidity/src/lsps5/url_utils.rs | 10 ++-- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/lightning-liquidity/src/lsps5/msgs.rs b/lightning-liquidity/src/lsps5/msgs.rs index 363a3255f92..6e9c5df1139 100644 --- a/lightning-liquidity/src/lsps5/msgs.rs +++ b/lightning-liquidity/src/lsps5/msgs.rs @@ -457,7 +457,11 @@ impl Writeable for LSPS5WebhookUrl { impl Readable for LSPS5WebhookUrl { fn read(reader: &mut R) -> Result { - Ok(Self(Readable::read(reader)?)) + let url: LSPSUrl = Readable::read(reader)?; + if url.url().len() > MAX_WEBHOOK_URL_LENGTH { + return Err(DecodeError::InvalidValue); + } + Ok(Self(url)) } } @@ -902,6 +906,58 @@ mod tests { } } + #[test] + fn test_lsps_url_readable_rejects_http() { + use lightning::util::ser::Writeable; + + let raw = + lightning_types::string::UntrustedString("http://example.com/webhook".to_string()); + let encoded = raw.encode(); + let result = LSPSUrl::read(&mut lightning::io::Cursor::new(&encoded)); + assert!(result.is_err(), "LSPSUrl::Readable should reject http:// URLs"); + } + + #[test] + fn test_lsps_url_readable_accepts_https() { + use lightning::util::ser::Writeable; + + let https_url = LSPSUrl::parse("https://example.com/webhook".to_string()).unwrap(); + let encoded = https_url.encode(); + let decoded = LSPSUrl::read(&mut lightning::io::Cursor::new(&encoded)).unwrap(); + assert_eq!(decoded.url(), "https://example.com/webhook"); + } + + #[test] + fn test_webhook_url_readable_rejects_http() { + use lightning::util::ser::Writeable; + + let raw = + lightning_types::string::UntrustedString("http://example.com/webhook".to_string()); + let encoded = raw.encode(); + let result = LSPS5WebhookUrl::read(&mut lightning::io::Cursor::new(&encoded)); + assert!(result.is_err(), "Readable should reject http:// webhook URLs"); + } + + #[test] + fn test_webhook_url_readable_rejects_too_long() { + use lightning::util::ser::Writeable; + + let long_url = LSPSUrl::parse(format!("https://example.com/{}", "a".repeat(2000))).unwrap(); + let encoded = long_url.encode(); + let result = LSPS5WebhookUrl::read(&mut lightning::io::Cursor::new(&encoded)); + assert!(result.is_err(), "Readable should reject URLs exceeding MAX_WEBHOOK_URL_LENGTH"); + } + + #[test] + fn test_webhook_url_readable_accepts_valid_https() { + use lightning::util::ser::Writeable; + + let valid_url = LSPS5WebhookUrl::new("https://example.com/webhook".to_string()).unwrap(); + let encoded = valid_url.encode(); + let decoded = LSPS5WebhookUrl::read(&mut lightning::io::Cursor::new(&encoded)).unwrap(); + assert_eq!(decoded.as_str(), "https://example.com/webhook"); + } + #[test] fn test_webhook_notification_parameter_binding() { let notification = WebhookNotification::expiry_soon(144); diff --git a/lightning-liquidity/src/lsps5/url_utils.rs b/lightning-liquidity/src/lsps5/url_utils.rs index 2d49c10ff08..2a660b4495f 100644 --- a/lightning-liquidity/src/lsps5/url_utils.rs +++ b/lightning-liquidity/src/lsps5/url_utils.rs @@ -69,9 +69,12 @@ impl LSPSUrl { Ok(LSPSUrl(UntrustedString(url_str))) } - /// Returns URL length. + /// Returns URL length in bytes. + /// + /// Since [`LSPSUrl::parse`] only accepts ASCII characters, this is equivalent + /// to the character count. pub fn url_length(&self) -> usize { - self.0 .0.chars().count() + self.0 .0.len() } /// Returns the full URL string. @@ -99,6 +102,7 @@ impl Writeable for LSPSUrl { impl Readable for LSPSUrl { fn read(reader: &mut R) -> Result { - Ok(Self(Readable::read(reader)?)) + let s: UntrustedString = Readable::read(reader)?; + Self::parse(s.0).map_err(|_| DecodeError::InvalidValue) } } From aac136e13ba67b93f6d20de0cf89a6d0e0901ec0 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Tue, 14 Apr 2026 09:25:07 +0200 Subject: [PATCH 291/627] ci: pin hyper-rustls for Rust 1.75 sync builds The build-sync job started resolving hyper-rustls v0.27.8 for the esplora-async-https configuration. That release requires rustc 1.85, but the sync CI job still runs on Rust 1.75.0. Pin hyper-rustls to 0.27.7 when building with rustc older than 1.85, alongside the existing MSRV dependency pins in ci-tests-common.sh. AI tools were used in preparing this commit. --- ci/ci-tests-common.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ci/ci-tests-common.sh b/ci/ci-tests-common.sh index d60c9d07df0..9631689fcdd 100755 --- a/ci/ci-tests-common.sh +++ b/ci/ci-tests-common.sh @@ -20,4 +20,7 @@ PIN_RELEASE_DEPS # pin the release dependencies in our main workspace # Starting with version 1.2.0, the `idna_adapter` crate has an MSRV of rustc 1.81.0. [ "$RUSTC_MINOR_VERSION" -lt 81 ] && cargo update -p idna_adapter --precise "1.1.0" --quiet +# Starting with version 0.27.8, the `hyper-rustls` crate has an MSRV of rustc 1.85.0. +[ "$RUSTC_MINOR_VERSION" -lt 85 ] && cargo update -p hyper-rustls --precise "0.27.7" --quiet + export RUST_BACKTRACE=1 From 6e5ec9d85ca22dd27dc18a1dfe9323b541b2ae2c Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Tue, 14 Apr 2026 09:54:18 +0200 Subject: [PATCH 292/627] Remove ldk-node integration workflow --- .github/workflows/ldk-node-integration.yml | 57 ---------------------- 1 file changed, 57 deletions(-) delete mode 100644 .github/workflows/ldk-node-integration.yml diff --git a/.github/workflows/ldk-node-integration.yml b/.github/workflows/ldk-node-integration.yml deleted file mode 100644 index 8ca66b75664..00000000000 --- a/.github/workflows/ldk-node-integration.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: LDK Node Integration Tests - -on: [push, pull_request] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - check-api: - runs-on: self-hosted - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - path: rust-lightning - - name: Checkout LDK Node - uses: actions/checkout@v4 - with: - repository: lightningdevkit/ldk-node - path: ldk-node - - name: Install Rust stable toolchain - run: | - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain stable - - name: Run LDK Node Integration Tests - run: | - cd ldk-node - cat <> Cargo.toml - [patch.crates-io] - lightning = { path = "../rust-lightning/lightning" } - lightning-types = { path = "../rust-lightning/lightning-types" } - lightning-invoice = { path = "../rust-lightning/lightning-invoice" } - lightning-net-tokio = { path = "../rust-lightning/lightning-net-tokio" } - lightning-persister = { path = "../rust-lightning/lightning-persister" } - lightning-background-processor = { path = "../rust-lightning/lightning-background-processor" } - lightning-rapid-gossip-sync = { path = "../rust-lightning/lightning-rapid-gossip-sync" } - lightning-block-sync = { path = "../rust-lightning/lightning-block-sync" } - lightning-transaction-sync = { path = "../rust-lightning/lightning-transaction-sync" } - lightning-liquidity = { path = "../rust-lightning/lightning-liquidity" } - lightning-macros = { path = "../rust-lightning/lightning-macros" } - - [patch."https://github.com/lightningdevkit/rust-lightning"] - lightning = { path = "../rust-lightning/lightning" } - lightning-types = { path = "../rust-lightning/lightning-types" } - lightning-invoice = { path = "../rust-lightning/lightning-invoice" } - lightning-net-tokio = { path = "../rust-lightning/lightning-net-tokio" } - lightning-persister = { path = "../rust-lightning/lightning-persister" } - lightning-background-processor = { path = "../rust-lightning/lightning-background-processor" } - lightning-rapid-gossip-sync = { path = "../rust-lightning/lightning-rapid-gossip-sync" } - lightning-block-sync = { path = "../rust-lightning/lightning-block-sync" } - lightning-transaction-sync = { path = "../rust-lightning/lightning-transaction-sync" } - lightning-liquidity = { path = "../rust-lightning/lightning-liquidity" } - lightning-macros = { path = "../rust-lightning/lightning-macros" } - EOF - cargo check - cargo check --features uniffi From fd8846b5c8016f7b34166a69e8d3bd9617622611 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 13 Apr 2026 10:49:06 +0200 Subject: [PATCH 293/627] Apply MPP receive timeout to keysend payments Incomplete keysend MPPs skipped the receive timeout path, allowing partial payments to hold HTLC slots until CLTV expiry instead of failing after `MPP_TIMEOUT_TICKS`. Apply the existing `total_mpp_amount_msat` completeness check to all MPP receives and add a regression test covering the keysend case. The timeout logic was originally added only for invoice-backed MPPs in 2022, and that invoice-only guard remained when receive-side MPP keysend support landed in 2023, leaving this gap latent until now. Co-Authored-By: HAL 9000 --- lightning/src/ln/channelmanager.rs | 39 +++++++++++------------- lightning/src/ln/payment_tests.rs | 49 +++++++++++++++++++++++++----- 2 files changed, 60 insertions(+), 28 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 7f6d6535e58..3bd90dbe01a 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8878,27 +8878,24 @@ impl< debug_assert!(false); return false; } - if let OnionPayload::Invoice { .. } = payment.htlcs[0].onion_payload { - // Check if we've received all the parts we need for an MPP (the value of the parts adds to total_msat). - // In this case we're not going to handle any timeouts of the parts here. - // This condition determining whether the MPP is complete here must match - // exactly the condition used in `process_pending_htlc_forwards`. - let total_intended_recvd_value = - payment.htlcs.iter().map(|h| h.sender_intended_value).sum(); - let total_mpp_value = payment.onion_fields.total_mpp_amount_msat; - if total_mpp_value <= total_intended_recvd_value { - return true; - } else if payment.htlcs.iter_mut().any(|htlc| { - htlc.timer_ticks += 1; - return htlc.timer_ticks >= MPP_TIMEOUT_TICKS; - }) { - let htlcs = payment - .htlcs - .drain(..) - .map(|htlc: ClaimableHTLC| (htlc.prev_hop, *payment_hash)); - timed_out_mpp_htlcs.extend(htlcs); - return false; - } + // Check if we've received all the parts we need for an MPP. + // This condition determining whether the MPP is complete here must match + // exactly the condition used in `process_pending_htlc_forwards`. + let total_intended_recvd_value = + payment.htlcs.iter().map(|h| h.sender_intended_value).sum(); + let total_mpp_value = payment.onion_fields.total_mpp_amount_msat; + if total_mpp_value <= total_intended_recvd_value { + return true; + } else if payment.htlcs.iter_mut().any(|htlc| { + htlc.timer_ticks += 1; + return htlc.timer_ticks >= MPP_TIMEOUT_TICKS; + }) { + let htlcs = payment + .htlcs + .drain(..) + .map(|htlc: ClaimableHTLC| (htlc.prev_hop, *payment_hash)); + timed_out_mpp_htlcs.extend(htlcs); + return false; } true }, diff --git a/lightning/src/ln/payment_tests.rs b/lightning/src/ln/payment_tests.rs index 807d1a1af39..5b4f5f93d71 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -335,7 +335,7 @@ fn mpp_retry_overpay() { expect_payment_sent!(&nodes[0], payment_preimage, Some(expected_total_fee_msat)); } -fn do_mpp_receive_timeout(send_partial_mpp: bool) { +fn do_mpp_receive_timeout(send_partial_mpp: bool, keysend: bool) { let chanmon_cfgs = create_chanmon_cfgs(4); let node_cfgs = create_node_cfgs(4, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(4, &node_cfgs, &[None, None, None, None]); @@ -351,8 +351,12 @@ fn do_mpp_receive_timeout(send_partial_mpp: bool) { let (chan_3_update, _, chan_3_id, _) = create_announced_chan_between_nodes(&nodes, 1, 3); let (chan_4_update, _, _, _) = create_announced_chan_between_nodes(&nodes, 2, 3); - let (mut route, hash, payment_preimage, payment_secret) = - get_route_and_payment_hash!(nodes[0], nodes[3], 100_000); + let (mut route, hash, payment_preimage, payment_secret) = if keysend { + let payment_params = PaymentParameters::for_keysend(node_d_id, TEST_FINAL_CLTV, true); + get_route_and_payment_hash!(nodes[0], nodes[3], payment_params, 100_000) + } else { + get_route_and_payment_hash!(nodes[0], nodes[3], 100_000) + }; let path = route.paths[0].clone(); route.paths.push(path); route.paths[0].hops[0].pubkey = node_b_id; @@ -365,7 +369,22 @@ fn do_mpp_receive_timeout(send_partial_mpp: bool) { // Initiate the MPP payment. let onion = RecipientOnionFields::secret_only(payment_secret, 200_000); - nodes[0].node.send_payment_with_route(route, hash, onion, PaymentId(hash.0)).unwrap(); + if keysend { + let route_params = route.route_params.clone().unwrap(); + nodes[0].router.expect_find_route(route_params.clone(), Ok(route.clone())); + nodes[0] + .node + .send_spontaneous_payment( + Some(payment_preimage), + onion, + PaymentId(hash.0), + route_params, + Retry::Attempts(0), + ) + .unwrap(); + } else { + nodes[0].node.send_payment_with_route(route, hash, onion, PaymentId(hash.0)).unwrap(); + } check_added_monitors(&nodes[0], 2); // one monitor per path let mut events = nodes[0].node.get_and_clear_pending_msg_events(); assert_eq!(events.len(), 2); @@ -414,7 +433,17 @@ fn do_mpp_receive_timeout(send_partial_mpp: bool) { let node_2_msgs = remove_first_msg_event_to_node(&node_c_id, &mut events); let path = &[&nodes[2], &nodes[3]]; let payment_secret = Some(payment_secret); - pass_along_path(&nodes[0], path, 200_000, hash, payment_secret, node_2_msgs, true, None); + let expected_preimage = if keysend { Some(payment_preimage) } else { None }; + pass_along_path( + &nodes[0], + path, + 200_000, + hash, + payment_secret, + node_2_msgs, + true, + expected_preimage, + ); // Even after MPP_TIMEOUT_TICKS we should not timeout the MPP if we have all the parts for _ in 0..MPP_TIMEOUT_TICKS { @@ -428,8 +457,14 @@ fn do_mpp_receive_timeout(send_partial_mpp: bool) { #[test] fn mpp_receive_timeout() { - do_mpp_receive_timeout(true); - do_mpp_receive_timeout(false); + do_mpp_receive_timeout(true, false); + do_mpp_receive_timeout(false, false); +} + +#[test] +fn keysend_mpp_receive_timeout() { + do_mpp_receive_timeout(true, true); + do_mpp_receive_timeout(false, true); } #[test] From 3029bf4c9b9bd2e1e6135333679e0fbf66bb9096 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Tue, 14 Apr 2026 08:32:33 +0200 Subject: [PATCH 294/627] fuzz: handle BroadcastChannelUpdate in chanmon The regression was introduced in d627ce14c. That change switched fee update opcodes in chanmon_consistency from maybe_update_chan_fees() to timer_tick_occurred(), which can enqueue BroadcastChannelUpdate events while peers are disconnected. The harness already tolerated those events in one delivery path, but still treated them as unreachable in push_excess_b_events and disconnect draining. Accept BroadcastChannelUpdate in those match arms so the fuzz target no longer panics on valid timer tick driven message queues. AI tools were used in preparing this commit. --- fuzz/src/chanmon_consistency.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index f20f93c789c..d8d45706a78 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1608,6 +1608,7 @@ pub fn do_test(data: &[u8], out: Out) { }, MessageSendEvent::SendChannelReady { .. } => continue, MessageSendEvent::SendAnnouncementSignatures { .. } => continue, + MessageSendEvent::BroadcastChannelUpdate { .. } => continue, MessageSendEvent::SendChannelUpdate { ref node_id, .. } => { if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } *node_id == a_id @@ -1894,6 +1895,7 @@ pub fn do_test(data: &[u8], out: Out) { MessageSendEvent::SendStfu { .. } => {}, MessageSendEvent::SendChannelReady { .. } => {}, MessageSendEvent::SendAnnouncementSignatures { .. } => {}, + MessageSendEvent::BroadcastChannelUpdate { .. } => {}, MessageSendEvent::SendChannelUpdate { .. } => {}, MessageSendEvent::HandleError { ref action, .. } => { assert_action_timeout_awaiting_response(action); @@ -1916,6 +1918,7 @@ pub fn do_test(data: &[u8], out: Out) { MessageSendEvent::SendStfu { .. } => {}, MessageSendEvent::SendChannelReady { .. } => {}, MessageSendEvent::SendAnnouncementSignatures { .. } => {}, + MessageSendEvent::BroadcastChannelUpdate { .. } => {}, MessageSendEvent::SendChannelUpdate { .. } => {}, MessageSendEvent::HandleError { ref action, .. } => { assert_action_timeout_awaiting_response(action); From 9f59b33e9b3505c1c4dcf36daec8d7a2632accfb Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Tue, 14 Apr 2026 08:51:15 +0200 Subject: [PATCH 295/627] fuzz: gate splice opcodes on cfg(splicing) Keep the splice opcodes in chanmon_consistency available only when the crate is built with cfg(splicing). When splicing is disabled, return early from those opcode handlers instead of calling splice helpers that are not compiled in. Add cfg(splicing) to fuzz/Cargo.toml check-cfg so the guarded code builds cleanly in the fuzz crate. AI tools were used in preparing this commit. --- fuzz/Cargo.toml | 1 + fuzz/src/chanmon_consistency.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 5a2e397a064..252946be458 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -62,4 +62,5 @@ check-cfg = [ "cfg(fuzzing)", "cfg(secp256k1_fuzz)", "cfg(hashes_fuzz)", + "cfg(splicing)", ] diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index d8d45706a78..d4a0e560887 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1333,7 +1333,9 @@ pub fn do_test(data: &[u8], out: Out) { let (node_c, mut monitor_c, keys_manager_c, logger_c) = make_node!(2, fee_est_c, broadcast_c); let mut nodes = [node_a, node_b, node_c]; + #[allow(unused_variables)] let loggers = [logger_a, logger_b, logger_c]; + #[allow(unused_variables)] let fee_estimators = [Arc::clone(&fee_est_a), Arc::clone(&fee_est_b), Arc::clone(&fee_est_c)]; // Connect peers first, then create channels @@ -2426,24 +2428,36 @@ pub fn do_test(data: &[u8], out: Out) { }, 0xa0 => { + if !cfg!(splicing) { + test_return!(); + } let cp_node_id = nodes[1].get_our_node_id(); let wallet = WalletSync::new(&wallets[0], Arc::clone(&loggers[0])); let feerate_sat_per_kw = fee_estimators[0].feerate_sat_per_kw(); splice_in(&nodes[0], &cp_node_id, &chan_a_id, &wallet, feerate_sat_per_kw); }, 0xa1 => { + if !cfg!(splicing) { + test_return!(); + } let cp_node_id = nodes[0].get_our_node_id(); let wallet = WalletSync::new(&wallets[1], Arc::clone(&loggers[1])); let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw(); splice_in(&nodes[1], &cp_node_id, &chan_a_id, &wallet, feerate_sat_per_kw); }, 0xa2 => { + if !cfg!(splicing) { + test_return!(); + } let cp_node_id = nodes[2].get_our_node_id(); let wallet = WalletSync::new(&wallets[1], Arc::clone(&loggers[1])); let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw(); splice_in(&nodes[1], &cp_node_id, &chan_b_id, &wallet, feerate_sat_per_kw); }, 0xa3 => { + if !cfg!(splicing) { + test_return!(); + } let cp_node_id = nodes[1].get_our_node_id(); let wallet = WalletSync::new(&wallets[2], Arc::clone(&loggers[2])); let feerate_sat_per_kw = fee_estimators[2].feerate_sat_per_kw(); @@ -2451,6 +2465,9 @@ pub fn do_test(data: &[u8], out: Out) { }, 0xa4 => { + if !cfg!(splicing) { + test_return!(); + } let cp_node_id = nodes[1].get_our_node_id(); let wallet = &wallets[0]; let logger = Arc::clone(&loggers[0]); @@ -2458,6 +2475,9 @@ pub fn do_test(data: &[u8], out: Out) { splice_out(&nodes[0], &cp_node_id, &chan_a_id, wallet, logger, feerate_sat_per_kw); }, 0xa5 => { + if !cfg!(splicing) { + test_return!(); + } let cp_node_id = nodes[0].get_our_node_id(); let wallet = &wallets[1]; let logger = Arc::clone(&loggers[1]); @@ -2465,6 +2485,9 @@ pub fn do_test(data: &[u8], out: Out) { splice_out(&nodes[1], &cp_node_id, &chan_a_id, wallet, logger, feerate_sat_per_kw); }, 0xa6 => { + if !cfg!(splicing) { + test_return!(); + } let cp_node_id = nodes[2].get_our_node_id(); let wallet = &wallets[1]; let logger = Arc::clone(&loggers[1]); @@ -2472,6 +2495,9 @@ pub fn do_test(data: &[u8], out: Out) { splice_out(&nodes[1], &cp_node_id, &chan_b_id, wallet, logger, feerate_sat_per_kw); }, 0xa7 => { + if !cfg!(splicing) { + test_return!(); + } let cp_node_id = nodes[1].get_our_node_id(); let wallet = &wallets[2]; let logger = Arc::clone(&loggers[2]); From c285b3188fd21a8daf0c746194cfad5f4ee7c0b0 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Mon, 2 Mar 2026 15:47:37 +0200 Subject: [PATCH 296/627] ln/refactor: add previous_hop_data helper for HTLCSource --- lightning/src/ln/channelmanager.rs | 33 +++++++++++++----------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 3bd90dbe01a..7dd9d849b13 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -843,6 +843,14 @@ mod fuzzy_channelmanager { }, } } + + pub(crate) fn previous_hop_data(&self) -> &[HTLCPreviousHopData] { + match self { + HTLCSource::PreviousHopData(prev_hop) => core::slice::from_ref(prev_hop), + HTLCSource::TrampolineForward { previous_hop_data, .. } => &previous_hop_data[..], + HTLCSource::OutboundRoute { .. } => &[], + } + } } /// Tracks the inbound corresponding to an outbound HTLC @@ -12532,15 +12540,8 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ chan.update_fulfill_htlc(&msg), chan_entry ); - let prev_hops = match &res.0 { - HTLCSource::PreviousHopData(prev_hop) => vec![prev_hop], - HTLCSource::TrampolineForward { previous_hop_data, .. } => { - previous_hop_data.iter().collect() - }, - _ => vec![], - }; let logger = WithChannelContext::from(&self.logger, &chan.context, None); - for prev_hop in prev_hops { + for prev_hop in res.0.previous_hop_data() { log_trace!(logger, "Holding the next revoke_and_ack until the preimage is durably persisted in the inbound edge's ChannelMonitor", ); @@ -19709,17 +19710,11 @@ impl< .into_iter() .filter_map(|(htlc_source, (htlc, preimage_opt))| { let payment_preimage = preimage_opt?; - let prev_htlcs = match &htlc_source { - HTLCSource::PreviousHopData(prev_hop) => vec![prev_hop], - HTLCSource::TrampolineForward { previous_hop_data, .. } => { - previous_hop_data.iter().collect() - }, - // If it was an outbound payment, we've handled it above - if a preimage - // came in and we persisted the `ChannelManager` we either handled it - // and are good to go or the channel force-closed - we don't have to - // handle the channel still live case here. - _ => vec![], - }; + // If it was an outbound payment, we've handled it above - if a preimage + // came in and we persisted the `ChannelManager` we either handled it + // and are good to go or the channel force-closed - we don't have to + // handle the channel still live case here. + let prev_htlcs = htlc_source.previous_hop_data(); let prev_htlcs_count = prev_htlcs.len(); if prev_htlcs_count == 0 { return None; From c07180f4ba644b715baabd3aa7c3e1f23513e238 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Thu, 12 Mar 2026 08:25:58 -0400 Subject: [PATCH 297/627] ln/refactor: rename shared secret and populate in HTLCPreviousHopData --- lightning/src/ln/channelmanager.rs | 12 ++++++++---- lightning/src/ln/onion_payment.rs | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 7dd9d849b13..9b627d4f6cc 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -222,11 +222,12 @@ pub enum PendingHTLCRouting { }, /// An HTLC which should be forwarded on to another Trampoline node. TrampolineForward { - /// The onion shared secret we build with the sender (or the preceding Trampoline node) used - /// to decrypt the onion. + /// The onion shared secret we build with the node that forwarded us this trampoline + /// forward (either the original sender, or a preceding Trampoline node), used to decrypt + /// the inner trampoline onion. /// /// This is later used to encrypt failure packets in the event that the HTLC is failed. - incoming_shared_secret: [u8; 32], + trampoline_shared_secret: [u8; 32], /// The onion which should be included in the forwarded HTLC, telling the next hop what to /// do with the HTLC. onion_packet: msgs::TrampolineOnionPacket, @@ -465,6 +466,9 @@ impl PendingAddHTLCInfo { PendingHTLCRouting::Receive { trampoline_shared_secret, .. } => { trampoline_shared_secret }, + PendingHTLCRouting::TrampolineForward { trampoline_shared_secret, .. } => { + Some(trampoline_shared_secret) + }, _ => None, }; @@ -17483,7 +17487,7 @@ impl_writeable_tlv_based_enum!(PendingHTLCRouting, (11, invoice_request, option), }, (3, TrampolineForward) => { - (0, incoming_shared_secret, required), + (0, trampoline_shared_secret, required), (2, onion_packet, required), (4, blinded, option), (6, node_id, required), diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index 5111f6982fe..bb5b8f21a48 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -249,7 +249,7 @@ pub(super) fn create_fwd_pending_htlc_info( hmac: next_hop_hmac, }; PendingHTLCRouting::TrampolineForward { - incoming_shared_secret: shared_secret.secret_bytes(), + trampoline_shared_secret: shared_secret.secret_bytes(), onion_packet: outgoing_packet, node_id: next_trampoline, incoming_cltv_expiry: msg.cltv_expiry, From c3fbac3ab4bb3cd1fc17bd7ba9744c2406c96aac Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 14 Apr 2026 09:08:19 -0400 Subject: [PATCH 298/627] ln/refactor: move MPP information into separate struct to ClaimableHTLC Pull out all fields that are common to incoming claimable and trampoline MPP HTLCs. This will be used in future commits to accumulate MPP HTLCs that are part of trampoline forwards - we can't claim these, but need to accumulate them in the same way as receives before forwarding onwards. --- lightning/src/ln/channelmanager.rs | 216 +++++++++++++++++------------ 1 file changed, 125 insertions(+), 91 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 9b627d4f6cc..dd91f3cb37f 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -520,9 +520,8 @@ enum OnionPayload { Spontaneous(PaymentPreimage), } -/// HTLCs that are to us and can be failed/claimed by the user #[derive(PartialEq, Eq)] -struct ClaimableHTLC { +struct MppPart { prev_hop: HTLCPreviousHopData, cltv_expiry: u32, /// The amount (in msats) of this MPP part @@ -530,11 +529,34 @@ struct ClaimableHTLC { /// The amount (in msats) that the sender intended to be sent in this MPP /// part (used for validating total MPP amount) sender_intended_value: u64, - onion_payload: OnionPayload, timer_ticks: u8, /// The total value received for a payment (sum of all MPP parts if the payment is a MPP). /// Gets set to the amount reported when pushing [`Event::PaymentClaimable`]. total_value_received: Option, +} + +impl PartialOrd for MppPart { + fn partial_cmp(&self, other: &MppPart) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for MppPart { + fn cmp(&self, other: &MppPart) -> cmp::Ordering { + let res = (self.prev_hop.channel_id, self.prev_hop.htlc_id) + .cmp(&(other.prev_hop.channel_id, other.prev_hop.htlc_id)); + if res.is_eq() { + debug_assert!(self == other, "MppParts from the same source should be identical"); + } + res + } +} + +/// Represents an incoming HTLC that can be claimed or failed by the user. +#[derive(PartialEq, Eq)] +struct ClaimableHTLC { + mpp_part: MppPart, + onion_payload: OnionPayload, /// The extra fee our counterparty skimmed off the top of this HTLC. counterparty_skimmed_fee_msat: Option, } @@ -542,11 +564,11 @@ struct ClaimableHTLC { impl From<&ClaimableHTLC> for events::ClaimedHTLC { fn from(val: &ClaimableHTLC) -> Self { events::ClaimedHTLC { - counterparty_node_id: val.prev_hop.counterparty_node_id, - channel_id: val.prev_hop.channel_id, - user_channel_id: val.prev_hop.user_channel_id.unwrap_or(0), - cltv_expiry: val.cltv_expiry, - value_msat: val.value, + counterparty_node_id: val.mpp_part.prev_hop.counterparty_node_id, + channel_id: val.mpp_part.prev_hop.channel_id, + user_channel_id: val.mpp_part.prev_hop.user_channel_id.unwrap_or(0), + cltv_expiry: val.mpp_part.cltv_expiry, + value_msat: val.mpp_part.value, counterparty_skimmed_fee_msat: val.counterparty_skimmed_fee_msat.unwrap_or(0), } } @@ -559,12 +581,7 @@ impl PartialOrd for ClaimableHTLC { } impl Ord for ClaimableHTLC { fn cmp(&self, other: &ClaimableHTLC) -> cmp::Ordering { - let res = (self.prev_hop.channel_id, self.prev_hop.htlc_id) - .cmp(&(other.prev_hop.channel_id, other.prev_hop.htlc_id)); - if res.is_eq() { - debug_assert!(self == other, "ClaimableHTLCs from the same source should be identical"); - } - res + self.mpp_part.cmp(&other.mpp_part) } } @@ -1216,7 +1233,9 @@ impl ClaimablePayment { fn inbound_payment_id(&self, secret: &[u8; 32]) -> PaymentId { PaymentId::for_inbound_from_htlcs( secret, - self.htlcs.iter().map(|htlc| (htlc.prev_hop.channel_id, htlc.prev_hop.htlc_id)), + self.htlcs + .iter() + .map(|htlc| (htlc.mpp_part.prev_hop.channel_id, htlc.mpp_part.prev_hop.htlc_id)), ) } @@ -1226,7 +1245,7 @@ impl ClaimablePayment { fn receiving_channel_ids(&self) -> Vec<(ChannelId, Option)> { self.htlcs .iter() - .map(|htlc| (htlc.prev_hop.channel_id, htlc.prev_hop.user_channel_id)) + .map(|htlc| (htlc.mpp_part.prev_hop.channel_id, htlc.mpp_part.prev_hop.user_channel_id)) .collect() } } @@ -1323,7 +1342,7 @@ impl ClaimablePayments { let mut receiver_node_id = node_signer.get_node_id(Recipient::Node) .expect("Failed to get node_id for node recipient"); for htlc in payment.htlcs.iter() { - if htlc.prev_hop.phantom_shared_secret.is_some() { + if htlc.mpp_part.prev_hop.phantom_shared_secret.is_some() { let phantom_pubkey = node_signer.get_node_id(Recipient::PhantomNode) .expect("Failed to get node_id for phantom node recipient"); receiver_node_id = phantom_pubkey; @@ -1352,15 +1371,15 @@ impl ClaimablePayments { // Pick an "arbitrary" channel to block RAAs on until the `PaymentSent` // event is processed, specifically the last channel to get claimed. let durable_preimage_channel = payment.htlcs.last().map_or(None, |htlc| { - if let Some(node_id) = htlc.prev_hop.counterparty_node_id { - Some((htlc.prev_hop.outpoint, node_id, htlc.prev_hop.channel_id)) + if let Some(node_id) = htlc.mpp_part.prev_hop.counterparty_node_id { + Some((htlc.mpp_part.prev_hop.outpoint, node_id, htlc.mpp_part.prev_hop.channel_id)) } else { None } }); debug_assert!(durable_preimage_channel.is_some()); ClaimingPayment { - amount_msat: payment.htlcs.iter().map(|source| source.value).sum(), + amount_msat: payment.htlcs.iter().map(|source| source.mpp_part.value).sum(), payment_purpose: payment.purpose, receiver_node_id, htlcs, @@ -8315,15 +8334,17 @@ impl< }, }; let claimable_htlc = ClaimableHTLC { - prev_hop, - // We differentiate the received value from the sender intended value - // if possible so that we don't prematurely mark MPP payments complete - // if routing nodes overpay - value: incoming_amt_msat.unwrap_or(outgoing_amt_msat), - sender_intended_value: outgoing_amt_msat, - timer_ticks: 0, - total_value_received: None, - cltv_expiry, + mpp_part: MppPart { + prev_hop, + cltv_expiry, + // We differentiate the received value from the sender intended value + // if possible so that we don't prematurely mark MPP payments complete + // if routing nodes overpay + value: incoming_amt_msat.unwrap_or(outgoing_amt_msat), + sender_intended_value: outgoing_amt_msat, + timer_ticks: 0, + total_value_received: None, + }, onion_payload, counterparty_skimmed_fee_msat: skimmed_fee_msat, }; @@ -8334,21 +8355,22 @@ impl< ($htlc: expr, $payment_hash: expr) => { debug_assert!(!committed_to_claimable); let err_data = invalid_payment_err_data( - $htlc.value, + $htlc.mpp_part.value, self.best_block.read().unwrap().height, ); - let counterparty_node_id = $htlc.prev_hop.counterparty_node_id; + let counterparty_node_id = $htlc.mpp_part.prev_hop.counterparty_node_id; let incoming_packet_shared_secret = - $htlc.prev_hop.incoming_packet_shared_secret; - let prev_outbound_scid_alias = $htlc.prev_hop.prev_outbound_scid_alias; + $htlc.mpp_part.prev_hop.incoming_packet_shared_secret; + let prev_outbound_scid_alias = + $htlc.mpp_part.prev_hop.prev_outbound_scid_alias; failed_forwards.push(( HTLCSource::PreviousHopData(HTLCPreviousHopData { prev_outbound_scid_alias, - user_channel_id: $htlc.prev_hop.user_channel_id, + user_channel_id: $htlc.mpp_part.prev_hop.user_channel_id, counterparty_node_id, channel_id: prev_channel_id, outpoint: prev_funding_outpoint, - htlc_id: $htlc.prev_hop.htlc_id, + htlc_id: $htlc.mpp_part.prev_hop.htlc_id, incoming_packet_shared_secret, phantom_shared_secret, trampoline_shared_secret, @@ -8365,7 +8387,8 @@ impl< continue 'next_forwardable_htlc; }; } - let phantom_shared_secret = claimable_htlc.prev_hop.phantom_shared_secret; + let phantom_shared_secret = + claimable_htlc.mpp_part.prev_hop.phantom_shared_secret; let mut receiver_node_id = self.our_network_pubkey; if phantom_shared_secret.is_some() { receiver_node_id = self @@ -8404,11 +8427,11 @@ impl< fail_htlc!(claimable_htlc, payment_hash); } let mut total_intended_recvd_value = - claimable_htlc.sender_intended_value; - let mut earliest_expiry = claimable_htlc.cltv_expiry; + claimable_htlc.mpp_part.sender_intended_value; + let mut earliest_expiry = claimable_htlc.mpp_part.cltv_expiry; for htlc in claimable_payment.htlcs.iter() { - total_intended_recvd_value += htlc.sender_intended_value; - earliest_expiry = cmp::min(earliest_expiry, htlc.cltv_expiry); + total_intended_recvd_value += htlc.mpp_part.sender_intended_value; + earliest_expiry = cmp::min(earliest_expiry, htlc.mpp_part.cltv_expiry); if total_intended_recvd_value >= msgs::MAX_VALUE_MSAT { break; } } let total_mpp_value = @@ -8417,7 +8440,7 @@ impl< // match exactly the condition used in `timer_tick_occurred` if total_intended_recvd_value >= msgs::MAX_VALUE_MSAT { fail_htlc!(claimable_htlc, payment_hash); - } else if total_intended_recvd_value - claimable_htlc.sender_intended_value >= total_mpp_value { + } else if total_intended_recvd_value - claimable_htlc.mpp_part.sender_intended_value >= total_mpp_value { log_trace!(self.logger, "Failing HTLC with payment_hash {} as payment is already claimable", &payment_hash); fail_htlc!(claimable_htlc, payment_hash); @@ -8427,9 +8450,9 @@ impl< } claimable_payment.htlcs.push(claimable_htlc); let amount_msat = - claimable_payment.htlcs.iter().map(|htlc| htlc.value).sum(); + claimable_payment.htlcs.iter().map(|htlc| htlc.mpp_part.value).sum(); claimable_payment.htlcs.iter_mut() - .for_each(|htlc| htlc.total_value_received = Some(amount_msat)); + .for_each(|htlc| htlc.mpp_part.total_value_received = Some(amount_msat)); let counterparty_skimmed_fee_msat = claimable_payment.htlcs.iter() .map(|htlc| htlc.counterparty_skimmed_fee_msat.unwrap_or(0)).sum(); debug_assert!(total_intended_recvd_value.saturating_sub(amount_msat) @@ -8894,18 +8917,18 @@ impl< // This condition determining whether the MPP is complete here must match // exactly the condition used in `process_pending_htlc_forwards`. let total_intended_recvd_value = - payment.htlcs.iter().map(|h| h.sender_intended_value).sum(); + payment.htlcs.iter().map(|h| h.mpp_part.sender_intended_value).sum(); let total_mpp_value = payment.onion_fields.total_mpp_amount_msat; if total_mpp_value <= total_intended_recvd_value { return true; } else if payment.htlcs.iter_mut().any(|htlc| { - htlc.timer_ticks += 1; - return htlc.timer_ticks >= MPP_TIMEOUT_TICKS; + htlc.mpp_part.timer_ticks += 1; + return htlc.mpp_part.timer_ticks >= MPP_TIMEOUT_TICKS; }) { let htlcs = payment .htlcs .drain(..) - .map(|htlc: ClaimableHTLC| (htlc.prev_hop, *payment_hash)); + .map(|htlc: ClaimableHTLC| (htlc.mpp_part.prev_hop, *payment_hash)); timed_out_mpp_htlcs.extend(htlcs); return false; } @@ -8993,7 +9016,7 @@ impl< if let Some(payment) = removed_source { for htlc in payment.htlcs { let reason = self.get_htlc_fail_reason_from_failure_code(failure_code, &htlc); - let source = HTLCSource::PreviousHopData(htlc.prev_hop); + let source = HTLCSource::PreviousHopData(htlc.mpp_part.prev_hop); let receiver = HTLCHandlingFailureType::Receive { payment_hash: *payment_hash }; self.fail_htlc_backwards_internal(&source, &payment_hash, &reason, receiver, None); } @@ -9012,7 +9035,7 @@ impl< HTLCFailReason::from_failure_code(failure_code.into()) }, FailureCode::IncorrectOrUnknownPaymentDetails => { - let mut htlc_msat_height_data = htlc.value.to_be_bytes().to_vec(); + let mut htlc_msat_height_data = htlc.mpp_part.value.to_be_bytes().to_vec(); htlc_msat_height_data .extend_from_slice(&self.best_block.read().unwrap().height.to_be_bytes()); HTLCFailReason::reason(failure_code.into(), htlc_msat_height_data) @@ -9347,7 +9370,7 @@ impl< FailureCode::InvalidOnionPayload(None), &htlc, ); - let source = HTLCSource::PreviousHopData(htlc.prev_hop); + let source = HTLCSource::PreviousHopData(htlc.mpp_part.prev_hop); let receiver = HTLCHandlingFailureType::Receive { payment_hash }; self.fail_htlc_backwards_internal( &source, @@ -9373,14 +9396,16 @@ impl< let mut errs = Vec::new(); let per_peer_state = self.per_peer_state.read().unwrap(); for htlc in sources.iter() { - if expected_amt_msat.is_some() && expected_amt_msat != htlc.total_value_received { + if expected_amt_msat.is_some() + && expected_amt_msat != htlc.mpp_part.total_value_received + { log_error!(self.logger, "Somehow ended up with an MPP payment with different received total amounts - this should not be reachable!"); debug_assert!(false); valid_mpp = false; break; } - expected_amt_msat = htlc.total_value_received; - claimable_amt_msat += htlc.value; + expected_amt_msat = htlc.mpp_part.total_value_received; + claimable_amt_msat += htlc.mpp_part.value; } mem::drop(per_peer_state); if sources.is_empty() || expected_amt_msat.is_none() { @@ -9401,12 +9426,12 @@ impl< let mpp_parts: Vec<_> = sources .iter() .filter_map(|htlc| { - if let Some(cp_id) = htlc.prev_hop.counterparty_node_id { + if let Some(cp_id) = htlc.mpp_part.prev_hop.counterparty_node_id { Some(MPPClaimHTLCSource { counterparty_node_id: cp_id, - funding_txo: htlc.prev_hop.outpoint, - channel_id: htlc.prev_hop.channel_id, - htlc_id: htlc.prev_hop.htlc_id, + funding_txo: htlc.mpp_part.prev_hop.outpoint, + channel_id: htlc.mpp_part.prev_hop.channel_id, + htlc_id: htlc.mpp_part.prev_hop.htlc_id, }) } else { None @@ -9432,11 +9457,11 @@ impl< for htlc in sources { let this_mpp_claim = pending_mpp_claim_ptr_opt.as_ref().map(|pending_mpp_claim| { - let counterparty_id = htlc.prev_hop.counterparty_node_id; + let counterparty_id = htlc.mpp_part.prev_hop.counterparty_node_id; let counterparty_id = counterparty_id .expect("Prior to upgrading to LDK 0.1, all pending HTLCs forwarded by LDK 0.0.123 or before must be resolved. It appears at least one claimable payment was not resolved. Please downgrade to LDK 0.0.125 and resolve the HTLC by claiming the payment prior to upgrading."); let claim_ptr = PendingMPPClaimPointer(Arc::clone(pending_mpp_claim)); - (counterparty_id, htlc.prev_hop.channel_id, claim_ptr) + (counterparty_id, htlc.mpp_part.prev_hop.channel_id, claim_ptr) }); let raa_blocker = pending_mpp_claim_ptr_opt.as_ref().map(|pending_claim| { RAAMonitorUpdateBlockingAction::ClaimedMPPPayment { @@ -9448,7 +9473,7 @@ impl< // non-zero value will not make a difference in the penalty that may be applied by the sender. If there // is a phantom hop, we need to double-process. let attribution_data = - if let Some(phantom_secret) = htlc.prev_hop.phantom_shared_secret { + if let Some(phantom_secret) = htlc.mpp_part.prev_hop.phantom_shared_secret { let attribution_data = process_fulfill_attribution_data(None, &phantom_secret, 0); Some(attribution_data) @@ -9458,12 +9483,12 @@ impl< let attribution_data = process_fulfill_attribution_data( attribution_data, - &htlc.prev_hop.incoming_packet_shared_secret, + &htlc.mpp_part.prev_hop.incoming_packet_shared_secret, 0, ); self.claim_funds_from_hop( - htlc.prev_hop, + &htlc.mpp_part.prev_hop, payment_preimage, payment_info.clone(), Some(attribution_data), @@ -9484,9 +9509,11 @@ impl< } } else { for htlc in sources { - let err_data = - invalid_payment_err_data(htlc.value, self.best_block.read().unwrap().height); - let source = HTLCSource::PreviousHopData(htlc.prev_hop); + let err_data = invalid_payment_err_data( + htlc.mpp_part.value, + self.best_block.read().unwrap().height, + ); + let source = HTLCSource::PreviousHopData(htlc.mpp_part.prev_hop); let reason = HTLCFailReason::reason( LocalHTLCFailureReason::IncorrectPaymentDetails, err_data, @@ -9534,7 +9561,7 @@ impl< #[cfg(test)] let claiming_chan_funding_outpoint = hop_data.outpoint; self.claim_funds_from_hop( - hop_data, + &hop_data, payment_preimage, None, Some(attribution_data), @@ -9633,7 +9660,7 @@ impl< bool, ) -> (Option, Option), >( - &self, prev_hop: HTLCPreviousHopData, payment_preimage: PaymentPreimage, + &self, prev_hop: &HTLCPreviousHopData, payment_preimage: PaymentPreimage, payment_info: Option, attribution_data: Option, completion_action: ComplFunc, ) { @@ -16172,14 +16199,14 @@ impl< // our commitment transaction confirmed before the HTLC expires, plus the // number of blocks we generally consider it to take to do a commitment update, // just give up on it and fail the HTLC. - if height >= htlc.cltv_expiry - HTLC_FAIL_BACK_BUFFER { + if height >= htlc.mpp_part.cltv_expiry - HTLC_FAIL_BACK_BUFFER { let reason = LocalHTLCFailureReason::PaymentClaimBuffer; timed_out_htlcs.push(( - HTLCSource::PreviousHopData(htlc.prev_hop.clone()), + HTLCSource::PreviousHopData(htlc.mpp_part.prev_hop.clone()), payment_hash.clone(), HTLCFailReason::reason( reason, - invalid_payment_err_data(htlc.value, height), + invalid_payment_err_data(htlc.mpp_part.value, height), ), HTLCHandlingFailureType::Receive { payment_hash: payment_hash.clone(), @@ -17614,13 +17641,13 @@ fn write_claimable_htlc( OnionPayload::Spontaneous(preimage) => (None, Some(preimage)), }; write_tlv_fields!(writer, { - (0, htlc.prev_hop, required), + (0, htlc.mpp_part.prev_hop, required), (1, total_mpp_value_msat, required), - (2, htlc.value, required), - (3, htlc.sender_intended_value, required), + (2, htlc.mpp_part.value, required), + (3, htlc.mpp_part.sender_intended_value, required), (4, payment_data, option), - (5, htlc.total_value_received, option), - (6, htlc.cltv_expiry, required), + (5, htlc.mpp_part.total_value_received, option), + (6, htlc.mpp_part.cltv_expiry, required), (8, keysend_preimage, option), (10, htlc.counterparty_skimmed_fee_msat, option), }); @@ -17653,13 +17680,15 @@ impl Readable for (ClaimableHTLC, u64) { None => OnionPayload::Invoice { _legacy_hop_data: payment_data }, }; Ok((ClaimableHTLC { - prev_hop: prev_hop.0.unwrap(), - timer_ticks: 0, - value, - sender_intended_value: sender_intended_value.unwrap_or(value), - total_value_received, + mpp_part: MppPart { + prev_hop: prev_hop.0.unwrap(), + timer_ticks: 0, + value, + sender_intended_value: sender_intended_value.unwrap_or(value), + total_value_received, + cltv_expiry: cltv_expiry.0.unwrap(), + }, onion_payload, - cltv_expiry: cltv_expiry.0.unwrap(), counterparty_skimmed_fee_msat, }, total_msat.0.expect("required field"))) } @@ -19783,10 +19812,13 @@ impl< // panic if we attempted to claim them at this point. for (payment_hash, payment) in claimable_payments.iter() { for htlc in payment.htlcs.iter() { - if htlc.prev_hop.counterparty_node_id.is_some() { + if htlc.mpp_part.prev_hop.counterparty_node_id.is_some() { continue; } - if short_to_chan_info.get(&htlc.prev_hop.prev_outbound_scid_alias).is_some() { + if short_to_chan_info + .get(&htlc.mpp_part.prev_hop.prev_outbound_scid_alias) + .is_some() + { log_error!(args.logger, "We do not have the required information to claim a pending payment with payment hash {} reliably.\ As long as the channel for the inbound edge of the forward remains open, this may work okay, but we may panic at runtime!\ @@ -19974,10 +20006,10 @@ impl< // See above comment on `failed_htlcs`. for htlcs in claimable_payments.values().map(|pmt| &pmt.htlcs) { - for prev_hop_data in htlcs.iter().map(|h| &h.prev_hop) { + for htlc in htlcs.iter() { dedup_decode_update_add_htlcs( &mut decode_update_add_htlcs, - prev_hop_data, + &htlc.mpp_part.prev_hop, "HTLC was already decoded and marked as a claimable payment", &args.logger, ); @@ -20280,7 +20312,8 @@ impl< log_info!(channel_manager.logger, "Re-claiming HTLCs with payment hash {} as we've released the preimage to a ChannelMonitor!", &payment_hash); let mut claimable_amt_msat = 0; let mut receiver_node_id = Some(our_network_pubkey); - let phantom_shared_secret = payment.htlcs[0].prev_hop.phantom_shared_secret; + let phantom_shared_secret = + payment.htlcs[0].mpp_part.prev_hop.phantom_shared_secret; if phantom_shared_secret.is_some() { let phantom_pubkey = channel_manager .node_signer @@ -20289,7 +20322,7 @@ impl< receiver_node_id = Some(phantom_pubkey) } for claimable_htlc in &payment.htlcs { - claimable_amt_msat += claimable_htlc.value; + claimable_amt_msat += claimable_htlc.mpp_part.value; // Add a holding-cell claim of the payment to the Channel, which should be // applied ~immediately on peer reconnection. Because it won't generate a @@ -20306,7 +20339,7 @@ impl< // this channel as well. On the flip side, there's no harm in restarting // without the new monitor persisted - we'll end up right back here on // restart. - let previous_channel_id = claimable_htlc.prev_hop.channel_id; + let previous_channel_id = claimable_htlc.mpp_part.prev_hop.channel_id; let peer_node_id = monitor.get_counterparty_node_id(); { let peer_state_mutex = per_peer_state.get(&peer_node_id).unwrap(); @@ -20324,14 +20357,15 @@ impl< ); channel .claim_htlc_while_disconnected_dropping_mon_update_legacy( - claimable_htlc.prev_hop.htlc_id, + claimable_htlc.mpp_part.prev_hop.htlc_id, payment_preimage, &&logger, ); } } - if let Some(previous_hop_monitor) = - args.channel_monitors.get(&claimable_htlc.prev_hop.channel_id) + if let Some(previous_hop_monitor) = args + .channel_monitors + .get(&claimable_htlc.mpp_part.prev_hop.channel_id) { // Note that this is unsafe as we no longer require the // `ChannelMonitor`s to be re-persisted prior to this From 8c82af32653d799db9d91f964d926f6c6eff58c6 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 14 Apr 2026 09:15:18 -0400 Subject: [PATCH 299/627] ln/refactor: move mpp timeout into helper function We'll use this shared logic when we need to timeout trampoline HTLCs. Note that there's a slight behavior change in this commit. Previously, we'd do a first pass to check out total received value and return early if we'd reached it without applying a MPP tick to any HTLC. Now, we'll apply the MPP tick as we accumulate our total value received. This does not make any difference, because we never MPP-timeout fully accumulated MPP payments so it doesn't matter if we've applied the tick when we've reached our full amount. --- lightning/src/ln/channelmanager.rs | 70 ++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 24 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index dd91f3cb37f..26eb42d918d 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -1250,6 +1250,31 @@ impl ClaimablePayment { } } +/// Increments MPP timeout tick for all HTLCs and returns a boolean indicating whether the HTLC +/// set has hit its MPP timeout. Will return false if the set has reached the sender's intended +/// total, as the MPP has completed in this case. +fn check_mpp_timeout<'a>( + htlcs: impl Iterator, onion_fields: &RecipientOnionFields, +) -> bool { + // This condition determining whether the MPP is complete here must match exactly the condition + // used in `process_pending_htlc_forwards`. + let total_mpp_value = onion_fields.total_mpp_amount_msat; + let mut total_intended_recvd_value = 0; + let mut timed_out = false; + for htlc in htlcs { + total_intended_recvd_value += htlc.sender_intended_value; + htlc.timer_ticks += 1; + if htlc.timer_ticks >= MPP_TIMEOUT_TICKS { + timed_out = true; + } + } + if total_intended_recvd_value >= total_mpp_value { + return false; + } + + timed_out +} + /// Represent the channel funding transaction type. enum FundingType { /// This variant is useful when we want LDK to validate the funding transaction and @@ -8909,39 +8934,36 @@ impl< self.claimable_payments.lock().unwrap().claimable_payments.retain( |payment_hash, payment| { if payment.htlcs.is_empty() { - // This should be unreachable debug_assert!(false); return false; } - // Check if we've received all the parts we need for an MPP. - // This condition determining whether the MPP is complete here must match - // exactly the condition used in `process_pending_htlc_forwards`. - let total_intended_recvd_value = - payment.htlcs.iter().map(|h| h.mpp_part.sender_intended_value).sum(); - let total_mpp_value = payment.onion_fields.total_mpp_amount_msat; - if total_mpp_value <= total_intended_recvd_value { - return true; - } else if payment.htlcs.iter_mut().any(|htlc| { - htlc.mpp_part.timer_ticks += 1; - return htlc.mpp_part.timer_ticks >= MPP_TIMEOUT_TICKS; - }) { - let htlcs = payment - .htlcs - .drain(..) - .map(|htlc: ClaimableHTLC| (htlc.mpp_part.prev_hop, *payment_hash)); - timed_out_mpp_htlcs.extend(htlcs); - return false; + let mpp_timeout = check_mpp_timeout( + payment.htlcs.iter_mut().map(|htlc| &mut htlc.mpp_part), + &payment.onion_fields, + ); + if mpp_timeout { + timed_out_mpp_htlcs.extend(payment.htlcs.drain(..).map(|h| { + ( + HTLCSource::PreviousHopData(h.mpp_part.prev_hop), + *payment_hash, + HTLCHandlingFailureType::Receive { payment_hash: *payment_hash }, + ) + })); } - true + return !mpp_timeout; }, ); - for htlc_source in timed_out_mpp_htlcs.drain(..) { - let source = HTLCSource::PreviousHopData(htlc_source.0.clone()); + for (htlc_source, payment_hash, failure_type) in timed_out_mpp_htlcs.drain(..) { let failure_reason = LocalHTLCFailureReason::MPPTimeout; let reason = HTLCFailReason::from_failure_code(failure_reason); - let receiver = HTLCHandlingFailureType::Receive { payment_hash: htlc_source.1 }; - self.fail_htlc_backwards_internal(&source, &htlc_source.1, &reason, receiver, None); + self.fail_htlc_backwards_internal( + &htlc_source, + &payment_hash, + &reason, + failure_type, + None, + ); } for (err, counterparty_node_id) in handle_errors { From dbf3895bfafdae8ebc6c8628366d14586b229baa Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Fri, 10 Apr 2026 13:08:20 -0400 Subject: [PATCH 300/627] ln/refactor: move on chain timeout check into claimable htlc We'll re-use this to check trampoline MPP timeout in future commits. --- lightning/src/ln/channelmanager.rs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 26eb42d918d..cd1eb3940d2 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -535,6 +535,14 @@ struct MppPart { total_value_received: Option, } +impl MppPart { + /// Returns a boolean indicating whether the HTLC has timed out on chain, accounting for a buffer + /// that gives us time to resolve it. + fn check_onchain_timeout(&self, height: u32) -> bool { + height >= self.cltv_expiry - HTLC_FAIL_BACK_BUFFER + } +} + impl PartialOrd for MppPart { fn partial_cmp(&self, other: &MppPart) -> Option { Some(self.cmp(other)) @@ -16214,14 +16222,15 @@ impl< } if let Some(height) = height_opt { + // If height is approaching the number of blocks we think it takes us to get our + // commitment transaction confirmed before the HTLC expires, plus the number of blocks + // we generally consider it to take to do a commitment update, just give up on it and + // fail the HTLC. self.claimable_payments.lock().unwrap().claimable_payments.retain( |payment_hash, payment| { payment.htlcs.retain(|htlc| { - // If height is approaching the number of blocks we think it takes us to get - // our commitment transaction confirmed before the HTLC expires, plus the - // number of blocks we generally consider it to take to do a commitment update, - // just give up on it and fail the HTLC. - if height >= htlc.mpp_part.cltv_expiry - HTLC_FAIL_BACK_BUFFER { + let htlc_timed_out = htlc.mpp_part.check_onchain_timeout(height); + if htlc_timed_out { let reason = LocalHTLCFailureReason::PaymentClaimBuffer; timed_out_htlcs.push(( HTLCSource::PreviousHopData(htlc.mpp_part.prev_hop.clone()), @@ -16234,10 +16243,8 @@ impl< payment_hash: payment_hash.clone(), }, )); - false - } else { - true } + !htlc_timed_out }); !payment.htlcs.is_empty() // Only retain this entry if htlcs has at least one entry. }, From f517df52174aeb106241f6467cfbf1a33e985a86 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Fri, 10 Apr 2026 13:11:12 -0400 Subject: [PATCH 301/627] ln/refactor: remove claimable htlc from fail_htlc macro In the commit that follows we're going to need to take ownership of our htlc before this macro is used, so we pull out the information we need in advance. --- lightning/src/ln/channelmanager.rs | 73 ++++++++++++++---------------- 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index cd1eb3940d2..562316fb229 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8366,14 +8366,28 @@ impl< panic!("short_channel_id == 0 should imply any pending_forward entries are of type Receive"); }, }; + // We differentiate the received value from the sender intended value + // if possible so that we don't prematurely mark MPP payments complete + // if routing nodes overpay + let value = incoming_amt_msat.unwrap_or(outgoing_amt_msat); + let htlc_source = HTLCSource::PreviousHopData(HTLCPreviousHopData { + prev_outbound_scid_alias: prev_hop.prev_outbound_scid_alias, + user_channel_id: prev_hop.user_channel_id, + counterparty_node_id: prev_hop.counterparty_node_id, + channel_id: prev_channel_id, + outpoint: prev_funding_outpoint, + htlc_id: prev_hop.htlc_id, + incoming_packet_shared_secret: prev_hop.incoming_packet_shared_secret, + phantom_shared_secret, + trampoline_shared_secret, + blinded_failure, + cltv_expiry: Some(cltv_expiry), + }); let claimable_htlc = ClaimableHTLC { mpp_part: MppPart { prev_hop, cltv_expiry, - // We differentiate the received value from the sender intended value - // if possible so that we don't prematurely mark MPP payments complete - // if routing nodes overpay - value: incoming_amt_msat.unwrap_or(outgoing_amt_msat), + value, sender_intended_value: outgoing_amt_msat, timer_ticks: 0, total_value_received: None, @@ -8385,31 +8399,14 @@ impl< let mut committed_to_claimable = false; macro_rules! fail_htlc { - ($htlc: expr, $payment_hash: expr) => { + ($payment_hash: expr) => { debug_assert!(!committed_to_claimable); let err_data = invalid_payment_err_data( - $htlc.mpp_part.value, + value, self.best_block.read().unwrap().height, ); - let counterparty_node_id = $htlc.mpp_part.prev_hop.counterparty_node_id; - let incoming_packet_shared_secret = - $htlc.mpp_part.prev_hop.incoming_packet_shared_secret; - let prev_outbound_scid_alias = - $htlc.mpp_part.prev_hop.prev_outbound_scid_alias; failed_forwards.push(( - HTLCSource::PreviousHopData(HTLCPreviousHopData { - prev_outbound_scid_alias, - user_channel_id: $htlc.mpp_part.prev_hop.user_channel_id, - counterparty_node_id, - channel_id: prev_channel_id, - outpoint: prev_funding_outpoint, - htlc_id: $htlc.mpp_part.prev_hop.htlc_id, - incoming_packet_shared_secret, - phantom_shared_secret, - trampoline_shared_secret, - blinded_failure, - cltv_expiry: Some(cltv_expiry), - }), + htlc_source, payment_hash, HTLCFailReason::reason( LocalHTLCFailureReason::IncorrectPaymentDetails, @@ -8436,7 +8433,7 @@ impl< let is_keysend = $purpose.is_keysend(); let mut claimable_payments = self.claimable_payments.lock().unwrap(); if claimable_payments.pending_claiming_payments.contains_key(&payment_hash) { - fail_htlc!(claimable_htlc, payment_hash); + fail_htlc!(payment_hash); } let ref mut claimable_payment = claimable_payments.claimable_payments .entry(payment_hash) @@ -8452,12 +8449,12 @@ impl< if $purpose != claimable_payment.purpose { let log_keysend = |keysend| if keysend { "keysend" } else { "non-keysend" }; log_trace!(self.logger, "Failing new {} HTLC with payment_hash {} as we already had an existing {} HTLC with the same payment hash", log_keysend(is_keysend), &payment_hash, log_keysend(!is_keysend)); - fail_htlc!(claimable_htlc, payment_hash); + fail_htlc!(payment_hash); } let onions_compatible = claimable_payment.onion_fields.check_merge(&mut onion_fields); if onions_compatible.is_err() { - fail_htlc!(claimable_htlc, payment_hash); + fail_htlc!(payment_hash); } let mut total_intended_recvd_value = claimable_htlc.mpp_part.sender_intended_value; @@ -8472,11 +8469,11 @@ impl< // The condition determining whether an MPP is complete must // match exactly the condition used in `timer_tick_occurred` if total_intended_recvd_value >= msgs::MAX_VALUE_MSAT { - fail_htlc!(claimable_htlc, payment_hash); + fail_htlc!(payment_hash); } else if total_intended_recvd_value - claimable_htlc.mpp_part.sender_intended_value >= total_mpp_value { log_trace!(self.logger, "Failing HTLC with payment_hash {} as payment is already claimable", &payment_hash); - fail_htlc!(claimable_htlc, payment_hash); + fail_htlc!(payment_hash); } else if total_intended_recvd_value >= total_mpp_value { #[allow(unused_assignments)] { committed_to_claimable = true; @@ -8537,7 +8534,7 @@ impl< Ok(result) => result, Err(()) => { log_trace!(self.logger, "Failing new HTLC with payment_hash {} as payment verification failed", &payment_hash); - fail_htlc!(claimable_htlc, payment_hash); + fail_htlc!(payment_hash); }, }; if let Some(min_final_cltv_expiry_delta) = min_final_cltv_expiry_delta { @@ -8547,12 +8544,12 @@ impl< if (cltv_expiry as u64) < expected_min_expiry_height { log_trace!(self.logger, "Failing new HTLC with payment_hash {} as its CLTV expiry was too soon (had {}, earliest expected {})", &payment_hash, cltv_expiry, expected_min_expiry_height); - fail_htlc!(claimable_htlc, payment_hash); + fail_htlc!(payment_hash); } } payment_preimage } else { - fail_htlc!(claimable_htlc, payment_hash); + fail_htlc!(payment_hash); } } else { None @@ -8568,7 +8565,7 @@ impl< let purpose = match from_parts_res { Ok(purpose) => purpose, Err(()) => { - fail_htlc!(claimable_htlc, payment_hash); + fail_htlc!(payment_hash); }, }; check_total_value!(purpose); @@ -8585,7 +8582,7 @@ impl< false, "We checked that payment_data is Some above" ); - fail_htlc!(claimable_htlc, payment_hash); + fail_htlc!(payment_hash); }, }; @@ -8604,13 +8601,13 @@ impl< verified_invreq.amount_msats() { if payment_data.total_msat < invreq_amt_msat { - fail_htlc!(claimable_htlc, payment_hash); + fail_htlc!(payment_hash); } } verified_invreq }, None => { - fail_htlc!(claimable_htlc, payment_hash); + fail_htlc!(payment_hash); }, }; let payment_purpose_context = @@ -8626,12 +8623,12 @@ impl< match from_parts_res { Ok(purpose) => purpose, Err(()) => { - fail_htlc!(claimable_htlc, payment_hash); + fail_htlc!(payment_hash); }, } } else if payment_context.is_some() { log_trace!(self.logger, "Failing new HTLC with payment_hash {}: received a keysend payment to a non-async payments context {:#?}", payment_hash, payment_context); - fail_htlc!(claimable_htlc, payment_hash); + fail_htlc!(payment_hash); } else { events::PaymentPurpose::SpontaneousPayment(keysend_preimage) }; From bc7452e0f6a51a74c74c371ab78d6c538c8ce892 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Mon, 30 Mar 2026 16:19:17 -0400 Subject: [PATCH 302/627] ln/refactor: move checks on incoming mpp accumulation into method We're going to use the same logic for trampoline and for incoming MPP payments, so we pull this out into a separate function. --- lightning/src/ln/channelmanager.rs | 258 ++++++++++++++++++----------- 1 file changed, 164 insertions(+), 94 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 562316fb229..3bb64bafcb8 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -1256,6 +1256,11 @@ impl ClaimablePayment { .map(|htlc| (htlc.mpp_part.prev_hop.channel_id, htlc.mpp_part.prev_hop.user_channel_id)) .collect() } + + /// Returns the total counterparty skimmed fee across all HTLCs. + fn total_counterparty_skimmed_msat(&self) -> u64 { + self.htlcs.iter().map(|htlc| htlc.counterparty_skimmed_fee_msat.unwrap_or(0)).sum() + } } /// Increments MPP timeout tick for all HTLCs and returns a boolean indicating whether the HTLC @@ -8264,6 +8269,143 @@ impl< } } + // Checks whether an incoming HTLC can be added to an in-progress MPP payment, verifying onion + // field compatibility and that the total value is sensible. On success, the HTLC is added to + // the payment's claimable set and Ok(true) is returned if all MPP parts have arrived. + fn check_incoming_mpp_part( + &self, claimable_payment: &mut ClaimablePayment, claimable_htlc: ClaimableHTLC, + mut onion_fields: RecipientOnionFields, payment_hash: PaymentHash, + ) -> Result { + let onions_compatible = claimable_payment.onion_fields.check_merge(&mut onion_fields); + if onions_compatible.is_err() { + return Err(()); + } + let mut total_intended_recvd_value = claimable_htlc.mpp_part.sender_intended_value; + for htlc in claimable_payment.htlcs.iter() { + total_intended_recvd_value += htlc.mpp_part.sender_intended_value; + if total_intended_recvd_value >= msgs::MAX_VALUE_MSAT { + break; + } + } + let total_mpp_value = claimable_payment.onion_fields.total_mpp_amount_msat; + // The condition determining whether an MPP is complete must match exactly the condition + // used in `timer_tick_occurred` + if total_intended_recvd_value >= msgs::MAX_VALUE_MSAT { + return Err(()); + } else if total_intended_recvd_value - claimable_htlc.mpp_part.sender_intended_value + >= total_mpp_value + { + log_trace!( + self.logger, + "Failing HTLC with payment_hash {} as payment is already claimable", + &payment_hash + ); + return Err(()); + } else if total_intended_recvd_value >= total_mpp_value { + claimable_payment.htlcs.push(claimable_htlc); + let amount_msat = claimable_payment.htlcs.iter().map(|htlc| htlc.mpp_part.value).sum(); + claimable_payment + .htlcs + .iter_mut() + .for_each(|htlc| htlc.mpp_part.total_value_received = Some(amount_msat)); + let counterparty_skimmed_fee_msat = claimable_payment.total_counterparty_skimmed_msat(); + debug_assert!( + total_intended_recvd_value.saturating_sub(amount_msat) + <= counterparty_skimmed_fee_msat + ); + claimable_payment.htlcs.sort(); + Ok(true) + } else { + // Nothing to do - we haven't reached the total payment value yet, wait until we receive + // more MPP parts. + claimable_payment.htlcs.push(claimable_htlc); + Ok(false) + } + } + + // Handles the addition of a HTLC associated with a payment we're receiving. + fn handle_claimable_htlc( + &self, purpose: events::PaymentPurpose, claimable_htlc: ClaimableHTLC, + onion_fields: RecipientOnionFields, payment_hash: PaymentHash, receiver_node_id: PublicKey, + new_events: &mut VecDeque<(Event, Option)>, + ) -> Result<(), ()> { + let mut claimable_payments = self.claimable_payments.lock().unwrap(); + if claimable_payments.pending_claiming_payments.contains_key(&payment_hash) { + return Err(()); + } + + // We should not fail if we're adding the first htlc to a ClaimablePayment (as our + // validation compares fields across parts, and our first part can't overflow maximum + // msats because each htlc's amount is individually validated - overflow is only possible + // with multiple parts). + let mut first_claimable_htlc = false; + let ref mut claimable_payment = + claimable_payments.claimable_payments.entry(payment_hash).or_insert_with(|| { + first_claimable_htlc = true; + ClaimablePayment { + purpose: purpose.clone(), + htlcs: Vec::new(), + onion_fields: onion_fields.clone(), + } + }); + + let is_keysend = purpose.is_keysend(); + if purpose != claimable_payment.purpose { + let log_keysend = |keysend| if keysend { "keysend" } else { "non-keysend" }; + log_trace!(self.logger, "Failing new {} HTLC with payment_hash {} as we already had an existing {} HTLC with the same payment hash", log_keysend(is_keysend), &payment_hash, log_keysend(!is_keysend)); + debug_assert!(!first_claimable_htlc); + return Err(()); + } + + let htlc_expiry = claimable_htlc.mpp_part.cltv_expiry; + match self.check_incoming_mpp_part( + claimable_payment, + claimable_htlc, + onion_fields, + payment_hash, + ) { + Ok(true) => { + let claim_deadline = Some( + match claimable_payment.htlcs.iter().map(|h| h.mpp_part.cltv_expiry).min() { + Some(claim_deadline) => claim_deadline, + None => { + debug_assert!(false, "no htlcs in completed claimable_payment"); + htlc_expiry + }, + } - HTLC_FAIL_BACK_BUFFER, + ); + new_events.push_back(( + events::Event::PaymentClaimable { + receiver_node_id: Some(receiver_node_id), + payment_hash, + purpose, + amount_msat: claimable_payment + .htlcs + .iter() + .map(|htlc| htlc.mpp_part.value) + .sum(), + counterparty_skimmed_fee_msat: claimable_payment + .total_counterparty_skimmed_msat(), + receiving_channel_ids: claimable_payment.receiving_channel_ids(), + claim_deadline, + onion_fields: Some(claimable_payment.onion_fields.clone()), + payment_id: Some( + claimable_payment.inbound_payment_id(&self.inbound_payment_id_secret), + ), + }, + None, + )); + Ok(()) + }, + // No action if MPP hasn't completed yet. + Ok(false) => Ok(()), + Err(()) => { + debug_assert!(!first_claimable_htlc); + Err(()) + }, + } + } + fn process_receive_htlcs( &self, pending_forwards: &mut Vec, new_events: &mut VecDeque<(Event, Option)>, @@ -8294,7 +8436,7 @@ impl< payment_data, payment_context, phantom_shared_secret, - mut onion_fields, + onion_fields, has_recipient_created_payment_secret, invoice_request_opt, trampoline_shared_secret, @@ -8396,11 +8538,8 @@ impl< counterparty_skimmed_fee_msat: skimmed_fee_msat, }; - let mut committed_to_claimable = false; - macro_rules! fail_htlc { ($payment_hash: expr) => { - debug_assert!(!committed_to_claimable); let err_data = invalid_payment_err_data( value, self.best_block.read().unwrap().height, @@ -8427,94 +8566,6 @@ impl< .expect("Failed to get node_id for phantom node recipient"); } - macro_rules! check_total_value { - ($purpose: expr) => {{ - let mut payment_claimable_generated = false; - let is_keysend = $purpose.is_keysend(); - let mut claimable_payments = self.claimable_payments.lock().unwrap(); - if claimable_payments.pending_claiming_payments.contains_key(&payment_hash) { - fail_htlc!(payment_hash); - } - let ref mut claimable_payment = claimable_payments.claimable_payments - .entry(payment_hash) - // Note that if we insert here we MUST NOT fail_htlc!() - .or_insert_with(|| { - committed_to_claimable = true; - ClaimablePayment { - purpose: $purpose.clone(), - htlcs: Vec::new(), - onion_fields: onion_fields.clone(), - } - }); - if $purpose != claimable_payment.purpose { - let log_keysend = |keysend| if keysend { "keysend" } else { "non-keysend" }; - log_trace!(self.logger, "Failing new {} HTLC with payment_hash {} as we already had an existing {} HTLC with the same payment hash", log_keysend(is_keysend), &payment_hash, log_keysend(!is_keysend)); - fail_htlc!(payment_hash); - } - let onions_compatible = - claimable_payment.onion_fields.check_merge(&mut onion_fields); - if onions_compatible.is_err() { - fail_htlc!(payment_hash); - } - let mut total_intended_recvd_value = - claimable_htlc.mpp_part.sender_intended_value; - let mut earliest_expiry = claimable_htlc.mpp_part.cltv_expiry; - for htlc in claimable_payment.htlcs.iter() { - total_intended_recvd_value += htlc.mpp_part.sender_intended_value; - earliest_expiry = cmp::min(earliest_expiry, htlc.mpp_part.cltv_expiry); - if total_intended_recvd_value >= msgs::MAX_VALUE_MSAT { break; } - } - let total_mpp_value = - claimable_payment.onion_fields.total_mpp_amount_msat; - // The condition determining whether an MPP is complete must - // match exactly the condition used in `timer_tick_occurred` - if total_intended_recvd_value >= msgs::MAX_VALUE_MSAT { - fail_htlc!(payment_hash); - } else if total_intended_recvd_value - claimable_htlc.mpp_part.sender_intended_value >= total_mpp_value { - log_trace!(self.logger, "Failing HTLC with payment_hash {} as payment is already claimable", - &payment_hash); - fail_htlc!(payment_hash); - } else if total_intended_recvd_value >= total_mpp_value { - #[allow(unused_assignments)] { - committed_to_claimable = true; - } - claimable_payment.htlcs.push(claimable_htlc); - let amount_msat = - claimable_payment.htlcs.iter().map(|htlc| htlc.mpp_part.value).sum(); - claimable_payment.htlcs.iter_mut() - .for_each(|htlc| htlc.mpp_part.total_value_received = Some(amount_msat)); - let counterparty_skimmed_fee_msat = claimable_payment.htlcs.iter() - .map(|htlc| htlc.counterparty_skimmed_fee_msat.unwrap_or(0)).sum(); - debug_assert!(total_intended_recvd_value.saturating_sub(amount_msat) - <= counterparty_skimmed_fee_msat); - claimable_payment.htlcs.sort(); - let payment_id = - claimable_payment.inbound_payment_id(&self.inbound_payment_id_secret); - new_events.push_back((events::Event::PaymentClaimable { - receiver_node_id: Some(receiver_node_id), - payment_hash, - purpose: $purpose, - amount_msat, - counterparty_skimmed_fee_msat, - receiving_channel_ids: claimable_payment.receiving_channel_ids(), - claim_deadline: Some(earliest_expiry - HTLC_FAIL_BACK_BUFFER), - onion_fields: Some(claimable_payment.onion_fields.clone()), - payment_id: Some(payment_id), - }, None)); - payment_claimable_generated = true; - } else { - // Nothing to do - we haven't reached the total - // payment value yet, wait until we receive more - // MPP parts. - claimable_payment.htlcs.push(claimable_htlc); - #[allow(unused_assignments)] { - committed_to_claimable = true; - } - } - payment_claimable_generated - }} - } - // Check that the payment hash and secret are known. Note that we // MUST take care to handle the "unknown payment hash" and // "incorrect payment secret" cases here identically or we'd expose @@ -8568,7 +8619,17 @@ impl< fail_htlc!(payment_hash); }, }; - check_total_value!(purpose); + + if let Err(()) = self.handle_claimable_htlc( + purpose, + claimable_htlc, + onion_fields, + payment_hash, + receiver_node_id, + new_events, + ) { + fail_htlc!(payment_hash); + } }, OnionPayload::Spontaneous(keysend_preimage) => { let purpose = if let Some(PaymentContext::AsyncBolt12Offer( @@ -8632,7 +8693,16 @@ impl< } else { events::PaymentPurpose::SpontaneousPayment(keysend_preimage) }; - check_total_value!(purpose); + if let Err(()) = self.handle_claimable_htlc( + purpose, + claimable_htlc, + onion_fields, + payment_hash, + receiver_node_id, + new_events, + ) { + fail_htlc!(payment_hash); + } }, } }, From 8c9604cf63b862f225588b94de62def71b2f4393 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Fri, 10 Apr 2026 13:14:15 -0400 Subject: [PATCH 303/627] ln/refactor: introduce HasMppPart generic to share incoming mpp To allow re-use with trampoline payments which won't use the ClaimablePayment type, make handling generic for anything with MPP parts. Here we also move counterparty skimmed logic to claimable payments, as this doesn't apply for trampoline. --- lightning/src/ln/channelmanager.rs | 76 ++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 24 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 3bb64bafcb8..9da8e1f5b4c 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -560,6 +560,20 @@ impl Ord for MppPart { } } +trait HasMppPart { + fn mpp_part(&self) -> &MppPart; + fn mpp_part_mut(&mut self) -> &mut MppPart; +} + +impl HasMppPart for MppPart { + fn mpp_part(&self) -> &MppPart { + self + } + fn mpp_part_mut(&mut self) -> &mut MppPart { + self + } +} + /// Represents an incoming HTLC that can be claimed or failed by the user. #[derive(PartialEq, Eq)] struct ClaimableHTLC { @@ -569,6 +583,15 @@ struct ClaimableHTLC { counterparty_skimmed_fee_msat: Option, } +impl HasMppPart for ClaimableHTLC { + fn mpp_part(&self) -> &MppPart { + &self.mpp_part + } + fn mpp_part_mut(&mut self) -> &mut MppPart { + &mut self.mpp_part + } +} + impl From<&ClaimableHTLC> for events::ClaimedHTLC { fn from(val: &ClaimableHTLC) -> Self { events::ClaimedHTLC { @@ -8271,28 +8294,28 @@ impl< // Checks whether an incoming HTLC can be added to an in-progress MPP payment, verifying onion // field compatibility and that the total value is sensible. On success, the HTLC is added to - // the payment's claimable set and Ok(true) is returned if all MPP parts have arrived. - fn check_incoming_mpp_part( - &self, claimable_payment: &mut ClaimablePayment, claimable_htlc: ClaimableHTLC, + // the htlc claimable set and Ok(true) is returned if all MPP parts have arrived. + fn check_incoming_mpp_part( + &self, htlc_set: &mut Vec, payment_onion_fields: &mut RecipientOnionFields, new_htlc: H, mut onion_fields: RecipientOnionFields, payment_hash: PaymentHash, ) -> Result { - let onions_compatible = claimable_payment.onion_fields.check_merge(&mut onion_fields); + let onions_compatible = payment_onion_fields.check_merge(&mut onion_fields); if onions_compatible.is_err() { return Err(()); } - let mut total_intended_recvd_value = claimable_htlc.mpp_part.sender_intended_value; - for htlc in claimable_payment.htlcs.iter() { - total_intended_recvd_value += htlc.mpp_part.sender_intended_value; + let mut total_intended_recvd_value = new_htlc.mpp_part().sender_intended_value; + for htlc in htlc_set.iter() { + total_intended_recvd_value += htlc.mpp_part().sender_intended_value; if total_intended_recvd_value >= msgs::MAX_VALUE_MSAT { break; } } - let total_mpp_value = claimable_payment.onion_fields.total_mpp_amount_msat; + let total_mpp_value = payment_onion_fields.total_mpp_amount_msat; // The condition determining whether an MPP is complete must match exactly the condition // used in `timer_tick_occurred` if total_intended_recvd_value >= msgs::MAX_VALUE_MSAT { return Err(()); - } else if total_intended_recvd_value - claimable_htlc.mpp_part.sender_intended_value + } else if total_intended_recvd_value - new_htlc.mpp_part().sender_intended_value >= total_mpp_value { log_trace!( @@ -8302,23 +8325,17 @@ impl< ); return Err(()); } else if total_intended_recvd_value >= total_mpp_value { - claimable_payment.htlcs.push(claimable_htlc); - let amount_msat = claimable_payment.htlcs.iter().map(|htlc| htlc.mpp_part.value).sum(); - claimable_payment - .htlcs + htlc_set.push(new_htlc); + let amount_msat = htlc_set.iter().map(|htlc| htlc.mpp_part().value).sum(); + htlc_set .iter_mut() - .for_each(|htlc| htlc.mpp_part.total_value_received = Some(amount_msat)); - let counterparty_skimmed_fee_msat = claimable_payment.total_counterparty_skimmed_msat(); - debug_assert!( - total_intended_recvd_value.saturating_sub(amount_msat) - <= counterparty_skimmed_fee_msat - ); - claimable_payment.htlcs.sort(); + .for_each(|htlc| htlc.mpp_part_mut().total_value_received = Some(amount_msat)); + htlc_set.sort(); Ok(true) } else { - // Nothing to do - we haven't reached the total payment value yet, wait until we receive - // more MPP parts. - claimable_payment.htlcs.push(claimable_htlc); + // Nothing to do - we haven't reached the total payment value yet, wait until we + // receive more MPP parts. + htlc_set.push(new_htlc); Ok(false) } } @@ -8359,12 +8376,23 @@ impl< let htlc_expiry = claimable_htlc.mpp_part.cltv_expiry; match self.check_incoming_mpp_part( - claimable_payment, + &mut claimable_payment.htlcs, + &mut claimable_payment.onion_fields, claimable_htlc, onion_fields, payment_hash, ) { Ok(true) => { + let counterparty_skimmed_fee_msat = + claimable_payment.total_counterparty_skimmed_msat(); + let amount_msat: u64 = + claimable_payment.htlcs.iter().map(|h| h.mpp_part.value).sum(); + let total_sender_intended: u64 = + claimable_payment.htlcs.iter().map(|h| h.mpp_part.sender_intended_value).sum(); + debug_assert!( + total_sender_intended.saturating_sub(amount_msat) + <= counterparty_skimmed_fee_msat + ); let claim_deadline = Some( match claimable_payment.htlcs.iter().map(|h| h.mpp_part.cltv_expiry).min() { Some(claim_deadline) => claim_deadline, From 80c35072329259fa66708259e4f8d4a45ea6b659 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Thu, 12 Feb 2026 13:31:49 +0200 Subject: [PATCH 304/627] ln/refactor: pass minimum delta into check_incoming_htlc_cltv For trampoline payments, we don't want to enforce a minimum cltv delta between our incoming and outer onion outgoing CLTV because we'll calculate our delta from the inner trampoline onion's value. However, we still want to check that we get at least the CLTV that the sending node intended for us and we still want to validate our incoming value. Refactor to allow setting a zero delta, for use for trampoline payments. --- lightning/src/ln/channelmanager.rs | 8 ++++++-- lightning/src/ln/onion_payment.rs | 6 +++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 9da8e1f5b4c..a3c33b8320f 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5203,8 +5203,12 @@ impl< }; let cur_height = self.best_block.read().unwrap().height + 1; - check_incoming_htlc_cltv(cur_height, next_hop.outgoing_cltv_value, msg.cltv_expiry)?; - + check_incoming_htlc_cltv( + cur_height, + next_hop.outgoing_cltv_value, + msg.cltv_expiry, + MIN_CLTV_EXPIRY_DELTA, + )?; Ok(intercept) } diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index bb5b8f21a48..615c357d11b 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -515,7 +515,7 @@ pub fn peel_payment_onion }; if let Err(reason) = check_incoming_htlc_cltv( - cur_height, outgoing_cltv_value, msg.cltv_expiry, + cur_height, outgoing_cltv_value, msg.cltv_expiry, MIN_CLTV_EXPIRY_DELTA, ) { return Err(InboundHTLCErr { msg: "incoming cltv check failed", @@ -719,9 +719,9 @@ pub(super) fn decode_incoming_update_add_htlc_onion Result<(), LocalHTLCFailureReason> { - if (cltv_expiry as u64) < (outgoing_cltv_value) as u64 + MIN_CLTV_EXPIRY_DELTA as u64 { + if (cltv_expiry as u64) < (outgoing_cltv_value) as u64 + min_cltv_expiry_delta as u64 { return Err(LocalHTLCFailureReason::IncorrectCLTVExpiry); } // Theoretically, channel counterparty shouldn't send us a HTLC expiring now, From 82bec57a946384eba96fc1bab0e03097279e9209 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 17 Mar 2026 08:37:11 -0400 Subject: [PATCH 305/627] blinded_path/refactor: make construction generic over forwarding type To use helper functions for either trampoline or regular paths. We only want to support trampoline and regular blinded paths, so we don't want to support implementation outside of this crate. We need the trait itself to be public as it's part of a public struct, so we use a sealed trait to disallow external implementation. --- lightning/src/blinded_path/payment.rs | 104 ++++++++++++++++++++------ 1 file changed, 83 insertions(+), 21 deletions(-) diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs index 03b676adc92..5766bdfb2c8 100644 --- a/lightning/src/blinded_path/payment.rs +++ b/lightning/src/blinded_path/payment.rs @@ -161,8 +161,12 @@ impl BlindedPaymentPath { ) } - fn new_inner( - intermediate_nodes: &[PaymentForwardNode], payee_node_id: PublicKey, + fn new_inner< + F: ForwardTlvsInfo, + ES: EntropySource, + T: secp256k1::Signing + secp256k1::Verification, + >( + intermediate_nodes: &[ForwardNode], payee_node_id: PublicKey, local_node_receive_key: ReceiveAuthKey, dummy_tlvs: &[DummyTlvs], payee_tlvs: ReceiveTlvs, htlc_maximum_msat: u64, min_final_cltv_expiry_delta: u16, entropy_source: ES, secp_ctx: &Secp256k1, @@ -323,18 +327,42 @@ impl BlindedPaymentPath { } } -/// An intermediate node, its outbound channel, and relay parameters. +mod sealed { + pub trait ForwardTlvsInfo {} +} + +/// Common interface for forward TLV types used in blinded payment paths. +/// +/// Both [`ForwardTlvs`] (channel-based forwarding) and [`TrampolineForwardTlvs`] (trampoline +/// node-based forwarding) implement this trait, allowing blinded path construction to be generic +/// over the forwarding mechanism. +/// +/// This trait is sealed and is not intended for implementation outside of this crate. +pub trait ForwardTlvsInfo: Writeable + Clone + sealed::ForwardTlvsInfo { + /// The payment relay parameters for this hop. + fn payment_relay(&self) -> &PaymentRelay; + /// The payment constraints for this hop. + fn payment_constraints(&self) -> &PaymentConstraints; + /// The features for this hop. + fn features(&self) -> &BlindedHopFeatures; +} + +/// An intermediate node, its forwarding parameters, and its [`ForwardTlvsInfo`] for use in a +/// [`BlindedPaymentPath`]. #[derive(Clone, Debug)] -pub struct PaymentForwardNode { +pub struct ForwardNode { /// The TLVs for this node's [`BlindedHop`], where the fee parameters contained within are also /// used for [`BlindedPayInfo`] construction. - pub tlvs: ForwardTlvs, + pub tlvs: F, /// This node's pubkey. pub node_id: PublicKey, /// The maximum value, in msat, that may be accepted by this node. pub htlc_maximum_msat: u64, } +/// An intermediate node for a regular (non-trampoline) [`BlindedPaymentPath`]. +pub type PaymentForwardNode = ForwardNode; + /// Data to construct a [`BlindedHop`] for forwarding a payment. #[derive(Clone, Debug)] pub struct ForwardTlvs { @@ -354,6 +382,20 @@ pub struct ForwardTlvs { pub next_blinding_override: Option, } +impl sealed::ForwardTlvsInfo for ForwardTlvs {} + +impl ForwardTlvsInfo for ForwardTlvs { + fn payment_relay(&self) -> &PaymentRelay { + &self.payment_relay + } + fn payment_constraints(&self) -> &PaymentConstraints { + &self.payment_constraints + } + fn features(&self) -> &BlindedHopFeatures { + &self.features + } +} + /// Data to construct a [`BlindedHop`] for forwarding a Trampoline payment. #[derive(Clone, Debug)] pub struct TrampolineForwardTlvs { @@ -373,6 +415,20 @@ pub struct TrampolineForwardTlvs { pub next_blinding_override: Option, } +impl sealed::ForwardTlvsInfo for TrampolineForwardTlvs {} + +impl ForwardTlvsInfo for TrampolineForwardTlvs { + fn payment_relay(&self) -> &PaymentRelay { + &self.payment_relay + } + fn payment_constraints(&self) -> &PaymentConstraints { + &self.payment_constraints + } + fn features(&self) -> &BlindedHopFeatures { + &self.features + } +} + /// TLVs carried by a dummy hop within a blinded payment path. /// /// Dummy hops do not correspond to real forwarding decisions, but are processed @@ -440,8 +496,8 @@ pub(crate) enum BlindedTrampolineTlvs { // Used to include forward and receive TLVs in the same iterator for encoding. #[derive(Clone)] -enum BlindedPaymentTlvsRef<'a> { - Forward(&'a ForwardTlvs), +enum BlindedPaymentTlvsRef<'a, F: ForwardTlvsInfo = ForwardTlvs> { + Forward(&'a F), Dummy(&'a DummyTlvs), Receive(&'a ReceiveTlvs), } @@ -619,7 +675,7 @@ impl Writeable for ReceiveTlvs { } } -impl<'a> Writeable for BlindedPaymentTlvsRef<'a> { +impl<'a, F: ForwardTlvsInfo> Writeable for BlindedPaymentTlvsRef<'a, F> { fn write(&self, w: &mut W) -> Result<(), io::Error> { match self { Self::Forward(tlvs) => tlvs.write(w)?, @@ -723,8 +779,8 @@ impl Readable for BlindedTrampolineTlvs { pub(crate) const PAYMENT_PADDING_ROUND_OFF: usize = 30; /// Construct blinded payment hops for the given `intermediate_nodes` and payee info. -pub(super) fn blinded_hops( - secp_ctx: &Secp256k1, intermediate_nodes: &[PaymentForwardNode], payee_node_id: PublicKey, +pub(super) fn blinded_hops( + secp_ctx: &Secp256k1, intermediate_nodes: &[ForwardNode], payee_node_id: PublicKey, dummy_tlvs: &[DummyTlvs], payee_tlvs: ReceiveTlvs, session_priv: &SecretKey, local_node_receive_key: ReceiveAuthKey, ) -> Vec { @@ -823,15 +879,15 @@ where Ok((curr_base_fee, curr_prop_mil)) } -pub(super) fn compute_payinfo( - intermediate_nodes: &[PaymentForwardNode], dummy_tlvs: &[DummyTlvs], payee_tlvs: &ReceiveTlvs, +pub(super) fn compute_payinfo( + intermediate_nodes: &[ForwardNode], dummy_tlvs: &[DummyTlvs], payee_tlvs: &ReceiveTlvs, payee_htlc_maximum_msat: u64, min_final_cltv_expiry_delta: u16, ) -> Result { let routing_fees = intermediate_nodes .iter() .map(|node| RoutingFees { - base_msat: node.tlvs.payment_relay.fee_base_msat, - proportional_millionths: node.tlvs.payment_relay.fee_proportional_millionths, + base_msat: node.tlvs.payment_relay().fee_base_msat, + proportional_millionths: node.tlvs.payment_relay().fee_proportional_millionths, }) .chain(dummy_tlvs.iter().map(|tlvs| RoutingFees { base_msat: tlvs.payment_relay.fee_base_msat, @@ -847,24 +903,24 @@ pub(super) fn compute_payinfo( for node in intermediate_nodes.iter() { // In the future, we'll want to take the intersection of all supported features for the // `BlindedPayInfo`, but there are no features in that context right now. - if node.tlvs.features.requires_unknown_bits_from(&BlindedHopFeatures::empty()) { + if node.tlvs.features().requires_unknown_bits_from(&BlindedHopFeatures::empty()) { return Err(()); } cltv_expiry_delta = - cltv_expiry_delta.checked_add(node.tlvs.payment_relay.cltv_expiry_delta).ok_or(())?; + cltv_expiry_delta.checked_add(node.tlvs.payment_relay().cltv_expiry_delta).ok_or(())?; // The min htlc for an intermediate node is that node's min minus the fees charged by all of the // following hops for forwarding that min, since that fee amount will automatically be included // in the amount that this node receives and contribute towards reaching its min. htlc_minimum_msat = amt_to_forward_msat( - core::cmp::max(node.tlvs.payment_constraints.htlc_minimum_msat, htlc_minimum_msat), - &node.tlvs.payment_relay, + core::cmp::max(node.tlvs.payment_constraints().htlc_minimum_msat, htlc_minimum_msat), + node.tlvs.payment_relay(), ) .unwrap_or(1); // If underflow occurs, we definitely reached this node's min htlc_maximum_msat = amt_to_forward_msat( core::cmp::min(node.htlc_maximum_msat, htlc_maximum_msat), - &node.tlvs.payment_relay, + node.tlvs.payment_relay(), ) .ok_or(())?; // If underflow occurs, we cannot send to this hop without exceeding their max } @@ -1038,8 +1094,14 @@ mod tests { payment_constraints: PaymentConstraints { max_cltv_expiry: 0, htlc_minimum_msat: 1 }, payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), }; - let blinded_payinfo = - super::compute_payinfo(&[], &[], &recv_tlvs, 4242, TEST_FINAL_CLTV as u16).unwrap(); + let blinded_payinfo = super::compute_payinfo::( + &[], + &[], + &recv_tlvs, + 4242, + TEST_FINAL_CLTV as u16, + ) + .unwrap(); assert_eq!(blinded_payinfo.fee_base_msat, 0); assert_eq!(blinded_payinfo.fee_proportional_millionths, 0); assert_eq!(blinded_payinfo.cltv_expiry_delta, TEST_FINAL_CLTV as u16); From 95ae963f760fbab943f581cbdb9b51aff64b4f55 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 17 Mar 2026 13:46:45 -0400 Subject: [PATCH 306/627] blinded_path: add constructor for trampoline blinded path --- lightning/src/blinded_path/payment.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs index 5766bdfb2c8..f06c91bf6e0 100644 --- a/lightning/src/blinded_path/payment.rs +++ b/lightning/src/blinded_path/payment.rs @@ -161,6 +161,29 @@ impl BlindedPaymentPath { ) } + /// Create a blinded path for a trampoline payment, to be forwarded along `intermediate_nodes`. + #[cfg(any(test, feature = "_test_utils"))] + pub(crate) fn new_for_trampoline< + ES: EntropySource, + T: secp256k1::Signing + secp256k1::Verification, + >( + intermediate_nodes: &[ForwardNode], payee_node_id: PublicKey, + local_node_receive_key: ReceiveAuthKey, payee_tlvs: ReceiveTlvs, htlc_maximum_msat: u64, + min_final_cltv_expiry_delta: u16, entropy_source: ES, secp_ctx: &Secp256k1, + ) -> Result { + Self::new_inner( + intermediate_nodes, + payee_node_id, + local_node_receive_key, + &[], + payee_tlvs, + htlc_maximum_msat, + min_final_cltv_expiry_delta, + entropy_source, + secp_ctx, + ) + } + fn new_inner< F: ForwardTlvsInfo, ES: EntropySource, From 07925700fd036fb1f7365a0bba0a64cefdcddffc Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 17 Mar 2026 08:39:50 -0400 Subject: [PATCH 307/627] ln/test: add multi-purpose trampoline test helper To create trampoline forwarding and single hop receiving tails. --- lightning/src/ln/blinded_payment_tests.rs | 58 +++++------------------ lightning/src/ln/functional_test_utils.rs | 51 +++++++++++++++++++- lightning/src/routing/router.rs | 2 +- 3 files changed, 61 insertions(+), 50 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index d62f79957eb..621c5103353 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -2428,50 +2428,6 @@ fn test_trampoline_blinded_receive() { do_test_trampoline_relay(true, TrampolineTestCase::OuterCLTVLessThanTrampoline); } -/// Creates a blinded tail where Carol receives via a blinded path. -fn create_blinded_tail( - secp_ctx: &Secp256k1, override_random_bytes: [u8; 32], carol_node_id: PublicKey, - carol_auth_key: ReceiveAuthKey, trampoline_cltv_expiry_delta: u32, - excess_final_cltv_delta: u32, final_value_msat: u64, payment_secret: PaymentSecret, -) -> BlindedTail { - let outer_session_priv = SecretKey::from_slice(&override_random_bytes).unwrap(); - let trampoline_session_priv = onion_utils::compute_trampoline_session_priv(&outer_session_priv); - - let carol_blinding_point = PublicKey::from_secret_key(&secp_ctx, &trampoline_session_priv); - let carol_blinded_hops = { - let payee_tlvs = ReceiveTlvs { - payment_secret, - payment_constraints: PaymentConstraints { - max_cltv_expiry: u32::max_value(), - htlc_minimum_msat: final_value_msat, - }, - payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), - } - .encode(); - - let path = [((carol_node_id, Some(carol_auth_key)), WithoutLength(&payee_tlvs))]; - - blinded_path::utils::construct_blinded_hops( - &secp_ctx, - path.into_iter(), - &trampoline_session_priv, - ) - }; - - BlindedTail { - trampoline_hops: vec![TrampolineHop { - pubkey: carol_node_id, - node_features: Features::empty(), - fee_msat: final_value_msat, - cltv_expiry_delta: trampoline_cltv_expiry_delta + excess_final_cltv_delta, - }], - hops: carol_blinded_hops, - blinding_point: carol_blinding_point, - excess_final_cltv_expiry_delta: excess_final_cltv_delta, - final_value_msat, - } -} - // Creates a replacement onion that is used to produce scenarios that we don't support, specifically // payloads that send to unblinded receives and invalid payloads. fn replacement_onion( @@ -2639,15 +2595,23 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { // Create a blinded tail where Carol is receiving. In our unblinded test cases, we'll // override this anyway (with a tail sending to an unblinded receive, which LDK doesn't // allow). - blinded_tail: Some(create_blinded_tail( + blinded_tail: Some(create_trampoline_forward_blinded_tail( &secp_ctx, - override_random_bytes, + &nodes[2].keys_manager, + &[], carol_node_id, nodes[2].keys_manager.get_receive_auth_key(), + ReceiveTlvs { + payment_secret, + payment_constraints: PaymentConstraints { + max_cltv_expiry: u32::max_value(), + htlc_minimum_msat: original_amt_msat, + }, + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), + }, original_trampoline_cltv, excess_final_cltv, original_amt_msat, - payment_secret, )), }], route_params: None, diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index d39cee78b0f..c1923730a3d 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -10,7 +10,9 @@ //! A bunch of useful utilities for building networks of nodes and exchanging messages between //! nodes for functional tests. -use crate::blinded_path::payment::DummyTlvs; +use crate::blinded_path::payment::{ + BlindedPaymentPath, DummyTlvs, ForwardNode, ReceiveTlvs, TrampolineForwardTlvs, +}; use crate::chain::channelmonitor::{ChannelMonitor, HTLC_FAIL_BACK_BUFFER}; use crate::chain::transaction::OutPoint; use crate::chain::{BestBlock, ChannelMonitorUpdateStatus, Confirm, Listen, Watch}; @@ -40,7 +42,8 @@ use crate::ln::types::ChannelId; use crate::onion_message::messenger::OnionMessenger; use crate::routing::gossip::{NetworkGraph, NetworkUpdate, P2PGossipSync}; use crate::routing::router::{self, PaymentParameters, Route, RouteParameters}; -use crate::sign::{EntropySource, RandomBytes}; +use crate::routing::router::{compute_fees, BlindedTail, TrampolineHop}; +use crate::sign::{EntropySource, RandomBytes, ReceiveAuthKey}; use crate::types::features::ChannelTypeFeatures; use crate::types::features::InitFeatures; use crate::types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; @@ -5768,3 +5771,47 @@ pub fn get_scid_from_channel_id<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, channel_id: .short_channel_id .unwrap() } + +/// Creates a [`BlindedTail`] for a trampoline forward through a single intermediate node. +/// +/// The resulting tail contains blinded hops built from `intermediate_nodes` plus a dummy receive +/// TLV, with the `TrampolineHop` fee and CLTV derived from the blinded path's aggregated payinfo. +pub fn create_trampoline_forward_blinded_tail( + secp_ctx: &bitcoin::secp256k1::Secp256k1, entropy_source: ES, + intermediate_nodes: &[ForwardNode], payee_node_id: PublicKey, + payee_receive_key: ReceiveAuthKey, payee_tlvs: ReceiveTlvs, min_final_cltv_expiry_delta: u32, + excess_final_cltv_delta: u32, final_value_msat: u64, +) -> BlindedTail { + let blinded_path = BlindedPaymentPath::new_for_trampoline( + intermediate_nodes, + payee_node_id, + payee_receive_key, + payee_tlvs, + u64::max_value(), + min_final_cltv_expiry_delta as u16, + entropy_source, + secp_ctx, + ) + .unwrap(); + + BlindedTail { + trampoline_hops: vec![TrampolineHop { + pubkey: intermediate_nodes.first().map(|n| n.node_id).unwrap_or(payee_node_id), + node_features: types::features::Features::empty(), + fee_msat: compute_fees( + final_value_msat, + lightning_types::routing::RoutingFees { + base_msat: blinded_path.payinfo.fee_base_msat, + proportional_millionths: blinded_path.payinfo.fee_proportional_millionths, + }, + ) + .unwrap(), + cltv_expiry_delta: blinded_path.payinfo.cltv_expiry_delta as u32 + + excess_final_cltv_delta, + }], + hops: blinded_path.blinded_hops().to_vec(), + blinding_point: blinded_path.blinding_point(), + excess_final_cltv_expiry_delta: excess_final_cltv_delta, + final_value_msat, + } +} diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 0c0d14b43fd..edb048c8c7d 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -2464,7 +2464,7 @@ impl<'a> PaymentPath<'a> { #[inline(always)] /// Calculate the fees required to route the given amount over a channel with the given fees. #[rustfmt::skip] -fn compute_fees(amount_msat: u64, channel_fees: RoutingFees) -> Option { +pub(crate) fn compute_fees(amount_msat: u64, channel_fees: RoutingFees) -> Option { amount_msat.checked_mul(channel_fees.proportional_millionths as u64) .and_then(|part| (channel_fees.base_msat as u64).checked_add(part / 1_000_000)) } From a5610ff4bf978210599022d794cf6506c33a89df Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Tue, 14 Apr 2026 18:22:01 +0200 Subject: [PATCH 308/627] expose scorer --- lightning/src/routing/scoring.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lightning/src/routing/scoring.rs b/lightning/src/routing/scoring.rs index 47621e37380..1592bc0ccb2 100644 --- a/lightning/src/routing/scoring.rs +++ b/lightning/src/routing/scoring.rs @@ -1871,6 +1871,21 @@ impl> + Clone, L: Logger + Clone> CombinedScor } } +impl>, L: Logger> CombinedScorer { + /// Returns a reference to the merged [`ProbabilisticScorer`] used for routing decisions, + /// which combines locally acquired data with any externally supplied scores. + pub fn scorer(&self) -> &ProbabilisticScorer { + &self.scorer + } + + /// Returns a reference to the [`ProbabilisticScorer`] tracking only locally acquired data + /// (i.e. excluding any externally supplied scores merged via [`Self::merge`] or + /// [`Self::set_scores`]). + pub fn local_only_scorer(&self) -> &ProbabilisticScorer { + &self.local_only_scorer + } +} + impl>, L: Logger> ScoreLookUp for CombinedScorer { type ScoreParams = ProbabilisticScoringFeeParameters; From 1ee948008fa066f93c10d9dd8523c7493eca3ad7 Mon Sep 17 00:00:00 2001 From: Bortlesboat Date: Tue, 14 Apr 2026 13:05:30 -0400 Subject: [PATCH 309/627] Expose current dust exposure in ChannelDetails Co-authored-by: Codex --- fuzz/src/router.rs | 1 + lightning/src/ln/channel.rs | 10 ++++++++++ lightning/src/ln/channel_state.rs | 19 +++++++++++++++++++ lightning/src/ln/channelmanager.rs | 1 + lightning/src/routing/router.rs | 2 ++ lightning/src/sign/tx_builder.rs | 2 ++ 6 files changed, 35 insertions(+) diff --git a/fuzz/src/router.rs b/fuzz/src/router.rs index 2e5b15fc7f4..7c62b3ac5a0 100644 --- a/fuzz/src/router.rs +++ b/fuzz/src/router.rs @@ -255,6 +255,7 @@ pub fn do_test(data: &[u8], out: Out) { channel_shutdown_state: Some(ChannelShutdownState::NotShuttingDown), pending_inbound_htlcs: Vec::new(), pending_outbound_htlcs: Vec::new(), + current_dust_exposure_msat: None, }); } Some(&$first_hops_vec[..]) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 55d4a84eb91..52c6fed2e5a 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -122,6 +122,15 @@ pub struct AvailableBalances { pub next_outbound_htlc_limit_msat: u64, /// The minimum value we can assign to the next outbound HTLC pub next_outbound_htlc_minimum_msat: u64, + /// The current total dust exposure on this channel, in millisatoshis. + /// + /// This is the maximum of the dust exposure on the holder and counterparty commitment + /// transactions, and includes both the value of all pending HTLCs that are below the dust + /// threshold as well as any excess commitment transaction fees that contribute to dust + /// exposure. + /// + /// See [`ChannelConfig::max_dust_htlc_exposure`] for more information on the dust calculation and to configure a limit. + pub dust_exposure_msat: u64, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -13603,6 +13612,7 @@ where next_outbound_htlc_minimum_msat: acc .next_outbound_htlc_minimum_msat .max(e.next_outbound_htlc_minimum_msat), + dust_exposure_msat: acc.dust_exposure_msat.max(e.dust_exposure_msat), }) }) } diff --git a/lightning/src/ln/channel_state.rs b/lightning/src/ln/channel_state.rs index 5547bee8f4c..d59e30f8db1 100644 --- a/lightning/src/ln/channel_state.rs +++ b/lightning/src/ln/channel_state.rs @@ -479,6 +479,21 @@ pub struct ChannelDetails { /// /// This field will be `None` for objects serialized with LDK versions prior to 0.2.0. pub funding_redeem_script: Option, + /// The current total dust exposure on this channel, in millisatoshis. + /// + /// This is the maximum of the dust exposure on the holder and counterparty commitment + /// transactions, and includes both the value of all pending HTLCs that are below the dust + /// threshold as well as the portion of commitment transaction fees that contribute to dust + /// exposure. + /// + /// The dust exposure is compared against + /// [`ChannelConfig::max_dust_htlc_exposure`] to determine whether new HTLCs can be + /// accepted or offered on this channel. + /// + /// This field will be `None` for objects serialized with LDK versions prior to 0.3. + /// + /// [`ChannelConfig::max_dust_htlc_exposure`]: crate::util::config::ChannelConfig::max_dust_htlc_exposure + pub current_dust_exposure_msat: Option, } impl ChannelDetails { @@ -533,6 +548,7 @@ impl ChannelDetails { outbound_capacity_msat: 0, next_outbound_htlc_limit_msat: 0, next_outbound_htlc_minimum_msat: u64::MAX, + dust_exposure_msat: 0, } }); let (to_remote_reserve_satoshis, to_self_reserve_satoshis) = @@ -596,6 +612,7 @@ impl ChannelDetails { channel_shutdown_state: Some(context.shutdown_state()), pending_inbound_htlcs: context.get_pending_inbound_htlc_details(funding), pending_outbound_htlcs: context.get_pending_outbound_htlc_details(funding), + current_dust_exposure_msat: Some(balance.dust_exposure_msat), } } } @@ -636,6 +653,7 @@ impl_writeable_tlv_based!(ChannelDetails, { (43, pending_inbound_htlcs, optional_vec), (45, pending_outbound_htlcs, optional_vec), (47, funding_redeem_script, option), + (49, current_dust_exposure_msat, option), (_unused, user_channel_id, (static_value, _user_channel_id_low.unwrap_or(0) as u128 | ((_user_channel_id_high.unwrap_or(0) as u128) << 64) )), @@ -756,6 +774,7 @@ mod tests { skimmed_fee_msat: Some(42), is_dust: false, }], + current_dust_exposure_msat: Some(150_000), }; let mut buffer = Vec::new(); channel_details.write(&mut buffer).unwrap(); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 7f6d6535e58..1f63d78b636 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8025,6 +8025,7 @@ impl< outbound_capacity_msat: 0, next_outbound_htlc_limit_msat: 0, next_outbound_htlc_minimum_msat: u64::MAX, + dust_exposure_msat: 0, } }); let is_in_range = (balances.next_outbound_htlc_minimum_msat diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 0c0d14b43fd..5de18695720 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -4164,6 +4164,7 @@ mod tests { channel_shutdown_state: Some(ChannelShutdownState::NotShuttingDown), pending_inbound_htlcs: Vec::new(), pending_outbound_htlcs: Vec::new(), + current_dust_exposure_msat: None, } } @@ -9665,6 +9666,7 @@ pub(crate) mod bench_utils { channel_shutdown_state: Some(ChannelShutdownState::NotShuttingDown), pending_inbound_htlcs: Vec::new(), pending_outbound_htlcs: Vec::new(), + current_dust_exposure_msat: None, } } diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index a54f8f70f8d..0af30dae3ea 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -575,6 +575,7 @@ fn get_available_balances( next_outbound_htlc_minimum_msat, available_capacity_msat, ); + let dust_exposure_msat = cmp::max(local_dust_exposure_msat, remote_dust_exposure_msat); crate::ln::channel::AvailableBalances { inbound_capacity_msat: remote_balance_before_fee_msat @@ -582,6 +583,7 @@ fn get_available_balances( outbound_capacity_msat, next_outbound_htlc_limit_msat: available_capacity_msat, next_outbound_htlc_minimum_msat, + dust_exposure_msat, } } From 85bfc1464de3799d877f83f759897e69deb7214f Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 14 Apr 2026 17:47:10 -0500 Subject: [PATCH 310/627] Enforce minimum RBF feerate from counterparty The spec's tx_init_rbf recipient requirements now mandate rejecting a feerate below max(prev + 25 sat/kwu, ceil(prev * 25/24)), matching the sender requirement. Previously we only enforced the 25/24 rule on counterparties. Reuse the existing min_rbf_feerate function for both our own and counterparty validation. Co-Authored-By: Claude Opus 4.6 (1M context) --- lightning/src/ln/channel.rs | 21 ++++--- lightning/src/ln/funding.rs | 6 +- lightning/src/ln/interactivetxs.rs | 4 +- lightning/src/ln/splicing_tests.rs | 90 +++++++++++++++++++++++++++++- 4 files changed, 103 insertions(+), 18 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 32c0e94bdc8..85c40bb56df 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2900,7 +2900,7 @@ struct PendingFunding { received_funding_txid: Option, /// The feerate used in the last successfully negotiated funding transaction. - /// Used for validating the 25/24 feerate increase rule on RBF attempts. + /// Used for validating the minimum feerate increase rule on RBF attempts. last_funding_feerate_sat_per_1000_weight: Option, /// The funding contributions from splice/RBF rounds where we contributed. @@ -6769,13 +6769,12 @@ fn get_v2_channel_reserve_satoshis( cmp::min(channel_value_satoshis, cmp::max(q, dust_limit_satoshis)) } -/// Returns the minimum feerate for our own RBF attempts given a previous feerate. +/// Returns the minimum feerate for RBF attempts given a previous feerate. /// -/// The spec (tx_init_rbf) requires the new feerate to be >= 25/24 of the previous feerate. -/// However, at low feerates that multiplier doesn't always satisfy BIP125's relay requirement of -/// an absolute fee increase, so we take the max of a flat +25 sat/kwu (0.1 sat/vB) increment -/// and the spec's multiplicative rule. We still accept the bare 25/24 rule from counterparties -/// in [`FundedChannel::validate_tx_init_rbf`]. +/// The spec (tx_init_rbf) requires the new feerate to be >= the maximum of 25/24 of the previous +/// feerate and the previous feerate + 25 sat/kwu. The flat +25 sat/kwu increment ensures BIP125's +/// relay requirement of an absolute fee increase is satisfied at low feerates where the +/// multiplicative 25/24 rule alone would be insufficient. fn min_rbf_feerate(prev_feerate: u32) -> FeeRate { let flat_increment = (prev_feerate as u64).saturating_add(25); let spec_increment = ((prev_feerate as u64) * 25).div_ceil(24); @@ -13008,17 +13007,17 @@ where }, }; - // Check the 25/24 feerate increase rule let prev_feerate = pending_splice.last_funding_feerate_sat_per_1000_weight.unwrap_or_else(|| { fee_estimator.bounded_sat_per_1000_weight(ConfirmationTarget::UrgentOnChainSweep) }); - let new_feerate = msg.feerate_sat_per_1000_weight; - if (new_feerate as u64) * 24 < (prev_feerate as u64) * 25 { + let new_feerate = FeeRate::from_sat_per_kwu(msg.feerate_sat_per_1000_weight as u64); + if new_feerate < min_rbf_feerate(prev_feerate) { return Err(ChannelError::Abort(AbortReason::InsufficientRbfFeerate)); } - if !pending_splice.is_rbf_feerate_sufficient(new_feerate, fee_estimator) { + if !pending_splice.is_rbf_feerate_sufficient(msg.feerate_sat_per_1000_weight, fee_estimator) + { return Err(ChannelError::Abort(AbortReason::InsufficientRbfFeerate)); } diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index c08a0a9f471..f4db0296383 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -1019,9 +1019,9 @@ impl FundingContribution { /// Adjusts the contribution's change output for the minimum RBF feerate. /// - /// When a pending splice exists with negotiated candidates and the contribution's feerate - /// is below the minimum RBF feerate (25/24 of the previous feerate), this adjusts the - /// change output so the initiator pays fees at the minimum RBF feerate. + /// When a pending splice exists with negotiated candidates and the contribution's feerate is + /// below the minimum RBF feerate, this adjusts the change output so the initiator pays fees + /// at the minimum RBF feerate. pub(super) fn for_initiator_at_feerate( self, feerate: FeeRate, holder_balance: Amount, ) -> Result { diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index 9957205716c..ca8c4450012 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -136,8 +136,8 @@ pub(crate) enum AbortReason { DuplicateFundingOutput, /// More than one funding (shared) input found. DuplicateFundingInput, - /// The RBF feerate is insufficient (e.g., doesn't satisfy the 25/24 rule or can't accommodate - /// prior contributions). + /// The RBF feerate is insufficient (e.g., doesn't satisfy the minimum feerate increase rule or + /// can't accommodate prior contributions). InsufficientRbfFeerate, /// A funding negotiation is already in progress. NegotiationInProgress, diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 9adccd17627..398715ea3cd 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -4600,8 +4600,8 @@ fn test_splice_rbf_insufficient_feerate() { let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); assert_eq!(tx_abort.channel_id, channel_id); - // Acceptor-side: a counterparty feerate that satisfies the spec's 25/24 rule (264) is - // accepted, even though our own RBF floor (+25 sat/kwu = 278) is higher. + // Acceptor-side: a counterparty feerate that only satisfies the 25/24 rule (264) is + // rejected — the spec requires max(prev + 25, ceil(prev * 25/24)) = 278 at low feerates. // After tx_abort the channel remains quiescent, so no need to re-enter quiescence. nodes[0].node.handle_tx_abort(node_id_1, &tx_abort); @@ -4613,6 +4613,92 @@ fn test_splice_rbf_insufficient_feerate() { funding_output_contribution: Some(added_value.to_sat() as i64), }; + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); + assert_eq!(tx_abort.channel_id, channel_id); + + // Acceptor-side: prev + 25 = 278 satisfies the combined BIP125 rule and is accepted. + nodes[0].node.handle_tx_abort(node_id_1, &tx_abort); + + let min_rbf_feerate = FEERATE_FLOOR_SATS_PER_KW + 25; + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: min_rbf_feerate, + funding_output_contribution: Some(added_value.to_sat() as i64), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + let _tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0); +} + +#[test] +fn test_splice_rbf_insufficient_feerate_high() { + // At high feerates (above ~600 sat/kwu) the 25/24 multiplicative rule dominates the +25 + // flat increment. Verify that the counterparty validation rejects a feerate satisfying only + // the flat increment and accepts one satisfying the 25/24 rule. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Complete a splice-in at floor feerate, then RBF to 1000 sat/kwu. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_splice_tx, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + provide_utxo_reserves(&nodes, 2, added_value * 2); + let high_feerate = FeeRate::from_sat_per_kwu(1000); + let contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, high_feerate); + complete_rbf_handshake(&nodes[0], &nodes[1]); + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + contribution, + new_funding_script, + ); + let (_, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + assert!(splice_locked.is_none()); + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // prev=1000: flat increment gives 1000+25=1025, 25/24 rule gives ceil(1000*25/24)=1042. + // Feerate 1025 satisfies the flat increment but not 25/24 — rejected. + reenter_quiescence(&nodes[0], &nodes[1], &channel_id); + + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: 1025, + funding_output_contribution: Some(added_value.to_sat() as i64), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); + assert_eq!(tx_abort.channel_id, channel_id); + + // Feerate 1042 satisfies both rules — accepted. + nodes[0].node.handle_tx_abort(node_id_1, &tx_abort); + + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: 1042, + funding_output_contribution: Some(added_value.to_sat() as i64), + }; + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); let _tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0); } From 154f06bd5301aaf616c672d644938626d01186a3 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 14 Apr 2026 17:49:52 -0500 Subject: [PATCH 311/627] Use floor division for the spec's 25/24 RBF feerate rule The spec says the 25/24 multiplicative feerate is "rounded down", but min_rbf_feerate used ceiling division. This made the computed minimum 1 sat/kwu too high when prev * 25 is not evenly divisible by 24, which could reject valid counterparty feerates. Co-Authored-By: Claude Opus 4.6 (1M context) --- lightning/src/ln/channel.rs | 2 +- lightning/src/ln/splicing_tests.rs | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 85c40bb56df..faf435ce35b 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -6777,7 +6777,7 @@ fn get_v2_channel_reserve_satoshis( /// multiplicative 25/24 rule alone would be insufficient. fn min_rbf_feerate(prev_feerate: u32) -> FeeRate { let flat_increment = (prev_feerate as u64).saturating_add(25); - let spec_increment = ((prev_feerate as u64) * 25).div_ceil(24); + let spec_increment = (prev_feerate as u64) * 25 / 24; FeeRate::from_sat_per_kwu(cmp::max(flat_increment, spec_increment)) } diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 398715ea3cd..aec7fa9d1e1 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -4600,12 +4600,12 @@ fn test_splice_rbf_insufficient_feerate() { let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); assert_eq!(tx_abort.channel_id, channel_id); - // Acceptor-side: a counterparty feerate that only satisfies the 25/24 rule (264) is - // rejected — the spec requires max(prev + 25, ceil(prev * 25/24)) = 278 at low feerates. + // Acceptor-side: a counterparty feerate that only satisfies the 25/24 rule (263) is + // rejected — the spec requires max(prev + 25, prev * 25/24) = 278 at low feerates. // After tx_abort the channel remains quiescent, so no need to re-enter quiescence. nodes[0].node.handle_tx_abort(node_id_1, &tx_abort); - let rbf_feerate_25_24 = ((FEERATE_FLOOR_SATS_PER_KW as u64) * 25).div_ceil(24) as u32; + let rbf_feerate_25_24 = ((FEERATE_FLOOR_SATS_PER_KW as u64) * 25 / 24) as u32; let tx_init_rbf = msgs::TxInitRbf { channel_id, locktime: 0, @@ -4674,7 +4674,7 @@ fn test_splice_rbf_insufficient_feerate_high() { expect_splice_pending_event(&nodes[0], &node_id_1); expect_splice_pending_event(&nodes[1], &node_id_0); - // prev=1000: flat increment gives 1000+25=1025, 25/24 rule gives ceil(1000*25/24)=1042. + // prev=1000: flat increment gives 1000+25=1025, 25/24 rule gives 1000*25/24=1041. // Feerate 1025 satisfies the flat increment but not 25/24 — rejected. reenter_quiescence(&nodes[0], &nodes[1], &channel_id); @@ -4689,13 +4689,13 @@ fn test_splice_rbf_insufficient_feerate_high() { let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); assert_eq!(tx_abort.channel_id, channel_id); - // Feerate 1042 satisfies both rules — accepted. + // Feerate 1041 satisfies both rules — accepted. nodes[0].node.handle_tx_abort(node_id_1, &tx_abort); let tx_init_rbf = msgs::TxInitRbf { channel_id, locktime: 0, - feerate_sat_per_1000_weight: 1042, + feerate_sat_per_1000_weight: 1041, funding_output_contribution: Some(added_value.to_sat() as i64), }; @@ -6253,7 +6253,7 @@ fn test_funding_contributed_rbf_adjustment_insufficient_budget() { funding_template.splice_in_sync(added_value, floor_feerate, FeeRate::MAX, &wallet).unwrap(); // Node 1 initiates a splice at a HIGH feerate (10,000 sat/kwu). The minimum RBF feerate will be - // max(10,000 + 25, ceil(10,000 * 25/24)) = 10,417 sat/kwu — far above what node 0's tight + // max(10,000 + 25, 10,000 * 25/24) = 10,416 sat/kwu — far above what node 0's tight // budget can handle. let high_feerate = FeeRate::from_sat_per_kwu(10_000); let node_1_template = nodes[1].node.splice_channel(&channel_id, &node_id_0).unwrap(); From 41a2baac933aa00ec66ce591cfffefe8ad9fe797 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Thu, 9 Apr 2026 11:42:06 -0700 Subject: [PATCH 312/627] Make PriorContribution::holder_balance non-optional The `holder_balance` is computed by `FundedChannel::get_holder_counterparty_balances_floor_incl_fee`, which may unexpectedly fail due to the balance either being too high or too low. These cases are highly unlikely to happen given we have validation to ensure we never enter such a state to begin with. If they were to happen, something has gone wrong with the channel and it doesn't make sense to allow splicing anyway. Therefore, we opt to make `PriorContribution::holder_balance` non-optional and return an error that the channel cannot be spliced at the moment. --- lightning/src/ln/channel.rs | 35 ++++++++++++----------- lightning/src/ln/funding.rs | 55 +++++++++++++++++-------------------- 2 files changed, 44 insertions(+), 46 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 32c0e94bdc8..b99b2a19667 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12324,7 +12324,25 @@ where ); let min_rbf_feerate = prev_feerate.map(min_rbf_feerate); let prior = if pending_splice.last_funding_feerate_sat_per_1000_weight.is_some() { - self.build_prior_contribution() + if let Some(prior) = self + .pending_splice + .as_ref() + .and_then(|pending_splice| pending_splice.contributions.last()) + { + let holder_balance = self + .get_holder_counterparty_balances_floor_incl_fee(&self.funding) + .map(|(h, _)| h) + .map_err(|e| APIError::ChannelUnavailable { + err: format!( + "Channel {} cannot be spliced at this time: {}", + self.context.channel_id(), + e + ), + })?; + Some(PriorContribution::new(prior.clone(), holder_balance)) + } else { + None + } } else { None }; @@ -12346,21 +12364,6 @@ where Ok(FundingTemplate::new(Some(shared_input), min_rbf_feerate, prior_contribution)) } - /// Clones the prior contribution and fetches the holder balance for deferred feerate - /// adjustment. - fn build_prior_contribution(&self) -> Option { - debug_assert!( - self.pending_splice.is_some(), - "build_prior_contribution requires pending_splice" - ); - let prior = self.pending_splice.as_ref()?.contributions.last()?; - let holder_balance = self - .get_holder_counterparty_balances_floor_incl_fee(&self.funding) - .map(|(h, _)| h) - .ok(); - Some(PriorContribution::new(prior.clone(), holder_balance)) - } - /// Returns whether this channel can ever RBF, independent of splice state. fn is_rbf_compatible(&self) -> Result<(), String> { if self.context.minimum_depth(&self.funding) == Some(0) { diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 470e8bc71f1..6341b104dbb 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -174,8 +174,7 @@ impl core::fmt::Display for FundingContributionError { #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct PriorContribution { contribution: FundingContribution, - /// The holder's balance, used for feerate adjustment. `None` when the balance computation - /// fails, in which case adjustment is skipped and coin selection is re-run. + /// The holder's balance, used for feerate adjustment. /// /// This value is captured at [`ChannelManager::splice_channel`] time and may become stale /// if balances change before the contribution is used. Staleness is acceptable here because @@ -186,11 +185,11 @@ pub(super) struct PriorContribution { /// /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel /// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed - holder_balance: Option, + holder_balance: Amount, } impl PriorContribution { - pub(super) fn new(contribution: FundingContribution, holder_balance: Option) -> Self { + pub(super) fn new(contribution: FundingContribution, holder_balance: Amount) -> Self { Self { contribution, holder_balance } } } @@ -562,17 +561,15 @@ impl FundingTemplate { // buffer is insufficient (splice-in), or if the prior's feerate is already // above rbf_feerate (e.g., from a counterparty-initiated RBF that locked // at a higher feerate). In all cases, fall through to re-run coin selection. - if let Some(holder_balance) = holder_balance { - if contribution - .net_value_for_initiator_at_feerate(rbf_feerate, holder_balance) - .is_ok() - { - let mut adjusted = contribution - .for_initiator_at_feerate(rbf_feerate, holder_balance) - .expect("feerate compatibility already checked"); - adjusted.max_feerate = max_feerate; - return Ok(adjusted); - } + if contribution + .net_value_for_initiator_at_feerate(rbf_feerate, holder_balance) + .is_ok() + { + let mut adjusted = contribution + .for_initiator_at_feerate(rbf_feerate, holder_balance) + .expect("feerate compatibility already checked"); + adjusted.max_feerate = max_feerate; + return Ok(adjusted); } build_funding_contribution!( contribution.value_added, @@ -620,17 +617,15 @@ impl FundingTemplate { match prior_contribution { Some(PriorContribution { contribution, holder_balance }) => { // See comment in `rbf` for details on when this adjustment fails. - if let Some(holder_balance) = holder_balance { - if contribution - .net_value_for_initiator_at_feerate(rbf_feerate, holder_balance) - .is_ok() - { - let mut adjusted = contribution - .for_initiator_at_feerate(rbf_feerate, holder_balance) - .expect("feerate compatibility already checked"); - adjusted.max_feerate = max_feerate; - return Ok(adjusted); - } + if contribution + .net_value_for_initiator_at_feerate(rbf_feerate, holder_balance) + .is_ok() + { + let mut adjusted = contribution + .for_initiator_at_feerate(rbf_feerate, holder_balance) + .expect("feerate compatibility already checked"); + adjusted.max_feerate = max_feerate; + return Ok(adjusted); } build_funding_contribution!( contribution.value_added, @@ -2355,7 +2350,7 @@ mod tests { let template = FundingTemplate::new( None, Some(min_rbf_feerate), - Some(PriorContribution::new(prior, None)), + Some(PriorContribution::new(prior, Amount::MAX)), ); assert!(matches!( template.rbf_sync(max_feerate, UnreachableWallet), @@ -2390,7 +2385,7 @@ mod tests { let template = FundingTemplate::new( None, Some(min_rbf_feerate), - Some(PriorContribution::new(prior, Some(Amount::MAX))), + Some(PriorContribution::new(prior, Amount::MAX)), ); let contribution = template.rbf_sync(max_feerate, UnreachableWallet).unwrap(); assert_eq!(contribution.feerate, min_rbf_feerate); @@ -2452,7 +2447,7 @@ mod tests { let template = FundingTemplate::new( Some(shared_input(100_000)), Some(min_rbf_feerate), - Some(PriorContribution::new(prior, None)), + Some(PriorContribution::new(prior, Amount::ZERO)), ); let wallet = SingleUtxoWallet { @@ -2513,7 +2508,7 @@ mod tests { let template = FundingTemplate::new( Some(shared_input(100_000)), Some(min_rbf_feerate), - Some(PriorContribution::new(prior, None)), + Some(PriorContribution::new(prior, Amount::MAX)), ); let wallet = SingleUtxoWallet { From 11610af9b2312144dc807378b9d79557e3a2cd17 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Fri, 3 Apr 2026 10:53:28 -0700 Subject: [PATCH 313/627] Derive FundingContribution::net_value implicitly This commit removes `FundingContribution::value_added` as tracking it is unnecessary -- it can just be derived from the total amount in minus total amount out minus fees. --- fuzz/src/full_stack.rs | 18 +- lightning/src/ln/funding.rs | 439 ++++++----------------------- lightning/src/ln/splicing_tests.rs | 39 +-- 3 files changed, 98 insertions(+), 398 deletions(-) diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index f300ded4fb7..1f1cf425c92 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -1886,8 +1886,8 @@ fn splice_seed() -> Vec { // CommitmentSigned message with proper signature (r=f7, s=01...) and funding_txid TLV // signature r encodes sighash first byte f7, s follows the pattern from funding_created // TLV type 1 (odd/optional) for funding_txid as per impl_writeable_msg!(CommitmentSigned, ...) - // Note: txid is encoded in reverse byte order (Bitcoin standard), so to get display 0000...0033, encode 3300...0000 - ext_from_hex("0084 c000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000f7 0100000000000000000000000000000000000000000000000000000000000000 0000 01 20 3300000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test); + // Note: txid is encoded in reverse byte order (Bitcoin standard), so to get display 0000...0031, encode 3100...0000 + ext_from_hex("0084 c000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000f7 0100000000000000000000000000000000000000000000000000000000000000 0000 01 20 3100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test); // After commitment_signed exchange, we need to exchange tx_signatures. // Message type IDs: TxSignatures = 71 (0x0047) @@ -1900,19 +1900,19 @@ fn splice_seed() -> Vec { // inbound read from peer id 0 of len 150 (134 message + 16 MAC) ext_from_hex("030096", &mut test); // TxSignatures message with shared_input_signature TLV (type 0) - // txid must match the splice funding txid (0x33 in reverse byte order) + // txid must match the splice funding txid (0x31 in reverse byte order) // shared_input_signature: 64-byte fuzz signature for the shared input - ext_from_hex("0047 c000000000000000000000000000000000000000000000000000000000000000 3300000000000000000000000000000000000000000000000000000000000000 0000 00 40 00000000000000000000000000000000000000000000000000000000000000dc 0100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test); + ext_from_hex("0047 c000000000000000000000000000000000000000000000000000000000000000 3100000000000000000000000000000000000000000000000000000000000000 0000 00 40 00000000000000000000000000000000000000000000000000000000000000dc 0100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test); // Connect a block with the splice funding transaction to confirm it // The splice funding tx: version(4) + input_count(1) + txid(32) + vout(4) + script_len(1) + sequence(4) // + output_count(1) + value(8) + script_len(1) + script(34) + locktime(4) = 94 bytes = 0x5e // Transaction structure from FundingTransactionReadyForSigning: // - Input: spending c000...00:0 with sequence 0xfffffffd - // - Output: 115536 sats to OP_0 PUSH32 6e00...00 + // - Output: 115538 sats to OP_0 PUSH32 6e00...00 // - Locktime: 13 ext_from_hex("0c005e", &mut test); - ext_from_hex("02000000 01 c000000000000000000000000000000000000000000000000000000000000000 00000000 00 fdffffff 01 50c3010000000000 22 00206e00000000000000000000000000000000000000000000000000000000000000 0d000000", &mut test); + ext_from_hex("02000000 01 c000000000000000000000000000000000000000000000000000000000000000 00000000 00 fdffffff 01 52c3010000000000 22 00206e00000000000000000000000000000000000000000000000000000000000000 0d000000", &mut test); // Connect additional blocks to reach minimum_depth confirmations for _ in 0..5 { @@ -1929,8 +1929,8 @@ fn splice_seed() -> Vec { // inbound read from peer id 0 of len 82 (66 message + 16 MAC) ext_from_hex("030052", &mut test); // SpliceLocked message (type 77 = 0x004d): channel_id + splice_txid + mac - // splice_txid must match the splice funding txid (0x33 in reverse byte order) - ext_from_hex("004d c000000000000000000000000000000000000000000000000000000000000000 3300000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test); + // splice_txid must match the splice funding txid (0x31 in reverse byte order) + ext_from_hex("004d c000000000000000000000000000000000000000000000000000000000000000 3100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test); test } @@ -2060,6 +2060,6 @@ mod tests { // Splice locked assert_eq!(log_entries.get(&("lightning::ln::peer_handler".to_string(), "Handling SendSpliceLocked event in peer_handler for node 030000000000000000000000000000000000000000000000000000000000000002 for channel c000000000000000000000000000000000000000000000000000000000000000".to_string())), Some(&1)); - assert_eq!(log_entries.get(&("lightning::ln::channel".to_string(), "Promoting splice funding txid 0000000000000000000000000000000000000000000000000000000000000033".to_string())), Some(&1)); + assert_eq!(log_entries.get(&("lightning::ln::channel".to_string(), "Promoting splice funding txid 0000000000000000000000000000000000000000000000000000000000000031".to_string())), Some(&1)); } } diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 6341b104dbb..80c12178ce4 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -334,7 +334,6 @@ macro_rules! build_funding_contribution { let CoinSelection { confirmed_utxos: inputs, change_output } = coin_selection; Ok(FundingContribution::new( - value_added, outputs, inputs, change_output, @@ -455,7 +454,6 @@ impl FundingTemplate { max_feerate, )?; Ok(FundingContribution::new( - Amount::ZERO, outputs, vec![], None, @@ -572,7 +570,7 @@ impl FundingTemplate { return Ok(adjusted); } build_funding_contribution!( - contribution.value_added, + contribution.value_added(), contribution.outputs, shared_input, min_rbf_feerate, @@ -628,7 +626,7 @@ impl FundingTemplate { return Ok(adjusted); } build_funding_contribution!( - contribution.value_added, + contribution.value_added(), contribution.outputs, shared_input, min_rbf_feerate, @@ -707,12 +705,6 @@ fn estimate_transaction_fee( /// The components of a funding transaction contributed by one party. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FundingContribution { - /// The amount to contribute to the channel. - /// - /// If `value_added` is [`Amount::ZERO`], then any fees will be deducted from the channel - /// balance instead of paid by `inputs`. - value_added: Amount, - /// The estimate fees responsible to be paid for the contribution. estimated_fee: Amount, @@ -738,20 +730,19 @@ pub struct FundingContribution { } impl_writeable_tlv_based!(FundingContribution, { - (1, value_added, required), - (3, estimated_fee, required), - (5, inputs, optional_vec), - (7, outputs, optional_vec), - (9, change_output, option), - (11, feerate, required), - (13, max_feerate, required), - (15, is_splice, required), + (1, estimated_fee, required), + (3, inputs, optional_vec), + (5, outputs, optional_vec), + (7, change_output, option), + (9, feerate, required), + (11, max_feerate, required), + (13, is_splice, required), }); impl FundingContribution { fn new( - value_added: Amount, outputs: Vec, inputs: Vec, - change_output: Option, feerate: FeeRate, max_feerate: FeeRate, is_splice: bool, + outputs: Vec, inputs: Vec, change_output: Option, + feerate: FeeRate, max_feerate: FeeRate, is_splice: bool, ) -> Self { // The caller creating a FundingContribution is always the initiator for fee estimation // purposes — this is conservative, overestimating rather than underestimating fees if the @@ -766,16 +757,7 @@ impl FundingContribution { ); debug_assert!(estimated_fee <= Amount::MAX_MONEY); - Self { - value_added, - estimated_fee, - inputs, - outputs, - change_output, - feerate, - max_feerate, - is_splice, - } + Self { estimated_fee, inputs, outputs, change_output, feerate, max_feerate, is_splice } } pub(super) fn feerate(&self) -> FeeRate { @@ -794,9 +776,20 @@ impl FundingContribution { self.outputs.iter().chain(self.change_output.iter()) } - /// Returns the amount added to the channel by this contribution. + /// The value that will be added to the channel after fees. See [`Self::net_value`] for the net + /// value contribution to the channel. pub fn value_added(&self) -> Amount { - self.value_added + let total_input_value = self.inputs.iter().map(|i| i.utxo.output.value).sum::(); + let total_output_value = self.outputs.iter().map(|output| output.value).sum::(); + total_input_value + .checked_sub(total_output_value) + .and_then(|v| v.checked_sub(self.estimated_fee)) + .and_then(|v| { + v.checked_sub( + self.change_output.as_ref().map_or(Amount::ZERO, |output| output.value), + ) + }) + .unwrap_or(Amount::ZERO) } /// Returns the outputs (e.g., withdrawal destinations) included in this contribution. @@ -849,7 +842,7 @@ impl FundingContribution { } /// Validates that the funding inputs are suitable for use in the interactive transaction - /// protocol, checking prevtx sizes and input sufficiency. + /// protocol, checking prevtx sizes. pub fn validate(&self) -> Result<(), String> { for FundingTxInput { utxo, prevtx, .. } in self.inputs.iter() { use crate::util::ser::Writeable; @@ -871,38 +864,6 @@ impl FundingContribution { } } - // Fees for splice-out are paid from the channel balance whereas fees for splice-in - // are paid by the funding inputs. Therefore, in the case of splice-out, we add the - // fees on top of the user-specified contribution. We leave the user-specified - // contribution as-is for splice-ins. - if !self.inputs.is_empty() { - let mut total_input_value = Amount::ZERO; - for FundingTxInput { utxo, .. } in self.inputs.iter() { - total_input_value = total_input_value - .checked_add(utxo.output.value) - .ok_or("Sum of input values is greater than the total bitcoin supply")?; - } - - // If the inputs are enough to cover intended contribution amount plus fees (which - // include the change output weight when present), we are fine. - // If the inputs are less, but enough to cover intended contribution amount with - // (lower) fees without change, we are also fine (change will not be generated). - // Since estimated_fee includes change weight, this check is conservative. - // - // Note: dust limit is not relevant in this check. - - let contributed_input_value = self.value_added; - let estimated_fee = self.estimated_fee; - let minimal_input_amount_needed = contributed_input_value - .checked_add(estimated_fee) - .ok_or(format!("{contributed_input_value} contribution plus {estimated_fee} fee estimate exceeds the total bitcoin supply"))?; - if total_input_value < minimal_input_amount_needed { - return Err(format!( - "Total input amount {total_input_value} is lower than needed for splice-in contribution {contributed_input_value}, considering fees of {estimated_fee}. Need more inputs.", - )); - } - } - Ok(()) } @@ -1006,21 +967,10 @@ impl FundingContribution { self.is_splice, target_feerate, ); - // The fee buffer is total input value minus value_added and output values. - // This is estimated_fee plus the coin selection surplus (dust burned to - // fees), ensuring we never silently reduce value_added beyond the small - // surplus from coin selection. - let total_input_value: Amount = - self.inputs.iter().map(|i| i.utxo.output.value).sum(); - let output_values: Amount = self.outputs.iter().map(|o| o.value).sum(); - let fee_buffer = total_input_value - .checked_sub(self.value_added) - .and_then(|v| v.checked_sub(output_values)) - .ok_or(FeeRateAdjustmentError::FeeBufferOverflow)?; - if target_fee > fee_buffer { + if target_fee > self.estimated_fee { return Err(FeeRateAdjustmentError::FeeBufferInsufficient { - source: "estimated fee + coin selection surplus", - available: fee_buffer, + source: "estimated fee", + available: self.estimated_fee, required: target_fee, }); } @@ -1062,12 +1012,10 @@ impl FundingContribution { ) -> Result { let (new_estimated_fee, new_change) = self.compute_feerate_adjustment(feerate, holder_balance, is_initiator)?; - let surplus = self.fee_buffer_surplus(new_estimated_fee, &new_change); match new_change { Some(value) => self.change_output.as_mut().unwrap().value = value, None => self.change_output = None, } - self.value_added += surplus; self.estimated_fee = new_estimated_fee; self.feerate = feerate; Ok(self) @@ -1106,15 +1054,28 @@ impl FundingContribution { ) -> Result { let (new_estimated_fee, new_change) = self.compute_feerate_adjustment(target_feerate, holder_balance, is_initiator)?; - let surplus = self - .fee_buffer_surplus(new_estimated_fee, &new_change) + + let prev_fee = self + .estimated_fee + .to_signed() + .expect("total input amount cannot exceed Amount::MAX_MONEY"); + let prev_change = self + .change_output + .as_ref() + .map_or(Amount::ZERO, |output| output.value) + .to_signed() + .expect("total input amount cannot exceed Amount::MAX_MONEY"); + + let new_fee = new_estimated_fee .to_signed() - .expect("surplus does not exceed Amount::MAX_MONEY"); - let net_value = self - .net_value_with_fee(new_estimated_fee) - .checked_add(surplus) - .expect("net_value + surplus does not overflow"); - Ok(net_value) + .expect("total input amount cannot exceed Amount::MAX_MONEY"); + let new_change = new_change + .unwrap_or(Amount::ZERO) + .to_signed() + .expect("total input amount cannot exceed Amount::MAX_MONEY"); + + let prev_net_value = self.net_value(); + Ok(prev_net_value + prev_fee + prev_change - new_fee - new_change) } /// Returns the net value at the given target feerate without mutating `self`, @@ -1133,55 +1094,35 @@ impl FundingContribution { self.net_value_at_feerate(target_feerate, holder_balance, true) } - /// Returns the fee buffer surplus when a change output is removed. - /// - /// The fee buffer is the actual amount available for fees from inputs: total input value - /// minus value_added and output values. This includes both the weight-based estimated_fee - /// and any coin selection surplus (dust burned to fees). When the change output is removed, - /// the fee buffer may exceed the new fee; the surplus is returned so it can be redirected - /// to value_added rather than being burned as excess fees. - /// - /// Returns [`Amount::ZERO`] when there are no inputs or the change output is kept. - fn fee_buffer_surplus(&self, new_estimated_fee: Amount, new_change: &Option) -> Amount { - if !self.inputs.is_empty() && new_change.is_none() { - let total_input_value: Amount = self.inputs.iter().map(|i| i.utxo.output.value).sum(); - let output_values: Amount = self.outputs.iter().map(|o| o.value).sum(); - let fee_buffer = total_input_value - self.value_added - output_values; - debug_assert!(fee_buffer >= new_estimated_fee); - fee_buffer - new_estimated_fee - } else { - Amount::ZERO - } - } - - /// The net value contributed to a channel by the splice. If negative, more value will be - /// spliced out than spliced in. Fees will be deducted from the expected splice-out amount - /// if no inputs were included. + /// The net value contributed to a channel by the splice. pub fn net_value(&self) -> SignedAmount { - self.net_value_with_fee(self.estimated_fee) + let estimated_fee = self + .estimated_fee + .to_signed() + .expect("total_input_value is validated to not exceed Amount::MAX_MONEY"); + self.net_value_without_fee() + .checked_sub(estimated_fee) + .expect("all amounts are validated to not exceed Amount::MAX_MONEY") } - /// Computes the net value using the given `estimated_fee` for the splice-out (no inputs) - /// case. For splice-in, fees are paid by inputs so `estimated_fee` is not deducted. - fn net_value_with_fee(&self, estimated_fee: Amount) -> SignedAmount { - let unpaid_fees = if self.inputs.is_empty() { estimated_fee } else { Amount::ZERO } - .to_signed() - .expect("estimated_fee is validated to not exceed Amount::MAX_MONEY"); - let value_added = self - .value_added + fn net_value_without_fee(&self) -> SignedAmount { + let total_input_value = self + .inputs + .iter() + .map(|input| input.utxo.output.value) + .sum::() .to_signed() - .expect("value_added is validated to not exceed Amount::MAX_MONEY"); - let value_removed = self + .expect("total_input_value is validated to not exceed Amount::MAX_MONEY"); + let total_output_value = self .outputs .iter() + .chain(self.change_output.iter()) .map(|txout| txout.value) .sum::() .to_signed() - .expect("value_removed is validated to not exceed Amount::MAX_MONEY"); - - let contribution_amount = value_added - value_removed; - contribution_amount - .checked_sub(unpaid_fees) + .expect("total_output_value is validated to not exceed Amount::MAX_MONEY"); + total_input_value + .checked_sub(total_output_value) .expect("all amounts are validated to not exceed Amount::MAX_MONEY") } } @@ -1298,190 +1239,6 @@ mod tests { } } - #[test] - #[rustfmt::skip] - fn test_check_v2_funding_inputs_sufficient() { - // positive case, inputs well over intended contribution - { - let expected_fee = if cfg!(feature = "grind_signatures") { 2278 } else { 2284 }; - let contribution = FundingContribution { - value_added: Amount::from_sat(220_000), - estimated_fee: Amount::from_sat(expected_fee), - inputs: vec![ - funding_input_sats(200_000), - funding_input_sats(100_000), - ], - outputs: vec![], - change_output: None, - is_splice: true, - feerate: FeeRate::from_sat_per_kwu(2000), - max_feerate: FeeRate::MAX, - }; - assert!(contribution.validate().is_ok()); - assert_eq!(contribution.net_value(), contribution.value_added.to_signed().unwrap()); - } - - // Net splice-in - { - let expected_fee = if cfg!(feature = "grind_signatures") { 2526 } else { 2532 }; - let contribution = FundingContribution { - value_added: Amount::from_sat(220_000), - estimated_fee: Amount::from_sat(expected_fee), - inputs: vec![ - funding_input_sats(200_000), - funding_input_sats(100_000), - ], - outputs: vec![ - funding_output_sats(200_000), - ], - change_output: None, - is_splice: true, - feerate: FeeRate::from_sat_per_kwu(2000), - max_feerate: FeeRate::MAX, - }; - assert!(contribution.validate().is_ok()); - assert_eq!(contribution.net_value(), SignedAmount::from_sat(220_000 - 200_000)); - } - - // Net splice-out - { - let expected_fee = if cfg!(feature = "grind_signatures") { 2526 } else { 2532 }; - let contribution = FundingContribution { - value_added: Amount::from_sat(220_000), - estimated_fee: Amount::from_sat(expected_fee), - inputs: vec![ - funding_input_sats(200_000), - funding_input_sats(100_000), - ], - outputs: vec![ - funding_output_sats(400_000), - ], - change_output: None, - is_splice: true, - feerate: FeeRate::from_sat_per_kwu(2000), - max_feerate: FeeRate::MAX, - }; - assert!(contribution.validate().is_ok()); - assert_eq!(contribution.net_value(), SignedAmount::from_sat(220_000 - 400_000)); - } - - // Net splice-out, inputs insufficient to cover fees - { - let expected_fee = if cfg!(feature = "grind_signatures") { 113670 } else { 113940 }; - let contribution = FundingContribution { - value_added: Amount::from_sat(220_000), - estimated_fee: Amount::from_sat(expected_fee), - inputs: vec![ - funding_input_sats(200_000), - funding_input_sats(100_000), - ], - outputs: vec![ - funding_output_sats(400_000), - ], - change_output: None, - is_splice: true, - feerate: FeeRate::from_sat_per_kwu(90000), - max_feerate: FeeRate::MAX, - }; - assert_eq!( - contribution.validate(), - Err(format!( - "Total input amount 0.00300000 BTC is lower than needed for splice-in contribution 0.00220000 BTC, considering fees of {}. Need more inputs.", - Amount::from_sat(expected_fee), - )), - ); - } - - // negative case, inputs clearly insufficient - { - let expected_fee = if cfg!(feature = "grind_signatures") { 1736 } else { 1740 }; - let contribution = FundingContribution { - value_added: Amount::from_sat(220_000), - estimated_fee: Amount::from_sat(expected_fee), - inputs: vec![ - funding_input_sats(100_000), - ], - outputs: vec![], - change_output: None, - is_splice: true, - feerate: FeeRate::from_sat_per_kwu(2000), - max_feerate: FeeRate::MAX, - }; - assert_eq!( - contribution.validate(), - Err(format!( - "Total input amount 0.00100000 BTC is lower than needed for splice-in contribution 0.00220000 BTC, considering fees of {}. Need more inputs.", - Amount::from_sat(expected_fee), - )), - ); - } - - // barely covers - { - let expected_fee = if cfg!(feature = "grind_signatures") { 2278 } else { 2284 }; - let contribution = FundingContribution { - value_added: Amount::from_sat(300_000 - expected_fee - 20), - estimated_fee: Amount::from_sat(expected_fee), - inputs: vec![ - funding_input_sats(200_000), - funding_input_sats(100_000), - ], - outputs: vec![], - change_output: None, - is_splice: true, - feerate: FeeRate::from_sat_per_kwu(2000), - max_feerate: FeeRate::MAX, - }; - assert!(contribution.validate().is_ok()); - assert_eq!(contribution.net_value(), contribution.value_added.to_signed().unwrap()); - } - - // higher fee rate, does not cover - { - let expected_fee = if cfg!(feature = "grind_signatures") { 2506 } else { 2513 }; - let contribution = FundingContribution { - value_added: Amount::from_sat(298032), - estimated_fee: Amount::from_sat(expected_fee), - inputs: vec![ - funding_input_sats(200_000), - funding_input_sats(100_000), - ], - outputs: vec![], - change_output: None, - is_splice: true, - feerate: FeeRate::from_sat_per_kwu(2200), - max_feerate: FeeRate::MAX, - }; - assert_eq!( - contribution.validate(), - Err(format!( - "Total input amount 0.00300000 BTC is lower than needed for splice-in contribution 0.00298032 BTC, considering fees of {}. Need more inputs.", - Amount::from_sat(expected_fee), - )), - ); - } - - // barely covers, less fees (not a splice) - { - let expected_fee = if cfg!(feature = "grind_signatures") { 1512 } else { 1516 }; - let contribution = FundingContribution { - value_added: Amount::from_sat(300_000 - expected_fee - 20), - estimated_fee: Amount::from_sat(expected_fee), - inputs: vec![ - funding_input_sats(200_000), - funding_input_sats(100_000), - ], - outputs: vec![], - change_output: None, - is_splice: false, - feerate: FeeRate::from_sat_per_kwu(2000), - max_feerate: FeeRate::MAX, - }; - assert!(contribution.validate().is_ok()); - assert_eq!(contribution.net_value(), contribution.value_added.to_signed().unwrap()); - } - } - struct UnreachableWallet; impl CoinSelectionSourceSync for UnreachableWallet { @@ -1612,7 +1369,6 @@ mod tests { estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate); let contribution = FundingContribution { - value_added: Amount::from_sat(50_000), estimated_fee, inputs: inputs.clone(), outputs: vec![], @@ -1650,7 +1406,6 @@ mod tests { estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate); let contribution = FundingContribution { - value_added: Amount::from_sat(50_000), estimated_fee, inputs, outputs: vec![], @@ -1691,7 +1446,6 @@ mod tests { let change = funding_output_sats(change_value.to_sat()); let contribution = FundingContribution { - value_added, estimated_fee, inputs: inputs.clone(), outputs: vec![], @@ -1727,7 +1481,6 @@ mod tests { estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate); let contribution = FundingContribution { - value_added: Amount::from_sat(50_000), estimated_fee, inputs, outputs: vec![], @@ -1753,7 +1506,6 @@ mod tests { estimate_transaction_fee(&[], &outputs, None, true, true, original_feerate); let contribution = FundingContribution { - value_added: Amount::ZERO, estimated_fee, inputs: vec![], outputs: outputs.clone(), @@ -1783,7 +1535,6 @@ mod tests { estimate_transaction_fee(&[], &outputs, None, true, true, original_feerate); let contribution = FundingContribution { - value_added: Amount::ZERO, estimated_fee, inputs: vec![], outputs, @@ -1807,12 +1558,12 @@ mod tests { let target_feerate = FeeRate::from_sat_per_kwu(3000); let inputs = vec![funding_input_sats(100_000)]; let change = funding_output_sats(10_000); + let change_value = change.value; let estimated_fee = estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate); let contribution = FundingContribution { - value_added: Amount::from_sat(50_000), estimated_fee, inputs, outputs: vec![], @@ -1827,7 +1578,10 @@ mod tests { let net_at_feerate = contribution.net_value_for_acceptor_at_feerate(target_feerate, Amount::MAX).unwrap(); assert_eq!(net_at_feerate, contribution.net_value()); - assert_eq!(net_at_feerate, Amount::from_sat(50_000).to_signed().unwrap()); + assert_eq!( + net_at_feerate, + (Amount::from_sat(100_000) - estimated_fee - change_value).to_signed().unwrap(), + ); } #[test] @@ -1842,7 +1596,6 @@ mod tests { estimate_transaction_fee(&[], &outputs, None, true, true, original_feerate); let contribution = FundingContribution { - value_added: Amount::ZERO, estimated_fee, inputs: vec![], outputs: outputs.clone(), @@ -1878,7 +1631,6 @@ mod tests { estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate); let contribution = FundingContribution { - value_added: Amount::from_sat(50_000), estimated_fee, inputs, outputs: vec![], @@ -1912,7 +1664,6 @@ mod tests { estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate); let contribution = FundingContribution { - value_added: Amount::from_sat(50_000), estimated_fee, inputs, outputs: vec![], @@ -1940,7 +1691,6 @@ mod tests { estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate); let contribution = FundingContribution { - value_added: Amount::from_sat(50_000), estimated_fee, inputs, outputs: vec![], @@ -1972,7 +1722,6 @@ mod tests { estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate); let contribution = FundingContribution { - value_added: Amount::from_sat(50_000), estimated_fee, inputs, outputs: vec![], @@ -2007,7 +1756,6 @@ mod tests { estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate); let contribution = FundingContribution { - value_added: Amount::from_sat(50_000), estimated_fee, inputs, outputs: vec![], @@ -2050,7 +1798,6 @@ mod tests { assert!(target_fee > estimated_fee); let contribution = FundingContribution { - value_added, estimated_fee, inputs, outputs: vec![], @@ -2083,7 +1830,6 @@ mod tests { assert!(target_fee > estimated_fee); let contribution = FundingContribution { - value_added, estimated_fee, inputs, outputs: vec![], @@ -2122,7 +1868,6 @@ mod tests { assert!(estimated_fee - target_fee < dust_limit); let contribution = FundingContribution { - value_added: Amount::from_sat(50_000), estimated_fee, inputs, outputs: vec![], @@ -2142,8 +1887,8 @@ mod tests { #[test] fn test_for_acceptor_at_feerate_no_change_surplus_absorbed() { // Inputs, no change. The estimated_fee (is_initiator=true) far exceeds the acceptor's - // target fee (is_initiator=false). The surplus stays in the channel balance rather than - // being burned as excess fees. + // target fee (is_initiator=false). The surplus stays in the channel contribution rather + // than being burned as excess fees. let feerate = FeeRate::from_sat_per_kwu(2000); let value_added = Amount::from_sat(50_000); @@ -2159,7 +1904,6 @@ mod tests { let target_fee = estimate_transaction_fee(&inputs, &[], None, false, true, feerate); let contribution = FundingContribution { - value_added, estimated_fee, inputs, outputs: vec![], @@ -2178,20 +1922,17 @@ mod tests { assert!(adjusted.change_output.is_none()); assert_eq!(adjusted.estimated_fee, target_fee); let surplus = estimated_fee - target_fee; - assert_eq!(adjusted.value_added, value_added + surplus); + assert_eq!(adjusted.value_added(), value_added + surplus); assert_eq!(adjusted.net_value(), net_value_before + surplus.to_signed().unwrap()); } #[test] - fn test_for_acceptor_at_feerate_fee_buffer_overflow() { - // Construct a contribution with estimated_fee and change values that overflow Amount. + fn test_for_acceptor_at_feerate_fee_buffer_overflow_with_change() { + // Overflow in estimated_fee + change value should surface as FeeBufferOverflow. let feerate = FeeRate::from_sat_per_kwu(2000); - let inputs = vec![funding_input_sats(100_000)]; - let contribution = FundingContribution { - value_added: Amount::from_sat(50_000), estimated_fee: Amount::MAX, - inputs, + inputs: vec![funding_input_sats(100_000)], outputs: vec![], change_output: Some(funding_output_sats(1)), feerate, @@ -2214,7 +1955,6 @@ mod tests { estimate_transaction_fee(&[], &outputs, None, true, true, original_feerate); let contribution = FundingContribution { - value_added: Amount::ZERO, estimated_fee, inputs: vec![], outputs: outputs.clone(), @@ -2241,7 +1981,6 @@ mod tests { estimate_transaction_fee(&[], &outputs, None, true, true, original_feerate); let contribution = FundingContribution { - value_added: Amount::ZERO, estimated_fee, inputs: vec![], outputs: outputs.clone(), @@ -2272,7 +2011,6 @@ mod tests { estimate_transaction_fee(&[], &outputs, None, true, true, original_feerate); let contribution = FundingContribution { - value_added: Amount::ZERO, estimated_fee, inputs: vec![], outputs, @@ -2301,7 +2039,6 @@ mod tests { estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate); let contribution = FundingContribution { - value_added: Amount::from_sat(50_000), estimated_fee, inputs, outputs: vec![], @@ -2336,7 +2073,6 @@ mod tests { let max_feerate = FeeRate::from_sat_per_kwu(2020); let prior = FundingContribution { - value_added: Amount::from_sat(50_000), estimated_fee: Amount::from_sat(1_000), inputs: vec![funding_input_sats(100_000)], outputs: vec![], @@ -2372,7 +2108,6 @@ mod tests { estimate_transaction_fee(&inputs, &[], Some(&change), true, true, prior_feerate); let prior = FundingContribution { - value_added: Amount::from_sat(50_000), estimated_fee, inputs, outputs: vec![], @@ -2434,7 +2169,6 @@ mod tests { let withdrawal = funding_output_sats(20_000); let prior = FundingContribution { - value_added: Amount::ZERO, estimated_fee: Amount::from_sat(500), inputs: vec![], outputs: vec![withdrawal.clone()], @@ -2452,13 +2186,13 @@ mod tests { let wallet = SingleUtxoWallet { utxo: funding_input_sats(50_000), - change_output: Some(funding_output_sats(40_000)), + change_output: Some(funding_output_sats(25_000)), }; // rbf_sync should succeed and the contribution should have inputs from coin selection. let contribution = template.rbf_sync(FeeRate::MAX, &wallet).unwrap(); - assert_eq!(contribution.value_added, Amount::ZERO); assert!(!contribution.inputs.is_empty(), "coin selection should have added inputs"); + assert!(contribution.value_added() > Amount::ZERO); assert_eq!(contribution.outputs, vec![withdrawal]); assert_eq!(contribution.feerate, min_rbf_feerate); } @@ -2478,8 +2212,8 @@ mod tests { }; let contribution = template.rbf_sync(FeeRate::MAX, &wallet).unwrap(); - assert_eq!(contribution.value_added, Amount::ZERO); assert!(!contribution.inputs.is_empty(), "coin selection should have added inputs"); + assert!(contribution.value_added() > Amount::ZERO); assert!(contribution.outputs.is_empty()); assert_eq!(contribution.feerate, min_rbf_feerate); } @@ -2495,7 +2229,6 @@ mod tests { let withdrawal = funding_output_sats(20_000); let prior = FundingContribution { - value_added: Amount::ZERO, estimated_fee: Amount::from_sat(500), inputs: vec![], outputs: vec![withdrawal.clone()], @@ -2513,7 +2246,7 @@ mod tests { let wallet = SingleUtxoWallet { utxo: funding_input_sats(50_000), - change_output: Some(funding_output_sats(40_000)), + change_output: Some(funding_output_sats(25_000)), }; let contribution = template.rbf_sync(callers_max_feerate, &wallet).unwrap(); @@ -2537,7 +2270,7 @@ mod tests { let contribution = template.splice_out(vec![withdrawal.clone()], feerate, FeeRate::MAX).unwrap(); - assert_eq!(contribution.value_added, Amount::ZERO); + assert_eq!(contribution.value_added(), Amount::ZERO); assert!(contribution.inputs.is_empty()); assert!(contribution.change_output.is_none()); assert_eq!(contribution.outputs, vec![withdrawal]); diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 54929214ab6..3004c76fb93 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -158,40 +158,6 @@ impl CoinSelectionSourceSync for TightBudgetWallet { } } -#[test] -fn test_validate_accounts_for_change_output_weight() { - // Demonstrates that estimated_fee includes the change output's weight when building a - // FundingContribution. A mock wallet returns a single input whose value is between - // estimated_fee_without_change (1736/1740 sats) and estimated_fee_with_change (1984/1988 - // sats) above value_added. The validate() check correctly catches that the inputs are - // insufficient when the change output weight is included. Without accounting for the change - // output weight, the check would incorrectly pass. - let chanmon_cfgs = create_chanmon_cfgs(2); - let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); - let nodes = create_network(2, &node_cfgs, &node_chanmgrs); - - let (_, _, channel_id, _) = - create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); - - let feerate = FeeRate::from_sat_per_kwu(2000); - let funding_template = - nodes[0].node.splice_channel(&channel_id, &nodes[1].node.get_our_node_id()).unwrap(); - - // Input value = value_added + 1800: above 1736/1740 (fee without change), below 1984/1988 - // (fee with change). - let value_added = Amount::from_sat(20_000); - let wallet = TightBudgetWallet { - utxo_value: value_added + Amount::from_sat(1800), - change_value: Amount::from_sat(1000), - }; - let contribution = - funding_template.splice_in_sync(value_added, feerate, FeeRate::MAX, &wallet).unwrap(); - - assert!(contribution.change_output().is_some()); - assert!(contribution.validate().is_err()); -} - pub fn negotiate_splice_tx<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, funding_contribution: FundingContribution, @@ -1862,7 +1828,8 @@ fn do_test_splice_commitment_broadcast(splice_status: SpliceStatus, claim_htlcs: let splice_in_amount = initial_channel_capacity / 2; let initiator_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, Amount::from_sat(splice_in_amount)); - let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); + let (splice_tx, _) = + splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution.clone()); let (preimage2, payment_hash2, ..) = route_payment(&nodes[0], &[&nodes[1]], payment_amount); let htlc_expiry = nodes[0].best_block_info().1 + TEST_FINAL_CLTV + LATENCY_GRACE_PERIOD_BLOCKS; @@ -1913,7 +1880,7 @@ fn do_test_splice_commitment_broadcast(splice_status: SpliceStatus, claim_htlcs: message: "test".to_owned(), }; let closed_channel_capacity = if splice_status == SpliceStatus::Locked { - initial_channel_capacity + splice_in_amount + initial_channel_capacity + initiator_contribution.net_value().to_sat() as u64 } else { initial_channel_capacity }; From bfc7c7bb932ed34605fe3486ef59e6a5dab20975 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Tue, 14 Apr 2026 10:52:01 +0200 Subject: [PATCH 314/627] Split fuzz runners by hash mode Move shared fuzz logic into the root fuzz crate and generate fake-hashes and real-hashes runner crates. Keep `chanmon_consistency_target` on the real-hashes side, remove the fuzz-local Cargo config, and update scripts, CI, coverage, and docs to use explicit flags for each runner. Generate the hash-mode compile checks in the wrapper bins without a synthetic Cargo feature, while keeping the wrapper template close to its original shape. AI tools were used in preparing this commit. --- .github/workflows/build.yml | 7 +- ci/check-compiles.sh | 6 +- contrib/generate_fuzz_coverage.sh | 46 +++-- fuzz/.cargo/config.toml | 2 - fuzz/Cargo.toml | 21 +-- fuzz/README.md | 46 +++-- fuzz/ci-fuzz.sh | 101 ++++++----- fuzz/fuzz-fake-hashes/Cargo.toml | 31 ++++ .../src/bin/base32_target.rs | 6 +- .../src/bin/bech32_parse_target.rs | 6 +- .../src/bin/bolt11_deser_target.rs | 6 +- .../src/bin/chanmon_deser_target.rs | 6 +- .../src/bin/feature_flags_target.rs | 6 +- .../src/bin/fromstr_to_netaddress_target.rs | 6 +- .../src/bin/fs_store_target.rs | 6 +- .../src/bin/full_stack_target.rs | 6 +- .../src/bin/gossip_discovery_target.rs | 6 +- .../src/bin/indexedmap_target.rs | 6 +- .../src/bin/invoice_deser_target.rs | 6 +- .../src/bin/invoice_request_deser_target.rs | 6 +- .../src/bin/lsps_message_target.rs | 6 +- .../src/bin/msg_accept_channel_target.rs | 6 +- .../src/bin/msg_accept_channel_v2_target.rs | 6 +- .../bin/msg_announcement_signatures_target.rs | 6 +- .../bin/msg_blinded_message_path_target.rs | 6 +- .../bin/msg_channel_announcement_target.rs | 6 +- .../src/bin/msg_channel_details_target.rs | 6 +- .../src/bin/msg_channel_ready_target.rs | 6 +- .../src/bin/msg_channel_reestablish_target.rs | 6 +- .../src/bin/msg_channel_update_target.rs | 6 +- .../src/bin/msg_closing_complete_target.rs | 6 +- .../src/bin/msg_closing_sig_target.rs | 6 +- .../src/bin/msg_closing_signed_target.rs | 6 +- .../src/bin/msg_commitment_signed_target.rs | 6 +- .../msg_decoded_onion_error_packet_target.rs | 6 +- .../src/bin/msg_error_message_target.rs | 6 +- .../src/bin/msg_funding_created_target.rs | 6 +- .../src/bin/msg_funding_signed_target.rs | 6 +- .../bin/msg_gossip_timestamp_filter_target.rs | 6 +- .../src/bin/msg_init_target.rs | 6 +- .../src/bin/msg_node_announcement_target.rs | 6 +- .../src/bin/msg_open_channel_target.rs | 6 +- .../src/bin/msg_open_channel_v2_target.rs | 6 +- .../src/bin/msg_ping_target.rs | 6 +- .../src/bin/msg_pong_target.rs | 6 +- .../src/bin/msg_query_channel_range_target.rs | 6 +- .../bin/msg_query_short_channel_ids_target.rs | 6 +- .../src/bin/msg_reply_channel_range_target.rs | 6 +- .../msg_reply_short_channel_ids_end_target.rs | 6 +- .../src/bin/msg_revoke_and_ack_target.rs | 6 +- .../src/bin/msg_shutdown_target.rs | 6 +- .../src/bin/msg_splice_ack_target.rs | 6 +- .../src/bin/msg_splice_init_target.rs | 6 +- .../src/bin/msg_splice_locked_target.rs | 6 +- .../src/bin/msg_stfu_target.rs | 6 +- .../src/bin/msg_tx_abort_target.rs | 6 +- .../src/bin/msg_tx_ack_rbf_target.rs | 6 +- .../src/bin/msg_tx_add_input_target.rs | 6 +- .../src/bin/msg_tx_add_output_target.rs | 6 +- .../src/bin/msg_tx_complete_target.rs | 6 +- .../src/bin/msg_tx_init_rbf_target.rs | 6 +- .../src/bin/msg_tx_remove_input_target.rs | 6 +- .../src/bin/msg_tx_remove_output_target.rs | 6 +- .../src/bin/msg_tx_signatures_target.rs | 6 +- .../src/bin/msg_update_add_htlc_target.rs | 6 +- .../src/bin/msg_update_fail_htlc_target.rs | 6 +- .../msg_update_fail_malformed_htlc_target.rs | 6 +- .../src/bin/msg_update_fee_target.rs | 6 +- .../src/bin/msg_update_fulfill_htlc_target.rs | 6 +- .../src/bin/offer_deser_target.rs | 6 +- .../src/bin/onion_hop_data_target.rs | 6 +- .../src/bin/onion_message_target.rs | 6 +- .../src/bin/peer_crypt_target.rs | 6 +- .../src/bin/process_network_graph_target.rs | 6 +- .../src/bin/process_onion_failure_target.rs | 6 +- .../src/bin/refund_deser_target.rs | 6 +- .../src/bin/router_target.rs | 6 +- .../src/bin/static_invoice_deser_target.rs | 6 +- .../src/bin/zbase32_target.rs | 6 +- fuzz/fuzz-real-hashes/Cargo.toml | 31 ++++ .../src/bin/chanmon_consistency_target.rs | 8 +- fuzz/src/bin/gen_target.sh | 163 ++++++++++-------- fuzz/src/bin/target_template.txt | 8 +- fuzz/src/lib.rs | 3 - fuzz/test_cases/base32/smoke | 1 + fuzz/test_cases/bech32_parse/smoke | 1 + fuzz/test_cases/chanmon_consistency/smoke | 1 + fuzz/write-seeds/Cargo.toml | 4 - 88 files changed, 513 insertions(+), 393 deletions(-) delete mode 100644 fuzz/.cargo/config.toml create mode 100644 fuzz/fuzz-fake-hashes/Cargo.toml rename fuzz/{ => fuzz-fake-hashes}/src/bin/base32_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/bech32_parse_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/bolt11_deser_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/chanmon_deser_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/feature_flags_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/fromstr_to_netaddress_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/fs_store_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/full_stack_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/gossip_discovery_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/indexedmap_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/invoice_deser_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/invoice_request_deser_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/lsps_message_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_accept_channel_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_accept_channel_v2_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_announcement_signatures_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_blinded_message_path_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_channel_announcement_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_channel_details_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_channel_ready_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_channel_reestablish_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_channel_update_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_closing_complete_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_closing_sig_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_closing_signed_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_commitment_signed_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_decoded_onion_error_packet_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_error_message_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_funding_created_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_funding_signed_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_gossip_timestamp_filter_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_init_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_node_announcement_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_open_channel_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_open_channel_v2_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_ping_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_pong_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_query_channel_range_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_query_short_channel_ids_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_reply_channel_range_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_reply_short_channel_ids_end_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_revoke_and_ack_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_shutdown_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_splice_ack_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_splice_init_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_splice_locked_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_stfu_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_tx_abort_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_tx_ack_rbf_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_tx_add_input_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_tx_add_output_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_tx_complete_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_tx_init_rbf_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_tx_remove_input_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_tx_remove_output_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_tx_signatures_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_update_add_htlc_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_update_fail_htlc_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_update_fail_malformed_htlc_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_update_fee_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/msg_update_fulfill_htlc_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/offer_deser_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/onion_hop_data_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/onion_message_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/peer_crypt_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/process_network_graph_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/process_onion_failure_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/refund_deser_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/router_target.rs (95%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/static_invoice_deser_target.rs (94%) rename fuzz/{ => fuzz-fake-hashes}/src/bin/zbase32_target.rs (95%) create mode 100644 fuzz/fuzz-real-hashes/Cargo.toml rename fuzz/{ => fuzz-real-hashes}/src/bin/chanmon_consistency_target.rs (94%) create mode 100644 fuzz/test_cases/base32/smoke create mode 100644 fuzz/test_cases/bech32_parse/smoke create mode 100644 fuzz/test_cases/chanmon_consistency/smoke diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b68d545ac3e..d6a5deda322 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -93,7 +93,8 @@ jobs: # Could you use this to fake the coverage report for your PR? Sure. # Will anyone be impressed by your amazing coverage? No # Maybe if codecov wasn't broken we wouldn't need to do this... - ./codecov --verbose upload-process --disable-search --fail-on-error -f fuzz-codecov.json -t "f421b687-4dc2-4387-ac3d-dc3b2528af57" -F 'fuzzing' + ./codecov --verbose upload-process --disable-search --fail-on-error -f fuzz-fake-hashes-codecov.json -t "f421b687-4dc2-4387-ac3d-dc3b2528af57" -F 'fuzzing-fake-hashes' + ./codecov --verbose upload-process --disable-search --fail-on-error -f fuzz-real-hashes-codecov.json -t "f421b687-4dc2-4387-ac3d-dc3b2528af57" -F 'fuzzing-real-hashes' benchmark: runs-on: ubuntu-latest @@ -218,7 +219,9 @@ jobs: - name: Sanity check fuzz targets on Rust ${{ env.TOOLCHAIN }} run: | cd fuzz - cargo test --quiet --color always --lib --bins -j8 + RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz" cargo test --quiet --color always --lib -j8 + RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz" cargo test --manifest-path fuzz-fake-hashes/Cargo.toml --quiet --color always --bins -j8 + RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz" cargo test --manifest-path fuzz-real-hashes/Cargo.toml --quiet --color always --bins -j8 fuzz: runs-on: self-hosted diff --git a/ci/check-compiles.sh b/ci/check-compiles.sh index a067861fb56..cd1e0759c63 100755 --- a/ci/check-compiles.sh +++ b/ci/check-compiles.sh @@ -5,6 +5,10 @@ echo "Testing $(git log -1 --oneline)" cargo check cargo doc cargo doc --document-private-items -cd fuzz && RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz" cargo check --features=stdin_fuzz +cd fuzz +RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz" \ + cargo check --manifest-path fuzz-fake-hashes/Cargo.toml --features=stdin_fuzz +RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz" \ + cargo check --manifest-path fuzz-real-hashes/Cargo.toml --features=stdin_fuzz cd ../lightning && cargo check --no-default-features cd .. && RUSTC_BOOTSTRAP=1 RUSTFLAGS="--cfg=c_bindings" cargo check -Z avoid-dev-deps diff --git a/contrib/generate_fuzz_coverage.sh b/contrib/generate_fuzz_coverage.sh index 6be9956bbca..45119c5517b 100755 --- a/contrib/generate_fuzz_coverage.sh +++ b/contrib/generate_fuzz_coverage.sh @@ -55,18 +55,37 @@ fi # Create output directory if it doesn't exist mkdir -p "$OUTPUT_DIR" +generate_coverage_report() { + local manifest_path="$1" + local output_path="$2" + local rustflags="$3" + + cargo llvm-cov clean --workspace + RUSTFLAGS="$rustflags" cargo llvm-cov -j8 --manifest-path "$manifest_path" --codecov \ + --dep-coverage lightning,lightning-invoice,lightning-liquidity,lightning-rapid-gossip-sync,lightning-persister \ + --no-default-ignore-filename-regex \ + --ignore-filename-regex "(\.cargo/registry|\.rustup/toolchains|/fuzz/)" \ + --output-path "$output_path" --tests +} + # dont run this command when running in CI if [ "$OUTPUT_CODECOV_JSON" = "0" ]; then - cargo llvm-cov --html \ + cargo llvm-cov clean --workspace + RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz" \ + cargo llvm-cov --manifest-path fuzz-fake-hashes/Cargo.toml --html \ --dep-coverage lightning,lightning-invoice,lightning-liquidity,lightning-rapid-gossip-sync,lightning-persister \ --no-default-ignore-filename-regex \ --ignore-filename-regex "(\.cargo/registry|\.rustup/toolchains|/fuzz/)" \ - --output-dir "$OUTPUT_DIR" - echo "Coverage report generated in $OUTPUT_DIR/html/index.html" -else - # Clean previous coverage artifacts to ensure a fresh run. + --output-dir "$OUTPUT_DIR/fake-hashes" --tests cargo llvm-cov clean --workspace - + RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz" \ + cargo llvm-cov --manifest-path fuzz-real-hashes/Cargo.toml --html \ + --dep-coverage lightning,lightning-invoice,lightning-liquidity,lightning-rapid-gossip-sync,lightning-persister \ + --no-default-ignore-filename-regex \ + --ignore-filename-regex "(\.cargo/registry|\.rustup/toolchains|/fuzz/)" \ + --output-dir "$OUTPUT_DIR/real-hashes" --tests + echo "Coverage reports generated in $OUTPUT_DIR/fake-hashes and $OUTPUT_DIR/real-hashes" +else # Import honggfuzz corpus if the artifact was downloaded. if [ -d "hfuzz_workspace" ]; then echo "Importing corpus from hfuzz_workspace..." @@ -82,11 +101,14 @@ else fi echo "Replaying imported corpus (if found) via tests to generate coverage..." - cargo llvm-cov -j8 --codecov \ - --dep-coverage lightning,lightning-invoice,lightning-liquidity,lightning-rapid-gossip-sync,lightning-persister \ - --no-default-ignore-filename-regex \ - --ignore-filename-regex "(\.cargo/registry|\.rustup/toolchains|/fuzz/)" \ - --output-path "$OUTPUT_DIR/fuzz-codecov.json" --tests + generate_coverage_report \ + "fuzz-fake-hashes/Cargo.toml" \ + "$OUTPUT_DIR/fuzz-fake-hashes-codecov.json" \ + "--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz" + generate_coverage_report \ + "fuzz-real-hashes/Cargo.toml" \ + "$OUTPUT_DIR/fuzz-real-hashes-codecov.json" \ + "--cfg=fuzzing --cfg=secp256k1_fuzz" - echo "Fuzz codecov report available at $OUTPUT_DIR/fuzz-codecov.json" + echo "Fuzz codecov reports available at $OUTPUT_DIR/fuzz-fake-hashes-codecov.json and $OUTPUT_DIR/fuzz-real-hashes-codecov.json" fi diff --git a/fuzz/.cargo/config.toml b/fuzz/.cargo/config.toml deleted file mode 100644 index 86513788566..00000000000 --- a/fuzz/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[build] -rustflags = ["--cfg=fuzzing", "--cfg=secp256k1_fuzz", "--cfg=hashes_fuzz"] diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 252946be458..8cafdd1f2fb 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -4,18 +4,6 @@ version = "0.0.1" authors = ["Automatically generated"] publish = false edition = "2021" -# Because the function is unused it gets dropped before we link lightning, so -# we have to duplicate build.rs here. Note that this is only required for -# fuzzing mode. - -[package.metadata] -cargo-fuzz = true - -[features] -afl_fuzz = ["afl"] -honggfuzz_fuzz = ["honggfuzz"] -libfuzzer_fuzz = ["libfuzzer-sys"] -stdin_fuzz = [] [dependencies] lightning = { path = "../lightning", features = ["regex", "_test_utils"] } @@ -27,16 +15,9 @@ bech32 = "0.11.0" bitcoin = { version = "0.32.4", features = ["secp-lowmemory"] } tokio = { version = "~1.35", default-features = false, features = ["rt-multi-thread"] } -afl = { version = "0.12", optional = true } -honggfuzz = { version = "0.5", optional = true, default-features = false } -libfuzzer-sys = { version = "0.4", optional = true } - -[build-dependencies] -cc = "1.0" - # Prevent this from interfering with workspaces [workspace] -members = ["."] +members = [".", "fuzz-fake-hashes", "fuzz-real-hashes", "write-seeds"] [profile.release] panic = "abort" diff --git a/fuzz/README.md b/fuzz/README.md index 4af70390d7d..f4a2ef8c6d2 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -10,6 +10,11 @@ configured for. Fuzzing is further only effective with a lot of CPU time, indica scenarios are discovered on CI with its low runtime constraints, the crash is caused relatively easily. +The `fuzz/` directory now contains three crates: +- `fuzz/`, the shared fuzz target logic and corpus directories +- `fuzz/fuzz-fake-hashes`, the fuzz targets that require `--cfg=hashes_fuzz` +- `fuzz/fuzz-real-hashes`, the real-hashes fuzz targets, currently `chanmon_consistency_target` + ## How do I run fuzz tests locally? We support multiple fuzzing engines such as `honggfuzz`, `libFuzzer` and `AFL`. You typically won't @@ -47,34 +52,45 @@ cargo install --force cargo-fuzz To run fuzzing using `honggfuzz`, do ```shell +cd fuzz export CPU_COUNT=1 # replace as needed export HFUZZ_BUILD_ARGS="--features honggfuzz_fuzz" export HFUZZ_RUN_ARGS="-n $CPU_COUNT --exit_upon_crash" export TARGET="msg_ping_target" # replace with the target to be fuzzed -cargo hfuzz run $TARGET +export RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz" +cargo hfuzz run --manifest-path fuzz-fake-hashes/Cargo.toml $TARGET ``` -(Or, for a prettier output, replace the last line with `cargo --color always hfuzz run $TARGET`.) +(For `fuzz-real-hashes`, use +`RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz" cargo hfuzz run --manifest-path fuzz-real-hashes/Cargo.toml chanmon_consistency_target`.) +For a prettier output, replace the last line with +`cargo --color always hfuzz run --manifest-path fuzz-fake-hashes/Cargo.toml $TARGET`. #### cargo-fuzz / libFuzzer To run fuzzing using `cargo-fuzz / libFuzzer`, run ```shell rustup install nightly # Note: libFuzzer requires a nightly version of rust. +cd fuzz export RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz" -cargo +nightly fuzz run --features "libfuzzer_fuzz" msg_ping_target +cargo +nightly fuzz run --fuzz-dir fuzz-fake-hashes --features "libfuzzer_fuzz" msg_ping_target ``` Note: If you encounter a `SIGKILL` during run/build check for OOM in kernel logs and consider increasing RAM size for VM. +For `fuzz-real-hashes`, use +`RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz" cargo +nightly fuzz run --fuzz-dir fuzz-real-hashes --features "libfuzzer_fuzz" chanmon_consistency_target`. + ##### Fast builds for development The default build uses LTO and single codegen unit, which is slow. For faster iteration during development, use the `-D` (dev) flag: ```shell -cargo +nightly fuzz run --features "libfuzzer_fuzz" -D msg_ping_target +cd fuzz +RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz" \ + cargo +nightly fuzz run --fuzz-dir fuzz-fake-hashes --features "libfuzzer_fuzz" -D msg_ping_target ``` The `-D` flag builds in development mode with faster compilation (still has optimizations via @@ -83,7 +99,9 @@ sanitizer instrumentation, but subsequent builds will be fast. If you wish to just generate fuzzing binary executables for `libFuzzer` and not run them: ```shell -cargo +nightly fuzz build --features "libfuzzer_fuzz" msg_ping_target +cd fuzz +RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz" \ + cargo +nightly fuzz build --fuzz-dir fuzz-fake-hashes --features "libfuzzer_fuzz" msg_ping_target # Generates binary artifact in path ./target/aarch64-unknown-linux-gnu/release/msg_ping_target # Exact path depends on your system architecture. ``` @@ -93,7 +111,8 @@ You can upload the build artifact generated above to `ClusterFuzz` for distribut To see a list of available fuzzing targets, run: ```shell -ls ./src/bin/ +ls ./fuzz-fake-hashes/src/bin/ +ls ./fuzz-real-hashes/src/bin/ ``` ## A fuzz test failed, what do I do? @@ -134,7 +153,8 @@ mkdir -p ./test_cases/$TARGET echo $HEX | xxd -r -p > ./test_cases/$TARGET/any_filename_works export RUST_BACKTRACE=1 -cargo test +RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz" \ + cargo test --manifest-path fuzz-fake-hashes/Cargo.toml --bin "${TARGET}_target" ``` Note that if the fuzz test failed locally, moving the offending run's trace @@ -151,7 +171,10 @@ Alternatively, you can use the `stdin_fuzz` feature to pipe the crash input dire creating test case files on disk: ```shell -echo -ne '\x2d\x31\x36\x38\x37\x34\x09\x01...' | cargo run --features stdin_fuzz --bin full_stack_target +cd fuzz +echo -ne '\x2d\x31\x36\x38\x37\x34\x09\x01...' | \ + RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz" \ + cargo run --manifest-path fuzz-fake-hashes/Cargo.toml --features stdin_fuzz --bin full_stack_target ``` Panics will abort the process directly (the crate uses `panic = "abort"`), resulting in a @@ -171,10 +194,13 @@ file are `do_test`, `my_fuzzy_experiment_test`, and `my_fuzzy_experiment_run`. 3. Adjust the body (not the signature!) of `do_test` as necessary for the new fuzz test. -4. In `fuzz/src/bin/gen_target.sh`, add a line reading `GEN_TEST my_fuzzy_experiment` to the -first group of `GEN_TEST` lines (starting in line 9). +4. In `fuzz/src/bin/gen_target.sh`, add a line reading `GEN_FAKE_HASHES_TEST my_fuzzy_experiment` +to the appropriate target list. Use `GEN_REAL_HASHES_TEST` only for targets that must run without +`hashes_fuzz`. 5. If your test relies on a new local crate, add that crate as a dependency to `fuzz/Cargo.toml`. +If the dependency is only needed by a specific runner crate or fuzz engine setup, add it to the +matching target crate under `fuzz/fuzz-fake-hashes/Cargo.toml` or `fuzz/fuzz-real-hashes/Cargo.toml` instead. 6. In `fuzz/src/lib.rs`, add the line `pub mod my_fuzzy_experiment`. Additionally, if you added a new crate dependency, add the `extern crate […]` import line. diff --git a/fuzz/ci-fuzz.sh b/fuzz/ci-fuzz.sh index 47bf41ba620..3fc206bd0ee 100755 --- a/fuzz/ci-fuzz.sh +++ b/fuzz/ci-fuzz.sh @@ -8,16 +8,16 @@ rm msg_*.rs [ "$(git diff)" != "" ] && exit 1 popd pushd src/bin -rm *_target.rs +rm -f ../../fuzz-fake-hashes/src/bin/*_target.rs ../../fuzz-real-hashes/src/bin/*_target.rs ./gen_target.sh [ "$(git diff)" != "" ] && exit 1 popd -export RUSTFLAGS="--cfg=secp256k1_fuzz --cfg=hashes_fuzz" +export RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz" mkdir -p hfuzz_workspace/full_stack_target/input pushd write-seeds -RUSTFLAGS="$RUSTFLAGS --cfg=fuzzing" cargo run ../hfuzz_workspace/full_stack_target/input +cargo run ../hfuzz_workspace/full_stack_target/input cargo clean popd @@ -27,57 +27,70 @@ cargo install --color always --force honggfuzz --no-default-features # compiler optimizations aren't necessary, so we turn off LTO sed -i 's/lto = true//' Cargo.toml -export HFUZZ_BUILD_ARGS="--features honggfuzz_fuzz" - -cargo --color always hfuzz build -j8 - SUMMARY="" check_crash() { - local FILE=$1 - if [ -f "hfuzz_workspace/$FILE/HONGGFUZZ.REPORT.TXT" ]; then - cat "hfuzz_workspace/$FILE/HONGGFUZZ.REPORT.TXT" - for CASE in "hfuzz_workspace/$FILE"/SIG*; do + local WORKSPACE_DIR=$1 + local FILE=$2 + if [ -f "$WORKSPACE_DIR/$FILE/HONGGFUZZ.REPORT.TXT" ]; then + cat "$WORKSPACE_DIR/$FILE/HONGGFUZZ.REPORT.TXT" + for CASE in "$WORKSPACE_DIR/$FILE"/SIG*; do cat "$CASE" | xxd -p done exit 1 fi } -for TARGET in src/bin/*.rs; do - FILENAME=$(basename $TARGET) - FILE="${FILENAME%.*}" - CORPUS_DIR="hfuzz_workspace/$FILE/input" - CORPUS_COUNT=$(find "$CORPUS_DIR" -type f 2>/dev/null | wc -l) - # Run 8x the corpus size plus a baseline, ensuring full corpus replay - # with room for new mutations. The 10-minute hard cap (--run_time 600) - # prevents slow-per-iteration targets from running too long. - ITERATIONS=$((CORPUS_COUNT * 8 + 1000)) - HFUZZ_RUN_ARGS="--exit_upon_crash -q -n8 -t 3 -N $ITERATIONS --run_time 600" - if [ "$FILE" = "chanmon_consistency_target" -o "$FILE" = "fs_store_target" ]; then - HFUZZ_RUN_ARGS="$HFUZZ_RUN_ARGS -F 64" - fi - export HFUZZ_RUN_ARGS - FUZZ_START=$(date +%s) - cargo --color always hfuzz run $FILE - FUZZ_END=$(date +%s) - FUZZ_TIME=$((FUZZ_END - FUZZ_START)) - FUZZ_CORPUS_COUNT=$(find "$CORPUS_DIR" -type f 2>/dev/null | wc -l) - check_crash "$FILE" - if [ "$GITHUB_REF" = "refs/heads/main" ] || [ "$FUZZ_MINIMIZE" = "true" ]; then - HFUZZ_RUN_ARGS="-M -q -n8 -t 3" +run_targets() { + local CRATE_DIR=$1 + local TARGET_RUSTFLAGS=$2 + + pushd "$CRATE_DIR" + export HFUZZ_WORKSPACE="../hfuzz_workspace" + export HFUZZ_BUILD_ARGS="--features honggfuzz_fuzz" + export RUSTFLAGS="$TARGET_RUSTFLAGS" + cargo --color always hfuzz build -j8 + + for TARGET in src/bin/*.rs; do + FILENAME=$(basename "$TARGET") + FILE="${FILENAME%.*}" + CORPUS_DIR="$HFUZZ_WORKSPACE/$FILE/input" + CORPUS_COUNT=$(find "$CORPUS_DIR" -type f 2>/dev/null | wc -l) + # Run 8x the corpus size plus a baseline, ensuring full corpus replay + # with room for new mutations. The 10-minute hard cap (--run_time 600) + # prevents slow-per-iteration targets from running too long. + ITERATIONS=$((CORPUS_COUNT * 8 + 1000)) + HFUZZ_RUN_ARGS="--exit_upon_crash -q -n8 -t 3 -N $ITERATIONS --run_time 600" + if [ "$FILE" = "chanmon_consistency_target" -o "$FILE" = "fs_store_target" ]; then + HFUZZ_RUN_ARGS="$HFUZZ_RUN_ARGS -F 64" + fi export HFUZZ_RUN_ARGS - MIN_START=$(date +%s) - cargo --color always hfuzz run $FILE - MIN_END=$(date +%s) - MIN_TIME=$((MIN_END - MIN_START)) - MIN_CORPUS_COUNT=$(find "$CORPUS_DIR" -type f 2>/dev/null | wc -l) - check_crash "$FILE" - SUMMARY="${SUMMARY}${FILE}|${ITERATIONS}|${CORPUS_COUNT}|${FUZZ_CORPUS_COUNT}|${FUZZ_TIME}|${MIN_CORPUS_COUNT}|${MIN_TIME}\n" - else - SUMMARY="${SUMMARY}${FILE}|${ITERATIONS}|${CORPUS_COUNT}|${FUZZ_CORPUS_COUNT}|${FUZZ_TIME}|-|-\n" - fi -done + FUZZ_START=$(date +%s) + cargo --color always hfuzz run "$FILE" + FUZZ_END=$(date +%s) + FUZZ_TIME=$((FUZZ_END - FUZZ_START)) + FUZZ_CORPUS_COUNT=$(find "$CORPUS_DIR" -type f 2>/dev/null | wc -l) + check_crash "$HFUZZ_WORKSPACE" "$FILE" + if [ "$GITHUB_REF" = "refs/heads/main" ] || [ "$FUZZ_MINIMIZE" = "true" ]; then + HFUZZ_RUN_ARGS="-M -q -n8 -t 3" + export HFUZZ_RUN_ARGS + MIN_START=$(date +%s) + cargo --color always hfuzz run "$FILE" + MIN_END=$(date +%s) + MIN_TIME=$((MIN_END - MIN_START)) + MIN_CORPUS_COUNT=$(find "$CORPUS_DIR" -type f 2>/dev/null | wc -l) + check_crash "$HFUZZ_WORKSPACE" "$FILE" + SUMMARY="${SUMMARY}${FILE}|${ITERATIONS}|${CORPUS_COUNT}|${FUZZ_CORPUS_COUNT}|${FUZZ_TIME}|${MIN_CORPUS_COUNT}|${MIN_TIME}\n" + else + SUMMARY="${SUMMARY}${FILE}|${ITERATIONS}|${CORPUS_COUNT}|${FUZZ_CORPUS_COUNT}|${FUZZ_TIME}|-|-\n" + fi + done + + popd +} + +run_targets fuzz-fake-hashes "--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz" +run_targets fuzz-real-hashes "--cfg=fuzzing --cfg=secp256k1_fuzz" fmt_time() { local secs=$1 diff --git a/fuzz/fuzz-fake-hashes/Cargo.toml b/fuzz/fuzz-fake-hashes/Cargo.toml new file mode 100644 index 00000000000..d027540a056 --- /dev/null +++ b/fuzz/fuzz-fake-hashes/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "lightning-fuzz-fake-hashes" +version = "0.0.1" +authors = ["Automatically generated"] +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[features] +afl_fuzz = ["afl"] +honggfuzz_fuzz = ["honggfuzz"] +libfuzzer_fuzz = ["libfuzzer-sys"] +stdin_fuzz = [] + +[dependencies] +lightning-fuzz = { path = ".." } + +afl = { version = "0.12", optional = true } +honggfuzz = { version = "0.5", optional = true, default-features = false } +libfuzzer-sys = { version = "0.4", optional = true } + +[lints.rust.unexpected_cfgs] +level = "forbid" +# When adding a new cfg attribute, ensure that it is added to this list. +check-cfg = [ + "cfg(fuzzing)", + "cfg(secp256k1_fuzz)", + "cfg(hashes_fuzz)", +] diff --git a/fuzz/src/bin/base32_target.rs b/fuzz/fuzz-fake-hashes/src/bin/base32_target.rs similarity index 95% rename from fuzz/src/bin/base32_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/base32_target.rs index e79e6db7380..e3cd1a66dd2 100644 --- a/fuzz/src/bin/base32_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/base32_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - base32_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + base32_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/base32") { + if let Ok(tests) = fs::read_dir("../test_cases/base32") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/bech32_parse_target.rs b/fuzz/fuzz-fake-hashes/src/bin/bech32_parse_target.rs similarity index 95% rename from fuzz/src/bin/bech32_parse_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/bech32_parse_target.rs index f9493bb1bc1..226ff19c472 100644 --- a/fuzz/src/bin/bech32_parse_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/bech32_parse_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - bech32_parse_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + bech32_parse_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/bech32_parse") { + if let Ok(tests) = fs::read_dir("../test_cases/bech32_parse") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/bolt11_deser_target.rs b/fuzz/fuzz-fake-hashes/src/bin/bolt11_deser_target.rs similarity index 95% rename from fuzz/src/bin/bolt11_deser_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/bolt11_deser_target.rs index 28b1e2db679..befa78fc105 100644 --- a/fuzz/src/bin/bolt11_deser_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/bolt11_deser_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - bolt11_deser_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + bolt11_deser_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/bolt11_deser") { + if let Ok(tests) = fs::read_dir("../test_cases/bolt11_deser") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/chanmon_deser_target.rs b/fuzz/fuzz-fake-hashes/src/bin/chanmon_deser_target.rs similarity index 95% rename from fuzz/src/bin/chanmon_deser_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/chanmon_deser_target.rs index d3cf30b86e3..259f9d36ad2 100644 --- a/fuzz/src/bin/chanmon_deser_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/chanmon_deser_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - chanmon_deser_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + chanmon_deser_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/chanmon_deser") { + if let Ok(tests) = fs::read_dir("../test_cases/chanmon_deser") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/feature_flags_target.rs b/fuzz/fuzz-fake-hashes/src/bin/feature_flags_target.rs similarity index 95% rename from fuzz/src/bin/feature_flags_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/feature_flags_target.rs index b1f35f8820f..d54bba994e8 100644 --- a/fuzz/src/bin/feature_flags_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/feature_flags_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - feature_flags_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + feature_flags_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/feature_flags") { + if let Ok(tests) = fs::read_dir("../test_cases/feature_flags") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/fromstr_to_netaddress_target.rs b/fuzz/fuzz-fake-hashes/src/bin/fromstr_to_netaddress_target.rs similarity index 94% rename from fuzz/src/bin/fromstr_to_netaddress_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/fromstr_to_netaddress_target.rs index 8f3e5c3dc7f..94cedd91157 100644 --- a/fuzz/src/bin/fromstr_to_netaddress_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/fromstr_to_netaddress_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - fromstr_to_netaddress_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + fromstr_to_netaddress_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/fromstr_to_netaddress") { + if let Ok(tests) = fs::read_dir("../test_cases/fromstr_to_netaddress") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/fs_store_target.rs b/fuzz/fuzz-fake-hashes/src/bin/fs_store_target.rs similarity index 95% rename from fuzz/src/bin/fs_store_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/fs_store_target.rs index 8d84aad7b6b..e34cab13def 100644 --- a/fuzz/src/bin/fs_store_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/fs_store_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - fs_store_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + fs_store_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/fs_store") { + if let Ok(tests) = fs::read_dir("../test_cases/fs_store") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/full_stack_target.rs b/fuzz/fuzz-fake-hashes/src/bin/full_stack_target.rs similarity index 95% rename from fuzz/src/bin/full_stack_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/full_stack_target.rs index c1f20b10af4..81a49776b4b 100644 --- a/fuzz/src/bin/full_stack_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/full_stack_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - full_stack_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + full_stack_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/full_stack") { + if let Ok(tests) = fs::read_dir("../test_cases/full_stack") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/gossip_discovery_target.rs b/fuzz/fuzz-fake-hashes/src/bin/gossip_discovery_target.rs similarity index 95% rename from fuzz/src/bin/gossip_discovery_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/gossip_discovery_target.rs index 960ba80ec8c..470ad17fe26 100644 --- a/fuzz/src/bin/gossip_discovery_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/gossip_discovery_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - gossip_discovery_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + gossip_discovery_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/gossip_discovery") { + if let Ok(tests) = fs::read_dir("../test_cases/gossip_discovery") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/indexedmap_target.rs b/fuzz/fuzz-fake-hashes/src/bin/indexedmap_target.rs similarity index 95% rename from fuzz/src/bin/indexedmap_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/indexedmap_target.rs index 3bc4390fee4..e8d7626a238 100644 --- a/fuzz/src/bin/indexedmap_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/indexedmap_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - indexedmap_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + indexedmap_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/indexedmap") { + if let Ok(tests) = fs::read_dir("../test_cases/indexedmap") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/invoice_deser_target.rs b/fuzz/fuzz-fake-hashes/src/bin/invoice_deser_target.rs similarity index 95% rename from fuzz/src/bin/invoice_deser_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/invoice_deser_target.rs index 44bf1851a40..c1338f62e0e 100644 --- a/fuzz/src/bin/invoice_deser_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/invoice_deser_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - invoice_deser_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + invoice_deser_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/invoice_deser") { + if let Ok(tests) = fs::read_dir("../test_cases/invoice_deser") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/invoice_request_deser_target.rs b/fuzz/fuzz-fake-hashes/src/bin/invoice_request_deser_target.rs similarity index 94% rename from fuzz/src/bin/invoice_request_deser_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/invoice_request_deser_target.rs index 06d8f87fa55..2198b64b207 100644 --- a/fuzz/src/bin/invoice_request_deser_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/invoice_request_deser_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - invoice_request_deser_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + invoice_request_deser_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/invoice_request_deser") { + if let Ok(tests) = fs::read_dir("../test_cases/invoice_request_deser") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/lsps_message_target.rs b/fuzz/fuzz-fake-hashes/src/bin/lsps_message_target.rs similarity index 95% rename from fuzz/src/bin/lsps_message_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/lsps_message_target.rs index 37e6f103fb4..68e1c8b0e06 100644 --- a/fuzz/src/bin/lsps_message_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/lsps_message_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - lsps_message_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + lsps_message_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/lsps_message") { + if let Ok(tests) = fs::read_dir("../test_cases/lsps_message") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_accept_channel_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_accept_channel_target.rs similarity index 95% rename from fuzz/src/bin/msg_accept_channel_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_accept_channel_target.rs index ee08a5fc344..798e2d9e5aa 100644 --- a/fuzz/src/bin/msg_accept_channel_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_accept_channel_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_accept_channel_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_accept_channel_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_accept_channel") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_accept_channel") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_accept_channel_v2_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_accept_channel_v2_target.rs similarity index 94% rename from fuzz/src/bin/msg_accept_channel_v2_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_accept_channel_v2_target.rs index 2903e111f56..eff73d11ded 100644 --- a/fuzz/src/bin/msg_accept_channel_v2_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_accept_channel_v2_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_accept_channel_v2_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_accept_channel_v2_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_accept_channel_v2") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_accept_channel_v2") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_announcement_signatures_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_announcement_signatures_target.rs similarity index 94% rename from fuzz/src/bin/msg_announcement_signatures_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_announcement_signatures_target.rs index 064880abc18..09b76396873 100644 --- a/fuzz/src/bin/msg_announcement_signatures_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_announcement_signatures_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_announcement_signatures_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_announcement_signatures_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_announcement_signatures") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_announcement_signatures") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_blinded_message_path_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_blinded_message_path_target.rs similarity index 94% rename from fuzz/src/bin/msg_blinded_message_path_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_blinded_message_path_target.rs index 277e04c9656..92c0976dc79 100644 --- a/fuzz/src/bin/msg_blinded_message_path_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_blinded_message_path_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_blinded_message_path_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_blinded_message_path_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_blinded_message_path") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_blinded_message_path") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_channel_announcement_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_channel_announcement_target.rs similarity index 94% rename from fuzz/src/bin/msg_channel_announcement_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_channel_announcement_target.rs index 42e72d54b72..482dbbc4345 100644 --- a/fuzz/src/bin/msg_channel_announcement_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_channel_announcement_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_channel_announcement_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_channel_announcement_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_channel_announcement") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_channel_announcement") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_channel_details_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_channel_details_target.rs similarity index 94% rename from fuzz/src/bin/msg_channel_details_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_channel_details_target.rs index a03a7a44920..04af6755917 100644 --- a/fuzz/src/bin/msg_channel_details_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_channel_details_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_channel_details_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_channel_details_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_channel_details") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_channel_details") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_channel_ready_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_channel_ready_target.rs similarity index 95% rename from fuzz/src/bin/msg_channel_ready_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_channel_ready_target.rs index a0457815036..34511509f39 100644 --- a/fuzz/src/bin/msg_channel_ready_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_channel_ready_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_channel_ready_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_channel_ready_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_channel_ready") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_channel_ready") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_channel_reestablish_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_channel_reestablish_target.rs similarity index 94% rename from fuzz/src/bin/msg_channel_reestablish_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_channel_reestablish_target.rs index b5449a90e37..0541cedafe2 100644 --- a/fuzz/src/bin/msg_channel_reestablish_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_channel_reestablish_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_channel_reestablish_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_channel_reestablish_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_channel_reestablish") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_channel_reestablish") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_channel_update_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_channel_update_target.rs similarity index 95% rename from fuzz/src/bin/msg_channel_update_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_channel_update_target.rs index 9feb6e6c6b4..7d08ee24005 100644 --- a/fuzz/src/bin/msg_channel_update_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_channel_update_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_channel_update_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_channel_update_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_channel_update") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_channel_update") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_closing_complete_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_closing_complete_target.rs similarity index 94% rename from fuzz/src/bin/msg_closing_complete_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_closing_complete_target.rs index 22dd97c79c9..7bcb76d2fbd 100644 --- a/fuzz/src/bin/msg_closing_complete_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_closing_complete_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_closing_complete_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_closing_complete_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_closing_complete") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_closing_complete") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_closing_sig_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_closing_sig_target.rs similarity index 95% rename from fuzz/src/bin/msg_closing_sig_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_closing_sig_target.rs index 26058a5277d..54669e259c3 100644 --- a/fuzz/src/bin/msg_closing_sig_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_closing_sig_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_closing_sig_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_closing_sig_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_closing_sig") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_closing_sig") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_closing_signed_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_closing_signed_target.rs similarity index 95% rename from fuzz/src/bin/msg_closing_signed_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_closing_signed_target.rs index 94408bc2ba9..f5813a7919d 100644 --- a/fuzz/src/bin/msg_closing_signed_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_closing_signed_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_closing_signed_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_closing_signed_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_closing_signed") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_closing_signed") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_commitment_signed_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_commitment_signed_target.rs similarity index 94% rename from fuzz/src/bin/msg_commitment_signed_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_commitment_signed_target.rs index e8987848417..a62449b1673 100644 --- a/fuzz/src/bin/msg_commitment_signed_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_commitment_signed_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_commitment_signed_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_commitment_signed_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_commitment_signed") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_commitment_signed") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_decoded_onion_error_packet_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_decoded_onion_error_packet_target.rs similarity index 94% rename from fuzz/src/bin/msg_decoded_onion_error_packet_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_decoded_onion_error_packet_target.rs index 47d8970b453..75e37116d79 100644 --- a/fuzz/src/bin/msg_decoded_onion_error_packet_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_decoded_onion_error_packet_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_decoded_onion_error_packet_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_decoded_onion_error_packet_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_decoded_onion_error_packet") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_decoded_onion_error_packet") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_error_message_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_error_message_target.rs similarity index 95% rename from fuzz/src/bin/msg_error_message_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_error_message_target.rs index ee3904a724e..23c9524478d 100644 --- a/fuzz/src/bin/msg_error_message_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_error_message_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_error_message_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_error_message_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_error_message") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_error_message") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_funding_created_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_funding_created_target.rs similarity index 94% rename from fuzz/src/bin/msg_funding_created_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_funding_created_target.rs index 028aa17ad8a..c423e6e9c24 100644 --- a/fuzz/src/bin/msg_funding_created_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_funding_created_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_funding_created_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_funding_created_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_funding_created") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_funding_created") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_funding_signed_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_funding_signed_target.rs similarity index 95% rename from fuzz/src/bin/msg_funding_signed_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_funding_signed_target.rs index 4894c66df0b..de10f0e71dc 100644 --- a/fuzz/src/bin/msg_funding_signed_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_funding_signed_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_funding_signed_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_funding_signed_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_funding_signed") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_funding_signed") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_gossip_timestamp_filter_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_gossip_timestamp_filter_target.rs similarity index 94% rename from fuzz/src/bin/msg_gossip_timestamp_filter_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_gossip_timestamp_filter_target.rs index 6da383b2e6f..cef5bc576c2 100644 --- a/fuzz/src/bin/msg_gossip_timestamp_filter_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_gossip_timestamp_filter_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_gossip_timestamp_filter_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_gossip_timestamp_filter_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_gossip_timestamp_filter") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_gossip_timestamp_filter") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_init_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_init_target.rs similarity index 95% rename from fuzz/src/bin/msg_init_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_init_target.rs index f1d17c99289..7e51e6e63e5 100644 --- a/fuzz/src/bin/msg_init_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_init_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_init_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_init_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_init") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_init") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_node_announcement_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_node_announcement_target.rs similarity index 94% rename from fuzz/src/bin/msg_node_announcement_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_node_announcement_target.rs index b0615f3c5e5..c7aaecb644a 100644 --- a/fuzz/src/bin/msg_node_announcement_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_node_announcement_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_node_announcement_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_node_announcement_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_node_announcement") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_node_announcement") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_open_channel_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_open_channel_target.rs similarity index 95% rename from fuzz/src/bin/msg_open_channel_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_open_channel_target.rs index b3dbf388c08..bb49be7d994 100644 --- a/fuzz/src/bin/msg_open_channel_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_open_channel_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_open_channel_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_open_channel_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_open_channel") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_open_channel") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_open_channel_v2_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_open_channel_v2_target.rs similarity index 94% rename from fuzz/src/bin/msg_open_channel_v2_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_open_channel_v2_target.rs index 0df11adf32e..a6d45dc3a45 100644 --- a/fuzz/src/bin/msg_open_channel_v2_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_open_channel_v2_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_open_channel_v2_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_open_channel_v2_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_open_channel_v2") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_open_channel_v2") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_ping_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_ping_target.rs similarity index 95% rename from fuzz/src/bin/msg_ping_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_ping_target.rs index 48f855985de..70bb751c594 100644 --- a/fuzz/src/bin/msg_ping_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_ping_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_ping_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_ping_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_ping") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_ping") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_pong_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_pong_target.rs similarity index 95% rename from fuzz/src/bin/msg_pong_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_pong_target.rs index 434e9cfe310..74df6d86474 100644 --- a/fuzz/src/bin/msg_pong_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_pong_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_pong_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_pong_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_pong") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_pong") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_query_channel_range_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_query_channel_range_target.rs similarity index 94% rename from fuzz/src/bin/msg_query_channel_range_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_query_channel_range_target.rs index cb87260e1ef..e497491083f 100644 --- a/fuzz/src/bin/msg_query_channel_range_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_query_channel_range_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_query_channel_range_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_query_channel_range_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_query_channel_range") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_query_channel_range") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_query_short_channel_ids_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_query_short_channel_ids_target.rs similarity index 94% rename from fuzz/src/bin/msg_query_short_channel_ids_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_query_short_channel_ids_target.rs index bc286a7e523..31169f9e665 100644 --- a/fuzz/src/bin/msg_query_short_channel_ids_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_query_short_channel_ids_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_query_short_channel_ids_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_query_short_channel_ids_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_query_short_channel_ids") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_query_short_channel_ids") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_reply_channel_range_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_reply_channel_range_target.rs similarity index 94% rename from fuzz/src/bin/msg_reply_channel_range_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_reply_channel_range_target.rs index c7df076c6c6..a0aaadf321d 100644 --- a/fuzz/src/bin/msg_reply_channel_range_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_reply_channel_range_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_reply_channel_range_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_reply_channel_range_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_reply_channel_range") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_reply_channel_range") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_reply_short_channel_ids_end_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_reply_short_channel_ids_end_target.rs similarity index 94% rename from fuzz/src/bin/msg_reply_short_channel_ids_end_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_reply_short_channel_ids_end_target.rs index 2c73d866bd9..8931538e4f5 100644 --- a/fuzz/src/bin/msg_reply_short_channel_ids_end_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_reply_short_channel_ids_end_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_reply_short_channel_ids_end_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_reply_short_channel_ids_end_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_reply_short_channel_ids_end") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_reply_short_channel_ids_end") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_revoke_and_ack_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_revoke_and_ack_target.rs similarity index 95% rename from fuzz/src/bin/msg_revoke_and_ack_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_revoke_and_ack_target.rs index 6379d39591f..6ed40d3ab91 100644 --- a/fuzz/src/bin/msg_revoke_and_ack_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_revoke_and_ack_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_revoke_and_ack_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_revoke_and_ack_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_revoke_and_ack") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_revoke_and_ack") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_shutdown_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_shutdown_target.rs similarity index 95% rename from fuzz/src/bin/msg_shutdown_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_shutdown_target.rs index 6bf0409b7b5..a731a1dd91f 100644 --- a/fuzz/src/bin/msg_shutdown_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_shutdown_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_shutdown_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_shutdown_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_shutdown") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_shutdown") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_splice_ack_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_splice_ack_target.rs similarity index 95% rename from fuzz/src/bin/msg_splice_ack_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_splice_ack_target.rs index 96f373d5a1c..20625fc759c 100644 --- a/fuzz/src/bin/msg_splice_ack_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_splice_ack_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_splice_ack_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_splice_ack_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_splice_ack") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_splice_ack") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_splice_init_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_splice_init_target.rs similarity index 95% rename from fuzz/src/bin/msg_splice_init_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_splice_init_target.rs index 73d4319c44a..b3d30a660a1 100644 --- a/fuzz/src/bin/msg_splice_init_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_splice_init_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_splice_init_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_splice_init_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_splice_init") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_splice_init") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_splice_locked_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_splice_locked_target.rs similarity index 95% rename from fuzz/src/bin/msg_splice_locked_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_splice_locked_target.rs index 9210113a0c8..deb57b61974 100644 --- a/fuzz/src/bin/msg_splice_locked_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_splice_locked_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_splice_locked_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_splice_locked_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_splice_locked") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_splice_locked") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_stfu_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_stfu_target.rs similarity index 95% rename from fuzz/src/bin/msg_stfu_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_stfu_target.rs index d00536c7bcd..de3a64f542b 100644 --- a/fuzz/src/bin/msg_stfu_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_stfu_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_stfu_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_stfu_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_stfu") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_stfu") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_tx_abort_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_abort_target.rs similarity index 95% rename from fuzz/src/bin/msg_tx_abort_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_tx_abort_target.rs index 8f216b46e63..0b335c23b18 100644 --- a/fuzz/src/bin/msg_tx_abort_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_abort_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_abort_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_tx_abort_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_tx_abort") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_tx_abort") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_tx_ack_rbf_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_ack_rbf_target.rs similarity index 95% rename from fuzz/src/bin/msg_tx_ack_rbf_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_tx_ack_rbf_target.rs index 90b34c7f93f..d69077c9075 100644 --- a/fuzz/src/bin/msg_tx_ack_rbf_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_ack_rbf_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_ack_rbf_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_tx_ack_rbf_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_tx_ack_rbf") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_tx_ack_rbf") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_tx_add_input_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_add_input_target.rs similarity index 95% rename from fuzz/src/bin/msg_tx_add_input_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_tx_add_input_target.rs index ce9700bd344..8dff0a621c9 100644 --- a/fuzz/src/bin/msg_tx_add_input_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_add_input_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_add_input_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_tx_add_input_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_tx_add_input") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_tx_add_input") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_tx_add_output_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_add_output_target.rs similarity index 95% rename from fuzz/src/bin/msg_tx_add_output_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_tx_add_output_target.rs index 02682194e13..f6808399aba 100644 --- a/fuzz/src/bin/msg_tx_add_output_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_add_output_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_add_output_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_tx_add_output_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_tx_add_output") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_tx_add_output") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_tx_complete_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_complete_target.rs similarity index 95% rename from fuzz/src/bin/msg_tx_complete_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_tx_complete_target.rs index 48864f053c8..2edccfbf690 100644 --- a/fuzz/src/bin/msg_tx_complete_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_complete_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_complete_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_tx_complete_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_tx_complete") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_tx_complete") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_tx_init_rbf_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_init_rbf_target.rs similarity index 95% rename from fuzz/src/bin/msg_tx_init_rbf_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_tx_init_rbf_target.rs index a8b613cdfca..80acf0f11c8 100644 --- a/fuzz/src/bin/msg_tx_init_rbf_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_init_rbf_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_init_rbf_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_tx_init_rbf_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_tx_init_rbf") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_tx_init_rbf") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_tx_remove_input_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_remove_input_target.rs similarity index 94% rename from fuzz/src/bin/msg_tx_remove_input_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_tx_remove_input_target.rs index 1e46c547dbf..b1555a2412a 100644 --- a/fuzz/src/bin/msg_tx_remove_input_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_remove_input_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_remove_input_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_tx_remove_input_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_tx_remove_input") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_tx_remove_input") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_tx_remove_output_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_remove_output_target.rs similarity index 94% rename from fuzz/src/bin/msg_tx_remove_output_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_tx_remove_output_target.rs index 3a9c178e75f..a8e5b20d06d 100644 --- a/fuzz/src/bin/msg_tx_remove_output_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_remove_output_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_remove_output_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_tx_remove_output_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_tx_remove_output") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_tx_remove_output") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_tx_signatures_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_signatures_target.rs similarity index 95% rename from fuzz/src/bin/msg_tx_signatures_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_tx_signatures_target.rs index 77f34cc1f6a..2a1fbf9d16e 100644 --- a/fuzz/src/bin/msg_tx_signatures_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_signatures_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_signatures_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_tx_signatures_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_tx_signatures") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_tx_signatures") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_update_add_htlc_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_update_add_htlc_target.rs similarity index 94% rename from fuzz/src/bin/msg_update_add_htlc_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_update_add_htlc_target.rs index 3ff5cf83dbe..d3b45d589eb 100644 --- a/fuzz/src/bin/msg_update_add_htlc_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_update_add_htlc_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_update_add_htlc_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_update_add_htlc_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_update_add_htlc") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_update_add_htlc") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_update_fail_htlc_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_update_fail_htlc_target.rs similarity index 94% rename from fuzz/src/bin/msg_update_fail_htlc_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_update_fail_htlc_target.rs index 5b8a7e55dcb..bec5bc9e331 100644 --- a/fuzz/src/bin/msg_update_fail_htlc_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_update_fail_htlc_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_update_fail_htlc_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_update_fail_htlc_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_update_fail_htlc") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_update_fail_htlc") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_update_fail_malformed_htlc_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_update_fail_malformed_htlc_target.rs similarity index 94% rename from fuzz/src/bin/msg_update_fail_malformed_htlc_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_update_fail_malformed_htlc_target.rs index e3e8918e492..190412bc0a7 100644 --- a/fuzz/src/bin/msg_update_fail_malformed_htlc_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_update_fail_malformed_htlc_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_update_fail_malformed_htlc_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_update_fail_malformed_htlc_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_update_fail_malformed_htlc") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_update_fail_malformed_htlc") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_update_fee_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_update_fee_target.rs similarity index 95% rename from fuzz/src/bin/msg_update_fee_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_update_fee_target.rs index 98e51181c79..386db47ae9f 100644 --- a/fuzz/src/bin/msg_update_fee_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_update_fee_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_update_fee_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_update_fee_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_update_fee") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_update_fee") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/msg_update_fulfill_htlc_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_update_fulfill_htlc_target.rs similarity index 94% rename from fuzz/src/bin/msg_update_fulfill_htlc_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/msg_update_fulfill_htlc_target.rs index cb156448e13..ab49c21043e 100644 --- a/fuzz/src/bin/msg_update_fulfill_htlc_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_update_fulfill_htlc_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_update_fulfill_htlc_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + msg_update_fulfill_htlc_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/msg_update_fulfill_htlc") { + if let Ok(tests) = fs::read_dir("../test_cases/msg_update_fulfill_htlc") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/offer_deser_target.rs b/fuzz/fuzz-fake-hashes/src/bin/offer_deser_target.rs similarity index 95% rename from fuzz/src/bin/offer_deser_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/offer_deser_target.rs index c4a03f628b3..25eda5618f0 100644 --- a/fuzz/src/bin/offer_deser_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/offer_deser_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - offer_deser_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + offer_deser_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/offer_deser") { + if let Ok(tests) = fs::read_dir("../test_cases/offer_deser") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/onion_hop_data_target.rs b/fuzz/fuzz-fake-hashes/src/bin/onion_hop_data_target.rs similarity index 95% rename from fuzz/src/bin/onion_hop_data_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/onion_hop_data_target.rs index 3b9b55bbfa9..05ce4d76aeb 100644 --- a/fuzz/src/bin/onion_hop_data_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/onion_hop_data_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - onion_hop_data_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + onion_hop_data_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/onion_hop_data") { + if let Ok(tests) = fs::read_dir("../test_cases/onion_hop_data") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/onion_message_target.rs b/fuzz/fuzz-fake-hashes/src/bin/onion_message_target.rs similarity index 95% rename from fuzz/src/bin/onion_message_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/onion_message_target.rs index bb343e9de83..f5a0eb60171 100644 --- a/fuzz/src/bin/onion_message_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/onion_message_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - onion_message_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + onion_message_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/onion_message") { + if let Ok(tests) = fs::read_dir("../test_cases/onion_message") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/peer_crypt_target.rs b/fuzz/fuzz-fake-hashes/src/bin/peer_crypt_target.rs similarity index 95% rename from fuzz/src/bin/peer_crypt_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/peer_crypt_target.rs index c68111deb06..3095f2a870c 100644 --- a/fuzz/src/bin/peer_crypt_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/peer_crypt_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - peer_crypt_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + peer_crypt_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/peer_crypt") { + if let Ok(tests) = fs::read_dir("../test_cases/peer_crypt") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/process_network_graph_target.rs b/fuzz/fuzz-fake-hashes/src/bin/process_network_graph_target.rs similarity index 94% rename from fuzz/src/bin/process_network_graph_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/process_network_graph_target.rs index 7da2aafe3c8..36ea42bcb6a 100644 --- a/fuzz/src/bin/process_network_graph_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/process_network_graph_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - process_network_graph_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + process_network_graph_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/process_network_graph") { + if let Ok(tests) = fs::read_dir("../test_cases/process_network_graph") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/process_onion_failure_target.rs b/fuzz/fuzz-fake-hashes/src/bin/process_onion_failure_target.rs similarity index 94% rename from fuzz/src/bin/process_onion_failure_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/process_onion_failure_target.rs index 9e1cc8aa6d0..1d6c64c5863 100644 --- a/fuzz/src/bin/process_onion_failure_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/process_onion_failure_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - process_onion_failure_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + process_onion_failure_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/process_onion_failure") { + if let Ok(tests) = fs::read_dir("../test_cases/process_onion_failure") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/refund_deser_target.rs b/fuzz/fuzz-fake-hashes/src/bin/refund_deser_target.rs similarity index 95% rename from fuzz/src/bin/refund_deser_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/refund_deser_target.rs index 13837d2be73..a5295b8d793 100644 --- a/fuzz/src/bin/refund_deser_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/refund_deser_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - refund_deser_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + refund_deser_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/refund_deser") { + if let Ok(tests) = fs::read_dir("../test_cases/refund_deser") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/router_target.rs b/fuzz/fuzz-fake-hashes/src/bin/router_target.rs similarity index 95% rename from fuzz/src/bin/router_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/router_target.rs index 52a8c3408ff..ecf6dbe9b57 100644 --- a/fuzz/src/bin/router_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/router_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - router_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + router_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/router") { + if let Ok(tests) = fs::read_dir("../test_cases/router") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/static_invoice_deser_target.rs b/fuzz/fuzz-fake-hashes/src/bin/static_invoice_deser_target.rs similarity index 94% rename from fuzz/src/bin/static_invoice_deser_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/static_invoice_deser_target.rs index 477f7869e7f..787817de00e 100644 --- a/fuzz/src/bin/static_invoice_deser_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/static_invoice_deser_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - static_invoice_deser_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + static_invoice_deser_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/static_invoice_deser") { + if let Ok(tests) = fs::read_dir("../test_cases/static_invoice_deser") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/zbase32_target.rs b/fuzz/fuzz-fake-hashes/src/bin/zbase32_target.rs similarity index 95% rename from fuzz/src/bin/zbase32_target.rs rename to fuzz/fuzz-fake-hashes/src/bin/zbase32_target.rs index 68c8cf3e19c..1007df19acf 100644 --- a/fuzz/src/bin/zbase32_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/zbase32_target.rs @@ -17,7 +17,7 @@ compile_error!("Fuzz targets need cfg=fuzzing"); #[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - zbase32_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + zbase32_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/zbase32") { + if let Ok(tests) = fs::read_dir("../test_cases/zbase32") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/fuzz-real-hashes/Cargo.toml b/fuzz/fuzz-real-hashes/Cargo.toml new file mode 100644 index 00000000000..a6d77d28137 --- /dev/null +++ b/fuzz/fuzz-real-hashes/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "lightning-fuzz-real-hashes" +version = "0.0.1" +authors = ["Automatically generated"] +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[features] +afl_fuzz = ["afl"] +honggfuzz_fuzz = ["honggfuzz"] +libfuzzer_fuzz = ["libfuzzer-sys"] +stdin_fuzz = [] + +[dependencies] +lightning-fuzz = { path = ".." } + +afl = { version = "0.12", optional = true } +honggfuzz = { version = "0.5", optional = true, default-features = false } +libfuzzer-sys = { version = "0.4", optional = true } + +[lints.rust.unexpected_cfgs] +level = "forbid" +# When adding a new cfg attribute, ensure that it is added to this list. +check-cfg = [ + "cfg(fuzzing)", + "cfg(secp256k1_fuzz)", + "cfg(hashes_fuzz)", +] diff --git a/fuzz/src/bin/chanmon_consistency_target.rs b/fuzz/fuzz-real-hashes/src/bin/chanmon_consistency_target.rs similarity index 94% rename from fuzz/src/bin/chanmon_consistency_target.rs rename to fuzz/fuzz-real-hashes/src/bin/chanmon_consistency_target.rs index 7649900bae5..335c8169c75 100644 --- a/fuzz/src/bin/chanmon_consistency_target.rs +++ b/fuzz/fuzz-real-hashes/src/bin/chanmon_consistency_target.rs @@ -16,8 +16,8 @@ #[cfg(not(fuzzing))] compile_error!("Fuzz targets need cfg=fuzzing"); -#[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +#[cfg(hashes_fuzz)] +compile_error!("Fuzz target does not support cfg(hashes_fuzz)"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - chanmon_consistency_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + chanmon_consistency_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/chanmon_consistency") { + if let Ok(tests) = fs::read_dir("../test_cases/chanmon_consistency") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/bin/gen_target.sh b/fuzz/src/bin/gen_target.sh index fd308a1f10e..868a07652c6 100755 --- a/fuzz/src/bin/gen_target.sh +++ b/fuzz/src/bin/gen_target.sh @@ -2,88 +2,103 @@ echo "#include " > ../../targets.h GEN_TEST() { - cat target_template.txt | sed s/TARGET_NAME/$1/ | sed s/TARGET_MOD/$2$1/ > $1_target.rs - echo "void $1_run(const unsigned char* data, size_t data_len);" >> ../../targets.h + dest_dir=$1 + target_name=$2 + target_mod=$3 + hashes_flag=$4 + + mkdir -p "$dest_dir" + sed "s/TARGET_NAME/$target_name/g; s|TARGET_MOD|$target_mod$target_name|g; s/HASHES_FLAG/$hashes_flag/g" \ + target_template.txt > "$dest_dir/${target_name}_target.rs" + echo "void ${target_name}_run(const unsigned char* data, size_t data_len);" >> ../../targets.h +} + +GEN_FAKE_HASHES_TEST() { + GEN_TEST ../../fuzz-fake-hashes/src/bin "$1" "$2" "not(hashes_fuzz)" +} + +GEN_REAL_HASHES_TEST() { + GEN_TEST ../../fuzz-real-hashes/src/bin "$1" "$2" "hashes_fuzz" } -GEN_TEST bech32_parse -GEN_TEST chanmon_deser -GEN_TEST chanmon_consistency -GEN_TEST full_stack -GEN_TEST invoice_deser -GEN_TEST invoice_request_deser -GEN_TEST offer_deser -GEN_TEST bolt11_deser -GEN_TEST static_invoice_deser -GEN_TEST onion_message -GEN_TEST peer_crypt -GEN_TEST process_network_graph -GEN_TEST process_onion_failure -GEN_TEST refund_deser -GEN_TEST router -GEN_TEST zbase32 -GEN_TEST indexedmap -GEN_TEST onion_hop_data -GEN_TEST base32 -GEN_TEST fromstr_to_netaddress -GEN_TEST feature_flags -GEN_TEST lsps_message -GEN_TEST fs_store -GEN_TEST gossip_discovery +GEN_FAKE_HASHES_TEST bech32_parse +GEN_FAKE_HASHES_TEST chanmon_deser +GEN_REAL_HASHES_TEST chanmon_consistency +GEN_FAKE_HASHES_TEST full_stack +GEN_FAKE_HASHES_TEST invoice_deser +GEN_FAKE_HASHES_TEST invoice_request_deser +GEN_FAKE_HASHES_TEST offer_deser +GEN_FAKE_HASHES_TEST bolt11_deser +GEN_FAKE_HASHES_TEST static_invoice_deser +GEN_FAKE_HASHES_TEST onion_message +GEN_FAKE_HASHES_TEST peer_crypt +GEN_FAKE_HASHES_TEST process_network_graph +GEN_FAKE_HASHES_TEST process_onion_failure +GEN_FAKE_HASHES_TEST refund_deser +GEN_FAKE_HASHES_TEST router +GEN_FAKE_HASHES_TEST zbase32 +GEN_FAKE_HASHES_TEST indexedmap +GEN_FAKE_HASHES_TEST onion_hop_data +GEN_FAKE_HASHES_TEST base32 +GEN_FAKE_HASHES_TEST fromstr_to_netaddress +GEN_FAKE_HASHES_TEST feature_flags +GEN_FAKE_HASHES_TEST lsps_message +GEN_FAKE_HASHES_TEST fs_store +GEN_FAKE_HASHES_TEST gossip_discovery -GEN_TEST msg_accept_channel msg_targets:: -GEN_TEST msg_announcement_signatures msg_targets:: -GEN_TEST msg_channel_reestablish msg_targets:: -GEN_TEST msg_closing_signed msg_targets:: -GEN_TEST msg_closing_complete msg_targets:: -GEN_TEST msg_closing_sig msg_targets:: -GEN_TEST msg_commitment_signed msg_targets:: -GEN_TEST msg_decoded_onion_error_packet msg_targets:: -GEN_TEST msg_funding_created msg_targets:: -GEN_TEST msg_channel_ready msg_targets:: -GEN_TEST msg_funding_signed msg_targets:: -GEN_TEST msg_init msg_targets:: -GEN_TEST msg_open_channel msg_targets:: -GEN_TEST msg_revoke_and_ack msg_targets:: -GEN_TEST msg_shutdown msg_targets:: -GEN_TEST msg_update_fail_htlc msg_targets:: -GEN_TEST msg_update_fail_malformed_htlc msg_targets:: -GEN_TEST msg_update_fee msg_targets:: -GEN_TEST msg_update_fulfill_htlc msg_targets:: +GEN_FAKE_HASHES_TEST msg_accept_channel msg_targets:: +GEN_FAKE_HASHES_TEST msg_announcement_signatures msg_targets:: +GEN_FAKE_HASHES_TEST msg_channel_reestablish msg_targets:: +GEN_FAKE_HASHES_TEST msg_closing_signed msg_targets:: +GEN_FAKE_HASHES_TEST msg_closing_complete msg_targets:: +GEN_FAKE_HASHES_TEST msg_closing_sig msg_targets:: +GEN_FAKE_HASHES_TEST msg_commitment_signed msg_targets:: +GEN_FAKE_HASHES_TEST msg_decoded_onion_error_packet msg_targets:: +GEN_FAKE_HASHES_TEST msg_funding_created msg_targets:: +GEN_FAKE_HASHES_TEST msg_channel_ready msg_targets:: +GEN_FAKE_HASHES_TEST msg_funding_signed msg_targets:: +GEN_FAKE_HASHES_TEST msg_init msg_targets:: +GEN_FAKE_HASHES_TEST msg_open_channel msg_targets:: +GEN_FAKE_HASHES_TEST msg_revoke_and_ack msg_targets:: +GEN_FAKE_HASHES_TEST msg_shutdown msg_targets:: +GEN_FAKE_HASHES_TEST msg_update_fail_htlc msg_targets:: +GEN_FAKE_HASHES_TEST msg_update_fail_malformed_htlc msg_targets:: +GEN_FAKE_HASHES_TEST msg_update_fee msg_targets:: +GEN_FAKE_HASHES_TEST msg_update_fulfill_htlc msg_targets:: -GEN_TEST msg_channel_announcement msg_targets:: -GEN_TEST msg_node_announcement msg_targets:: -GEN_TEST msg_query_short_channel_ids msg_targets:: -GEN_TEST msg_reply_short_channel_ids_end msg_targets:: -GEN_TEST msg_query_channel_range msg_targets:: -GEN_TEST msg_reply_channel_range msg_targets:: -GEN_TEST msg_gossip_timestamp_filter msg_targets:: +GEN_FAKE_HASHES_TEST msg_channel_announcement msg_targets:: +GEN_FAKE_HASHES_TEST msg_node_announcement msg_targets:: +GEN_FAKE_HASHES_TEST msg_query_short_channel_ids msg_targets:: +GEN_FAKE_HASHES_TEST msg_reply_short_channel_ids_end msg_targets:: +GEN_FAKE_HASHES_TEST msg_query_channel_range msg_targets:: +GEN_FAKE_HASHES_TEST msg_reply_channel_range msg_targets:: +GEN_FAKE_HASHES_TEST msg_gossip_timestamp_filter msg_targets:: -GEN_TEST msg_update_add_htlc msg_targets:: -GEN_TEST msg_error_message msg_targets:: -GEN_TEST msg_channel_update msg_targets:: +GEN_FAKE_HASHES_TEST msg_update_add_htlc msg_targets:: +GEN_FAKE_HASHES_TEST msg_error_message msg_targets:: +GEN_FAKE_HASHES_TEST msg_channel_update msg_targets:: -GEN_TEST msg_ping msg_targets:: -GEN_TEST msg_pong msg_targets:: +GEN_FAKE_HASHES_TEST msg_ping msg_targets:: +GEN_FAKE_HASHES_TEST msg_pong msg_targets:: -GEN_TEST msg_channel_details msg_targets:: +GEN_FAKE_HASHES_TEST msg_channel_details msg_targets:: -GEN_TEST msg_open_channel_v2 msg_targets:: -GEN_TEST msg_accept_channel_v2 msg_targets:: -GEN_TEST msg_tx_add_input msg_targets:: -GEN_TEST msg_tx_add_output msg_targets:: -GEN_TEST msg_tx_remove_input msg_targets:: -GEN_TEST msg_tx_remove_output msg_targets:: -GEN_TEST msg_tx_complete msg_targets:: -GEN_TEST msg_tx_signatures msg_targets:: -GEN_TEST msg_tx_init_rbf msg_targets:: -GEN_TEST msg_tx_ack_rbf msg_targets:: -GEN_TEST msg_tx_abort msg_targets:: +GEN_FAKE_HASHES_TEST msg_open_channel_v2 msg_targets:: +GEN_FAKE_HASHES_TEST msg_accept_channel_v2 msg_targets:: +GEN_FAKE_HASHES_TEST msg_tx_add_input msg_targets:: +GEN_FAKE_HASHES_TEST msg_tx_add_output msg_targets:: +GEN_FAKE_HASHES_TEST msg_tx_remove_input msg_targets:: +GEN_FAKE_HASHES_TEST msg_tx_remove_output msg_targets:: +GEN_FAKE_HASHES_TEST msg_tx_complete msg_targets:: +GEN_FAKE_HASHES_TEST msg_tx_signatures msg_targets:: +GEN_FAKE_HASHES_TEST msg_tx_init_rbf msg_targets:: +GEN_FAKE_HASHES_TEST msg_tx_ack_rbf msg_targets:: +GEN_FAKE_HASHES_TEST msg_tx_abort msg_targets:: -GEN_TEST msg_stfu msg_targets:: +GEN_FAKE_HASHES_TEST msg_stfu msg_targets:: -GEN_TEST msg_splice_init msg_targets:: -GEN_TEST msg_splice_ack msg_targets:: -GEN_TEST msg_splice_locked msg_targets:: +GEN_FAKE_HASHES_TEST msg_splice_init msg_targets:: +GEN_FAKE_HASHES_TEST msg_splice_ack msg_targets:: +GEN_FAKE_HASHES_TEST msg_splice_locked msg_targets:: -GEN_TEST msg_blinded_message_path msg_targets:: +GEN_FAKE_HASHES_TEST msg_blinded_message_path msg_targets:: diff --git a/fuzz/src/bin/target_template.txt b/fuzz/src/bin/target_template.txt index 9b0dff8eb8c..78bc7f37d87 100644 --- a/fuzz/src/bin/target_template.txt +++ b/fuzz/src/bin/target_template.txt @@ -16,8 +16,8 @@ #[cfg(not(fuzzing))] compile_error!("Fuzz targets need cfg=fuzzing"); -#[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); +#[cfg(HASHES_FLAG)] +compile_error!("Fuzz target does not support cfg(HASHES_FLAG)"); #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); @@ -71,7 +71,7 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - TARGET_NAME_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); + TARGET_NAME_test(&data, test_logger::Stdout {}); } #[test] @@ -87,7 +87,7 @@ fn run_test_cases() { } let mut threads = Vec::new(); let threads_running = Arc::new(atomic::AtomicUsize::new(0)); - if let Ok(tests) = fs::read_dir("test_cases/TARGET_NAME") { + if let Ok(tests) = fs::read_dir("../test_cases/TARGET_NAME") { for test in tests { let mut data: Vec = Vec::new(); let path = test.unwrap().path(); diff --git a/fuzz/src/lib.rs b/fuzz/src/lib.rs index 5f429ea2c3b..25c2fffa23e 100644 --- a/fuzz/src/lib.rs +++ b/fuzz/src/lib.rs @@ -15,9 +15,6 @@ extern crate lightning_rapid_gossip_sync; #[cfg(not(fuzzing))] compile_error!("Fuzz targets need cfg=fuzzing"); -#[cfg(not(hashes_fuzz))] -compile_error!("Fuzz targets need cfg=hashes_fuzz"); - #[cfg(not(secp256k1_fuzz))] compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); diff --git a/fuzz/test_cases/base32/smoke b/fuzz/test_cases/base32/smoke new file mode 100644 index 00000000000..573541ac970 --- /dev/null +++ b/fuzz/test_cases/base32/smoke @@ -0,0 +1 @@ +0 diff --git a/fuzz/test_cases/bech32_parse/smoke b/fuzz/test_cases/bech32_parse/smoke new file mode 100644 index 00000000000..573541ac970 --- /dev/null +++ b/fuzz/test_cases/bech32_parse/smoke @@ -0,0 +1 @@ +0 diff --git a/fuzz/test_cases/chanmon_consistency/smoke b/fuzz/test_cases/chanmon_consistency/smoke new file mode 100644 index 00000000000..573541ac970 --- /dev/null +++ b/fuzz/test_cases/chanmon_consistency/smoke @@ -0,0 +1 @@ +0 diff --git a/fuzz/write-seeds/Cargo.toml b/fuzz/write-seeds/Cargo.toml index 6e1952ea8a3..1c5acb7919f 100644 --- a/fuzz/write-seeds/Cargo.toml +++ b/fuzz/write-seeds/Cargo.toml @@ -9,7 +9,3 @@ edition = "2021" [dependencies] lightning-fuzz = { path = "../" } - -# Prevent this from interfering with workspaces -[workspace] -members = ["."] From c26f3c7257dcdf98ae21322cb3053cf0a3142765 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Tue, 14 Apr 2026 12:45:46 +0200 Subject: [PATCH 315/627] Fix chanmon_consistency for real hashes Store real payment preimages in `chanmon_consistency` and use them when claiming funds, so the real-hashes runner does not treat `payment_hash` bytes as a stand-in preimage. AI tools were used in preparing this commit. --- fuzz/src/chanmon_consistency.rs | 37 ++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index d4a0e560887..73b3f22d4d6 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -568,12 +568,18 @@ type ChanMan<'a> = ChannelManager< >; #[inline] -fn get_payment_secret_hash(dest: &ChanMan, payment_ctr: &mut u64) -> (PaymentSecret, PaymentHash) { +fn get_payment_secret_hash( + dest: &ChanMan, payment_ctr: &mut u64, + payment_preimages: &RefCell>, +) -> (PaymentSecret, PaymentHash) { *payment_ctr += 1; - let payment_hash = PaymentHash(Sha256::hash(&[*payment_ctr as u8]).to_byte_array()); + let mut payment_preimage = PaymentPreimage([0; 32]); + payment_preimage.0[0..8].copy_from_slice(&payment_ctr.to_be_bytes()); + let payment_hash = PaymentHash(Sha256::hash(&payment_preimage.0).to_byte_array()); let payment_secret = dest .create_inbound_payment_for_hash(payment_hash, None, 3600, None) .expect("create_inbound_payment_for_hash failed"); + assert!(payment_preimages.borrow_mut().insert(payment_hash, payment_preimage).is_none()); (payment_secret, payment_hash) } @@ -1344,10 +1350,8 @@ pub fn do_test(data: &[u8], out: Out) { // Create 3 channels between A-B and 3 channels between B-C (6 total). // - // Use version numbers 1-6 to avoid txid collisions under fuzz hashing. - // Fuzz mode uses XOR-based hashing (all bytes XOR to one byte), and - // versions 0-5 cause collisions between A-B and B-C channel pairs - // (e.g., A-B with Version(1) collides with B-C with Version(3)). + // Use distinct version numbers for each funding transaction so each test channel gets its own + // txid and funding outpoint. // A-B: channel 2 A and B have 0-reserve (trusted open + trusted accept), // channel 3 A has 0-reserve (trusted accept) make_channel!(nodes[0], nodes[1], monitor_a, monitor_b, keys_manager_b, 1, false, false); @@ -1424,6 +1428,8 @@ pub fn do_test(data: &[u8], out: Out) { let resolved_payments: RefCell<[HashMap>; 3]> = RefCell::new([new_hash_map(), new_hash_map(), new_hash_map()]); let claimed_payment_hashes: RefCell> = RefCell::new(HashSet::new()); + let payment_preimages: RefCell> = + RefCell::new(new_hash_map()); macro_rules! test_return { () => {{ @@ -1940,9 +1946,8 @@ pub fn do_test(data: &[u8], out: Out) { macro_rules! process_events { ($node: expr, $fail: expr) => {{ - // In case we get 256 payments we may have a hash collision, resulting in the - // second claim/fail call not finding the duplicate-hash HTLC, so we have to - // deduplicate the calls here. + // Multiple HTLCs can resolve for the same payment hash, so deduplicate + // claim/fail handling per event batch. let mut claim_set = new_hash_map(); let mut events = nodes[$node].get_and_clear_pending_events(); let had_events = !events.is_empty(); @@ -1955,7 +1960,11 @@ pub fn do_test(data: &[u8], out: Out) { if $fail { nodes[$node].fail_htlc_backwards(&payment_hash); } else { - nodes[$node].claim_funds(PaymentPreimage(payment_hash.0)); + let payment_preimage = *payment_preimages + .borrow() + .get(&payment_hash) + .expect("PaymentClaimable for unknown payment hash"); + nodes[$node].claim_funds(payment_preimage); claimed_payment_hashes.borrow_mut().insert(payment_hash); } } @@ -2095,7 +2104,7 @@ pub fn do_test(data: &[u8], out: Out) { |source_idx: usize, dest_idx: usize, dest_chan_id, amt, payment_ctr: &mut u64| { let source = &nodes[source_idx]; let dest = &nodes[dest_idx]; - let (secret, hash) = get_payment_secret_hash(dest, payment_ctr); + let (secret, hash) = get_payment_secret_hash(dest, payment_ctr, &payment_preimages); let mut id = PaymentId([0; 32]); id.0[0..8].copy_from_slice(&payment_ctr.to_ne_bytes()); let succeeded = send_payment(source, dest, dest_chan_id, amt, secret, hash, id); @@ -2118,7 +2127,7 @@ pub fn do_test(data: &[u8], out: Out) { let source = &nodes[source_idx]; let middle = &nodes[middle_idx]; let dest = &nodes[dest_idx]; - let (secret, hash) = get_payment_secret_hash(dest, payment_ctr); + let (secret, hash) = get_payment_secret_hash(dest, payment_ctr, &payment_preimages); let mut id = PaymentId([0; 32]); id.0[0..8].copy_from_slice(&payment_ctr.to_ne_bytes()); let succeeded = send_hop_payment( @@ -2145,7 +2154,7 @@ pub fn do_test(data: &[u8], out: Out) { payment_ctr: &mut u64| { let source = &nodes[source_idx]; let dest = &nodes[dest_idx]; - let (secret, hash) = get_payment_secret_hash(dest, payment_ctr); + let (secret, hash) = get_payment_secret_hash(dest, payment_ctr, &payment_preimages); let mut id = PaymentId([0; 32]); id.0[0..8].copy_from_slice(&payment_ctr.to_ne_bytes()); let succeeded = send_mpp_payment(source, dest, dest_chan_ids, amt, secret, hash, id); @@ -2165,7 +2174,7 @@ pub fn do_test(data: &[u8], out: Out) { let source = &nodes[source_idx]; let middle = &nodes[middle_idx]; let dest = &nodes[dest_idx]; - let (secret, hash) = get_payment_secret_hash(dest, payment_ctr); + let (secret, hash) = get_payment_secret_hash(dest, payment_ctr, &payment_preimages); let mut id = PaymentId([0; 32]); id.0[0..8].copy_from_slice(&payment_ctr.to_ne_bytes()); let succeeded = send_mpp_hop_payment( From 58f226e140aa487554af5eaa4c3d33ecab99fcd3 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Tue, 14 Apr 2026 14:42:01 -0700 Subject: [PATCH 316/627] Disallow net-negative contributions when adding value When a user requests to add value via coin-selected inputs, we should strive to fulfill their request. Allowing them to remove value from the channel is undesired as it goes against their request. While we still allow adding outputs to enabled mixed contributions, their funds must now always come from the set of coin-selected inputs, and must never draw from the channel balance resulting in a smaller added value. --- lightning/src/ln/channelmanager.rs | 28 +++++-- lightning/src/ln/funding.rs | 127 ++++++++++++----------------- lightning/src/ln/splicing_tests.rs | 116 +++++--------------------- 3 files changed, 93 insertions(+), 178 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 2c97e4adaa1..7cbdd39fba9 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -6638,20 +6638,32 @@ impl< /// The splice initiator is responsible for paying fees for common fields, shared inputs, and /// shared outputs along with any contributed inputs and outputs. When building a /// [`FundingContribution`], fees are estimated at `min_feerate` assuming initiator - /// responsibility and must be covered by the supplied inputs for splice-in or the channel - /// balance for splice-out. If the counterparty also initiates a splice and wins the - /// tie-break, they become the initiator and choose the feerate. The fee is then - /// re-estimated at the counterparty's feerate for only our contributed inputs and outputs, - /// which may be higher or lower than the original estimate. The contribution is dropped and - /// the splice proceeds without it when: + /// responsibility. Contributions fall into two cases: + /// - **input-backed contributions**: when wallet inputs are selected, those inputs pay for both + /// the requested value added to the channel and any explicit withdrawal outputs. For + /// example, a 60,000 sat input might add 50,000 sat to the channel, pay a 2,000 sat fee, + /// and return 8,000 sat as change. A later RBF first tries to preserve that 50,000 sat + /// value added and cover any higher fee or newly requested withdrawal from the original + /// 10,000 sat fee buffer (2,000 sat fee + 8,000 sat change). If that buffer is not enough, + /// the prior contribution cannot be reused without selecting new wallet inputs. + /// - **input-less contributions**: when no wallet inputs are selected, fees and explicit + /// withdrawal outputs are paid from the channel balance. For example, a pure splice-out that + /// withdraws 20,000 sat from a 100,000 sat holder balance leaves up to 80,000 sat available + /// for fees. A later RBF keeps the 20,000 sat withdrawal only while that remaining balance + /// can still cover the re-estimated fee. + /// + /// If the counterparty also initiates a splice and wins the tie-break, they become the + /// initiator and choose the feerate. The fee is then re-estimated at the counterparty's + /// feerate for only our contributed inputs and outputs, which may be higher or lower than the + /// original estimate. The contribution is dropped and the splice proceeds without it when: /// - the counterparty's feerate is below `min_feerate` /// - the counterparty's feerate is above `max_feerate` and the re-estimated fee exceeds the /// original fee estimate /// - the re-estimated fee exceeds the *fee buffer* regardless of `max_feerate` /// /// The fee buffer is the maximum fee that can be accommodated: - /// - **splice-in**: the selected inputs' value minus the contributed amount - /// - **splice-out**: the channel balance minus the withdrawal outputs + /// - **input-backed contributions**: the original fee plus any change output value + /// - **input-less contributions**: the channel balance minus the withdrawal outputs /// /// # Events /// diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 80c12178ce4..31878e35074 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -59,8 +59,8 @@ pub(super) enum FeeRateAdjustmentError { FeeBufferOverflow, /// The re-estimated fee exceeds the available fee buffer regardless of `max_feerate`. The fee /// buffer is the maximum fee that can be accommodated: - /// - **splice-in**: the selected inputs' value minus the contributed amount - /// - **splice-out**: the channel balance minus the withdrawal outputs + /// - **input-backed contributions**: the original fee plus any change output value + /// - **input-less contributions**: the channel balance minus the withdrawal outputs FeeBufferInsufficient { source: &'static str, available: Amount, required: Amount }, } @@ -288,7 +288,7 @@ macro_rules! build_funding_contribution { let max_feerate: FeeRate = $max_feerate; let force_coin_selection: bool = $force_coin_selection; - let value_removed = validate_funding_contribution_params( + let _value_removed = validate_funding_contribution_params( value_added, &outputs, min_rbf_feerate, @@ -312,8 +312,6 @@ macro_rules! build_funding_contribution { .map(|shared_input| shared_input.previous_utxo.value) .unwrap_or(Amount::ZERO) .checked_add(value_added) - .ok_or(FundingContributionError::InvalidSpliceValue)? - .checked_sub(value_removed) .ok_or(FundingContributionError::InvalidSpliceValue)?, script_pubkey: make_funding_redeemscript(&dummy_pubkey, &dummy_pubkey).to_p2wsh(), }; @@ -469,7 +467,8 @@ impl FundingTemplate { /// `value_added` and `outputs` are the complete parameters for this contribution, not /// increments on top of a prior contribution. When replacing a prior contribution via RBF, /// use [`FundingTemplate::prior_contribution`] to inspect the prior parameters and combine - /// them as needed. + /// them as needed. The withdrawal `outputs` are funded by the selected wallet inputs and do + /// not reduce the requested `value_added` to the channel. pub async fn splice_in_and_out( self, value_added: Amount, outputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, @@ -528,9 +527,9 @@ impl FundingTemplate { /// the fee difference. For splice-out (no wallet inputs), the holder's channel balance /// covers the higher fees. /// - If adjustment fails, coin selection is re-run using the prior contribution's - /// parameters and the caller's `max_feerate`. For splice-out contributions, this changes - /// the fee source: wallet inputs are selected to cover fees instead of deducting them - /// from the channel balance. + /// parameters and the caller's `max_feerate`. For prior contributions without inputs, + /// this changes the funding source: wallet inputs are selected to cover the outputs and + /// fees instead of deducting them from the channel balance. /// - If no prior contribution exists, coin selection is run for a fee-bump-only contribution /// (`value_added = 0`), covering fees for the common fields and shared input/output via /// a newly selected input. Check [`FundingTemplate::prior_contribution`] to see if this @@ -712,8 +711,10 @@ pub struct FundingContribution { /// excess amount will be sent to a change output. inputs: Vec, - /// The outputs to include in the funding transaction. The total value of all outputs plus fees - /// will be the amount that is removed. + /// The outputs to include in the funding transaction. + /// + /// When no wallet inputs are contributed, these outputs are paid from the channel balance. + /// Otherwise, they are paid by the contributed inputs. outputs: Vec, /// The output where any change will be sent. @@ -912,54 +913,35 @@ impl FundingContribution { } } + let target_fee = estimate_transaction_fee( + &self.inputs, + &self.outputs, + self.change_output.as_ref(), + is_initiator, + self.is_splice, + target_feerate, + ); + if !self.inputs.is_empty() { - if let Some(ref change_output) = self.change_output { - let old_change_value = change_output.value; - let dust_limit = change_output.script_pubkey.minimal_non_dust(); + let fee_buffer = self + .estimated_fee + .checked_add( + self.change_output.as_ref().map_or(Amount::ZERO, |output| output.value), + ) + .ok_or(FeeRateAdjustmentError::FeeBufferOverflow)?; - // Target fee including the change output's weight. - let target_fee = estimate_transaction_fee( - &self.inputs, - &self.outputs, - self.change_output.as_ref(), - is_initiator, - self.is_splice, - target_feerate, - ); + if let Some(change_output) = self.change_output.as_ref() { + let dust_limit = change_output.script_pubkey.minimal_non_dust(); + if let Some(new_change_value) = fee_buffer.checked_sub(target_fee) { + if new_change_value >= dust_limit { + return Ok((target_fee, Some(new_change_value))); + } - let fee_buffer = self - .estimated_fee - .checked_add(old_change_value) - .ok_or(FeeRateAdjustmentError::FeeBufferOverflow)?; - - match fee_buffer.checked_sub(target_fee) { - Some(new_change_value) if new_change_value >= dust_limit => { - Ok((target_fee, Some(new_change_value))) - }, - _ => { - // Change would be below dust or negative. Try without change. - let target_fee_no_change = estimate_transaction_fee( - &self.inputs, - &self.outputs, - None, - is_initiator, - self.is_splice, - target_feerate, - ); - if target_fee_no_change > fee_buffer { - Err(FeeRateAdjustmentError::FeeBufferInsufficient { - source: "estimated fee + change value", - available: fee_buffer, - required: target_fee_no_change, - }) - } else { - Ok((target_fee_no_change, None)) - } - }, + // Our remaining change was not enough to be a valid output, fallthrough to the + // no remaining change case. } - } else { - // No change output. - let target_fee = estimate_transaction_fee( + + let target_fee_no_change = estimate_transaction_fee( &self.inputs, &self.outputs, None, @@ -967,27 +949,27 @@ impl FundingContribution { self.is_splice, target_feerate, ); - if target_fee > self.estimated_fee { - return Err(FeeRateAdjustmentError::FeeBufferInsufficient { - source: "estimated fee", - available: self.estimated_fee, - required: target_fee, - }); + if target_fee_no_change > fee_buffer { + Err(FeeRateAdjustmentError::FeeBufferInsufficient { + source: "estimated fee + change value", + available: fee_buffer, + required: target_fee_no_change, + }) + } else { + Ok((target_fee_no_change, None)) } + } else if let Some(_surplus) = fee_buffer.checked_sub(target_fee) { Ok((target_fee, None)) + } else { + Err(FeeRateAdjustmentError::FeeBufferInsufficient { + source: "estimated fee", + available: fee_buffer, + required: target_fee, + }) } } else { - // No inputs (splice-out): fees paid from channel balance. - let target_fee = estimate_transaction_fee( - &[], - &self.outputs, - None, - is_initiator, - self.is_splice, - target_feerate, - ); - - // Check that the channel balance can cover the withdrawal outputs plus fees. + // Without coin-selected inputs, both the withdrawals and the fee come from the channel + // balance. let value_removed: Amount = self.outputs.iter().map(|o| o.value).sum(); let total_cost = target_fee .checked_add(value_removed) @@ -999,7 +981,6 @@ impl FundingContribution { required: target_fee, }); } - // Surplus goes back to the channel balance. Ok((target_fee, None)) } } diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 3004c76fb93..6cec7a443a3 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -1177,7 +1177,7 @@ fn test_splice_out() { } #[test] -fn test_splice_in_and_out() { +fn test_splice_in_and_out_funds_outputs_from_inputs() { let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let mut config = test_default_channel_config(); @@ -1190,118 +1190,40 @@ fn test_splice_in_and_out() { let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); - let _ = send_payment(&nodes[0], &[&nodes[1]], 100_000); - - // Contribute a net negative value, with fees taken from the contributed inputs and the - // remaining value sent to change - let htlc_limit_msat = nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat; - let added_value = Amount::from_sat(htlc_limit_msat / 1000); - let removed_value = added_value * 2; - let utxo_value = added_value * 3 / 4; - let fees = if cfg!(feature = "grind_signatures") { - Amount::from_sat(385) - } else { - Amount::from_sat(385) - }; - - assert!(htlc_limit_msat > initial_channel_value_sat / 2 * 1000); - - provide_utxo_reserves(&nodes, 2, utxo_value); - + let value_added = Amount::from_sat(20_000); + let utxo_value = Amount::from_sat(50_000); let outputs = vec![ TxOut { - value: removed_value / 2, + value: Amount::from_sat(20_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }, TxOut { - value: removed_value / 2, + value: Amount::from_sat(20_000), script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), }, ]; - let funding_contribution = - do_initiate_splice_in_and_out(&nodes[0], &nodes[1], channel_id, added_value, outputs); - - let (splice_tx, new_funding_script) = - splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); - let expected_change = utxo_value * 2 - added_value - fees; - assert_eq!( - splice_tx - .output - .iter() - .filter(|txout| txout.value != removed_value / 2) - .find(|txout| txout.script_pubkey != new_funding_script) - .unwrap() - .value, - expected_change, - ); - - mine_transaction(&nodes[0], &splice_tx); - mine_transaction(&nodes[1], &splice_tx); - - let htlc_limit_msat = nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat; - assert!(htlc_limit_msat < added_value.to_sat() * 1000); - let _ = send_payment(&nodes[0], &[&nodes[1]], htlc_limit_msat); - - lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); - - let htlc_limit_msat = nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat; - assert!(htlc_limit_msat < added_value.to_sat() * 1000); - let _ = send_payment(&nodes[0], &[&nodes[1]], htlc_limit_msat); - - // Contribute a net positive value, with fees taken from the contributed inputs and the - // remaining value sent to change - let added_value = Amount::from_sat(initial_channel_value_sat * 2); - let removed_value = added_value / 2; - let utxo_value = added_value * 3 / 4; - let fees = if cfg!(feature = "grind_signatures") { - Amount::from_sat(385) - } else { - Amount::from_sat(385) - }; - - // Clear UTXOs so that the change output from the previous splice isn't considered - nodes[0].wallet_source.clear_utxos(); - provide_utxo_reserves(&nodes, 2, utxo_value); - let outputs = vec![ - TxOut { - value: removed_value / 2, - script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), - }, - TxOut { - value: removed_value / 2, - script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), - }, - ]; let funding_contribution = - do_initiate_splice_in_and_out(&nodes[0], &nodes[1], channel_id, added_value, outputs); - - let (splice_tx, new_funding_script) = - splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); - let expected_change = utxo_value * 2 - added_value - fees; - assert_eq!( - splice_tx - .output - .iter() - .filter(|txout| txout.value != removed_value / 2) - .find(|txout| txout.script_pubkey != new_funding_script) - .unwrap() - .value, - expected_change, - ); + initiate_splice_in_and_out(&nodes[0], &nodes[1], channel_id, value_added, outputs); + let fees = Amount::from_sat(385); + let total_output_value: Amount = + funding_contribution.outputs().iter().map(|output| output.value).sum(); + let expected_change = utxo_value * 2 - value_added - total_output_value - fees; + assert_eq!(funding_contribution.change_output().unwrap().value, expected_change); + assert!(funding_contribution.net_value() >= value_added.to_signed().unwrap()); + let (splice_tx, _) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution.clone()); mine_transaction(&nodes[0], &splice_tx); mine_transaction(&nodes[1], &splice_tx); - - let htlc_limit_msat = nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat; - assert_eq!(htlc_limit_msat, 0); - lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); - let htlc_limit_msat = nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat; - assert!(htlc_limit_msat > initial_channel_value_sat / 2 * 1000); - let _ = send_payment(&nodes[0], &[&nodes[1]], htlc_limit_msat); + let channel = &nodes[0].node.list_channels()[0]; + assert_eq!( + channel.channel_value_satoshis, + initial_channel_value_sat + funding_contribution.net_value().to_sat() as u64, + ); } #[test] From b1c3e29a257a70dbc273eb7080f35c42e87d6615 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Fri, 3 Apr 2026 11:44:34 -0700 Subject: [PATCH 317/627] Introduce FundingBuilder for splice requests This lets callers easily amend a prior contribution in place and only re-run coin selection when the new request cannot be satisfied with the existing inputs. --- lightning/src/ln/channel.rs | 22 +- lightning/src/ln/funding.rs | 944 ++++++++++++++++++++++++++++++++++-- 2 files changed, 925 insertions(+), 41 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index b99b2a19667..c9301ac67f5 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12535,14 +12535,12 @@ where }; } - if let Err(e) = contribution.validate().and_then(|()| { - // For splice-out, our_funding_contribution is adjusted to cover fees if there - // aren't any inputs. - let our_funding_contribution = contribution.net_value(); + let our_funding_contribution = contribution.net_value(); + + if let Err(e) = self.validate_splice_contributions(our_funding_contribution, SignedAmount::ZERO) - }) { + { log_error!(logger, "Channel {} cannot be funded: {}", self.context.channel_id(), e); - return Err(QuiescentError::FailSplice(self.splice_funding_failed_for(contribution))); } @@ -14104,13 +14102,11 @@ where // funding_contributed and quiescence, reducing the holder's // balance. If invalid, disconnect and return the contribution so // the user can reclaim their inputs. - if let Err(e) = contribution.validate().and_then(|()| { - let our_funding_contribution = contribution.net_value(); - self.validate_splice_contributions( - our_funding_contribution, - SignedAmount::ZERO, - ) - }) { + let our_funding_contribution = contribution.net_value(); + if let Err(e) = self.validate_splice_contributions( + our_funding_contribution, + SignedAmount::ZERO, + ) { let failed = self.splice_funding_failed_for(contribution); return Err(( ChannelError::WarnAndDisconnect(format!( diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 31878e35074..b628f5422cc 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -133,8 +133,19 @@ pub enum FundingContributionError { }, /// The splice value is invalid (zero, empty outputs, or exceeds the maximum money supply). InvalidSpliceValue, + /// An input's `prevtx` is too large to fit in a `tx_add_input` message. + PrevTxTooLarge, /// Coin selection failed to find suitable inputs. CoinSelectionFailed, + /// Coin selection is required but no coin selection source was provided. + /// + /// This can also be returned when reusing a prior contribution would otherwise satisfy the + /// request, but that prior contribution cannot be adjusted in-place to the requested feerate. + /// For example, an input-backed prior contribution may no longer have enough fee buffer in its + /// change output to absorb the higher fee. In that case, providing a coin selection source lets + /// the builder fall back to fresh coin selection, which may replace the prior input set instead + /// of preserving it. + MissingCoinSelectionSource, /// This is not an RBF scenario (no minimum RBF feerate available). NotRbfScenario, } @@ -151,9 +162,15 @@ impl core::fmt::Display for FundingContributionError { FundingContributionError::InvalidSpliceValue => { write!(f, "Invalid splice value (zero, empty, or exceeds limit)") }, + FundingContributionError::PrevTxTooLarge => { + write!(f, "Input prevtx is too large to fit in a tx_add_input message") + }, FundingContributionError::CoinSelectionFailed => { write!(f, "Coin selection failed to find suitable inputs") }, + FundingContributionError::MissingCoinSelectionSource => { + write!(f, "Coin selection source required to build this contribution") + }, FundingContributionError::NotRbfScenario => { write!(f, "Not an RBF scenario (no minimum RBF feerate)") }, @@ -276,6 +293,33 @@ impl FundingTemplate { pub fn prior_contribution(&self) -> Option<&FundingContribution> { self.prior_contribution.as_ref().map(|p| &p.contribution) } + + /// Creates a [`FundingBuilder`] for constructing a contribution. + /// + /// If a prior contribution is available, the builder starts from it automatically and builder + /// mutations amend that prior request. Use [`FundingTemplate::without_prior_contribution`] to + /// start empty instead. + /// + /// `feerate` is the feerate used for fee estimation and, if wallet inputs are needed, coin + /// selection. When [`FundingTemplate::min_rbf_feerate`] is set, it must be at least that value. + /// `max_feerate` is the highest feerate we are willing to tolerate if we end up as the + /// acceptor, and must be at least `feerate`. + pub fn with_prior_contribution(self, feerate: FeeRate, max_feerate: FeeRate) -> FundingBuilder { + FundingBuilder::new(self, feerate, max_feerate) + } + + /// Creates a [`FundingBuilder`] for constructing a contribution without using any prior + /// contribution. + /// + /// `feerate` and `max_feerate` have the same meaning as in + /// [`FundingTemplate::with_prior_contribution`]. This is useful when an RBF template carries a + /// prior contribution but the caller wants to replace, rather than amend, that request. + pub fn without_prior_contribution( + mut self, feerate: FeeRate, max_feerate: FeeRate, + ) -> FundingBuilder { + self.prior_contribution.take(); + FundingBuilder::new(self, feerate, max_feerate) + } } macro_rules! build_funding_contribution { @@ -701,6 +745,44 @@ fn estimate_transaction_fee( Weight::from_wu(weight) * feerate } +fn validate_inputs(inputs: &[FundingTxInput]) -> Result<(), FundingContributionError> { + let mut total_value = Amount::ZERO; + for input in inputs { + use crate::util::ser::Writeable; + const MESSAGE_TEMPLATE: msgs::TxAddInput = msgs::TxAddInput { + channel_id: ChannelId([0; 32]), + serial_id: 0, + prevtx: None, + prevtx_out: 0, + sequence: 0, + // Mutually exclusive with prevtx, which is accounted for below. + shared_input_txid: None, + }; + let message_len = MESSAGE_TEMPLATE.serialized_length() + input.prevtx.serialized_length(); + (message_len <= LN_MAX_MSG_LEN) + .then(|| ()) + .ok_or(FundingContributionError::PrevTxTooLarge)?; + + total_value = match total_value.checked_add(input.utxo.output.value) { + Some(sum) if sum <= Amount::MAX_MONEY => sum, + _ => return Err(FundingContributionError::InvalidSpliceValue), + }; + } + + Ok(()) +} + +/// Describes how an amended contribution should source its wallet-backed inputs. +enum FundingInputs { + None, + /// Reuses the contribution's existing inputs while targeting at least `value_added` added to + /// the channel after fees. If dropping the change output leaves surplus value, it remains in + /// the channel contribution. + CoinSelected { + value_added: Amount, + }, +} + /// The components of a funding transaction contributed by one party. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FundingContribution { @@ -808,6 +890,105 @@ impl FundingContribution { self.change_output.as_ref() } + /// Tries to satisfy a new request using only this contribution's existing inputs. + /// + /// For input-backed contributions, this reuses the current inputs, adjusts the explicit + /// outputs, and shrinks or drops the change output as needed before applying + /// `target_feerate`. If dropping change leaves surplus value, that surplus remains in the + /// channel contribution. + /// + /// For input-less contributions, `holder_balance` must be provided to cover the outputs and + /// fees from the channel balance. + /// + /// Returns `None` if the request would require new wallet inputs or cannot accommodate the + /// requested feerate. + fn amend_without_coin_selection( + self, inputs: FundingInputs, outputs: &[TxOut], target_feerate: FeeRate, + max_feerate: FeeRate, holder_balance: Amount, + ) -> Option { + // NOTE: The contribution returned is not guaranteed to be valid. We defer doing so until + // `compute_feerate_adjustment`. + let adjust_for_inputs_and_outputs = + |contribution: Self, inputs: FundingInputs, outputs: &[TxOut]| -> Option { + let (target_value_added, inputs) = match inputs { + FundingInputs::None => (None, Vec::new()), + FundingInputs::CoinSelected { value_added } => { + (Some(value_added), contribution.inputs) + }, + }; + + if inputs.is_empty() && target_value_added.unwrap_or(Amount::ZERO) != Amount::ZERO { + // Prior contribution didn't have any inputs, but now we need some. + return None; + } + + // When inputs are coin-selected, adjust the existing change output, if any, to account + // for the requested value added and any explicit outputs that must also be funded by + // the inputs. + if let Some(value_added) = target_value_added { + let estimated_fee = estimate_transaction_fee( + &inputs, + &outputs, + contribution.change_output.as_ref(), + true, + contribution.is_splice, + contribution.feerate, + ); + let total_output_value: Amount = + outputs.iter().map(|output| output.value).sum(); + let required_value = + value_added.checked_add(total_output_value)?.checked_add(estimated_fee)?; + + if let Some(change_output) = contribution.change_output.as_ref() { + let dust_limit = change_output.script_pubkey.minimal_non_dust(); + let total_input_value: Amount = + inputs.iter().map(|input| input.utxo.output.value).sum(); + match total_input_value.checked_sub(required_value) { + Some(new_change_value) if new_change_value >= dust_limit => { + let new_change_output = TxOut { + value: new_change_value, + script_pubkey: change_output.script_pubkey.clone(), + }; + return Some(FundingContribution { + estimated_fee, + inputs, + outputs: outputs.to_vec(), + change_output: Some(new_change_output), + ..contribution + }); + }, + _ => {}, + } + } + } + + let estimated_fee_no_change = estimate_transaction_fee( + &inputs, + &outputs, + None, + true, + contribution.is_splice, + contribution.feerate, + ); + Some(FundingContribution { + estimated_fee: estimated_fee_no_change, + outputs: outputs.to_vec(), + inputs, + change_output: None, + ..contribution + }) + }; + + let new_contribution_at_current_feerate = + adjust_for_inputs_and_outputs(self, inputs, outputs)?; + let mut new_contribution_at_target_feerate = new_contribution_at_current_feerate + .at_feerate(target_feerate, holder_balance, true) + .ok()?; + new_contribution_at_target_feerate.max_feerate = max_feerate; + + Some(new_contribution_at_target_feerate) + } + pub(super) fn into_tx_parts(self) -> (Vec, Vec) { let FundingContribution { inputs, mut outputs, change_output, .. } = self; @@ -842,32 +1023,6 @@ impl FundingContribution { } } - /// Validates that the funding inputs are suitable for use in the interactive transaction - /// protocol, checking prevtx sizes. - pub fn validate(&self) -> Result<(), String> { - for FundingTxInput { utxo, prevtx, .. } in self.inputs.iter() { - use crate::util::ser::Writeable; - const MESSAGE_TEMPLATE: msgs::TxAddInput = msgs::TxAddInput { - channel_id: ChannelId([0; 32]), - serial_id: 0, - prevtx: None, - prevtx_out: 0, - sequence: 0, - // Mutually exclusive with prevtx, which is accounted for below. - shared_input_txid: None, - }; - let message_len = MESSAGE_TEMPLATE.serialized_length() + prevtx.serialized_length(); - if message_len > LN_MAX_MSG_LEN { - return Err(format!( - "Funding input references a prevtx that is too large for tx_add_input: {}", - utxo.outpoint - )); - } - } - - Ok(()) - } - /// Computes the adjusted fee and change output value at the given target feerate, which may /// differ from the feerate used during coin selection. /// @@ -1112,17 +1267,511 @@ impl FundingContribution { /// establishment protocol or when splicing. pub type FundingTxInput = crate::util::wallet_utils::ConfirmedUtxo; +#[derive(Debug, Clone, PartialEq, Eq)] +struct NoCoinSelectionSource; +#[derive(Debug, Clone, PartialEq, Eq)] +struct AsyncCoinSelectionSource(W); +#[derive(Debug, Clone, PartialEq, Eq)] +struct SyncCoinSelectionSource(W); + +#[derive(Debug, Clone, PartialEq, Eq)] +struct FundingBuilderInner { + shared_input: Option, + min_rbf_feerate: Option, + prior_contribution: Option, + value_added: Amount, + outputs: Vec, + feerate: FeeRate, + max_feerate: FeeRate, + state: State, +} + +/// A builder for composing or amending a [`FundingContribution`]. +/// +/// The builder tracks a requested amount to add to the channel together with any explicit +/// withdrawal outputs. Building without an attached wallet only succeeds when the request can be +/// satisfied by reusing or amending a prior contribution, or by constructing a pure splice-out +/// that pays fees from the channel balance. +/// +/// Attach a wallet via [`FundingBuilder::with_coin_selection_source`] or +/// [`FundingBuilder::with_coin_selection_source_sync`] when the request may need new wallet +/// inputs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FundingBuilder(FundingBuilderInner); + +/// A [`FundingBuilder`] with an attached asynchronous [`CoinSelectionSource`]. +/// +/// Created by [`FundingBuilder::with_coin_selection_source`]. The attached wallet is only used +/// if the request cannot be satisfied by reusing a prior contribution or by building a pure +/// splice-out directly. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AsyncFundingBuilder(FundingBuilderInner>); + +/// A [`FundingBuilder`] with an attached synchronous [`CoinSelectionSourceSync`]. +/// +/// Created by [`FundingBuilder::with_coin_selection_source_sync`]. The attached wallet is only +/// used if the request cannot be satisfied by reusing a prior contribution or by building a pure +/// splice-out directly. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SyncFundingBuilder(FundingBuilderInner>); + +impl FundingBuilderInner { + fn request_matches_prior(&self, prior_contribution: &FundingContribution) -> bool { + self.value_added == prior_contribution.value_added() + && self.outputs == prior_contribution.outputs + } + + fn build_from_prior_contribution( + &mut self, contribution: PriorContribution, + ) -> Result { + let PriorContribution { contribution, holder_balance } = contribution; + + if self.request_matches_prior(&contribution) { + // Same request, but the feerate may have changed. Adjust the prior contribution + // to the new feerate if possible. + return contribution + .for_initiator_at_feerate(self.feerate, holder_balance) + .map(|mut adjusted| { + adjusted.max_feerate = self.max_feerate; + adjusted + }) + .map_err(|_| FundingContributionError::MissingCoinSelectionSource); + } + + let funding_inputs = if self.value_added != Amount::ZERO { + FundingInputs::CoinSelected { value_added: self.value_added } + } else { + FundingInputs::None + }; + return contribution + .amend_without_coin_selection( + funding_inputs, + &self.outputs, + self.feerate, + self.max_feerate, + holder_balance, + ) + .ok_or_else(|| FundingContributionError::MissingCoinSelectionSource); + } + + /// Tries to build the current request without selecting any new wallet inputs. + /// + /// This first attempts to reuse or amend any prior contribution. If there is no prior + /// contribution, it also supports pure splice-out requests by building a contribution that pays + /// fees from the channel balance. + /// + /// Returns [`FundingContributionError::MissingCoinSelectionSource`] if the request is + /// otherwise valid but needs wallet inputs. + fn try_build_without_coin_selection( + &mut self, + ) -> Result { + if let Some(contribution) = self.prior_contribution.take() { + return self.build_from_prior_contribution(contribution); + } + + if self.value_added == Amount::ZERO { + let estimated_fee = estimate_transaction_fee( + &[], + &self.outputs, + None, + true, + self.shared_input.is_some(), + self.feerate, + ); + return Ok(FundingContribution { + estimated_fee, + inputs: vec![], + outputs: core::mem::take(&mut self.outputs), + change_output: None, + feerate: self.feerate, + max_feerate: self.max_feerate, + is_splice: self.shared_input.is_some(), + }); + } + + Err(FundingContributionError::MissingCoinSelectionSource) + } + + fn prepare_coin_selection_request( + &self, + ) -> Result<(Vec, Vec), FundingContributionError> { + let dummy_pubkey = PublicKey::from_slice(&[2; 33]).unwrap(); + let shared_output = bitcoin::TxOut { + value: self + .shared_input + .as_ref() + .map(|shared_input| shared_input.previous_utxo.value) + .unwrap_or(Amount::ZERO) + .checked_add(self.value_added) + .ok_or(FundingContributionError::InvalidSpliceValue)?, + script_pubkey: make_funding_redeemscript(&dummy_pubkey, &dummy_pubkey).to_p2wsh(), + }; + + let must_spend = self.shared_input.clone().map(|input| vec![input]).unwrap_or_default(); + let must_pay_to = if self.outputs.is_empty() { + vec![shared_output] + } else { + self.outputs.iter().cloned().chain(core::iter::once(shared_output)).collect() + }; + + Ok((must_spend, must_pay_to)) + } + + fn validate_contribution_parameters(&self) -> Result<(), FundingContributionError> { + if self.feerate > self.max_feerate { + return Err(FundingContributionError::FeeRateExceedsMaximum { + feerate: self.feerate, + max_feerate: self.max_feerate, + }); + } + + if let Some(min_rbf_feerate) = self.min_rbf_feerate.as_ref() { + if self.feerate < *min_rbf_feerate { + return Err(FundingContributionError::FeeRateBelowRbfMinimum { + feerate: self.feerate, + min_rbf_feerate: *min_rbf_feerate, + }); + } + } + + if self.value_added == Amount::ZERO && self.outputs.is_empty() { + return Err(FundingContributionError::InvalidSpliceValue); + } + + // Validate user-provided amounts are within MAX_MONEY before coin selection to + // ensure FundingContribution::net_value() arithmetic cannot overflow. With all + // amounts bounded by MAX_MONEY (~2.1e15 sat), the worst-case net_value() + // computation is -2 * MAX_MONEY (~-4.2e15), well within i64::MIN (~-9.2e18). + if self.value_added > Amount::MAX_MONEY { + return Err(FundingContributionError::InvalidSpliceValue); + } + + let mut value_removed = Amount::ZERO; + for output in self.outputs.iter() { + value_removed = match value_removed.checked_add(output.value) { + Some(sum) if sum <= Amount::MAX_MONEY => sum, + _ => return Err(FundingContributionError::InvalidSpliceValue), + }; + } + + Ok(()) + } +} + +impl FundingBuilder { + fn new(template: FundingTemplate, feerate: FeeRate, max_feerate: FeeRate) -> FundingBuilder { + let FundingTemplate { shared_input, min_rbf_feerate, prior_contribution } = template; + let (value_added, outputs) = match prior_contribution.as_ref() { + Some(prior) => { + let outputs = prior.contribution.outputs.clone(); + (prior.contribution.value_added(), outputs) + }, + None => (Amount::ZERO, Vec::new()), + }; + + FundingBuilder(FundingBuilderInner { + shared_input, + min_rbf_feerate, + prior_contribution, + value_added, + outputs, + feerate, + max_feerate, + state: NoCoinSelectionSource, + }) + } + + /// Attaches an asynchronous [`CoinSelectionSource`] for later use. + /// + /// The wallet is only consulted if [`AsyncFundingBuilder::build`] cannot satisfy the request by + /// reusing a prior contribution or by constructing a pure splice-out directly. + pub fn with_coin_selection_source( + self, wallet: W, + ) -> AsyncFundingBuilder { + AsyncFundingBuilder(self.0.with_state(AsyncCoinSelectionSource(wallet))) + } + + /// Attaches a synchronous [`CoinSelectionSourceSync`] for later use. + /// + /// The wallet is only consulted if [`SyncFundingBuilder::build`] cannot satisfy the request by + /// reusing a prior contribution or by constructing a pure splice-out directly. + pub fn with_coin_selection_source_sync( + self, wallet: W, + ) -> SyncFundingBuilder { + SyncFundingBuilder(self.0.with_state(SyncCoinSelectionSource(wallet))) + } + + /// Adds a withdrawal output to the request. + /// + /// `output` is appended to the current set of explicit outputs. If the builder was seeded from + /// a prior contribution, this adds an additional withdrawal on top of the prior outputs. This + /// does not affect any change output derived when the contribution is built. + pub fn add_output(self, output: TxOut) -> Self { + FundingBuilder(self.0.add_output_inner(output)) + } + + /// Removes all explicit withdrawal outputs whose script pubkey matches `script_pubkey`. + /// + /// This only affects outputs returned by [`FundingContribution::outputs`]; it never removes the + /// change output returned by [`FundingContribution::change_output`]. + pub fn remove_outputs(self, script_pubkey: &ScriptBuf) -> Self { + FundingBuilder(self.0.remove_outputs_inner(script_pubkey)) + } + + /// Builds a [`FundingContribution`] without coin selection. + /// + /// This succeeds when the request can be satisfied by reusing or amending a prior + /// contribution, or by building a splice-out contribution that pays fees from the channel + /// balance. + /// + /// Returns [`FundingContributionError::MissingCoinSelectionSource`] if additional wallet + /// inputs are needed. + pub fn build(mut self) -> Result { + self.0.build_without_coin_selection() + } +} + +impl FundingBuilderInner { + fn with_state(self, state: NewState) -> FundingBuilderInner { + FundingBuilderInner { + shared_input: self.shared_input, + min_rbf_feerate: self.min_rbf_feerate, + prior_contribution: self.prior_contribution, + value_added: self.value_added, + outputs: self.outputs, + feerate: self.feerate, + max_feerate: self.max_feerate, + state, + } + } + + fn add_value_inner(mut self, value: Amount) -> Self { + self.value_added = + Amount::from_sat(self.value_added.to_sat().saturating_add(value.to_sat())); + self + } + + fn remove_value_inner(mut self, value: Amount) -> Self { + self.value_added = + Amount::from_sat(self.value_added.to_sat().saturating_sub(value.to_sat())); + self + } + + fn add_output_inner(mut self, output: TxOut) -> Self { + self.outputs.push(output); + self + } + + fn remove_outputs_inner(mut self, script_pubkey: &ScriptBuf) -> Self { + self.outputs.retain(|output| output.script_pubkey != *script_pubkey); + self + } + + /// Validates the current request and then tries to build it without selecting new wallet + /// inputs. + /// + /// Returns [`FundingContributionError::MissingCoinSelectionSource`] if the request is valid but + /// cannot be satisfied without wallet inputs. + fn build_without_coin_selection( + &mut self, + ) -> Result { + self.validate_contribution_parameters()?; + self.try_build_without_coin_selection() + } +} + +impl AsyncFundingBuilder { + /// Adds a withdrawal output to the request. + /// + /// `output` is appended to the current set of explicit outputs. If the builder was seeded from + /// a prior contribution, this adds an additional withdrawal on top of the prior outputs. This + /// does not affect any change output derived when the contribution is built. + pub fn add_output(self, output: TxOut) -> Self { + AsyncFundingBuilder(self.0.add_output_inner(output)) + } + + /// Removes all explicit withdrawal outputs whose script pubkey matches `script_pubkey`. + /// + /// This only affects outputs returned by [`FundingContribution::outputs`]; it never removes the + /// change output returned by [`FundingContribution::change_output`]. + pub fn remove_outputs(self, script_pubkey: &ScriptBuf) -> Self { + AsyncFundingBuilder(self.0.remove_outputs_inner(script_pubkey)) + } + + /// Increases the requested amount to add to the channel. + /// + /// `value` is added on top of the builder's current request. If the builder was seeded from a + /// prior contribution, this increases that prior contribution's current amount added to the + /// channel. If the updated request cannot be satisfied in-place, [`AsyncFundingBuilder::build`] + /// may re-run coin selection and return a contribution with a different input set. + pub fn add_value(self, value: Amount) -> Self { + AsyncFundingBuilder(self.0.add_value_inner(value)) + } + + /// Decreases the requested amount to add to the channel. + /// + /// `value` is subtracted from the builder's current request, saturating at zero. If the builder + /// was seeded from a prior contribution, this decreases that prior contribution's current + /// amount added to the channel. If the updated request cannot be satisfied in-place, + /// [`AsyncFundingBuilder::build`] may re-run coin selection and return a contribution with a + /// different input set. + pub fn remove_value(self, value: Amount) -> Self { + AsyncFundingBuilder(self.0.remove_value_inner(value)) + } +} + +impl AsyncFundingBuilder { + /// Builds a [`FundingContribution`], using the attached asynchronous wallet only when needed. + /// + /// If the request can be satisfied by reusing or amending a prior contribution, or by building + /// a pure splice-out directly, the attached wallet is ignored. + pub async fn build(self) -> Result { + let mut inner = self.0; + match inner.build_without_coin_selection() { + Err(FundingContributionError::MissingCoinSelectionSource) => {}, + other => return other, + } + + let (must_spend, must_pay_to) = inner.prepare_coin_selection_request()?; + let AsyncCoinSelectionSource(wallet) = inner.state; + let coin_selection = wallet + .select_confirmed_utxos( + None, + must_spend, + &must_pay_to, + inner.feerate.to_sat_per_kwu() as u32, + u64::MAX, + ) + .await + .map_err(|_| FundingContributionError::CoinSelectionFailed)?; + + let CoinSelection { confirmed_utxos: inputs, change_output } = coin_selection; + validate_inputs(&inputs)?; + + let outputs = inner.outputs; + let is_splice = inner.shared_input.is_some(); + let estimated_fee = estimate_transaction_fee( + &inputs, + &outputs, + change_output.as_ref(), + true, + is_splice, + inner.feerate, + ); + + return Ok(FundingContribution { + estimated_fee, + inputs, + outputs, + change_output, + feerate: inner.feerate, + max_feerate: inner.max_feerate, + is_splice, + }); + } +} + +impl SyncFundingBuilder { + /// Adds a withdrawal output to the request. + /// + /// `output` is appended to the current set of explicit outputs. If the builder was seeded from + /// a prior contribution, this adds an additional withdrawal on top of the prior outputs. This + /// does not affect any change output derived when the contribution is built. + pub fn add_output(self, output: TxOut) -> Self { + SyncFundingBuilder(self.0.add_output_inner(output)) + } + + /// Removes all explicit withdrawal outputs whose script pubkey matches `script_pubkey`. + /// + /// This only affects outputs returned by [`FundingContribution::outputs`]; it never removes the + /// change output returned by [`FundingContribution::change_output`]. + pub fn remove_outputs(self, script_pubkey: &ScriptBuf) -> Self { + SyncFundingBuilder(self.0.remove_outputs_inner(script_pubkey)) + } + + /// Increases the requested amount to add to the channel. + /// + /// `value` is added on top of the builder's current request. If the builder was seeded from a + /// prior contribution, this increases that prior contribution's current amount added to the + /// channel. If the updated request cannot be satisfied in-place, [`SyncFundingBuilder::build`] + /// may re-run coin selection and return a contribution with a different input set. + pub fn add_value(self, value: Amount) -> Self { + SyncFundingBuilder(self.0.add_value_inner(value)) + } + + /// Decreases the requested amount to add to the channel. + /// + /// `value` is subtracted from the builder's current request, saturating at zero. If the builder + /// was seeded from a prior contribution, this decreases that prior contribution's current + /// amount added to the channel. If the updated request cannot be satisfied in-place, + /// [`SyncFundingBuilder::build`] may re-run coin selection and return a contribution with a + /// different input set. + pub fn remove_value(self, value: Amount) -> Self { + SyncFundingBuilder(self.0.remove_value_inner(value)) + } +} + +impl SyncFundingBuilder { + /// Builds a [`FundingContribution`], using the attached synchronous wallet only when needed. + /// + /// If the request can be satisfied by reusing or amending a prior contribution, or by building + /// a pure splice-out directly, the attached wallet is ignored. + pub fn build(self) -> Result { + let mut inner = self.0; + match inner.build_without_coin_selection() { + Err(FundingContributionError::MissingCoinSelectionSource) => {}, + other => return other, + } + + let (must_spend, must_pay_to) = inner.prepare_coin_selection_request()?; + let SyncCoinSelectionSource(wallet) = inner.state; + let coin_selection = wallet + .select_confirmed_utxos( + None, + must_spend, + &must_pay_to, + inner.feerate.to_sat_per_kwu() as u32, + u64::MAX, + ) + .map_err(|_| FundingContributionError::CoinSelectionFailed)?; + + let CoinSelection { confirmed_utxos: inputs, change_output } = coin_selection; + validate_inputs(&inputs)?; + + let outputs = inner.outputs; + let is_splice = inner.shared_input.is_some(); + let estimated_fee = estimate_transaction_fee( + &inputs, + &outputs, + change_output.as_ref(), + true, + is_splice, + inner.feerate, + ); + + return Ok(FundingContribution { + estimated_fee, + inputs, + outputs, + change_output, + feerate: inner.feerate, + max_feerate: inner.max_feerate, + is_splice, + }); + } +} + #[cfg(test)] mod tests { use super::{ - estimate_transaction_fee, FeeRateAdjustmentError, FundingContribution, + estimate_transaction_fee, FeeRateAdjustmentError, FundingBuilder, FundingContribution, FundingContributionError, FundingTemplate, FundingTxInput, PriorContribution, }; use crate::chain::ClaimId; use crate::util::wallet_utils::{CoinSelection, CoinSelectionSourceSync, Input}; use bitcoin::hashes::Hash; use bitcoin::transaction::{Transaction, TxOut, Version}; - use bitcoin::{Amount, FeeRate, Psbt, ScriptBuf, SignedAmount, WPubkeyHash}; + use bitcoin::{Amount, FeeRate, Psbt, ScriptBuf, SignedAmount, WPubkeyHash, WScriptHash}; #[test] #[rustfmt::skip] @@ -1234,6 +1883,218 @@ mod tests { } } + struct MustPayToWallet { + utxo: FundingTxInput, + change_output: Option, + expected_must_pay_to_values: Vec, + } + + impl CoinSelectionSourceSync for MustPayToWallet { + fn select_confirmed_utxos( + &self, _claim_id: Option, _must_spend: Vec, must_pay_to: &[TxOut], + _target_feerate_sat_per_1000_weight: u32, _max_tx_weight: u64, + ) -> Result { + assert_eq!( + must_pay_to.iter().map(|output| output.value).collect::>(), + self.expected_must_pay_to_values, + ); + Ok(CoinSelection { + confirmed_utxos: vec![self.utxo.clone()], + change_output: self.change_output.clone(), + }) + } + + fn sign_psbt(&self, _psbt: Psbt) -> Result { + unreachable!("should not reach signing") + } + } + + #[test] + fn test_funding_builder_builds_splice_out_without_wallet() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let output = funding_output_sats(25_000); + + let contribution = + FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX) + .add_output(output.clone()) + .build() + .unwrap(); + + let expected_fee = estimate_transaction_fee( + &[], + std::slice::from_ref(&output), + None, + true, + false, + feerate, + ); + assert!(contribution.inputs.is_empty()); + assert_eq!(contribution.outputs, vec![output.clone()]); + assert!(contribution.change_output.is_none()); + assert_eq!(contribution.estimated_fee, expected_fee); + assert_eq!( + contribution.net_value(), + -output.value.to_signed().unwrap() - expected_fee.to_signed().unwrap(), + ); + } + + #[test] + fn test_funding_builder_requires_wallet_for_splice_in() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let builder = + FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX); + let builder = FundingBuilder(builder.0.add_value_inner(Amount::from_sat(25_000))); + + assert!(matches!( + builder.build(), + Err(FundingContributionError::MissingCoinSelectionSource), + )); + } + + #[test] + fn test_funding_builder_amends_prior_by_dropping_subdust_change() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let inputs = vec![funding_input_sats(100_000)]; + let change = funding_output_sats(500); + let dust_limit = change.script_pubkey.minimal_non_dust(); + assert!(change.value >= dust_limit); + + let estimated_fee_with_change = + estimate_transaction_fee(&inputs, &[], Some(&change), true, true, feerate); + let estimated_fee_no_change = + estimate_transaction_fee(&inputs, &[], None, true, true, feerate); + let prior = FundingContribution { + estimated_fee: estimated_fee_with_change, + inputs: inputs.clone(), + outputs: vec![], + change_output: Some(change.clone()), + feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + let delta = Amount::from_sat(change.value.to_sat() - dust_limit.to_sat() + 1); + let target_value_added = prior.value_added().checked_add(delta).unwrap(); + let total_input_value: Amount = inputs.iter().map(|input| input.utxo.output.value).sum(); + let remaining_change = total_input_value + .checked_sub(target_value_added.checked_add(estimated_fee_with_change).unwrap()) + .unwrap(); + assert_eq!(remaining_change.to_sat(), dust_limit.to_sat() - 1); + assert!( + total_input_value >= target_value_added.checked_add(estimated_fee_no_change).unwrap() + ); + + let builder = + FundingTemplate::new(None, None, Some(PriorContribution::new(prior, Amount::MAX))) + .with_prior_contribution(feerate, FeeRate::MAX); + let contribution = FundingBuilder(builder.0.add_value_inner(delta)).build().unwrap(); + + assert!(contribution.change_output.is_none()); + assert_eq!(contribution.inputs, inputs); + assert!(contribution.outputs.is_empty()); + assert_eq!(contribution.estimated_fee, estimated_fee_no_change); + assert_eq!( + contribution.value_added(), + total_input_value.checked_sub(estimated_fee_no_change).unwrap() + ); + assert!(contribution.value_added() > target_value_added); + } + + #[test] + fn test_funding_builder_remove_outputs_removes_all_matching_scripts() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let removed_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()); + let kept_script = ScriptBuf::new_p2wsh(&WScriptHash::all_zeros()); + let removed_output_1 = + TxOut { value: Amount::from_sat(10_000), script_pubkey: removed_script.clone() }; + let removed_output_2 = + TxOut { value: Amount::from_sat(12_000), script_pubkey: removed_script.clone() }; + let kept_output = TxOut { value: Amount::from_sat(15_000), script_pubkey: kept_script }; + + let contribution = + FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX) + .add_output(removed_output_1) + .add_output(kept_output.clone()) + .add_output(removed_output_2) + .remove_outputs(&removed_script) + .build() + .unwrap(); + + assert_eq!(contribution.outputs, vec![kept_output]); + } + + #[test] + fn test_funding_builder_add_and_remove_value_update_request() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let builder = + FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX) + .with_coin_selection_source_sync(UnreachableWallet) + .add_value(Amount::from_sat(20_000)) + .add_value(Amount::from_sat(5_000)) + .remove_value(Amount::from_sat(10_000)); + + let (_, must_pay_to) = builder.0.prepare_coin_selection_request().unwrap(); + assert_eq!(must_pay_to.len(), 1); + assert_eq!(must_pay_to[0].value, Amount::from_sat(15_000)); + } + + #[test] + fn test_coin_selection_request_funds_outputs_from_inputs() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let value_added = Amount::from_sat(15_000); + let output = funding_output_sats(8_000); + let input = funding_input_sats(50_000); + let change_template = funding_output_sats(1_000); + let estimated_fee = estimate_transaction_fee( + std::slice::from_ref(&input), + std::slice::from_ref(&output), + Some(&change_template), + true, + false, + feerate, + ); + let change_value = input.utxo.output.value - value_added - output.value - estimated_fee; + let wallet = MustPayToWallet { + utxo: input, + change_output: Some(TxOut { + value: change_value, + script_pubkey: change_template.script_pubkey, + }), + expected_must_pay_to_values: vec![output.value, value_added], + }; + + let contribution = + FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX) + .with_coin_selection_source_sync(wallet) + .add_value(value_added) + .add_output(output.clone()) + .build() + .unwrap(); + + assert_eq!(contribution.value_added(), value_added); + assert_eq!(contribution.outputs, vec![output]); + assert_eq!(contribution.change_output.as_ref().unwrap().value, change_value); + } + + #[test] + fn test_funding_builder_remove_value_saturates_at_zero() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let output = funding_output_sats(8_000); + let contribution = + FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX) + .with_coin_selection_source_sync(UnreachableWallet) + .add_value(Amount::from_sat(10_000)) + .remove_value(Amount::from_sat(15_000)) + .add_output(output.clone()) + .build() + .unwrap(); + + assert!(contribution.inputs.is_empty()); + assert_eq!(contribution.outputs, vec![output]); + assert!(contribution.change_output.is_none()); + assert_eq!(contribution.value_added(), Amount::ZERO); + } + #[test] fn test_build_funding_contribution_validates_max_money() { let over_max = Amount::MAX_MONEY + Amount::from_sat(1); @@ -1334,6 +2195,33 @@ mod tests { } } + #[test] + fn test_build_funding_contribution_rejects_oversized_prevtx() { + use crate::util::ser::Writeable; + + let feerate = FeeRate::from_sat_per_kwu(2000); + let prevtx = Transaction { + input: vec![], + output: vec![funding_output_sats(50_000); 2_200], + version: Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + }; + assert!(prevtx.serialized_length() > crate::ln::LN_MAX_MSG_LEN); + + let wallet = SingleUtxoWallet { + utxo: FundingTxInput::new_p2wpkh(prevtx, 0).unwrap(), + change_output: None, + }; + assert!(matches!( + FundingTemplate::new(None, None, None) + .with_prior_contribution(feerate, feerate) + .with_coin_selection_source_sync(wallet) + .add_value(Amount::from_sat(10_000)) + .build(), + Err(FundingContributionError::PrevTxTooLarge), + )); + } + #[test] fn test_for_acceptor_at_feerate_higher_change_adjusted() { // Splice-in: higher target feerate reduces the change output. From 9f9fe58bbefaaf7023980e068230cd79c8626bc9 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Fri, 3 Apr 2026 14:31:24 -0700 Subject: [PATCH 318/627] Replace FundingTemplate contribution methods with FundingBuilder This results in a slight change of behavior: now these methods reuse and amend the prior contribution, as opposed to always starting from a fresh contribution, which would be the desired expected behavior by users. --- lightning/src/ln/funding.rs | 753 ++++++++++++----------------- lightning/src/ln/splicing_tests.rs | 320 ++++++++++-- 2 files changed, 581 insertions(+), 492 deletions(-) diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index b628f5422cc..cb519b320f0 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -27,14 +27,15 @@ use crate::util::wallet_utils::{ CoinSelection, CoinSelectionSource, CoinSelectionSourceSync, Input, }; -/// Error returned when the acceptor's contribution cannot accommodate the initiator's proposed -/// feerate. +/// Error returned when a [`FundingContribution`] cannot be adjusted to a target feerate. /// -/// When building a [`FundingContribution`], fees are estimated at `min_feerate` assuming initiator -/// responsibility. If the counterparty also initiates a splice and wins the tie-break, they become -/// the initiator and choose the feerate. The fee is then re-estimated at the counterparty's -/// feerate for only our contributed inputs and outputs. When this re-estimation fails, the -/// contribution is dropped and the counterparty's splice proceeds without it. +/// This is used when re-estimating an already-built contribution at a different feerate than the +/// one used during coin selection. That includes, for example, acceptor-side adjustment to the +/// initiator's chosen feerate during splice tie-break resolution, as well as initiator-side +/// adjustment to a minimum RBF feerate for later attempts. +/// +/// Callers decide how to handle the failure. Depending on the context, they may drop the +/// contribution, wait and retry later, or abort the splice negotiation. /// /// See [`ChannelManager::splice_channel`] for further details. /// @@ -146,7 +147,7 @@ pub enum FundingContributionError { /// the builder fall back to fresh coin selection, which may replace the prior input set instead /// of preserving it. MissingCoinSelectionSource, - /// This is not an RBF scenario (no minimum RBF feerate available). + /// This template cannot build an RBF contribution. NotRbfScenario, } @@ -172,7 +173,7 @@ impl core::fmt::Display for FundingContributionError { write!(f, "Coin selection source required to build this contribution") }, FundingContributionError::NotRbfScenario => { - write!(f, "Not an RBF scenario (no minimum RBF feerate)") + write!(f, "This template cannot build an RBF contribution") }, } } @@ -181,13 +182,13 @@ impl core::fmt::Display for FundingContributionError { /// The user's prior contribution from a previous splice negotiation on this channel. /// /// When a pending splice exists with negotiated candidates, the prior contribution is -/// available for reuse (e.g., to bump the feerate via RBF). Contains the raw contribution and -/// the holder's balance for deferred feerate adjustment in [`FundingTemplate::rbf_sync`] or -/// [`FundingTemplate::rbf`]. +/// available for reuse. It stores the raw contribution together with the holder's balance for +/// deferred feerate adjustment when the contribution is later reused via +/// [`FundingTemplate::with_prior_contribution`] or [`FundingTemplate::rbf_prior_contribution`]. /// /// Use [`FundingTemplate::prior_contribution`] to inspect the prior contribution before -/// deciding whether to call [`FundingTemplate::rbf_sync`] or one of the splice methods -/// with different parameters. +/// deciding whether to reuse it or replace it with +/// [`FundingTemplate::without_prior_contribution`]. #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct PriorContribution { contribution: FundingContribution, @@ -219,29 +220,27 @@ impl PriorContribution { /// /// # Building a Contribution /// -/// For a fresh splice (no pending splice to replace), build a new contribution using one of -/// the splice methods: -/// - [`FundingTemplate::splice_in_sync`] to add funds to the channel -/// - [`FundingTemplate::splice_out`] to remove funds from the channel -/// - [`FundingTemplate::splice_in_and_out_sync`] to do both +/// For a fresh splice (no pending splice to replace), either use the convenience methods +/// [`FundingTemplate::splice_in_sync`] and [`FundingTemplate::splice_out`] or start with +/// [`FundingTemplate::without_prior_contribution`] to compose a request manually. /// -/// These require `min_feerate` and `max_feerate` parameters. The splice-in variants perform -/// coin selection when wallet inputs are needed, while splice-out spends only from the channel -/// balance. +/// The builder API supports adding value, adding withdrawal outputs, or both. Attach a wallet +/// when the request may need new wallet inputs; pure splice-out requests can be built without one +/// and pay fees from the channel balance. /// /// # Replace By Fee (RBF) /// -/// When a pending splice exists that hasn't been locked yet, use [`FundingTemplate::rbf_sync`] -/// (or [`FundingTemplate::rbf`] for async) to build an RBF contribution. This handles the -/// prior contribution logic internally — reusing an adjusted prior when possible, re-running -/// coin selection when needed, or creating a fee-bump-only contribution. +/// When a pending splice exists that hasn't been locked yet, use +/// [`FundingTemplate::rbf_prior_contribution_sync`] (or +/// [`FundingTemplate::rbf_prior_contribution`] for async) to retry the stored prior contribution +/// at an RBF-compatible feerate. To amend that prior request before building, start from +/// [`FundingTemplate::with_prior_contribution`] instead. /// /// Check [`FundingTemplate::min_rbf_feerate`] for the minimum feerate required (the greater of /// the previous feerate + 25 sat/kwu and the spec's 25/24 rule). Use -/// [`FundingTemplate::prior_contribution`] to inspect the prior -/// contribution's parameters (e.g., [`FundingContribution::value_added`], -/// [`FundingContribution::outputs`]) before deciding whether to reuse it via the RBF methods -/// or build a fresh contribution with different parameters using the splice methods above. +/// [`FundingTemplate::prior_contribution`] to inspect the stored contribution before deciding +/// whether to reuse it or replace it with a fresh request via +/// [`FundingTemplate::without_prior_contribution`]. /// /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel /// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed @@ -271,8 +270,8 @@ impl FundingTemplate { /// Returns the minimum RBF feerate, if this template is for an RBF attempt. /// - /// When set, the `min_feerate` passed to the splice methods (e.g., - /// [`FundingTemplate::splice_in_sync`]) must be at least this value. + /// When set, the `min_feerate` passed to the splice/builder methods must be at least this + /// value. pub fn min_rbf_feerate(&self) -> Option { self.min_rbf_feerate } @@ -280,16 +279,17 @@ impl FundingTemplate { /// Returns a reference to the prior contribution from a previous splice negotiation, if /// available. /// - /// Use this to inspect the prior contribution's parameters (e.g., - /// [`FundingContribution::value_added`], [`FundingContribution::outputs`]) before deciding - /// whether to reuse it via [`FundingTemplate::rbf_sync`] or build a fresh contribution - /// with different parameters using the splice methods. + /// Use this to inspect the prior contribution's current parameters (for example, + /// [`FundingContribution::outputs`], [`FundingContribution::change_output`], and + /// [`FundingContribution::net_value`]) before deciding + /// whether to reuse it via [`FundingTemplate::rbf_prior_contribution`] or build a fresh + /// contribution with different parameters using + /// [`FundingTemplate::without_prior_contribution`]. /// /// Note: the returned contribution may reflect a different feerate than originally provided, /// as it may have been adjusted for RBF or for the counterparty's feerate when acting as - /// the acceptor. This can change other parameters too (e.g., - /// [`FundingContribution::value_added`] may be higher if the change output was removed to - /// cover a higher fee). + /// the acceptor. This can change other parameters too; for example, the amount added to the + /// channel may increase if the change output was removed to cover a higher fee. pub fn prior_contribution(&self) -> Option<&FundingContribution> { self.prior_contribution.as_ref().map(|p| &p.contribution) } @@ -320,253 +320,84 @@ impl FundingTemplate { self.prior_contribution.take(); FundingBuilder::new(self, feerate, max_feerate) } -} - -macro_rules! build_funding_contribution { - ($value_added:expr, $outputs:expr, $shared_input:expr, $min_rbf_feerate:expr, $feerate:expr, $max_feerate:expr, $force_coin_selection:expr, $wallet:ident, $($await:tt)*) => {{ - let value_added: Amount = $value_added; - let outputs: Vec = $outputs; - let shared_input: Option = $shared_input; - let min_rbf_feerate: Option = $min_rbf_feerate; - let feerate: FeeRate = $feerate; - let max_feerate: FeeRate = $max_feerate; - let force_coin_selection: bool = $force_coin_selection; - - let _value_removed = validate_funding_contribution_params( - value_added, - &outputs, - min_rbf_feerate, - feerate, - max_feerate, - )?; - - let is_splice = shared_input.is_some(); - - let coin_selection = if value_added == Amount::ZERO && !force_coin_selection { - CoinSelection { confirmed_utxos: vec![], change_output: None } - } else { - // Used for creating a redeem script for the new funding txo, since the funding pubkeys - // are unknown at this point. Only needed when selecting which UTXOs to include in the - // funding tx that would be sufficient to pay for fees. Hence, the value doesn't matter. - let dummy_pubkey = PublicKey::from_slice(&[2; 33]).unwrap(); - - let shared_output = bitcoin::TxOut { - value: shared_input - .as_ref() - .map(|shared_input| shared_input.previous_utxo.value) - .unwrap_or(Amount::ZERO) - .checked_add(value_added) - .ok_or(FundingContributionError::InvalidSpliceValue)?, - script_pubkey: make_funding_redeemscript(&dummy_pubkey, &dummy_pubkey).to_p2wsh(), - }; - - let claim_id = None; - let must_spend = shared_input.map(|input| vec![input]).unwrap_or_default(); - if outputs.is_empty() { - let must_pay_to = &[shared_output]; - $wallet.select_confirmed_utxos(claim_id, must_spend, must_pay_to, feerate.to_sat_per_kwu() as u32, u64::MAX)$(.$await)*.map_err(|_| FundingContributionError::CoinSelectionFailed)? - } else { - let must_pay_to: Vec<_> = outputs.iter().cloned().chain(core::iter::once(shared_output)).collect(); - $wallet.select_confirmed_utxos(claim_id, must_spend, &must_pay_to, feerate.to_sat_per_kwu() as u32, u64::MAX)$(.$await)*.map_err(|_| FundingContributionError::CoinSelectionFailed)? - } - }; - - // NOTE: Must NOT fail after UTXO selection - - let CoinSelection { confirmed_utxos: inputs, change_output } = coin_selection; - - Ok(FundingContribution::new( - outputs, - inputs, - change_output, - feerate, - max_feerate, - is_splice, - )) - }}; -} - -fn validate_funding_contribution_params( - value_added: Amount, outputs: &[TxOut], min_rbf_feerate: Option, feerate: FeeRate, - max_feerate: FeeRate, -) -> Result { - if feerate > max_feerate { - return Err(FundingContributionError::FeeRateExceedsMaximum { feerate, max_feerate }); - } - - if let Some(min_rbf_feerate) = min_rbf_feerate { - if feerate < min_rbf_feerate { - return Err(FundingContributionError::FeeRateBelowRbfMinimum { - feerate, - min_rbf_feerate, - }); - } - } - // Validate user-provided amounts are within MAX_MONEY before coin selection to - // ensure FundingContribution::net_value() arithmetic cannot overflow. With all - // amounts bounded by MAX_MONEY (~2.1e15 sat), the worst-case net_value() - // computation is -2 * MAX_MONEY (~-4.2e15), well within i64::MIN (~-9.2e18). - if value_added > Amount::MAX_MONEY { - return Err(FundingContributionError::InvalidSpliceValue); - } - - let mut value_removed = Amount::ZERO; - for txout in outputs.iter() { - value_removed = match value_removed.checked_add(txout.value) { - Some(sum) if sum <= Amount::MAX_MONEY => sum, - _ => return Err(FundingContributionError::InvalidSpliceValue), - }; - } - - Ok(value_removed) -} - -impl FundingTemplate { - /// Creates a [`FundingContribution`] for adding funds to a channel using `wallet` to perform - /// coin selection. + /// Creates a [`FundingContribution`] for adding funds to a channel. + /// + /// This is a convenience wrapper around [`FundingTemplate::with_prior_contribution`]. As a + /// result, if this template carries a prior contribution, `value_added` is added on top of the + /// amount that prior request was already adding to the channel instead of replacing it. Use + /// [`FundingTemplate::without_prior_contribution`] if you want to replace the prior request + /// instead. /// - /// `value_added` is the total amount to add to the channel for this contribution. When - /// replacing a prior contribution via RBF, use [`FundingTemplate::prior_contribution`] to - /// inspect the prior parameters. To add funds on top of the prior contribution's amount, - /// combine them: `prior.value_added() + additional_amount`. + /// `value_added` is the amount of additional value to add to the channel. `min_feerate` is the + /// feerate used for fee estimation and, if needed, coin selection; when + /// [`FundingTemplate::min_rbf_feerate`] is set, it must be at least that value. `max_feerate` is + /// the highest feerate we are willing to tolerate if we end up as the acceptor, and must be at + /// least `min_feerate`. `wallet` is only consulted if the request cannot be satisfied by + /// reusing/amending the prior contribution. When this template carries a prior contribution, + /// increasing its value may therefore re-run coin selection and yield a different input set than + /// the prior contribution used. pub async fn splice_in( self, value_added: Amount, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, ) -> Result { - if value_added == Amount::ZERO { - return Err(FundingContributionError::InvalidSpliceValue); - } - let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; - build_funding_contribution!( - value_added, - vec![], - shared_input, - min_rbf_feerate, - min_feerate, - max_feerate, - false, - wallet, - await - ) + self.with_prior_contribution(min_feerate, max_feerate) + .with_coin_selection_source(wallet) + .add_value(value_added) + .build() + .await } - /// Creates a [`FundingContribution`] for adding funds to a channel using `wallet` to perform - /// coin selection. + /// Creates a [`FundingContribution`] for adding funds to a channel. /// - /// See [`FundingTemplate::splice_in`] for details. + /// This is the synchronous variant of [`FundingTemplate::splice_in`]; `value_added`, + /// `min_feerate`, `max_feerate`, and `wallet` have the same meaning. pub fn splice_in_sync( self, value_added: Amount, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, ) -> Result { - if value_added == Amount::ZERO { - return Err(FundingContributionError::InvalidSpliceValue); - } - let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; - build_funding_contribution!( - value_added, - vec![], - shared_input, - min_rbf_feerate, - min_feerate, - max_feerate, - false, - wallet, - ) + self.with_prior_contribution(min_feerate, max_feerate) + .with_coin_selection_source_sync(wallet) + .add_value(value_added) + .build() } /// Creates a [`FundingContribution`] for removing funds from a channel. /// - /// Fees are paid from the channel balance, so this does not perform coin selection or spend - /// wallet inputs. + /// This is a convenience wrapper around [`FundingTemplate::with_prior_contribution`] with no + /// wallet attached. For a fresh splice, fees are paid from the channel balance, so this does + /// not perform coin selection or spend wallet inputs. When a prior contribution is present, + /// `outputs` are appended to the prior [`FundingContribution::outputs`] instead of replacing + /// them. Use [`FundingTemplate::without_prior_contribution`] if you want to replace the prior + /// outputs instead. + /// + /// `outputs` are the additional withdrawal outputs to include. `min_feerate` is the feerate + /// used for fee estimation and must be at least [`FundingTemplate::min_rbf_feerate`] when that + /// is set. `max_feerate` is the highest feerate we are willing to tolerate if we end up as the + /// acceptor, and must be at least `min_feerate`. /// - /// `outputs` are the complete set of withdrawal outputs for this contribution. When - /// replacing a prior contribution via RBF, use [`FundingTemplate::prior_contribution`] to - /// inspect the prior parameters. To keep existing withdrawals and add new ones, include the - /// prior's outputs: combine [`FundingContribution::outputs`] with the new outputs. + /// If amending a prior contribution would require selecting new wallet inputs, this method + /// returns [`FundingContributionError::MissingCoinSelectionSource`]. This can happen, for + /// example, when the prior contribution was input-backed and its existing change output cannot + /// absorb the additional withdrawal outputs or the higher fee implied by `min_feerate`. In + /// that case, use the builder APIs with a coin selection source instead. pub fn splice_out( self, outputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, ) -> Result { - if outputs.is_empty() { - return Err(FundingContributionError::InvalidSpliceValue); - } - validate_funding_contribution_params( - Amount::ZERO, - &outputs, - self.min_rbf_feerate, - min_feerate, - max_feerate, - )?; - Ok(FundingContribution::new( - outputs, - vec![], - None, - min_feerate, - max_feerate, - self.shared_input.is_some(), - )) - } - - /// Creates a [`FundingContribution`] for both adding and removing funds from a channel using - /// `wallet` to perform coin selection. - /// - /// `value_added` and `outputs` are the complete parameters for this contribution, not - /// increments on top of a prior contribution. When replacing a prior contribution via RBF, - /// use [`FundingTemplate::prior_contribution`] to inspect the prior parameters and combine - /// them as needed. The withdrawal `outputs` are funded by the selected wallet inputs and do - /// not reduce the requested `value_added` to the channel. - pub async fn splice_in_and_out( - self, value_added: Amount, outputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, - wallet: W, - ) -> Result { - if value_added == Amount::ZERO && outputs.is_empty() { - return Err(FundingContributionError::InvalidSpliceValue); - } - let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; - build_funding_contribution!( - value_added, - outputs, - shared_input, - min_rbf_feerate, - min_feerate, - max_feerate, - false, - wallet, - await - ) - } - - /// Creates a [`FundingContribution`] for both adding and removing funds from a channel using - /// `wallet` to perform coin selection. - /// - /// See [`FundingTemplate::splice_in_and_out`] for details. - pub fn splice_in_and_out_sync( - self, value_added: Amount, outputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, - wallet: W, - ) -> Result { - if value_added == Amount::ZERO && outputs.is_empty() { - return Err(FundingContributionError::InvalidSpliceValue); - } - let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; - build_funding_contribution!( - value_added, - outputs, - shared_input, - min_rbf_feerate, - min_feerate, - max_feerate, - false, - wallet, - ) + self.with_prior_contribution(min_feerate, max_feerate).add_outputs(outputs).build() } /// Creates a [`FundingContribution`] for an RBF (Replace-By-Fee) attempt on a pending splice. /// - /// `max_feerate` is the maximum feerate the caller is willing to accept as acceptor. It is - /// used as the returned contribution's `max_feerate` and also constrains coin selection when - /// re-running it for prior contributions that cannot be adjusted or fee-bump-only - /// contributions. + /// This requires [`FundingTemplate::prior_contribution`] to be available. `feerate` overrides + /// the template's minimum RBF feerate; passing `None` uses + /// [`FundingTemplate::min_rbf_feerate`]. `max_feerate` is the highest feerate we are willing to + /// tolerate if we end up as the acceptor, and must be at least the effective feerate. `wallet` + /// is only consulted if the prior contribution cannot be reused or adjusted directly. The + /// chosen `max_feerate` is stored on the returned contribution so that any later acceptor-side + /// fee adjustment for that contribution remains capped at the caller's chosen maximum, even if + /// this RBF attempt had to fall back to a fresh coin selection. /// /// This handles the prior contribution logic internally: - /// - If the prior contribution's feerate can be adjusted to the minimum RBF feerate, the + /// - If the prior contribution's feerate can be adjusted to the effective target feerate, the /// adjusted contribution is returned directly. For splice-in, the change output absorbs /// the fee difference. For splice-out (no wallet inputs), the holder's channel balance /// covers the higher fees. @@ -581,117 +412,41 @@ impl FundingTemplate { /// /// # Errors /// - /// Returns a [`FundingContributionError`] if this is not an RBF scenario, if `max_feerate` - /// is below the minimum RBF feerate, or if coin selection fails. - pub async fn rbf( - self, max_feerate: FeeRate, wallet: W, + /// Returns a [`FundingContributionError`] if there is no reusable prior contribution, if no + /// effective RBF feerate is available, if the effective feerate violates the template's fee + /// constraints, or if coin selection fails. + pub async fn rbf_prior_contribution( + self, feerate: Option, max_feerate: FeeRate, wallet: W, ) -> Result { - let FundingTemplate { shared_input, min_rbf_feerate, prior_contribution } = self; - let rbf_feerate = min_rbf_feerate.ok_or(FundingContributionError::NotRbfScenario)?; - if rbf_feerate > max_feerate { - return Err(FundingContributionError::FeeRateExceedsMaximum { - feerate: rbf_feerate, - max_feerate, - }); - } - - match prior_contribution { - Some(PriorContribution { contribution, holder_balance }) => { - // Try to adjust the prior contribution to the RBF feerate. This fails if - // the holder balance can't cover the adjustment (splice-out) or the fee - // buffer is insufficient (splice-in), or if the prior's feerate is already - // above rbf_feerate (e.g., from a counterparty-initiated RBF that locked - // at a higher feerate). In all cases, fall through to re-run coin selection. - if contribution - .net_value_for_initiator_at_feerate(rbf_feerate, holder_balance) - .is_ok() - { - let mut adjusted = contribution - .for_initiator_at_feerate(rbf_feerate, holder_balance) - .expect("feerate compatibility already checked"); - adjusted.max_feerate = max_feerate; - return Ok(adjusted); - } - build_funding_contribution!( - contribution.value_added(), - contribution.outputs, - shared_input, - min_rbf_feerate, - rbf_feerate, - max_feerate, - true, - wallet, - await - ) - }, - None => { - build_funding_contribution!( - Amount::ZERO, - vec![], - shared_input, - min_rbf_feerate, - rbf_feerate, - max_feerate, - true, - wallet, - await - ) - }, + if self.prior_contribution().is_none() { + return Err(FundingContributionError::NotRbfScenario); } + let feerate = feerate + .or_else(|| self.min_rbf_feerate()) + .ok_or(FundingContributionError::NotRbfScenario)?; + self.with_prior_contribution(feerate, max_feerate) + .with_coin_selection_source(wallet) + .build() + .await } /// Creates a [`FundingContribution`] for an RBF (Replace-By-Fee) attempt on a pending splice. /// - /// See [`FundingTemplate::rbf`] for details. - pub fn rbf_sync( - self, max_feerate: FeeRate, wallet: W, + /// This is the synchronous variant of [`FundingTemplate::rbf_prior_contribution`]; `feerate`, + /// `max_feerate`, and `wallet` have the same meaning. + pub fn rbf_prior_contribution_sync( + self, feerate: Option, max_feerate: FeeRate, wallet: W, ) -> Result { - let FundingTemplate { shared_input, min_rbf_feerate, prior_contribution } = self; - let rbf_feerate = min_rbf_feerate.ok_or(FundingContributionError::NotRbfScenario)?; - if rbf_feerate > max_feerate { - return Err(FundingContributionError::FeeRateExceedsMaximum { - feerate: rbf_feerate, - max_feerate, - }); + if self.prior_contribution().is_none() { + return Err(FundingContributionError::NotRbfScenario); } + let feerate = feerate + .or_else(|| self.min_rbf_feerate()) + .ok_or(FundingContributionError::NotRbfScenario)?; - match prior_contribution { - Some(PriorContribution { contribution, holder_balance }) => { - // See comment in `rbf` for details on when this adjustment fails. - if contribution - .net_value_for_initiator_at_feerate(rbf_feerate, holder_balance) - .is_ok() - { - let mut adjusted = contribution - .for_initiator_at_feerate(rbf_feerate, holder_balance) - .expect("feerate compatibility already checked"); - adjusted.max_feerate = max_feerate; - return Ok(adjusted); - } - build_funding_contribution!( - contribution.value_added(), - contribution.outputs, - shared_input, - min_rbf_feerate, - rbf_feerate, - max_feerate, - true, - wallet, - ) - }, - None => { - build_funding_contribution!( - Amount::ZERO, - vec![], - shared_input, - min_rbf_feerate, - rbf_feerate, - max_feerate, - true, - wallet, - ) - }, - } + self.with_prior_contribution(feerate, max_feerate) + .with_coin_selection_source_sync(wallet) + .build() } } @@ -823,26 +578,6 @@ impl_writeable_tlv_based!(FundingContribution, { }); impl FundingContribution { - fn new( - outputs: Vec, inputs: Vec, change_output: Option, - feerate: FeeRate, max_feerate: FeeRate, is_splice: bool, - ) -> Self { - // The caller creating a FundingContribution is always the initiator for fee estimation - // purposes — this is conservative, overestimating rather than underestimating fees if the - // node ends up as the acceptor. - let estimated_fee = estimate_transaction_fee( - &inputs, - &outputs, - change_output.as_ref(), - true, - is_splice, - feerate, - ); - debug_assert!(estimated_fee <= Amount::MAX_MONEY); - - Self { estimated_fee, inputs, outputs, change_output, feerate, max_feerate, is_splice } - } - pub(super) fn feerate(&self) -> FeeRate { self.feerate } @@ -1510,6 +1245,15 @@ impl FundingBuilder { FundingBuilder(self.0.add_output_inner(output)) } + /// Adds withdrawal outputs to the request. + /// + /// `outputs` are appended to the current set of explicit outputs. If the builder was seeded + /// from a prior contribution, this adds additional withdrawals on top of the prior outputs. + /// This does not affect any change output derived when the contribution is built. + pub fn add_outputs(self, outputs: Vec) -> Self { + FundingBuilder(self.0.add_outputs_inner(outputs)) + } + /// Removes all explicit withdrawal outputs whose script pubkey matches `script_pubkey`. /// /// This only affects outputs returned by [`FundingContribution::outputs`]; it never removes the @@ -1562,6 +1306,11 @@ impl FundingBuilderInner { self } + fn add_outputs_inner(mut self, outputs: Vec) -> Self { + self.outputs.extend(outputs); + self + } + fn remove_outputs_inner(mut self, script_pubkey: &ScriptBuf) -> Self { self.outputs.retain(|output| output.script_pubkey != *script_pubkey); self @@ -1590,6 +1339,15 @@ impl AsyncFundingBuilder { AsyncFundingBuilder(self.0.add_output_inner(output)) } + /// Adds withdrawal outputs to the request. + /// + /// `outputs` are appended to the current set of explicit outputs. If the builder was seeded + /// from a prior contribution, this adds additional withdrawals on top of the prior outputs. + /// This does not affect any change output derived when the contribution is built. + pub fn add_outputs(self, outputs: Vec) -> Self { + AsyncFundingBuilder(self.0.add_outputs_inner(outputs)) + } + /// Removes all explicit withdrawal outputs whose script pubkey matches `script_pubkey`. /// /// This only affects outputs returned by [`FundingContribution::outputs`]; it never removes the @@ -1681,6 +1439,15 @@ impl SyncFundingBuilder { SyncFundingBuilder(self.0.add_output_inner(output)) } + /// Adds withdrawal outputs to the request. + /// + /// `outputs` are appended to the current set of explicit outputs. If the builder was seeded + /// from a prior contribution, this adds additional withdrawals on top of the prior outputs. + /// This does not affect any change output derived when the contribution is built. + pub fn add_outputs(self, outputs: Vec) -> Self { + SyncFundingBuilder(self.0.add_outputs_inner(outputs)) + } + /// Removes all explicit withdrawal outputs whose script pubkey matches `script_pubkey`. /// /// This only affects outputs returned by [`FundingContribution::outputs`]; it never removes the @@ -2132,38 +1899,38 @@ mod tests { Err(FundingContributionError::InvalidSpliceValue), )); } + } - // splice_in_and_out_sync with value_added > MAX_MONEY - { - let template = FundingTemplate::new(None, None, None); - let outputs = vec![funding_output_sats(1_000)]; - assert!(matches!( - template.splice_in_and_out_sync( - over_max, - outputs, - feerate, - feerate, - UnreachableWallet - ), - Err(FundingContributionError::InvalidSpliceValue), - )); - } + #[test] + fn test_funding_builder_validates_mixed_request_max_money() { + let over_max = Amount::MAX_MONEY + Amount::from_sat(1); + let feerate = FeeRate::from_sat_per_kwu(2000); - // splice_in_and_out_sync with output sum > MAX_MONEY - { - let template = FundingTemplate::new(None, None, None); - let outputs = vec![funding_output_sats(over_max.to_sat())]; - assert!(matches!( - template.splice_in_and_out_sync( - Amount::from_sat(1_000), - outputs, - feerate, - feerate, - UnreachableWallet, - ), - Err(FundingContributionError::InvalidSpliceValue), - )); - } + // Mixed add/remove request with value_added > MAX_MONEY. + assert!(matches!( + FundingTemplate::new(None, None, None) + .without_prior_contribution(feerate, feerate) + .with_coin_selection_source_sync(UnreachableWallet) + .add_value(over_max) + .add_outputs(vec![funding_output_sats(1_000)]) + .build(), + Err(FundingContributionError::InvalidSpliceValue), + )); + + // Mixed add/remove request with outputs summing > MAX_MONEY. + let half_over = Amount::MAX_MONEY / 2 + Amount::from_sat(1); + assert!(matches!( + FundingTemplate::new(None, None, None) + .without_prior_contribution(feerate, feerate) + .with_coin_selection_source_sync(UnreachableWallet) + .add_value(Amount::from_sat(1_000)) + .add_outputs(vec![ + funding_output_sats(half_over.to_sat()), + funding_output_sats(half_over.to_sat()), + ]) + .build(), + Err(FundingContributionError::InvalidSpliceValue), + )); } #[test] @@ -2934,9 +2701,9 @@ mod tests { } #[test] - fn test_rbf_sync_rejects_max_feerate_below_min_rbf_feerate() { - // When the caller's max_feerate is below the minimum RBF feerate, rbf_sync should - // return Err(()). + fn test_rbf_rejects_max_feerate_below_min_rbf_feerate() { + // When the caller's max_feerate is below the minimum RBF feerate, + // rbf_prior_contribution_sync should return an error. let prior_feerate = FeeRate::from_sat_per_kwu(2000); let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025); let max_feerate = FeeRate::from_sat_per_kwu(2020); @@ -2958,15 +2725,16 @@ mod tests { Some(PriorContribution::new(prior, Amount::MAX)), ); assert!(matches!( - template.rbf_sync(max_feerate, UnreachableWallet), + template.rbf_prior_contribution_sync(None, max_feerate, UnreachableWallet), Err(FundingContributionError::FeeRateExceedsMaximum { .. }), )); } #[test] - fn test_rbf_sync_adjusts_prior_to_rbf_feerate() { + fn test_rbf_adjusts_prior_to_rbf_feerate() { // When the prior contribution's feerate is below the minimum RBF feerate and holder - // balance is available, rbf_sync should adjust the prior to the RBF feerate. + // balance is available, rbf_prior_contribution_sync should adjust the prior to the + // RBF feerate. let prior_feerate = FeeRate::from_sat_per_kwu(2000); let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025); let max_feerate = FeeRate::from_sat_per_kwu(5000); @@ -2991,11 +2759,109 @@ mod tests { Some(min_rbf_feerate), Some(PriorContribution::new(prior, Amount::MAX)), ); - let contribution = template.rbf_sync(max_feerate, UnreachableWallet).unwrap(); + let contribution = + template.rbf_prior_contribution_sync(None, max_feerate, UnreachableWallet).unwrap(); assert_eq!(contribution.feerate, min_rbf_feerate); assert_eq!(contribution.max_feerate, max_feerate); } + #[test] + fn test_rbf_uses_explicit_override_feerate() { + let prior_feerate = FeeRate::from_sat_per_kwu(2000); + let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025); + let override_feerate = FeeRate::from_sat_per_kwu(2100); + let max_feerate = FeeRate::from_sat_per_kwu(5000); + + let inputs = vec![funding_input_sats(100_000)]; + let change = funding_output_sats(10_000); + let estimated_fee = + estimate_transaction_fee(&inputs, &[], Some(&change), true, true, prior_feerate); + + let prior = FundingContribution { + estimated_fee, + inputs, + outputs: vec![], + change_output: Some(change), + feerate: prior_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + let template = FundingTemplate::new( + None, + Some(min_rbf_feerate), + Some(PriorContribution::new(prior, Amount::MAX)), + ); + let contribution = template + .rbf_prior_contribution_sync(Some(override_feerate), max_feerate, UnreachableWallet) + .unwrap(); + assert_eq!(contribution.feerate, override_feerate); + assert_eq!(contribution.max_feerate, max_feerate); + } + + #[test] + fn test_rbf_rejects_explicit_override_below_min_rbf_feerate() { + let prior_feerate = FeeRate::from_sat_per_kwu(2000); + let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025); + let override_feerate = FeeRate::from_sat_per_kwu(2024); + + let prior = FundingContribution { + estimated_fee: Amount::from_sat(1_000), + inputs: vec![funding_input_sats(100_000)], + outputs: vec![], + change_output: None, + feerate: prior_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + let template = FundingTemplate::new( + None, + Some(min_rbf_feerate), + Some(PriorContribution::new(prior, Amount::MAX)), + ); + assert!(matches!( + template.rbf_prior_contribution_sync( + Some(override_feerate), + FeeRate::MAX, + UnreachableWallet, + ), + Err(FundingContributionError::FeeRateBelowRbfMinimum { .. }), + )); + } + + #[test] + fn test_rbf_rejects_explicit_override_above_max_feerate() { + let prior_feerate = FeeRate::from_sat_per_kwu(2000); + let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025); + let override_feerate = FeeRate::from_sat_per_kwu(2100); + let max_feerate = FeeRate::from_sat_per_kwu(2099); + + let prior = FundingContribution { + estimated_fee: Amount::from_sat(1_000), + inputs: vec![funding_input_sats(100_000)], + outputs: vec![], + change_output: None, + feerate: prior_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + let template = FundingTemplate::new( + None, + Some(min_rbf_feerate), + Some(PriorContribution::new(prior, Amount::MAX)), + ); + assert!(matches!( + template.rbf_prior_contribution_sync( + Some(override_feerate), + max_feerate, + UnreachableWallet, + ), + Err(FundingContributionError::FeeRateExceedsMaximum { .. }), + )); + } + /// A mock wallet that returns a single UTXO for coin selection. struct SingleUtxoWallet { utxo: FundingTxInput, @@ -3029,10 +2895,10 @@ mod tests { } #[test] - fn test_rbf_sync_unadjusted_splice_out_runs_coin_selection() { + fn test_rbf_unadjusted_splice_out_runs_coin_selection() { // When the prior contribution's feerate is below the minimum RBF feerate and no - // holder balance is available, rbf_sync should run coin selection to add inputs that - // cover the higher RBF fee. + // holder balance is available, rbf_prior_contribution_sync should run coin selection to + // add inputs that cover the higher RBF fee. let prior_feerate = FeeRate::from_sat_per_kwu(2000); let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025); let withdrawal = funding_output_sats(20_000); @@ -3058,8 +2924,10 @@ mod tests { change_output: Some(funding_output_sats(25_000)), }; - // rbf_sync should succeed and the contribution should have inputs from coin selection. - let contribution = template.rbf_sync(FeeRate::MAX, &wallet).unwrap(); + // rbf_prior_contribution_sync should succeed and the contribution should have inputs from + // coin selection. + let contribution = + template.rbf_prior_contribution_sync(None, FeeRate::MAX, &wallet).unwrap(); assert!(!contribution.inputs.is_empty(), "coin selection should have added inputs"); assert!(contribution.value_added() > Amount::ZERO); assert_eq!(contribution.outputs, vec![withdrawal]); @@ -3067,31 +2935,10 @@ mod tests { } #[test] - fn test_rbf_sync_no_prior_fee_bump_only_runs_coin_selection() { - // When there is no prior contribution (e.g., acceptor), rbf_sync should run coin - // selection to add inputs for a fee-bump-only contribution. - let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025); - - let template = - FundingTemplate::new(Some(shared_input(100_000)), Some(min_rbf_feerate), None); - - let wallet = SingleUtxoWallet { - utxo: funding_input_sats(50_000), - change_output: Some(funding_output_sats(45_000)), - }; - - let contribution = template.rbf_sync(FeeRate::MAX, &wallet).unwrap(); - assert!(!contribution.inputs.is_empty(), "coin selection should have added inputs"); - assert!(contribution.value_added() > Amount::ZERO); - assert!(contribution.outputs.is_empty()); - assert_eq!(contribution.feerate, min_rbf_feerate); - } - - #[test] - fn test_rbf_sync_unadjusted_uses_callers_max_feerate() { + fn test_rbf_unadjusted_uses_callers_max_feerate() { // When the prior contribution's feerate is below the minimum RBF feerate and no - // holder balance is available, rbf_sync should use the caller's max_feerate (not the - // prior's) for the resulting contribution. + // holder balance is available, rbf_prior_contribution_sync should use the caller's + // max_feerate (not the prior's) for the resulting contribution. let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025); let prior_max_feerate = FeeRate::from_sat_per_kwu(50_000); let callers_max_feerate = FeeRate::from_sat_per_kwu(10_000); @@ -3118,7 +2965,8 @@ mod tests { change_output: Some(funding_output_sats(25_000)), }; - let contribution = template.rbf_sync(callers_max_feerate, &wallet).unwrap(); + let contribution = + template.rbf_prior_contribution_sync(None, callers_max_feerate, &wallet).unwrap(); assert_eq!( contribution.max_feerate, callers_max_feerate, "should use caller's max_feerate, not prior's" @@ -3127,8 +2975,9 @@ mod tests { #[test] fn test_splice_out_skips_coin_selection_during_rbf() { - // When splice_out_sync is called on a template with min_rbf_feerate set (user - // choosing a fresh splice-out instead of rbf_sync), coin selection should NOT run. + // When splice_out is called on a template with min_rbf_feerate set (user choosing a + // fresh splice-out instead of rbf_prior_contribution_sync), coin selection should NOT + // run. // Fees come from the channel balance. let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025); let feerate = FeeRate::from_sat_per_kwu(2025); diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 6cec7a443a3..1d4da398b54 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -20,7 +20,7 @@ use crate::ln::channel::{ }; use crate::ln::channelmanager::{provided_init_features, PaymentId, BREAKDOWN_TIMEOUT}; use crate::ln::functional_test_utils::*; -use crate::ln::funding::FundingContribution; +use crate::ln::funding::{FundingContribution, FundingContributionError}; use crate::ln::msgs::{self, BaseMessageHandler, ChannelMessageHandler, MessageSendEvent}; use crate::ln::outbound_payment::RecipientOnionFields; use crate::ln::types::ChannelId; @@ -41,7 +41,7 @@ use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use bitcoin::transaction::Version; use bitcoin::{ Amount, FeeRate, OutPoint as BitcoinOutPoint, Psbt, ScriptBuf, Transaction, TxOut, Txid, - WPubkeyHash, + WPubkeyHash, WScriptHash, }; #[test] @@ -221,7 +221,11 @@ pub fn do_initiate_rbf_splice_in_and_out<'a, 'b, 'c, 'd>( let funding_template = node.node.splice_channel(&channel_id, &node_id_counterparty).unwrap(); let wallet = WalletSync::new(Arc::clone(&node.wallet_source), node.logger); let funding_contribution = funding_template - .splice_in_and_out_sync(value_added, outputs, feerate, FeeRate::MAX, &wallet) + .without_prior_contribution(feerate, FeeRate::MAX) + .with_coin_selection_source_sync(&wallet) + .add_value(value_added) + .add_outputs(outputs) + .build() .unwrap(); node.node .funding_contributed(&channel_id, &node_id_counterparty, funding_contribution.clone(), None) @@ -269,7 +273,11 @@ pub fn do_initiate_splice_in_and_out<'a, 'b, 'c, 'd>( let feerate = funding_template.min_rbf_feerate().unwrap_or(floor_feerate); let wallet = WalletSync::new(Arc::clone(&initiator.wallet_source), initiator.logger); let funding_contribution = funding_template - .splice_in_and_out_sync(value_added, outputs, feerate, FeeRate::MAX, &wallet) + .without_prior_contribution(feerate, FeeRate::MAX) + .with_coin_selection_source_sync(&wallet) + .add_value(value_added) + .add_outputs(outputs) + .build() .unwrap(); initiator .node @@ -3576,7 +3584,7 @@ fn test_funding_contributed_splice_already_pending() { let splice_in_amount = Amount::from_sat(20_000); provide_utxo_reserves(&nodes, 2, splice_in_amount * 2); - // Use splice_in_and_out with an output so we can test output filtering + // Use the contribution builder with an output so we can test output filtering let first_splice_out = TxOut { value: Amount::from_sat(5_000), script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_raw_hash(Hash::all_zeros())), @@ -3585,13 +3593,11 @@ fn test_funding_contributed_splice_already_pending() { let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); let first_contribution = funding_template - .splice_in_and_out_sync( - splice_in_amount, - vec![first_splice_out.clone()], - feerate, - FeeRate::MAX, - &wallet, - ) + .with_prior_contribution(feerate, FeeRate::MAX) + .with_coin_selection_source_sync(&wallet) + .add_value(splice_in_amount) + .add_output(first_splice_out.clone()) + .build() .unwrap(); // Initiate a second splice with a DIFFERENT output to test that different outputs @@ -3613,13 +3619,11 @@ fn test_funding_contributed_splice_already_pending() { let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); let second_contribution = funding_template - .splice_in_and_out_sync( - splice_in_amount, - vec![second_splice_out.clone()], - feerate, - FeeRate::MAX, - &wallet, - ) + .without_prior_contribution(feerate, FeeRate::MAX) + .with_coin_selection_source_sync(&wallet) + .add_value(splice_in_amount) + .add_output(second_splice_out.clone()) + .build() .unwrap(); // First funding_contributed - this sets up the quiescent action @@ -5429,7 +5433,8 @@ fn test_splice_rbf_after_counterparty_rbf_aborted() { nodes[0].node.get_and_clear_pending_events(); nodes[1].node.get_and_clear_pending_events(); - // Step 5: Node 1 initiates its own RBF via splice_channel → rbf_sync. + // Step 5: Node 1 initiates its own RBF via splice_channel → + // rbf_prior_contribution_sync. // The prior contribution's feerate is restored to the original floor feerate, not the // RBF-adjusted feerate. provide_utxo_reserves(&nodes, 2, added_value * 2); @@ -5443,7 +5448,8 @@ fn test_splice_rbf_after_counterparty_rbf_aborted() { ); let wallet = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); - let rbf_contribution = funding_template.rbf_sync(FeeRate::MAX, &wallet); + let rbf_contribution = + funding_template.rbf_prior_contribution_sync(None, FeeRate::MAX, &wallet); assert!(rbf_contribution.is_ok()); } @@ -5644,6 +5650,235 @@ fn test_splice_rbf_sequential() { ); } +#[test] +fn test_splice_rbf_amends_prior_net_positive_contribution_request() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); + + let initial_added_value = Amount::from_sat(100_000); + let half_added_value = Amount::from_sat(initial_added_value.to_sat() / 2); + provide_utxo_reserves(&nodes, 1, Amount::from_sat(250_000)); + + let initial_contribution = + do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, initial_added_value); + let (initial_inputs, _) = initial_contribution.clone().into_contributed_inputs_and_outputs(); + let (splice_tx_0, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, initial_contribution.clone()); + + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let first_output = TxOut { + value: Amount::from_sat(10_000), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_raw_hash(Hash::all_zeros())), + }; + let second_output = TxOut { + value: Amount::from_sat(15_000), + script_pubkey: ScriptBuf::new_p2wsh(&WScriptHash::all_zeros()), + }; + + let run_rbf_round = |contribution: FundingContribution| { + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, contribution.clone(), None) + .unwrap(); + complete_rbf_handshake(&nodes[0], &nodes[1]); + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + contribution, + new_funding_script.clone(), + ); + let (tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + assert!(splice_locked.is_none()); + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + tx + }; + + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + assert!(funding_template.prior_contribution().unwrap().outputs().is_empty()); + let rbf_feerate = funding_template.min_rbf_feerate().unwrap(); + let contribution_1 = funding_template + .splice_out(vec![first_output.clone(), second_output.clone()], rbf_feerate, FeeRate::MAX) + .unwrap(); + let (inputs_1, _) = contribution_1.clone().into_contributed_inputs_and_outputs(); + assert_eq!(inputs_1, initial_inputs); + assert_eq!(contribution_1.outputs(), &[first_output.clone(), second_output.clone()]); + assert_eq!(contribution_1.net_value(), initial_contribution.net_value()); + assert!( + contribution_1.change_output().unwrap().value + < initial_contribution.change_output().unwrap().value + ); + let splice_tx_1 = run_rbf_round(contribution_1.clone()); + + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + assert_eq!(funding_template.prior_contribution().unwrap().outputs(), contribution_1.outputs()); + let rbf_feerate = funding_template.min_rbf_feerate().unwrap(); + let contribution_2 = funding_template + .with_prior_contribution(rbf_feerate, FeeRate::MAX) + .with_coin_selection_source_sync(&wallet) + .remove_value(half_added_value) + .build() + .unwrap(); + let (inputs_2, _) = contribution_2.clone().into_contributed_inputs_and_outputs(); + assert_eq!(inputs_2, initial_inputs); + assert_eq!(contribution_2.outputs(), contribution_1.outputs()); + assert!(contribution_2.net_value() < contribution_1.net_value()); + let splice_tx_2 = run_rbf_round(contribution_2.clone()); + + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + assert_eq!(funding_template.prior_contribution().unwrap().outputs(), contribution_2.outputs()); + let rbf_feerate = funding_template.min_rbf_feerate().unwrap(); + let contribution_3 = funding_template + .with_prior_contribution(rbf_feerate, FeeRate::MAX) + .remove_outputs(&first_output.script_pubkey) + .build() + .unwrap(); + let (inputs_3, _) = contribution_3.clone().into_contributed_inputs_and_outputs(); + assert_eq!(inputs_3, initial_inputs); + assert_eq!(contribution_3.outputs(), std::slice::from_ref(&second_output)); + assert_eq!(contribution_3.net_value(), contribution_2.net_value()); + assert!( + contribution_3.change_output().unwrap().value + > contribution_2.change_output().unwrap().value + ); + let splice_tx_3 = run_rbf_round(contribution_3.clone()); + + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + assert_eq!(funding_template.prior_contribution().unwrap().outputs(), contribution_3.outputs()); + let contribution_4 = + funding_template.rbf_prior_contribution_sync(None, FeeRate::MAX, &wallet).unwrap(); + let (inputs_4, _) = contribution_4.clone().into_contributed_inputs_and_outputs(); + assert_eq!(inputs_4, initial_inputs); + assert_eq!(contribution_4.outputs(), contribution_3.outputs()); + assert_eq!(contribution_4.net_value(), contribution_3.net_value()); + assert!( + contribution_4.change_output().unwrap().value + < contribution_3.change_output().unwrap().value + ); + let rbf_tx_final = run_rbf_round(contribution_4); + + lock_rbf_splice_after_blocks( + &nodes[0], + &nodes[1], + &rbf_tx_final, + ANTI_REORG_DELAY - 1, + &[ + splice_tx_0.compute_txid(), + splice_tx_1.compute_txid(), + splice_tx_2.compute_txid(), + splice_tx_3.compute_txid(), + ], + ); +} + +#[test] +fn test_splice_rbf_amends_prior_net_negative_contribution_request() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); + + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let first_output = TxOut { + value: Amount::from_sat(10_000), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_raw_hash(Hash::all_zeros())), + }; + let second_output = TxOut { + value: Amount::from_sat(15_000), + script_pubkey: ScriptBuf::new_p2wsh(&WScriptHash::all_zeros()), + }; + + let initial_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, vec![first_output.clone()]).unwrap(); + let (initial_inputs, _) = initial_contribution.clone().into_contributed_inputs_and_outputs(); + assert!(initial_inputs.is_empty()); + let (splice_tx_0, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, initial_contribution.clone()); + + let run_rbf_round = |contribution: FundingContribution| { + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, contribution.clone(), None) + .unwrap(); + complete_rbf_handshake(&nodes[0], &nodes[1]); + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + contribution, + new_funding_script.clone(), + ); + let (tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + assert!(splice_locked.is_none()); + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + tx + }; + + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + assert_eq!( + funding_template.prior_contribution().unwrap().outputs(), + std::slice::from_ref(&first_output), + ); + let rbf_feerate = funding_template.min_rbf_feerate().unwrap(); + let contribution_1 = funding_template + .splice_out(vec![second_output.clone()], rbf_feerate, FeeRate::MAX) + .unwrap(); + let (inputs_1, _) = contribution_1.clone().into_contributed_inputs_and_outputs(); + assert!(inputs_1.is_empty()); + assert_eq!(contribution_1.outputs(), &[first_output.clone(), second_output.clone()]); + assert!(contribution_1.net_value() < initial_contribution.net_value()); + let splice_tx_1 = run_rbf_round(contribution_1.clone()); + + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + assert_eq!(funding_template.prior_contribution().unwrap().outputs(), contribution_1.outputs()); + let rbf_feerate = funding_template.min_rbf_feerate().unwrap(); + let contribution_2 = funding_template + .with_prior_contribution(rbf_feerate, FeeRate::MAX) + .remove_outputs(&first_output.script_pubkey) + .build() + .unwrap(); + let (inputs_2, _) = contribution_2.clone().into_contributed_inputs_and_outputs(); + assert!(inputs_2.is_empty()); + assert_eq!(contribution_2.outputs(), std::slice::from_ref(&second_output)); + assert!(contribution_2.net_value() > contribution_1.net_value()); + let splice_tx_2 = run_rbf_round(contribution_2.clone()); + + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + assert_eq!(funding_template.prior_contribution().unwrap().outputs(), contribution_2.outputs()); + let contribution_3 = + funding_template.rbf_prior_contribution_sync(None, FeeRate::MAX, &wallet).unwrap(); + let (inputs_3, _) = contribution_3.clone().into_contributed_inputs_and_outputs(); + assert!(inputs_3.is_empty()); + assert_eq!(contribution_3.outputs(), contribution_2.outputs()); + assert!(contribution_3.net_value() < contribution_2.net_value()); + assert!(contribution_3.change_output().is_none()); + let rbf_tx_final = run_rbf_round(contribution_3); + + lock_rbf_splice_after_blocks( + &nodes[0], + &nodes[1], + &rbf_tx_final, + ANTI_REORG_DELAY - 1, + &[splice_tx_0.compute_txid(), splice_tx_1.compute_txid(), splice_tx_2.compute_txid()], + ); +} + #[test] fn test_splice_rbf_acceptor_contributes_then_disconnects() { // When both nodes contribute to a splice and the initiator RBFs (with the acceptor @@ -5896,9 +6131,9 @@ fn test_splice_channel_with_pending_splice_includes_rbf_floor() { assert_eq!(funding_template.min_rbf_feerate(), Some(expected_floor)); assert!(funding_template.prior_contribution().is_some()); - // rbf_sync returns the Adjusted prior contribution directly. + // rbf_prior_contribution_sync returns the adjusted prior contribution directly. let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - assert!(funding_template.rbf_sync(FeeRate::MAX, &wallet).is_ok()); + assert!(funding_template.rbf_prior_contribution_sync(None, FeeRate::MAX, &wallet).is_ok()); } #[test] @@ -6099,8 +6334,8 @@ fn test_funding_contributed_rbf_adjustment_insufficient_budget() { #[test] fn test_prior_contribution_unadjusted_when_max_feerate_too_low() { - // Test that rbf_sync re-runs coin selection when the prior contribution's max_feerate is - // too low to accommodate the minimum RBF feerate. + // Test that rbf_prior_contribution_sync re-runs coin selection when the prior + // contribution's max_feerate is too low to accommodate the minimum RBF feerate. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); @@ -6130,13 +6365,13 @@ fn test_prior_contribution_unadjusted_when_max_feerate_too_low() { let (_splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); // Call splice_channel again — the minimum RBF feerate (floor + 25 sat/kwu) exceeds the prior - // contribution's max_feerate (floor), so adjustment fails. rbf_sync re-runs coin selection - // with the caller's max_feerate. + // contribution's max_feerate (floor), so adjustment fails. + // rbf_prior_contribution_sync re-runs coin selection with the caller's max_feerate. let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); assert!(funding_template.min_rbf_feerate().is_some()); assert!(funding_template.prior_contribution().is_some()); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - assert!(funding_template.rbf_sync(FeeRate::MAX, &wallet).is_ok()); + assert!(funding_template.rbf_prior_contribution_sync(None, FeeRate::MAX, &wallet).is_ok()); } #[test] @@ -6177,17 +6412,19 @@ fn test_splice_channel_during_negotiation_includes_rbf_feerate() { let expected_floor = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25); assert_eq!(template.min_rbf_feerate(), Some(expected_floor)); - // No prior contribution since there are no negotiated candidates yet. rbf_sync runs - // fee-bump-only coin selection. + // No prior contribution since there are no negotiated candidates yet, so RBF is rejected. assert!(template.prior_contribution().is_none()); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - assert!(template.rbf_sync(FeeRate::MAX, &wallet).is_ok()); + assert!(matches!( + template.rbf_prior_contribution_sync(None, FeeRate::MAX, &wallet), + Err(FundingContributionError::NotRbfScenario) + )); } #[test] fn test_rbf_sync_returns_err_when_no_min_rbf_feerate() { - // Test that rbf_sync returns Err(()) when there is no pending splice (min_rbf_feerate is - // None), indicating this is not an RBF scenario. + // Test that rbf_prior_contribution_sync returns `NotRbfScenario` when there is no pending + // splice (min_rbf_feerate is None). let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); @@ -6209,15 +6446,15 @@ fn test_rbf_sync_returns_err_when_no_min_rbf_feerate() { let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); assert!(matches!( - template.rbf_sync(FeeRate::MAX, &wallet), + template.rbf_prior_contribution_sync(None, FeeRate::MAX, &wallet), Err(crate::ln::funding::FundingContributionError::NotRbfScenario), )); } #[test] fn test_rbf_sync_returns_err_when_max_feerate_below_min_rbf() { - // Test that rbf_sync returns Err when the caller's max_feerate is below the minimum - // RBF feerate. + // Test that rbf_prior_contribution_sync returns an error when the caller's max_feerate is + // below the minimum RBF feerate. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); @@ -6245,7 +6482,7 @@ fn test_rbf_sync_returns_err_when_max_feerate_below_min_rbf() { FeeRate::from_sat_per_kwu(min_rbf_feerate.to_sat_per_kwu().saturating_sub(1)); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); assert!(matches!( - funding_template.rbf_sync(too_low_feerate, &wallet), + funding_template.rbf_prior_contribution_sync(None, too_low_feerate, &wallet), Err(crate::ln::funding::FundingContributionError::FeeRateExceedsMaximum { .. }), )); } @@ -6516,9 +6753,12 @@ fn test_splice_rbf_rejects_own_low_feerate_after_several_attempts() { let rbf_feerate = FeeRate::from_sat_per_kwu(next_feerate); let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let contribution = - funding_template.splice_in_sync(added_value, rbf_feerate, FeeRate::MAX, &wallet).unwrap(); - + let contribution = funding_template + .without_prior_contribution(rbf_feerate, FeeRate::MAX) + .with_coin_selection_source_sync(&wallet) + .add_value(added_value) + .build() + .unwrap(); let result = nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution, None); assert!(result.is_err(), "Expected rejection for low feerate: {:?}", result); From 61125214e7b220337f109456b0f885f883279d25 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Tue, 21 Apr 2026 13:50:30 -0500 Subject: [PATCH 319/627] Add FilesystemStoreV2Error for v1 data detection FilesystemStoreV2::new previously returned io::Error with ErrorKind::InvalidData when the data directory contained top-level files left behind by FilesystemStore (v1). That forced us to match on an error that could potentially be given by our normal io calls. This adds a dedicated FilesystemStoreV2Error enum with a V1DataDetected(PathBuf) so we can distinguish between normal io errors and an old V1 fs store. --- lightning-persister/src/fs_store/v2.rs | 68 ++++++++++++++++++++------ 1 file changed, 52 insertions(+), 16 deletions(-) diff --git a/lightning-persister/src/fs_store/v2.rs b/lightning-persister/src/fs_store/v2.rs index 773b22ac3fb..2f79cae0da3 100644 --- a/lightning-persister/src/fs_store/v2.rs +++ b/lightning-persister/src/fs_store/v2.rs @@ -10,6 +10,7 @@ use lightning::util::persist::{ use std::fs; use std::path::PathBuf; use std::time::UNIX_EPOCH; +use std::{error, fmt, io}; #[cfg(feature = "tokio")] use core::future::Future; @@ -17,6 +18,48 @@ use core::future::Future; use lightning::util::persist::{KVStore, PaginatedKVStore}; use std::sync::Arc; +/// An error returned when constructing a [`FilesystemStoreV2`]. +#[derive(Debug)] +pub enum FilesystemStoreV2Error { + /// The data directory contains a file at the top level, indicating it was previously used + /// by [`FilesystemStore`] (v1). Contains the path of the offending file. + /// + /// [`FilesystemStore`]: crate::fs_store::v1::FilesystemStore + V1DataDetected(PathBuf), + /// An I/O error occurred while inspecting the data directory. + Io(io::Error), +} + +impl fmt::Display for FilesystemStoreV2Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::V1DataDetected(path) => write!( + f, + "Found file `{}` in the top-level data directory. \ + This indicates the directory was previously used by FilesystemStore (v1). \ + Please migrate your data or use a different directory.", + path.display() + ), + Self::Io(err) => write!(f, "{}", err), + } + } +} + +impl error::Error for FilesystemStoreV2Error { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + match self { + Self::V1DataDetected(_) => None, + Self::Io(err) => Some(err), + } + } +} + +impl From for FilesystemStoreV2Error { + fn from(err: io::Error) -> Self { + Self::Io(err) + } +} + /// A [`KVStore`] and [`KVStoreSync`] implementation that writes to and reads from the file system. /// /// This is version 2 of the filesystem store which provides: @@ -53,25 +96,18 @@ pub struct FilesystemStoreV2 { impl FilesystemStoreV2 { /// Constructs a new [`FilesystemStoreV2`]. /// - /// Returns an error if the data directory already exists and contains files at the top level, - /// which would indicate it was previously used by a [`FilesystemStore`] (v1). The v2 store - /// expects only directories (namespaces) at the top level. + /// Returns [`FilesystemStoreV2Error::V1DataDetected`] if the data directory already exists + /// and contains files at the top level, which would indicate it was previously used by a + /// [`FilesystemStore`] (v1). The v2 store expects only directories (namespaces) at the top + /// level. /// /// [`FilesystemStore`]: crate::fs_store::v1::FilesystemStore - pub fn new(data_dir: PathBuf) -> std::io::Result { + pub fn new(data_dir: PathBuf) -> Result { if data_dir.exists() { for entry in fs::read_dir(&data_dir)? { let entry = entry?; if entry.file_type()?.is_file() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!( - "Found file `{}` in the top-level data directory. \ - This indicates the directory was previously used by FilesystemStore (v1). \ - Please migrate your data or use a different directory.", - entry.path().display() - ), - )); + return Err(FilesystemStoreV2Error::V1DataDetected(entry.path())); } } } @@ -667,10 +703,10 @@ mod tests { // V2 construction should fail match FilesystemStoreV2::new(temp_path.clone()) { - Err(err) => { - assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); - assert!(err.to_string().contains("FilesystemStore (v1)")); + Err(FilesystemStoreV2Error::V1DataDetected(path)) => { + assert_eq!(path, temp_path.join("some_key")); }, + Err(err) => panic!("Expected V1DataDetected, got {:?}", err), Ok(_) => panic!("Expected error for directory with top-level files"), } From adf87326b9ee92c782d9c3a624033483f361a901 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 17 Mar 2026 15:25:55 -0500 Subject: [PATCH 320/627] Exit quiescence when tx_init_rbf is rejected with Abort When tx_init_rbf is rejected with ChannelError::Abort (e.g., insufficient RBF feerate, negotiation in progress, feerate too high), the error is converted to a tx_abort message but quiescence is never exited and holding cells are never freed. This leaves the channel stuck in a quiescent state. Fix this by intercepting ChannelError::Abort before try_channel_entry! in internal_tx_init_rbf, calling exit_quiescence on the channel, and returning the error with exited_quiescence set so that handle_error frees holding cells. Also make exit_quiescence available in non-test builds by removing its cfg gate. Update tests to use the proper RBF initiation flow (with tampered feerates) so that handle_tx_abort correctly echoes the abort and exits quiescence, rather than manually crafting tx_init_rbf messages that leave node 0 without proper negotiation state. Co-Authored-By: Claude Opus 4.6 (1M context) --- lightning/src/ln/channel.rs | 2 - lightning/src/ln/channelmanager.rs | 9 ++ lightning/src/ln/splicing_tests.rs | 200 +++++++++++++++++++++++------ 3 files changed, 169 insertions(+), 42 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 10801edef01..e4f2466d5a9 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -14273,8 +14273,6 @@ where Some(msgs::Stfu { channel_id: self.context.channel_id, initiator }) } - #[cfg(any(test, fuzzing, feature = "_test_utils"))] - #[rustfmt::skip] pub fn exit_quiescence(&mut self) -> bool { // Make sure we either finished the quiescence handshake and are quiescent, or we never // attempted to initiate quiescence at all. diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 73d9a67f50f..7a5d0b1e577 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -13459,6 +13459,15 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ &self.fee_estimator, &self.logger, ); + if let Err(ChannelError::Abort(_)) = &init_res { + funded_channel.exit_quiescence(); + let chan_id = funded_channel.context.channel_id(); + let res = MsgHandleErrInternal::from_chan_no_close( + init_res.unwrap_err(), + chan_id, + ); + return Err(res.with_exited_quiescence(true)); + } let tx_ack_rbf_msg = try_channel_entry!(self, peer_state, init_res, chan_entry); peer_state.pending_msg_events.push(MessageSendEvent::SendTxAckRbf { node_id: *counterparty_node_id, diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index fa22ccb61c7..10f3434f233 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -4476,48 +4476,120 @@ fn test_splice_rbf_insufficient_feerate() { .is_ok()); // Acceptor-side: tx_init_rbf with an insufficient feerate is also rejected. - reenter_quiescence(&nodes[0], &nodes[1], &channel_id); + // Node 0 initiates a proper RBF but we tamper the feerate to be insufficient. + provide_utxo_reserves(&nodes, 2, added_value * 2); + let _funding_contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, min_rbf_feerate); - let tx_init_rbf = msgs::TxInitRbf { - channel_id, - locktime: 0, - feerate_sat_per_1000_weight: FEERATE_FLOOR_SATS_PER_KW, - funding_output_contribution: Some(added_value.to_sat() as i64), - }; + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + let mut tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + tx_init_rbf.feerate_sat_per_1000_weight = FEERATE_FLOOR_SATS_PER_KW; nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); assert_eq!(tx_abort.channel_id, channel_id); - // Acceptor-side: a counterparty feerate that only satisfies the 25/24 rule (263) is - // rejected — the spec requires max(prev + 25, prev * 25/24) = 278 at low feerates. - // After tx_abort the channel remains quiescent, so no need to re-enter quiescence. + // Queue a payment while quiescent. It should go to the holding cell and be freed once + // quiescence is exited by the tx_abort exchange. + let (route, payment_hash, _payment_preimage, payment_secret) = + get_route_and_payment_hash!(nodes[0], nodes[1], 1_000_000); + let onion = RecipientOnionFields::secret_only(payment_secret, 1_000_000); + let payment_id = PaymentId(payment_hash.0); + nodes[0].node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + + // Node 0 echoes tx_abort and exits quiescence, freeing the holding cell. nodes[0].node.handle_tx_abort(node_id_1, &tx_abort); - let rbf_feerate_25_24 = ((FEERATE_FLOOR_SATS_PER_KW as u64) * 25 / 24) as u32; - let tx_init_rbf = msgs::TxInitRbf { - channel_id, - locktime: 0, - feerate_sat_per_1000_weight: rbf_feerate_25_24, - funding_output_contribution: Some(added_value.to_sat() as i64), + // TODO: the RBF round's inputs are partially filtered against the prior round's committed + // UTXOs, so the DiscardFunding carries coin-selection-dependent residue. Revisit once + // #4514 lands to see if its semantics change what DiscardFunding contains here. + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2, "{events:?}"); + assert!( + matches!(&events[0], Event::SpliceFailed { channel_id: cid, .. } if *cid == channel_id) + ); + assert!( + matches!(&events[1], Event::DiscardFunding { channel_id: cid, .. } if *cid == channel_id) + ); + + let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + let tx_abort_echo = match &msg_events[0] { + MessageSendEvent::SendTxAbort { msg, .. } => msg.clone(), + other => panic!("Expected SendTxAbort, got {:?}", other), }; + match &msg_events[1] { + MessageSendEvent::UpdateHTLCs { updates, .. } => { + assert_eq!(updates.update_add_htlcs.len(), 1); + }, + other => panic!("Expected UpdateHTLCs, got {:?}", other), + } + + // Complete the HTLC commitment exchange so the channel is ready for the next RBF attempt. + // The holding cell free generated a monitor update for the outgoing HTLC. + check_added_monitors(&nodes[0], 1); + if let MessageSendEvent::UpdateHTLCs { updates, .. } = &msg_events[1] { + nodes[1].node.handle_update_add_htlc(node_id_0, &updates.update_add_htlcs[0]); + do_commitment_signed_dance(&nodes[1], &nodes[0], &updates.commitment_signed, false, false); + } else { + unreachable!(); + } + + // Node 1 handles the echo (no-op since it already aborted). + nodes[1].node.handle_tx_abort(node_id_0, &tx_abort_echo); + + // Acceptor-side: a counterparty feerate that only satisfies the 25/24 rule (263) is + // rejected — the spec requires max(prev + 25, prev * 25/24) = 278 at low feerates. + // Node 0 initiates another proper RBF but we tamper the feerate to the 25/24 value. + provide_utxo_reserves(&nodes, 2, added_value * 2); + let _funding_contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, min_rbf_feerate); + + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + let mut tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + let rbf_feerate_25_24 = ((FEERATE_FLOOR_SATS_PER_KW as u64) * 25 / 24) as u32; + tx_init_rbf.feerate_sat_per_1000_weight = rbf_feerate_25_24; nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); assert_eq!(tx_abort.channel_id, channel_id); - // Acceptor-side: prev + 25 = 278 satisfies the combined BIP125 rule and is accepted. + // Node 0 echoes tx_abort and exits quiescence. nodes[0].node.handle_tx_abort(node_id_1, &tx_abort); + let tx_abort_echo = get_event_msg!(nodes[0], MessageSendEvent::SendTxAbort, node_id_1); - let min_rbf_feerate = FEERATE_FLOOR_SATS_PER_KW + 25; - let tx_init_rbf = msgs::TxInitRbf { - channel_id, - locktime: 0, - feerate_sat_per_1000_weight: min_rbf_feerate, - funding_output_contribution: Some(added_value.to_sat() as i64), - }; + // TODO: same as above — revisit once #4514 lands. + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2); + assert!( + matches!(&events[0], Event::SpliceFailed { channel_id: cid, .. } if *cid == channel_id) + ); + assert!( + matches!(&events[1], Event::DiscardFunding { channel_id: cid, .. } if *cid == channel_id) + ); + + nodes[1].node.handle_tx_abort(node_id_0, &tx_abort_echo); + + // Acceptor-side: prev + 25 = 278 satisfies the combined BIP125 rule and is accepted. + provide_utxo_reserves(&nodes, 2, added_value * 2); + let _funding_contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, min_rbf_feerate); + + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, FEERATE_FLOOR_SATS_PER_KW + 25); nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); let _tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0); } @@ -4566,29 +4638,62 @@ fn test_splice_rbf_insufficient_feerate_high() { // prev=1000: flat increment gives 1000+25=1025, 25/24 rule gives 1000*25/24=1041. // Feerate 1025 satisfies the flat increment but not 25/24 — rejected. - reenter_quiescence(&nodes[0], &nodes[1], &channel_id); + // Node 0 initiates another proper RBF but we tamper the feerate to 1025. + provide_utxo_reserves(&nodes, 2, added_value * 2); + let min_rbf_feerate = FeeRate::from_sat_per_kwu(1041); + let _funding_contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, min_rbf_feerate); - let tx_init_rbf = msgs::TxInitRbf { - channel_id, - locktime: 0, - feerate_sat_per_1000_weight: 1025, - funding_output_contribution: Some(added_value.to_sat() as i64), - }; + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + let mut tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + tx_init_rbf.feerate_sat_per_1000_weight = 1025; nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); assert_eq!(tx_abort.channel_id, channel_id); - // Feerate 1041 satisfies both rules — accepted. + // Node 0 echoes tx_abort and exits quiescence. nodes[0].node.handle_tx_abort(node_id_1, &tx_abort); + let tx_abort_echo = get_event_msg!(nodes[0], MessageSendEvent::SendTxAbort, node_id_1); - let tx_init_rbf = msgs::TxInitRbf { - channel_id, - locktime: 0, - feerate_sat_per_1000_weight: 1041, - funding_output_contribution: Some(added_value.to_sat() as i64), - }; + // TODO: the RBF round's inputs are fully filtered against the prior round's committed + // UTXOs, so this DiscardFunding is emitted with empty inputs and outputs. Once #4514 + // lands, a fully-drained DiscardFunding should be suppressed entirely — expect + // `events.len() == 1`. + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2); + assert!( + matches!(&events[0], Event::SpliceFailed { channel_id: cid, .. } if *cid == channel_id) + ); + match &events[1] { + Event::DiscardFunding { + channel_id: cid, + funding_info: FundingInfo::Contribution { inputs, outputs }, + } => { + assert_eq!(*cid, channel_id); + assert!(inputs.is_empty(), "Expected inputs filtered, got {inputs:?}"); + assert!(outputs.is_empty(), "Expected outputs filtered, got {outputs:?}"); + }, + other => panic!("Expected DiscardFunding with Contribution, got {other:?}"), + } + + nodes[1].node.handle_tx_abort(node_id_0, &tx_abort_echo); + + // Feerate 1041 satisfies both rules — accepted. + provide_utxo_reserves(&nodes, 2, added_value * 2); + let _funding_contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, min_rbf_feerate); + + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, 1041); nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); let _tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0); } @@ -5276,10 +5381,25 @@ fn test_splice_rbf_tiebreak_feerate_too_high_rejected() { assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, high_feerate.to_sat_per_kwu() as u32); // Node 1 handles tx_init_rbf — TooHigh: target (100k) >> max (3k) and fair fee > budget. + // Node 1 exits quiescence upon rejecting with tx_abort, and since it has a pending + // QuiescentAction (from its own splice RBF attempt), it immediately re-proposes quiescence. nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); - let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); - assert_eq!(tx_abort.channel_id, channel_id); + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2); + match &msg_events[0] { + MessageSendEvent::SendTxAbort { node_id, msg } => { + assert_eq!(*node_id, node_id_0); + assert_eq!(msg.channel_id, channel_id); + }, + _ => panic!("Expected SendTxAbort, got {:?}", msg_events[0]), + }; + match &msg_events[1] { + MessageSendEvent::SendStfu { node_id, .. } => { + assert_eq!(*node_id, node_id_0); + }, + _ => panic!("Expected SendStfu, got {:?}", msg_events[1]), + }; } #[test] From bd5b04dcccce852bab8137b17805968b37515723 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 17 Mar 2026 15:43:46 -0500 Subject: [PATCH 321/627] Exit quiescence when splice_init is rejected with Abort The same bug fixed in the prior commit for tx_init_rbf also exists in internal_splice_init: when splice_init triggers FeeRateTooHigh in resolve_queued_contribution, the ChannelError::Abort goes through try_channel_entry! without exiting quiescence. Apply the same fix: intercept ChannelError::Abort before try_channel_entry!, call exit_quiescence, and return the error with exited_quiescence set. Co-Authored-By: Claude Opus 4.6 (1M context) --- lightning/src/ln/channelmanager.rs | 9 +++++++++ lightning/src/ln/splicing_tests.rs | 19 +++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 7a5d0b1e577..ae027da13fd 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -13414,6 +13414,15 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ &self.get_our_node_id(), &self.logger, ); + if let Err(ChannelError::Abort(_)) = &init_res { + funded_channel.exit_quiescence(); + let chan_id = funded_channel.context.channel_id(); + let res = MsgHandleErrInternal::from_chan_no_close( + init_res.unwrap_err(), + chan_id, + ); + return Err(res.with_exited_quiescence(true)); + } let splice_ack_msg = try_channel_entry!(self, peer_state, init_res, chan_entry); peer_state.pending_msg_events.push(MessageSendEvent::SendSpliceAck { node_id: *counterparty_node_id, diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 10f3434f233..1902df5cb1d 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -1709,10 +1709,25 @@ fn test_splice_tiebreak_feerate_too_high_rejected() { let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); // Node 1 handles SpliceInit — TooHigh: target (100k) >> max (3k) and fair fee > budget. + // Node 1 exits quiescence upon rejecting with tx_abort, and since it has a pending + // QuiescentAction (from its own splice attempt), it immediately re-proposes quiescence. nodes[1].node.handle_splice_init(node_id_0, &splice_init); - let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); - assert_eq!(tx_abort.channel_id, channel_id); + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2); + match &msg_events[0] { + MessageSendEvent::SendTxAbort { node_id, msg } => { + assert_eq!(*node_id, node_id_0); + assert_eq!(msg.channel_id, channel_id); + }, + _ => panic!("Expected SendTxAbort, got {:?}", msg_events[0]), + }; + match &msg_events[1] { + MessageSendEvent::SendStfu { node_id, .. } => { + assert_eq!(*node_id, node_id_0); + }, + _ => panic!("Expected SendStfu, got {:?}", msg_events[1]), + }; } #[cfg(test)] From 98ec3e487e566c915ed0f587cc252e1ec8420340 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 17 Mar 2026 16:31:10 -0500 Subject: [PATCH 322/627] Return InteractiveTxMsgError from splice_init and tx_init_rbf The prior two commits manually intercepted ChannelError::Abort in the channelmanager handlers for splice_init and tx_init_rbf to exit quiescence before returning, since the channel methods didn't signal this themselves. The interactive TX message handlers already solved this by returning InteractiveTxMsgError which bundles exited_quiescence into the error type. Apply the same pattern: change splice_init and tx_init_rbf to return InteractiveTxMsgError, adding a quiescent_negotiation_err helper on FundedChannel that exits quiescence for Abort errors and passes through other variants unchanged. Extract handle_interactive_tx_msg_err in channelmanager to deduplicate the error handling across internal_tx_msg, internal_splice_init, internal_tx_init_rbf, and internal_tx_complete. Co-Authored-By: Claude Opus 4.6 (1M context) Co-Authored-By: Claude Opus 4.7 (1M context) --- lightning/src/ln/channel.rs | 146 ++++++++++++----------- lightning/src/ln/channelmanager.rs | 183 ++++++++++++++--------------- lightning/src/ln/splicing_tests.rs | 90 ++++++++++++++ 3 files changed, 255 insertions(+), 164 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index e4f2466d5a9..93ef2b883fd 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12723,9 +12723,7 @@ where } /// Checks during handling splice_init - pub fn validate_splice_init( - &self, msg: &msgs::SpliceInit, our_funding_contribution: SignedAmount, - ) -> Result { + pub fn validate_splice_init(&self, msg: &msgs::SpliceInit) -> Result<(), ChannelError> { if self.holder_commitment_point.current_point().is_none() { return Err(ChannelError::WarnAndDisconnect(format!( "Channel {} commitment point needs to be advanced once before spliced", @@ -12762,32 +12760,7 @@ where ))); } - self.validate_splice_contributions(our_funding_contribution, their_funding_contribution) - .map_err(|e| ChannelError::WarnAndDisconnect(e))?; - - // Rotate the pubkeys using the prev_funding_txid as a tweak - let prev_funding_txid = self.funding.get_funding_txid(); - let funding_pubkey = match prev_funding_txid { - None => { - debug_assert!(false); - self.funding.get_holder_pubkeys().funding_pubkey - }, - Some(prev_funding_txid) => self - .context - .holder_signer - .new_funding_pubkey(prev_funding_txid, &self.context.secp_ctx), - }; - let mut new_keys = self.funding.get_holder_pubkeys().clone(); - new_keys.funding_pubkey = funding_pubkey; - - Ok(FundingScope::for_splice( - &self.funding, - &self.context, - our_funding_contribution, - their_funding_contribution, - msg.funding_pubkey, - new_keys, - )) + Ok(()) } fn validate_splice_contributions( @@ -12927,17 +12900,46 @@ where pub(crate) fn splice_init( &mut self, msg: &msgs::SpliceInit, entropy_source: &ES, holder_node_id: &PublicKey, logger: &L, - ) -> Result { + ) -> Result { + self.validate_splice_init(msg).map_err(|e| self.quiescent_negotiation_err(e))?; + let feerate = FeeRate::from_sat_per_kwu(msg.funding_feerate_per_kw as u64); - let (our_funding_contribution, holder_balance) = - self.resolve_queued_contribution(feerate, logger)?; + let (queued_net_value, holder_balance) = self + .resolve_queued_contribution(feerate, logger) + .map_err(|e| self.quiescent_negotiation_err(e))?; + + let our_funding_contribution = queued_net_value.unwrap_or(SignedAmount::ZERO); + let their_funding_contribution = SignedAmount::from_sat(msg.funding_contribution_satoshis); + self.validate_splice_contributions(our_funding_contribution, their_funding_contribution) + .map_err(|e| self.quiescent_negotiation_err(ChannelError::WarnAndDisconnect(e)))?; - let splice_funding = - self.validate_splice_init(msg, our_funding_contribution.unwrap_or(SignedAmount::ZERO))?; + // Rotate the pubkeys using the prev_funding_txid as a tweak + let prev_funding_txid = self.funding.get_funding_txid(); + let funding_pubkey = match prev_funding_txid { + None => { + debug_assert!(false); + self.funding.get_holder_pubkeys().funding_pubkey + }, + Some(prev_funding_txid) => self + .context + .holder_signer + .new_funding_pubkey(prev_funding_txid, &self.context.secp_ctx), + }; + let mut holder_pubkeys = self.funding.get_holder_pubkeys().clone(); + holder_pubkeys.funding_pubkey = funding_pubkey; + + let splice_funding = FundingScope::for_splice( + &self.funding, + &self.context, + our_funding_contribution, + their_funding_contribution, + msg.funding_pubkey, + holder_pubkeys, + ); // Adjust for the feerate and clone so we can store it for future RBF re-use. let (adjusted_contribution, our_funding_inputs, our_funding_outputs) = - if our_funding_contribution.is_some() { + if queued_net_value.is_some() { let adjusted_contribution = self .take_queued_funding_contribution() .expect("queued_funding_contribution was Some") @@ -12948,7 +12950,6 @@ where } else { (None, Default::default(), Default::default()) }; - let our_funding_contribution = our_funding_contribution.unwrap_or(SignedAmount::ZERO); log_info!( logger, @@ -12991,9 +12992,8 @@ where /// Checks during handling tx_init_rbf for an existing splice fn validate_tx_init_rbf( - &self, msg: &msgs::TxInitRbf, our_funding_contribution: SignedAmount, - fee_estimator: &LowerBoundedFeeEstimator, - ) -> Result { + &self, msg: &msgs::TxInitRbf, fee_estimator: &LowerBoundedFeeEstimator, + ) -> Result<(ChannelPublicKeys, PublicKey), ChannelError> { if self.holder_commitment_point.current_point().is_none() { return Err(ChannelError::WarnAndDisconnect(format!( "Channel {} commitment point needs to be advanced once before RBF", @@ -13059,36 +13059,26 @@ where return Err(ChannelError::Abort(AbortReason::InsufficientRbfFeerate)); } - let their_funding_contribution = match msg.funding_output_contribution { - Some(value) => SignedAmount::from_sat(value), - None => SignedAmount::ZERO, - }; - - self.validate_splice_contributions(our_funding_contribution, their_funding_contribution) - .map_err(|e| ChannelError::WarnAndDisconnect(e))?; - // Reuse funding pubkeys from the last negotiated candidate since all RBF candidates // for the same splice share the same funding output script. - let holder_pubkeys = last_candidate.get_holder_pubkeys().clone(); - let counterparty_funding_pubkey = *last_candidate.counterparty_funding_pubkey(); - - Ok(FundingScope::for_splice( - &self.funding, - &self.context, - our_funding_contribution, - their_funding_contribution, - counterparty_funding_pubkey, - holder_pubkeys, + Ok(( + last_candidate.get_holder_pubkeys().clone(), + *last_candidate.counterparty_funding_pubkey(), )) } pub(crate) fn tx_init_rbf( &mut self, msg: &msgs::TxInitRbf, entropy_source: &ES, holder_node_id: &PublicKey, fee_estimator: &LowerBoundedFeeEstimator, logger: &L, - ) -> Result { + ) -> Result { + let (holder_pubkeys, counterparty_funding_pubkey) = self + .validate_tx_init_rbf(msg, fee_estimator) + .map_err(|e| self.quiescent_negotiation_err(e))?; + let feerate = FeeRate::from_sat_per_kwu(msg.feerate_sat_per_1000_weight as u64); - let (queued_net_value, holder_balance) = - self.resolve_queued_contribution(feerate, logger)?; + let (queued_net_value, holder_balance) = self + .resolve_queued_contribution(feerate, logger) + .map_err(|e| self.quiescent_negotiation_err(e))?; // If no queued contribution, try prior contribution from previous negotiation. // Failing here means the RBF would erase our splice — reject it. @@ -13105,19 +13095,31 @@ where prior .net_value_for_acceptor_at_feerate(feerate, holder_balance) .map_err(|_| ChannelError::Abort(AbortReason::InsufficientRbfFeerate)) - })?; + }) + .map_err(|e| self.quiescent_negotiation_err(e))?; Some(net_value) } else { None }; let our_funding_contribution = queued_net_value.or(prior_net_value); + let our_funding_contribution = our_funding_contribution.unwrap_or(SignedAmount::ZERO); - let rbf_funding = self.validate_tx_init_rbf( - msg, - our_funding_contribution.unwrap_or(SignedAmount::ZERO), - fee_estimator, - )?; + let their_funding_contribution = match msg.funding_output_contribution { + Some(value) => SignedAmount::from_sat(value), + None => SignedAmount::ZERO, + }; + self.validate_splice_contributions(our_funding_contribution, their_funding_contribution) + .map_err(|e| self.quiescent_negotiation_err(ChannelError::WarnAndDisconnect(e)))?; + + let rbf_funding = FundingScope::for_splice( + &self.funding, + &self.context, + our_funding_contribution, + their_funding_contribution, + counterparty_funding_pubkey, + holder_pubkeys, + ); // Consume the appropriate contribution source. let (our_funding_inputs, our_funding_outputs) = if queued_net_value.is_some() { @@ -13154,8 +13156,6 @@ where Default::default() }; - let our_funding_contribution = our_funding_contribution.unwrap_or(SignedAmount::ZERO); - log_info!( logger, "Starting RBF funding negotiation for channel {} after receiving tx_init_rbf; channel value: {} sats", @@ -14285,6 +14285,16 @@ where was_quiescent } + fn quiescent_negotiation_err(&mut self, err: ChannelError) -> InteractiveTxMsgError { + let exited_quiescence = if matches!(err, ChannelError::Abort(_)) { + debug_assert!(self.context.channel_state.is_quiescent()); + self.exit_quiescence() + } else { + false + }; + InteractiveTxMsgError { err, splice_funding_failed: None, exited_quiescence } + } + pub fn remove_legacy_scids_before_block(&mut self, height: u32) -> alloc::vec::Drain<'_, u64> { let end = self .funding diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index ae027da13fd..7ea14976eda 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -11980,6 +11980,39 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } } + fn handle_interactive_tx_msg_err( + &self, err: InteractiveTxMsgError, channel_id: ChannelId, counterparty_node_id: &PublicKey, + user_channel_id: u128, + ) -> MsgHandleErrInternal { + if let Some(splice_funding_failed) = err.splice_funding_failed { + let pending_events = &mut self.pending_events.lock().unwrap(); + pending_events.push_back(( + events::Event::SpliceFailed { + channel_id, + counterparty_node_id: *counterparty_node_id, + user_channel_id, + abandoned_funding_txo: splice_funding_failed.funding_txo, + channel_type: splice_funding_failed.channel_type.clone(), + }, + None, + )); + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id, + funding_info: FundingInfo::Contribution { + inputs: splice_funding_failed.contributed_inputs, + outputs: splice_funding_failed.contributed_outputs, + }, + }, + None, + )); + } + debug_assert!(!err.exited_quiescence || matches!(err.err, ChannelError::Abort(_))); + + MsgHandleErrInternal::from_chan_no_close(err.err, channel_id) + .with_exited_quiescence(err.exited_quiescence) + } + fn internal_tx_msg< HandleTxMsgFn: Fn(&mut Channel) -> Result, >( @@ -12001,38 +12034,14 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ peer_state.pending_msg_events.push(msg_send_event); Ok(()) }, - Err(InteractiveTxMsgError { - err, - splice_funding_failed, - exited_quiescence, - }) => { - if let Some(splice_funding_failed) = splice_funding_failed { - let pending_events = &mut self.pending_events.lock().unwrap(); - pending_events.push_back(( - events::Event::SpliceFailed { - channel_id, - counterparty_node_id: *counterparty_node_id, - user_channel_id: channel.context().get_user_id(), - abandoned_funding_txo: splice_funding_failed.funding_txo, - channel_type: splice_funding_failed.channel_type.clone(), - }, - None, - )); - pending_events.push_back(( - events::Event::DiscardFunding { - channel_id, - funding_info: FundingInfo::Contribution { - inputs: splice_funding_failed.contributed_inputs, - outputs: splice_funding_failed.contributed_outputs, - }, - }, - None, - )); - } - debug_assert!(!exited_quiescence || matches!(err, ChannelError::Abort(_))); - - Err(MsgHandleErrInternal::from_chan_no_close(err, channel_id) - .with_exited_quiescence(exited_quiescence)) + Err(err) => { + let user_channel_id = channel.context().get_user_id(); + Err(self.handle_interactive_tx_msg_err( + err, + channel_id, + counterparty_node_id, + user_channel_id, + )) }, } }, @@ -12160,38 +12169,14 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ Ok(()) }, - Err(InteractiveTxMsgError { - err, - splice_funding_failed, - exited_quiescence, - }) => { - if let Some(splice_funding_failed) = splice_funding_failed { - let pending_events = &mut self.pending_events.lock().unwrap(); - pending_events.push_back(( - events::Event::SpliceFailed { - channel_id: msg.channel_id, - counterparty_node_id, - user_channel_id: chan.context().get_user_id(), - abandoned_funding_txo: splice_funding_failed.funding_txo, - channel_type: splice_funding_failed.channel_type.clone(), - }, - None, - )); - pending_events.push_back(( - events::Event::DiscardFunding { - channel_id: msg.channel_id, - funding_info: FundingInfo::Contribution { - inputs: splice_funding_failed.contributed_inputs, - outputs: splice_funding_failed.contributed_outputs, - }, - }, - None, - )); - } - debug_assert!(!exited_quiescence || matches!(err, ChannelError::Abort(_))); - - Err(MsgHandleErrInternal::from_chan_no_close(err, msg.channel_id) - .with_exited_quiescence(exited_quiescence)) + Err(err) => { + let user_channel_id = chan.context().get_user_id(); + Err(self.handle_interactive_tx_msg_err( + err, + msg.channel_id, + &counterparty_node_id, + user_channel_id, + )) }, } }, @@ -13408,27 +13393,30 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } if let Some(ref mut funded_channel) = chan_entry.get_mut().as_funded_mut() { - let init_res = funded_channel.splice_init( + let user_channel_id = funded_channel.context.get_user_id(); + match funded_channel.splice_init( msg, &self.entropy_source, &self.get_our_node_id(), &self.logger, - ); - if let Err(ChannelError::Abort(_)) = &init_res { - funded_channel.exit_quiescence(); - let chan_id = funded_channel.context.channel_id(); - let res = MsgHandleErrInternal::from_chan_no_close( - init_res.unwrap_err(), - chan_id, - ); - return Err(res.with_exited_quiescence(true)); + ) { + Ok(splice_ack_msg) => { + peer_state.pending_msg_events.push(MessageSendEvent::SendSpliceAck { + node_id: *counterparty_node_id, + msg: splice_ack_msg, + }); + Ok(()) + }, + Err(err) => { + debug_assert!(err.splice_funding_failed.is_none()); + Err(self.handle_interactive_tx_msg_err( + err, + msg.channel_id, + counterparty_node_id, + user_channel_id, + )) + }, } - let splice_ack_msg = try_channel_entry!(self, peer_state, init_res, chan_entry); - peer_state.pending_msg_events.push(MessageSendEvent::SendSpliceAck { - node_id: *counterparty_node_id, - msg: splice_ack_msg, - }); - Ok(()) } else { try_channel_entry!( self, @@ -13461,28 +13449,31 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }, hash_map::Entry::Occupied(mut chan_entry) => { if let Some(ref mut funded_channel) = chan_entry.get_mut().as_funded_mut() { - let init_res = funded_channel.tx_init_rbf( + let user_channel_id = funded_channel.context.get_user_id(); + match funded_channel.tx_init_rbf( msg, &self.entropy_source, &self.get_our_node_id(), &self.fee_estimator, &self.logger, - ); - if let Err(ChannelError::Abort(_)) = &init_res { - funded_channel.exit_quiescence(); - let chan_id = funded_channel.context.channel_id(); - let res = MsgHandleErrInternal::from_chan_no_close( - init_res.unwrap_err(), - chan_id, - ); - return Err(res.with_exited_quiescence(true)); + ) { + Ok(tx_ack_rbf_msg) => { + peer_state.pending_msg_events.push(MessageSendEvent::SendTxAckRbf { + node_id: *counterparty_node_id, + msg: tx_ack_rbf_msg, + }); + Ok(()) + }, + Err(err) => { + debug_assert!(err.splice_funding_failed.is_none()); + Err(self.handle_interactive_tx_msg_err( + err, + msg.channel_id, + counterparty_node_id, + user_channel_id, + )) + }, } - let tx_ack_rbf_msg = try_channel_entry!(self, peer_state, init_res, chan_entry); - peer_state.pending_msg_events.push(MessageSendEvent::SendTxAckRbf { - node_id: *counterparty_node_id, - msg: tx_ack_rbf_msg, - }); - Ok(()) } else { try_channel_entry!( self, diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 1902df5cb1d..98be1130849 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -6843,6 +6843,96 @@ fn test_splice_revalidation_at_quiescence() { expect_splice_failed_events(&nodes[0], &channel_id, contribution); } +#[test] +fn test_splice_init_before_quiescence_sends_warning() { + // A misbehaving peer sends splice_init before quiescence is established. The receiver + // should send a warning and disconnect. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + // Node 0 initiates quiescence. + nodes[0].node.maybe_propose_quiescence(&node_id_1, &channel_id).unwrap(); + let _stfu = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + + // Misbehaving node 1 sends splice_init before completing the STFU handshake. + let funding_pubkey = + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[42; 32]).unwrap()); + let splice_init = msgs::SpliceInit { + channel_id, + funding_contribution_satoshis: 50_000, + funding_feerate_per_kw: FEERATE_FLOOR_SATS_PER_KW, + locktime: 0, + funding_pubkey, + require_confirmed_inputs: None, + }; + nodes[0].node.handle_splice_init(node_id_1, &splice_init); + + // Node 0 should send a warning and disconnect. + let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + match &msg_events[0] { + MessageSendEvent::HandleError { node_id, .. } => assert_eq!(*node_id, node_id_1), + other => panic!("Expected HandleError, got {:?}", other), + } +} + +#[test] +fn test_tx_init_rbf_before_quiescence_sends_warning() { + // A misbehaving peer sends tx_init_rbf before quiescence is established. The receiver + // should send a warning and disconnect. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Complete a splice-in so there's a pending splice to RBF. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_splice_tx, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Node 0 initiates quiescence. + nodes[0].node.maybe_propose_quiescence(&node_id_1, &channel_id).unwrap(); + let _stfu = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + + // Misbehaving node 1 sends tx_init_rbf before completing the STFU handshake. + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: FEERATE_FLOOR_SATS_PER_KW + 25, + funding_output_contribution: Some(added_value.to_sat() as i64), + }; + nodes[0].node.handle_tx_init_rbf(node_id_1, &tx_init_rbf); + + // Node 0 should send a warning and disconnect. + let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + match &msg_events[0] { + MessageSendEvent::HandleError { node_id, .. } => assert_eq!(*node_id, node_id_1), + other => panic!("Expected HandleError, got {:?}", other), + } + + // Clean up events from the splice setup. + nodes[0].node.get_and_clear_pending_events(); + nodes[1].node.get_and_clear_pending_events(); +} + #[test] fn test_splice_rbf_rejects_low_feerate_after_several_attempts() { // After several RBF attempts, the counterparty's RBF feerate must be high enough to From c929248851101ba3e219ac952f84d779b193cd84 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 7 Apr 2026 19:30:33 -0500 Subject: [PATCH 323/627] Remove exited_quiescence from error handling The `exited_quiescence` field on `MsgHandleErrInternal` and `InteractiveTxMsgError` is a leaky abstraction -- the channelmanager error handling shouldn't know about quiescence, only whether the holding cell needs to be released. Infer this from the presence of a `tx_abort` instead, since exiting quiescence via an error always produces one. Remove `exited_quiescence` from `InteractiveTxMsgError`, `MsgHandleErrInternal`, and the return type of `Channel::tx_abort`, along with the `with_exited_quiescence` builder. For unfunded v2 channels, `tx_abort` may be present without quiescence having been exited, but the holding cell release is a no-op since an unfunded channel won't have any HTLCs. Similarly, the unreachable `debug_assert!(false)` branch in `fail_interactive_tx_negotiation` for funded channels produces a `tx_abort` without exiting quiescence, but the holding cell release is a no-op since the channel is still quiescent. Co-Authored-By: Claude Opus 4.6 (1M context) --- lightning/src/ln/channel.rs | 48 ++++++++++-------------------- lightning/src/ln/channelmanager.rs | 47 +++++++++++------------------ 2 files changed, 33 insertions(+), 62 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 93ef2b883fd..b2c6b602b6a 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -1172,9 +1172,6 @@ pub(super) struct InteractiveTxMsgError { /// If a splice was in progress when processing the message, this contains the splice funding /// information for emitting a `SpliceFailed` event. pub(super) splice_funding_failed: Option, - /// Whether we were quiescent when we received the message, and are no longer due to aborting - /// the session. - pub(super) exited_quiescence: bool, } /// The return value of `monitor_updating_restored` @@ -1818,30 +1815,24 @@ where let logger = WithChannelContext::from(logger, &self.context(), None); log_info!(logger, "Failed interactive transaction negotiation: {reason}"); - let (splice_funding_failed, exited_quiescence) = match &mut self.phase { + let splice_funding_failed = match &mut self.phase { ChannelPhase::Undefined => unreachable!(), - ChannelPhase::UnfundedOutboundV1(_) | ChannelPhase::UnfundedInboundV1(_) => { - (None, false) - }, + ChannelPhase::UnfundedOutboundV1(_) | ChannelPhase::UnfundedInboundV1(_) => None, ChannelPhase::UnfundedV2(pending_v2_channel) => { pending_v2_channel.interactive_tx_constructor.take(); - (None, false) + None }, ChannelPhase::Funded(funded_channel) => { if funded_channel.should_reset_pending_splice_state(false) { - (funded_channel.reset_pending_splice_state(), true) + funded_channel.reset_pending_splice_state() } else { debug_assert!(false, "We should never fail an interactive funding negotiation once we're exchanging tx_signatures"); - (None, false) + None } }, }; - InteractiveTxMsgError { - err: ChannelError::Abort(reason), - splice_funding_failed, - exited_quiescence, - } + InteractiveTxMsgError { err: ChannelError::Abort(reason), splice_funding_failed } } pub fn tx_add_input( @@ -1856,7 +1847,6 @@ where "Received unexpected interactive transaction negotiation message".to_owned(), ), splice_funding_failed: None, - exited_quiescence: false, }), } } @@ -1873,7 +1863,6 @@ where "Received unexpected interactive transaction negotiation message".to_owned(), ), splice_funding_failed: None, - exited_quiescence: false, }), } } @@ -1890,7 +1879,6 @@ where "Received unexpected interactive transaction negotiation message".to_owned(), ), splice_funding_failed: None, - exited_quiescence: false, }), } } @@ -1907,7 +1895,6 @@ where "Received unexpected interactive transaction negotiation message".to_owned(), ), splice_funding_failed: None, - exited_quiescence: false, }), } } @@ -1924,7 +1911,6 @@ where return Err(InteractiveTxMsgError { err: ChannelError::WarnAndDisconnect(err.to_owned()), splice_funding_failed: None, - exited_quiescence: false, }); }, }; @@ -1985,13 +1971,13 @@ where pub fn tx_abort( &mut self, msg: &msgs::TxAbort, logger: &L, - ) -> Result<(Option, Option, bool), ChannelError> { + ) -> Result<(Option, Option), ChannelError> { // If we have not sent a `tx_abort` message for this negotiation previously, we need to echo // back a tx_abort message according to the spec: // https://github.com/lightning/bolts/blob/247e83d/02-peer-protocol.md?plain=1#L560-L561 // For rationale why we echo back `tx_abort`: // https://github.com/lightning/bolts/blob/247e83d/02-peer-protocol.md?plain=1#L578-L580 - let (should_ack, splice_funding_failed, exited_quiescence) = match &mut self.phase { + let (should_ack, splice_funding_failed) = match &mut self.phase { ChannelPhase::Undefined => unreachable!(), ChannelPhase::UnfundedOutboundV1(_) | ChannelPhase::UnfundedInboundV1(_) => { let err = "Got an unexpected tx_abort message: This is an unfunded channel created with V1 channel establishment"; @@ -2000,7 +1986,7 @@ where ChannelPhase::UnfundedV2(pending_v2_channel) => { let had_constructor = pending_v2_channel.interactive_tx_constructor.take().is_some(); - (had_constructor, None, false) + (had_constructor, None) }, ChannelPhase::Funded(funded_channel) => { if funded_channel.has_pending_splice_awaiting_signatures() @@ -2028,11 +2014,11 @@ where .unwrap_or(false); debug_assert!(has_funding_negotiation); let splice_funding_failed = funded_channel.reset_pending_splice_state(); - (true, splice_funding_failed, true) + (true, splice_funding_failed) } else { // We were not tracking the pending funding negotiation state anymore, likely // due to a disconnection or already having sent our own `tx_abort`. - (false, None, false) + (false, None) } }, }; @@ -2048,7 +2034,7 @@ where } }); - Ok((tx_abort, splice_funding_failed, exited_quiescence)) + Ok((tx_abort, splice_funding_failed)) } #[rustfmt::skip] @@ -14286,13 +14272,11 @@ where } fn quiescent_negotiation_err(&mut self, err: ChannelError) -> InteractiveTxMsgError { - let exited_quiescence = if matches!(err, ChannelError::Abort(_)) { + if matches!(err, ChannelError::Abort(_)) { debug_assert!(self.context.channel_state.is_quiescent()); - self.exit_quiescence() - } else { - false - }; - InteractiveTxMsgError { err, splice_funding_failed: None, exited_quiescence } + self.exit_quiescence(); + } + InteractiveTxMsgError { err, splice_funding_failed: None } } pub fn remove_legacy_scids_before_block(&mut self, height: u32) -> alloc::vec::Drain<'_, u64> { diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 7ea14976eda..8e14b474578 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -1073,7 +1073,6 @@ struct MsgHandleErrInternal { closes_channel: bool, shutdown_finish: Option<(ShutdownResult, Option<(msgs::ChannelUpdate, NodeId, NodeId)>)>, tx_abort: Option, - exited_quiescence: bool, } impl MsgHandleErrInternal { @@ -1088,7 +1087,6 @@ impl MsgHandleErrInternal { closes_channel: false, shutdown_finish: None, tx_abort: None, - exited_quiescence: false, } } @@ -1108,13 +1106,7 @@ impl MsgHandleErrInternal { } fn from_no_close(err: msgs::LightningError) -> Self { - Self { - err, - closes_channel: false, - shutdown_finish: None, - tx_abort: None, - exited_quiescence: false, - } + Self { err, closes_channel: false, shutdown_finish: None, tx_abort: None } } fn from_finish_shutdown( @@ -1135,7 +1127,6 @@ impl MsgHandleErrInternal { closes_channel: true, shutdown_finish: Some((shutdown_res, channel_update)), tx_abort: None, - exited_quiescence: false, } } @@ -1171,13 +1162,7 @@ impl MsgHandleErrInternal { }, }, }; - Self { - err, - closes_channel: false, - shutdown_finish: None, - tx_abort, - exited_quiescence: false, - } + Self { err, closes_channel: false, shutdown_finish: None, tx_abort } } fn dont_send_error_message(&mut self) { @@ -1194,9 +1179,11 @@ impl MsgHandleErrInternal { self.closes_channel } - fn with_exited_quiescence(mut self, exited_quiescence: bool) -> Self { - self.exited_quiescence = exited_quiescence; - self + /// Whether the holding cell should be released after handling this error. This is inferred + /// from the presence of a `tx_abort`, which is sent when aborting an interactive transaction + /// negotiation that was conducted during quiescence. + fn needs_holding_cell_release(&self) -> bool { + self.tx_abort.is_some() } } @@ -4635,6 +4622,7 @@ impl< internal.map_err(|err_internal| { let mut msg_event = None; + let needs_holding_cell_release = err_internal.needs_holding_cell_release(); if let Some((shutdown_res, update_option)) = err_internal.shutdown_finish { let counterparty_node_id = shutdown_res.counterparty_node_id; @@ -4676,7 +4664,7 @@ impl< } let mut holding_cell_res = None; - if msg_event.is_some() || err_internal.exited_quiescence { + if msg_event.is_some() || needs_holding_cell_release { let per_peer_state = self.per_peer_state.read().unwrap(); if let Some(peer_state_mutex) = per_peer_state.get(&counterparty_node_id) { let mut peer_state = peer_state_mutex.lock().unwrap(); @@ -4687,8 +4675,7 @@ impl< } // We need to enqueue the `tx_abort` in `pending_msg_events` above before we // enqueue any commitment updates generated by freeing holding cell HTLCs. - holding_cell_res = err_internal - .exited_quiescence + holding_cell_res = needs_holding_cell_release .then(|| self.check_free_peer_holding_cells(&mut peer_state)); } } @@ -12007,10 +11994,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ None, )); } - debug_assert!(!err.exited_quiescence || matches!(err.err, ChannelError::Abort(_))); - MsgHandleErrInternal::from_chan_no_close(err.err, channel_id) - .with_exited_quiescence(err.exited_quiescence) } fn internal_tx_msg< @@ -12247,7 +12231,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } // We consider a splice negotiated when we exchange `tx_signatures`, // which also terminates quiescence. - let exited_quiescence = splice_negotiated.is_some(); + let needs_holding_cell_release = splice_negotiated.is_some(); if let Some(splice_negotiated) = splice_negotiated { self.pending_events.lock().unwrap().push_back(( events::Event::SplicePending { @@ -12262,7 +12246,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ None, )); } - let holding_cell_res = if exited_quiescence { + let holding_cell_res = if needs_holding_cell_release { self.check_free_peer_holding_cells(peer_state) } else { Vec::new() @@ -12304,7 +12288,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ match peer_state.channel_by_id.entry(msg.channel_id) { hash_map::Entry::Occupied(mut chan_entry) => { let res = chan_entry.get_mut().tx_abort(msg, &self.logger); - let (tx_abort, splice_failed, exited_quiescence) = + let (tx_abort, splice_failed) = try_channel_entry!(self, peer_state, res, chan_entry); let persist = if tx_abort.is_some() || splice_failed.is_some() { @@ -12313,6 +12297,9 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ NotifyOption::SkipPersistNoEvents }; + // Release any HTLCs held during quiescence now that we're + // exiting via tx_abort. + let needs_holding_cell_release = tx_abort.is_some(); if let Some(tx_abort_msg) = tx_abort { peer_state.pending_msg_events.push(MessageSendEvent::SendTxAbort { node_id: *counterparty_node_id, @@ -12344,7 +12331,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ )); } - let holding_cell_res = if exited_quiescence { + let holding_cell_res = if needs_holding_cell_release { self.check_free_peer_holding_cells(peer_state) } else { Vec::new() From d2f422bff25d62e5c65719d15c3fc96e60e44407 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 7 Apr 2026 17:57:07 -0500 Subject: [PATCH 324/627] Clear disconnect timer when exiting quiescence Several code paths exit quiescence by calling `clear_quiescent()` directly without also clearing the disconnect timer via `mark_response_received()`. This causes the timer to fire after the splice completes or is aborted, spuriously disconnecting the peer. Replace `clear_quiescent()` with `exit_quiescence()` in `on_tx_signatures_exchange`, `reset_pending_splice_state`, and `peer_connected_get_handshake`, which clears both the quiescent state and the disconnect timer. Co-Authored-By: Claude Opus 4.6 (1M context) --- lightning/src/ln/channel.rs | 10 +- lightning/src/ln/splicing_tests.rs | 194 ++++++++++++++++++++++++++++- 2 files changed, 200 insertions(+), 4 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index b2c6b602b6a..f6aa9861728 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -1719,7 +1719,10 @@ where // We shouldn't be quiescent anymore upon reconnecting if: // - We were in quiescence but a splice/RBF was never negotiated or // - We were in quiescence but the splice negotiation failed due to disconnecting - chan.context.channel_state.clear_quiescent(); + // + // NOTE: While `exit_quiescence` clears the disconnect timer, it should already + // have been cleared by `remove_uncommitted_htlcs_and_mark_paused`. + chan.exit_quiescence(); None } else { None @@ -7284,7 +7287,7 @@ where self.pending_splice.take(); } - self.context.channel_state.clear_quiescent(); + self.exit_quiescence(); if current_is_awaiting_signatures { self.context.interactive_tx_signing_session.take(); } @@ -9341,7 +9344,6 @@ where debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke()); if let Some(pending_splice) = self.pending_splice.as_mut() { - self.context.channel_state.clear_quiescent(); if let Some(FundingNegotiation::AwaitingSignatures { mut funding, funding_feerate_sat_per_1000_weight, @@ -9390,6 +9392,8 @@ where } else { debug_assert!(false); } + + self.exit_quiescence(); } else { self.funding.funding_transaction = Some(funding_tx.clone()); self.context.channel_state = diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 98be1130849..bfb6ee9556d 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -16,7 +16,8 @@ use crate::chain::ChannelMonitorUpdateStatus; use crate::events::{ClosureReason, Event, FundingInfo, HTLCHandlingFailureType}; use crate::ln::chan_utils; use crate::ln::channel::{ - CHANNEL_ANNOUNCEMENT_PROPAGATION_DELAY, FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE, + CHANNEL_ANNOUNCEMENT_PROPAGATION_DELAY, DISCONNECT_PEER_AWAITING_RESPONSE_TICKS, + FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE, }; use crate::ln::channelmanager::{provided_init_features, PaymentId, BREAKDOWN_TIMEOUT}; use crate::ln::functional_test_utils::*; @@ -7082,3 +7083,194 @@ fn test_splice_rbf_rejects_own_low_feerate_after_several_attempts() { other => panic!("Expected SpliceFailed, got {:?}", other), } } + +#[test] +fn test_no_disconnect_after_splice_completes() { + // Test that the disconnect timer is cleared when exiting quiescence after a successful splice + // negotiation. Previously, `on_tx_signatures_exchange` cleared the quiescent state but not the + // disconnect timer, causing a spurious disconnect after the splice completed. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let new_funding_script = complete_splice_handshake(&nodes[0], &nodes[1]); + + // Fire a tick while quiescent to arm the disconnect timer. + nodes[0].node.timer_tick_occurred(); + nodes[1].node.timer_tick_occurred(); + + // Complete the splice negotiation, which should clear the timer when exiting quiescence. + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + funding_contribution, + new_funding_script, + ); + let (_, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + assert!(splice_locked.is_none()); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Fire enough ticks to trigger a disconnect if the timer wasn't properly cleared. + for _ in 0..DISCONNECT_PEER_AWAITING_RESPONSE_TICKS { + nodes[0].node.timer_tick_occurred(); + nodes[1].node.timer_tick_occurred(); + } + + let has_disconnect = |events: &[MessageSendEvent]| { + events.iter().any(|event| { + matches!( + event, + MessageSendEvent::HandleError { + action: msgs::ErrorAction::DisconnectPeerWithWarning { .. }, + .. + } + ) + }) + }; + assert!(!has_disconnect(&nodes[0].node.get_and_clear_pending_msg_events())); + assert!(!has_disconnect(&nodes[1].node.get_and_clear_pending_msg_events())); +} + +#[test] +fn test_no_disconnect_after_splice_aborted() { + // Test that the disconnect timer is cleared when exiting quiescence after a splice negotiation + // is aborted via tx_abort. Previously, `reset_pending_splice_state` cleared the quiescent + // state but not the disconnect timer, causing a spurious disconnect after the abort. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + complete_splice_handshake(&nodes[0], &nodes[1]); + + // Fire a tick while quiescent to arm the disconnect timer. + nodes[0].node.timer_tick_occurred(); + nodes[1].node.timer_tick_occurred(); + + // Abort the splice, which should clear the timer when exiting quiescence. + nodes[0].node.abandon_splice(&channel_id, &node_id_1).unwrap(); + + expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); + + let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + let tx_abort = msg_events + .iter() + .find_map(|event| { + if let MessageSendEvent::SendTxAbort { msg, .. } = event { + Some(msg.clone()) + } else { + None + } + }) + .expect("Expected SendTxAbort"); + + nodes[1].node.handle_tx_abort(node_id_0, &tx_abort); + let tx_abort_echo = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); + nodes[1].node.get_and_clear_pending_events(); + + nodes[0].node.handle_tx_abort(node_id_1, &tx_abort_echo); + + // Fire enough ticks to trigger a disconnect if the timer wasn't properly cleared. + for _ in 0..DISCONNECT_PEER_AWAITING_RESPONSE_TICKS { + nodes[0].node.timer_tick_occurred(); + nodes[1].node.timer_tick_occurred(); + } + + let has_disconnect = |events: &[MessageSendEvent]| { + events.iter().any(|event| { + matches!( + event, + MessageSendEvent::HandleError { + action: msgs::ErrorAction::DisconnectPeerWithWarning { .. }, + .. + } + ) + }) + }; + assert!(!has_disconnect(&nodes[0].node.get_and_clear_pending_msg_events())); + assert!(!has_disconnect(&nodes[1].node.get_and_clear_pending_msg_events())); +} + +#[test] +fn test_no_disconnect_after_quiescence_on_reconnect() { + // Test that there is no spurious disconnect after reconnecting from a quiescent state. The + // disconnect timer is cleared by `remove_uncommitted_htlcs_and_mark_paused` during + // disconnection and by `exit_quiescence` during reconnection. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + complete_splice_handshake(&nodes[0], &nodes[1]); + + // Fire a tick while quiescent to arm the disconnect timer. + nodes[0].node.timer_tick_occurred(); + nodes[1].node.timer_tick_occurred(); + + // Disconnect and reconnect. + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + + expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); + + let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); + reconnect_args.send_channel_ready = (true, true); + reconnect_args.send_announcement_sigs = (true, true); + reconnect_nodes(reconnect_args); + + // Fire enough ticks to trigger a disconnect if the timer wasn't properly cleared. + for _ in 0..DISCONNECT_PEER_AWAITING_RESPONSE_TICKS { + nodes[0].node.timer_tick_occurred(); + nodes[1].node.timer_tick_occurred(); + } + + let has_disconnect = |events: &[MessageSendEvent]| { + events.iter().any(|event| { + matches!( + event, + MessageSendEvent::HandleError { + action: msgs::ErrorAction::DisconnectPeerWithWarning { .. }, + .. + } + ) + }) + }; + assert!(!has_disconnect(&nodes[0].node.get_and_clear_pending_msg_events())); + assert!(!has_disconnect(&nodes[1].node.get_and_clear_pending_msg_events())); +} From 64c5182bb264442b7f1b82aa4efff5184a6efd9c Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 1 Apr 2026 13:40:36 -0500 Subject: [PATCH 325/627] Handle DiscardFunding with FundingInfo::Tx variant in chanmon_consistency The process_events! macro only handled DiscardFunding events with FundingInfo::Contribution, but splice RBF replacements can produce DiscardFunding with FundingInfo::Tx when the original splice transaction is discarded. Co-Authored-By: Claude Opus 4.6 (1M context) --- fuzz/src/chanmon_consistency.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 5b5c6391b4b..b205120e913 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -2037,11 +2037,12 @@ pub fn do_test(data: &[u8], out: Out) { }, events::Event::SpliceFailed { .. } => {}, events::Event::DiscardFunding { - funding_info: events::FundingInfo::Contribution { .. }, + funding_info: events::FundingInfo::Contribution { .. } + | events::FundingInfo::Tx { .. }, .. } => {}, - _ => panic!("Unhandled event"), + _ => panic!("Unhandled event: {:?}", event), } } while nodes[$node].needs_pending_htlc_processing() { From 5237c9a9a7441317b4d8e1ee6f096234792d69ce Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 14 Apr 2026 11:38:09 +0200 Subject: [PATCH 326/627] Use `bitreq::Url` for LSPS5 webhook URLs Replace the custom LSPS5 URL parser with `bitreq::Url` while keeping the LSPS5-specific HTTPS and length checks. This reduces bespoke parsing logic and aligns accepted webhook URLs with the HTTP client's URL handling. Co-Authored-By: HAL 9000 Signed-off-by: Elias Rohrer --- lightning-liquidity/Cargo.toml | 1 + lightning-liquidity/src/lsps5/msgs.rs | 12 +++- lightning-liquidity/src/lsps5/url_utils.rs | 77 +++++++--------------- lightning/src/util/ser.rs | 13 ++++ 4 files changed, 49 insertions(+), 54 deletions(-) diff --git a/lightning-liquidity/Cargo.toml b/lightning-liquidity/Cargo.toml index cc7fb0c0f08..9b8114e47aa 100644 --- a/lightning-liquidity/Cargo.toml +++ b/lightning-liquidity/Cargo.toml @@ -33,6 +33,7 @@ chrono = { version = "0.4", default-features = false, features = ["serde", "allo serde = { version = "1.0", default-features = false, features = ["derive", "alloc"] } serde_json = { version = "1.0", default-features = false, features = ["alloc"] } backtrace = { version = "0.3", optional = true } +bitreq = { version = "0.3.2", default-features = false } [dev-dependencies] lightning = { version = "0.3.0", path = "../lightning", default-features = false, features = ["_test_utils"] } diff --git a/lightning-liquidity/src/lsps5/msgs.rs b/lightning-liquidity/src/lsps5/msgs.rs index 6e9c5df1139..41e05d687c5 100644 --- a/lightning-liquidity/src/lsps5/msgs.rs +++ b/lightning-liquidity/src/lsps5/msgs.rs @@ -876,7 +876,7 @@ mod tests { } #[test] - fn test_url_security_validation() { + fn test_webhook_url_validation() { let urls_that_should_throw = [ "test-app", "http://example.com/webhook", @@ -906,6 +906,16 @@ mod tests { } } + #[test] + fn test_webhook_url_accepts_https_userinfo_and_ipv6() { + let userinfo_url = + LSPS5WebhookUrl::new("https://user:pass@example.com/webhook".to_string()).unwrap(); + assert_eq!(userinfo_url.as_str(), "https://user:pass@example.com/webhook"); + + let ipv6_url = LSPS5WebhookUrl::new("https://[::1]/webhook".to_string()).unwrap(); + assert_eq!(ipv6_url.as_str(), "https://[::1]/webhook"); + } + #[test] fn test_lsps_url_readable_rejects_http() { use lightning::util::ser::Writeable; diff --git a/lightning-liquidity/src/lsps5/url_utils.rs b/lightning-liquidity/src/lsps5/url_utils.rs index 2a660b4495f..b45152649b4 100644 --- a/lightning-liquidity/src/lsps5/url_utils.rs +++ b/lightning-liquidity/src/lsps5/url_utils.rs @@ -11,15 +11,28 @@ use super::msgs::LSPS5ProtocolError; +use bitreq::Url; use lightning::ln::msgs::DecodeError; use lightning::util::ser::{Readable, Writeable}; -use lightning_types::string::UntrustedString; use alloc::string::String; +use core::hash::{Hash, Hasher}; /// Represents a parsed URL for LSPS5 webhook notifications. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct LSPSUrl(UntrustedString); +#[derive(Debug, Clone, Eq)] +pub struct LSPSUrl(Url); + +impl PartialEq for LSPSUrl { + fn eq(&self, other: &Self) -> bool { + self.0.as_str() == other.0.as_str() + } +} + +impl Hash for LSPSUrl { + fn hash(&self, state: &mut H) { + self.0.as_str().hash(state) + } +} impl LSPSUrl { /// Parses a URL string into a URL instance. @@ -30,65 +43,23 @@ impl LSPSUrl { /// # Returns /// A Result containing either the parsed URL or an error message. pub fn parse(url_str: String) -> Result { - if url_str.chars().any(|c| !Self::is_valid_url_char(c)) { - return Err(LSPS5ProtocolError::UrlParse); - } + let url = Url::parse(&url_str).map_err(|_| LSPS5ProtocolError::UrlParse)?; - let (scheme, remainder) = - url_str.split_once("://").ok_or_else(|| LSPS5ProtocolError::UrlParse)?; - - if !scheme.eq_ignore_ascii_case("https") { + if url.scheme() != "https" { return Err(LSPS5ProtocolError::UnsupportedProtocol); } - let host_section = - remainder.split(['/', '?', '#']).next().ok_or_else(|| LSPS5ProtocolError::UrlParse)?; - - let host_without_auth = host_section - .split('@') - .next_back() - .filter(|s| !s.is_empty()) - .ok_or_else(|| LSPS5ProtocolError::UrlParse)?; - - if host_without_auth.is_empty() - || host_without_auth.chars().any(|c| !Self::is_valid_host_char(c)) - { - return Err(LSPS5ProtocolError::UrlParse); - } - - match host_without_auth.rsplit_once(':') { - Some((hostname, _)) if hostname.is_empty() => return Err(LSPS5ProtocolError::UrlParse), - Some((_, port)) => { - if !port.is_empty() && port.parse::().is_err() { - return Err(LSPS5ProtocolError::UrlParse); - } - }, - None => {}, - }; - - Ok(LSPSUrl(UntrustedString(url_str))) + Ok(LSPSUrl(url)) } /// Returns URL length in bytes. - /// - /// Since [`LSPSUrl::parse`] only accepts ASCII characters, this is equivalent - /// to the character count. pub fn url_length(&self) -> usize { - self.0 .0.len() + self.0.as_str().len() } /// Returns the full URL string. pub fn url(&self) -> &str { - self.0 .0.as_str() - } - - fn is_valid_url_char(c: char) -> bool { - c.is_ascii_alphanumeric() - || matches!(c, ':' | '/' | '.' | '@' | '?' | '#' | '%' | '-' | '_' | '&' | '=') - } - - fn is_valid_host_char(c: char) -> bool { - c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '_') + self.0.as_str() } } @@ -96,13 +67,13 @@ impl Writeable for LSPSUrl { fn write( &self, writer: &mut W, ) -> Result<(), lightning::io::Error> { - self.0.write(writer) + self.0.as_str().write(writer) } } impl Readable for LSPSUrl { fn read(reader: &mut R) -> Result { - let s: UntrustedString = Readable::read(reader)?; - Self::parse(s.0).map_err(|_| DecodeError::InvalidValue) + let s: String = Readable::read(reader)?; + Self::parse(s).map_err(|_| DecodeError::InvalidValue) } } diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs index 2b02629d3b0..bd2488bd8d1 100644 --- a/lightning/src/util/ser.rs +++ b/lightning/src/util/ser.rs @@ -1631,6 +1631,13 @@ impl Readable for () { } impl Writeable for String { + #[inline] + fn write(&self, w: &mut W) -> Result<(), io::Error> { + self.as_str().write(w) + } +} + +impl Writeable for &str { #[inline] fn write(&self, w: &mut W) -> Result<(), io::Error> { CollectionLength(self.len() as u64).write(w)?; @@ -1797,6 +1804,12 @@ mod tests { assert_eq!(Hostname::read(&mut buf.as_slice()).unwrap().as_str(), "test"); } + #[test] + fn str_serialization_matches_string() { + let s = "test"; + assert_eq!(s.encode(), s.to_string().encode()); + } + #[test] /// Taproot will likely fill legacy signature fields with all 0s. /// This test ensures that doing so won't break serialization. From dcba68d2a86cd67fc565ab0fbebb60582dc051dd Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 23 Apr 2026 17:59:21 +0000 Subject: [PATCH 327/627] Rename `BestBlock` to `BlockLocator` `BestBlock` is now really a pointer to a block rather than just a block itself, so its weird to still call it `BestBlock`. Here we rename it to `BlockLocator`. Co-Authored-By: Claude Opus 4.7 (1M context) --- fuzz/src/chanmon_consistency.rs | 10 +-- fuzz/src/chanmon_deser.rs | 6 +- fuzz/src/full_stack.rs | 6 +- fuzz/src/lsps_message.rs | 4 +- lightning-background-processor/src/lib.rs | 10 +-- lightning-block-sync/src/init.rs | 18 +++--- lightning-block-sync/src/lib.rs | 12 ++-- lightning-block-sync/src/poll.rs | 8 +-- lightning-block-sync/src/test_utils.rs | 14 ++--- lightning/src/chain/chainmonitor.rs | 4 +- lightning/src/chain/channelmonitor.rs | 40 ++++++------ lightning/src/chain/mod.rs | 62 ++++++++++--------- lightning/src/ln/chanmon_update_fail_tests.rs | 4 +- lightning/src/ln/channel.rs | 30 ++++----- lightning/src/ln/channelmanager.rs | 49 ++++++++------- lightning/src/ln/functional_test_utils.rs | 16 ++--- lightning/src/ln/functional_tests.rs | 8 +-- lightning/src/ln/reload_tests.rs | 10 +-- lightning/src/offers/flow.rs | 8 +-- lightning/src/util/persist.rs | 24 +++---- lightning/src/util/sweep.rs | 22 +++---- lightning/src/util/test_utils.rs | 8 +-- 22 files changed, 189 insertions(+), 184 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 5b5c6391b4b..371fcc1a17f 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -44,7 +44,7 @@ use lightning::chain::chaininterface::{ use lightning::chain::channelmonitor::{ChannelMonitor, MonitorEvent}; use lightning::chain::transaction::OutPoint; use lightning::chain::{ - chainmonitor, channelmonitor, BestBlock, ChannelMonitorUpdateStatus, Confirm, Watch, + chainmonitor, channelmonitor, BlockLocator, ChannelMonitorUpdateStatus, Confirm, Watch, }; use lightning::events; use lightning::ln::channel::{ @@ -332,7 +332,7 @@ impl chain::Watch for TestChainMonitor { .map(|(_, data)| data) .unwrap_or(&map_entry.persisted_monitor); let deserialized_monitor = - <(BestBlock, channelmonitor::ChannelMonitor)>::read( + <(BlockLocator, channelmonitor::ChannelMonitor)>::read( &mut &latest_monitor_data[..], (&*self.keys, &*self.keys), ) @@ -958,7 +958,7 @@ pub fn do_test(data: &[u8], out: Out) { } let network = Network::Bitcoin; let best_block_timestamp = genesis_block(network).header.time; - let params = ChainParameters { network, best_block: BestBlock::from_network(network) }; + let params = ChainParameters { network, best_block: BlockLocator::from_network(network) }; ( ChannelManager::new( $fee_estimator.clone(), @@ -1039,7 +1039,7 @@ pub fn do_test(data: &[u8], out: Out) { // Use a different value of `use_old_mons` if we have another monitor (only for node B) // by shifting `use_old_mons` one in base-3. use_old_mons /= 3; - let mon = <(BestBlock, ChannelMonitor)>::read( + let mon = <(BlockLocator, ChannelMonitor)>::read( &mut &serialized_mon[..], (&**keys, &**keys), ) @@ -1074,7 +1074,7 @@ pub fn do_test(data: &[u8], out: Out) { }; let manager = - <(BestBlock, ChanMan)>::read(&mut &ser[..], read_args).expect("Failed to read manager"); + <(BlockLocator, ChanMan)>::read(&mut &ser[..], read_args).expect("Failed to read manager"); let res = (manager.1, chain_monitor.clone()); for (channel_id, mon) in monitors.drain() { assert_eq!( diff --git a/fuzz/src/chanmon_deser.rs b/fuzz/src/chanmon_deser.rs index be9ffe8f026..3206db0b143 100644 --- a/fuzz/src/chanmon_deser.rs +++ b/fuzz/src/chanmon_deser.rs @@ -1,7 +1,7 @@ // This file is auto-generated by gen_target.sh based on msg_target_template.txt // To modify it, modify msg_target_template.txt and run gen_target.sh instead. -use lightning::chain::{channelmonitor, BestBlock}; +use lightning::chain::{channelmonitor, BlockLocator}; use lightning::util::ser::{ReadableArgs, Writeable, Writer}; use lightning::util::test_channel_signer::TestChannelSigner; use lightning::util::test_utils::OnlyReadsKeysInterface; @@ -21,14 +21,14 @@ impl Writer for VecWriter { #[inline] pub fn do_test(data: &[u8], _out: Out) { if let Ok((latest_block_hash, monitor)) = - <(BestBlock, channelmonitor::ChannelMonitor)>::read( + <(BlockLocator, channelmonitor::ChannelMonitor)>::read( &mut Cursor::new(data), (&OnlyReadsKeysInterface {}, &OnlyReadsKeysInterface {}), ) { let mut w = VecWriter(Vec::new()); monitor.write(&mut w).unwrap(); let deserialized_copy = - <(BestBlock, channelmonitor::ChannelMonitor)>::read( + <(BlockLocator, channelmonitor::ChannelMonitor)>::read( &mut Cursor::new(&w.0), (&OnlyReadsKeysInterface {}, &OnlyReadsKeysInterface {}), ) diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index 1f1cf425c92..405d615e6f0 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -39,7 +39,7 @@ use lightning::chain::chaininterface::{ }; use lightning::chain::chainmonitor; use lightning::chain::transaction::OutPoint; -use lightning::chain::{BestBlock, ChannelMonitorUpdateStatus, Confirm, Listen}; +use lightning::chain::{BlockLocator, ChannelMonitorUpdateStatus, Confirm, Listen}; use lightning::events::Event; use lightning::ln::channel_state::ChannelDetails; use lightning::ln::channelmanager::{ChainParameters, ChannelManager, InterceptId, PaymentId}; @@ -354,7 +354,7 @@ impl<'a> MoneyLossDetector<'a> { self.header_hashes[self.height - 1].0, self.header_hashes[self.height].1, ); - let best_block = BestBlock::new(header.prev_blockhash, self.height as u32 - 1); + let best_block = BlockLocator::new(header.prev_blockhash, self.height as u32 - 1); self.manager.blocks_disconnected(best_block); self.monitor.blocks_disconnected(best_block); self.height -= 1; @@ -606,7 +606,7 @@ pub fn do_test(mut data: &[u8], logger: &Arc let network = Network::Bitcoin; let best_block_timestamp = genesis_block(network).header.time; - let params = ChainParameters { network, best_block: BestBlock::from_network(network) }; + let params = ChainParameters { network, best_block: BlockLocator::from_network(network) }; let channelmanager = Arc::new(ChannelManager::new( fee_est.clone(), monitor.clone(), diff --git a/fuzz/src/lsps_message.rs b/fuzz/src/lsps_message.rs index 8ff85d0fc24..83fa5ddab6d 100644 --- a/fuzz/src/lsps_message.rs +++ b/fuzz/src/lsps_message.rs @@ -6,7 +6,7 @@ use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use bitcoin::Network; use lightning::chain::Filter; -use lightning::chain::{chainmonitor, BestBlock}; +use lightning::chain::{chainmonitor, BlockLocator}; use lightning::ln::channelmanager::{ChainParameters, ChannelManager}; use lightning::ln::peer_handler::CustomMessageHandler; use lightning::ln::wire::CustomMessageReader; @@ -61,7 +61,7 @@ pub fn do_test(data: &[u8]) { keys_manager.get_peer_storage_key(), false, )); - let best_block = BestBlock::from_network(network); + let best_block = BlockLocator::from_network(network); let params = ChainParameters { network, best_block }; let manager = Arc::new(ChannelManager::new( Arc::clone(&fee_estimator), diff --git a/lightning-background-processor/src/lib.rs b/lightning-background-processor/src/lib.rs index c796c53a031..8ab20d5a1f3 100644 --- a/lightning-background-processor/src/lib.rs +++ b/lightning-background-processor/src/lib.rs @@ -1937,7 +1937,7 @@ mod tests { use lightning::chain::chainmonitor; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; use lightning::chain::transaction::OutPoint; - use lightning::chain::{BestBlock, Confirm}; + use lightning::chain::{BlockLocator, Confirm}; use lightning::events::{Event, PathFailure, ReplayEvent}; use lightning::ln::channelmanager; use lightning::ln::channelmanager::{ @@ -2121,7 +2121,7 @@ mod tests { tx_broadcaster: Arc, network_graph: Arc>>, logger: Arc, - best_block: BestBlock, + best_block: BlockLocator, scorer: Arc>, sweeper: Arc< OutputSweeperSync< @@ -2484,7 +2484,7 @@ mod tests { keys_manager.get_peer_storage_key(), true, )); - let best_block = BestBlock::from_network(network); + let best_block = BlockLocator::from_network(network); let params = ChainParameters { network, best_block }; let mut config = UserConfig::default(); config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; @@ -2726,7 +2726,7 @@ mod tests { let height = node.best_block.height + 1; let header = create_dummy_header(prev_blockhash, height); let txdata = vec![(0, tx)]; - node.best_block = BestBlock::new(header.block_hash(), height); + node.best_block = BlockLocator::new(header.block_hash(), height); match i { 1 => { node.node.transactions_confirmed(&header, &txdata, height); @@ -2753,7 +2753,7 @@ mod tests { let prev_blockhash = node.best_block.block_hash; let height = node.best_block.height + 1; let header = create_dummy_header(prev_blockhash, height); - node.best_block = BestBlock::new(header.block_hash(), height); + node.best_block = BlockLocator::new(header.block_hash(), height); if i == num_blocks { // We need the TestBroadcaster to know about the new height so that it doesn't think // we're violating the time lock requirements of transactions broadcasted at that diff --git a/lightning-block-sync/src/init.rs b/lightning-block-sync/src/init.rs index 07c9f230be3..b41489e0a28 100644 --- a/lightning-block-sync/src/init.rs +++ b/lightning-block-sync/src/init.rs @@ -9,7 +9,7 @@ use bitcoin::block::Header; use bitcoin::network::Network; use lightning::chain; -use lightning::chain::BestBlock; +use lightning::chain::BlockLocator; use std::ops::Deref; @@ -46,7 +46,7 @@ where /// use bitcoin::network::Network; /// /// use lightning::chain; -/// use lightning::chain::{BestBlock, Watch}; +/// use lightning::chain::{BlockLocator, Watch}; /// use lightning::chain::chainmonitor; /// use lightning::chain::chainmonitor::ChainMonitor; /// use lightning::chain::channelmonitor::ChannelMonitor; @@ -93,7 +93,7 @@ where /// ) { /// // Read a serialized channel monitor paired with the best block when it was persisted. /// let serialized_monitor = "..."; -/// let (monitor_best_block, mut monitor) = <(BestBlock, ChannelMonitor)>::read( +/// let (monitor_best_block, mut monitor) = <(BlockLocator, ChannelMonitor)>::read( /// &mut Cursor::new(&serialized_monitor), (entropy_source, signer_provider)).unwrap(); /// /// // Read the channel manager paired with the best block when it was persisted. @@ -112,7 +112,7 @@ where /// config, /// vec![&mut monitor], /// ); -/// <(BestBlock, ChannelManager<&ChainMonitor, &T, &ES, &NS, &SP, &F, &R, &MR, &L>)>::read( +/// <(BlockLocator, ChannelManager<&ChainMonitor, &T, &ES, &NS, &SP, &F, &R, &MR, &L>)>::read( /// &mut Cursor::new(&serialized_manager), read_args).unwrap() /// }; /// @@ -140,7 +140,7 @@ where /// [`ChannelManager`]: lightning::ln::channelmanager::ChannelManager /// [`ChannelMonitor`]: lightning::chain::channelmonitor::ChannelMonitor pub async fn synchronize_listeners( - block_source: B, network: Network, mut chain_listeners: Vec<(BestBlock, &L)>, + block_source: B, network: Network, mut chain_listeners: Vec<(BlockLocator, &L)>, ) -> BlockSourceResult<(HeaderCache, ValidatedBlockHeader)> where B::Target: BlockSource, @@ -242,7 +242,7 @@ impl<'a, L: chain::Listen + ?Sized> chain::Listen for DynamicChainListener<'a, L unreachable!() } - fn blocks_disconnected(&self, fork_point: BestBlock) { + fn blocks_disconnected(&self, fork_point: BlockLocator) { self.0.blocks_disconnected(fork_point) } } @@ -266,9 +266,9 @@ mod tests { let listener_3 = MockChainListener::new().expect_block_connected(*chain.at_height(4)); let listeners = vec![ - (chain.best_block_at_height(1), &listener_1 as &dyn chain::Listen), - (chain.best_block_at_height(2), &listener_2 as &dyn chain::Listen), - (chain.best_block_at_height(3), &listener_3 as &dyn chain::Listen), + (chain.block_locator_at_height(1), &listener_1 as &dyn chain::Listen), + (chain.block_locator_at_height(2), &listener_2 as &dyn chain::Listen), + (chain.block_locator_at_height(3), &listener_3 as &dyn chain::Listen), ]; match synchronize_listeners(&chain, Network::Bitcoin, listeners).await { Ok((cache, header)) => { diff --git a/lightning-block-sync/src/lib.rs b/lightning-block-sync/src/lib.rs index 8e2c5b500f6..b5d76e3bd06 100644 --- a/lightning-block-sync/src/lib.rs +++ b/lightning-block-sync/src/lib.rs @@ -54,7 +54,7 @@ use bitcoin::hash_types::BlockHash; use bitcoin::pow::Work; use lightning::chain; -use lightning::chain::BestBlock; +use lightning::chain::BlockLocator; use std::future::Future; use std::ops::Deref; @@ -372,7 +372,7 @@ impl<'a, L: chain::Listen + ?Sized> ChainNotifier<'a, L> { /// Updates the header cache as it goes, tracking headers needed to find the diff to reuse for /// other objects that might need similar headers. async fn find_difference_from_best_block( - &mut self, current_header: ValidatedBlockHeader, prev_best_block: BestBlock, + &mut self, current_header: ValidatedBlockHeader, prev_best_block: BlockLocator, chain_poller: &mut P, ) -> BlockSourceResult { // Try to resolve the header for the previous best block. First try the block_hash, @@ -393,7 +393,9 @@ impl<'a, L: chain::Listen + ?Sized> ChainNotifier<'a, L> { break; } let height = prev_best_block.height.checked_sub(height_diff).ok_or( - BlockSourceError::persistent("BestBlock had more previous_blocks than its height"), + BlockSourceError::persistent( + "BlockLocator had more previous_blocks than its height", + ), )?; if let Ok(header) = chain_poller.get_header(block_hash, Some(height)).await { found_header = Some(header); @@ -402,7 +404,7 @@ impl<'a, L: chain::Listen + ?Sized> ChainNotifier<'a, L> { } } let found_header = found_header.ok_or_else(|| { - BlockSourceError::persistent("could not resolve any block from BestBlock") + BlockSourceError::persistent("could not resolve any block from BlockLocator") })?; self.find_difference_from_header(current_header, &found_header, chain_poller).await @@ -456,7 +458,7 @@ impl<'a, L: chain::Listen + ?Sized> ChainNotifier<'a, L> { /// Notifies the chain listeners of disconnected blocks. fn disconnect_blocks(&mut self, fork_point: ValidatedBlockHeader) { self.header_cache.blocks_disconnected(&fork_point); - let best_block = BestBlock::new(fork_point.block_hash, fork_point.height); + let best_block = BlockLocator::new(fork_point.block_hash, fork_point.height); self.chain_listener.blocks_disconnected(best_block); } diff --git a/lightning-block-sync/src/poll.rs b/lightning-block-sync/src/poll.rs index fd8c546c56f..5637be174cc 100644 --- a/lightning-block-sync/src/poll.rs +++ b/lightning-block-sync/src/poll.rs @@ -4,7 +4,7 @@ use crate::{BlockData, BlockHeaderData, BlockSource, BlockSourceError, BlockSour use bitcoin::hash_types::BlockHash; use bitcoin::network::Network; -use lightning::chain::BestBlock; +use lightning::chain::BlockLocator; use std::future::Future; use std::ops::Deref; @@ -160,7 +160,7 @@ impl ValidatedBlockHeader { Ok(()) } - /// Returns the [`BestBlock`] corresponding to this validated block header, which can be passed + /// Returns the [`BlockLocator`] corresponding to this validated block header, which can be passed /// into [`ChannelManager::new`] as part of its [`ChainParameters`]. Useful for ensuring that /// the [`SpvClient`] and [`ChannelManager`] are initialized to the same block during a fresh /// start. @@ -169,8 +169,8 @@ impl ValidatedBlockHeader { /// [`ChainParameters`]: lightning::ln::channelmanager::ChainParameters /// [`ChannelManager`]: lightning::ln::channelmanager::ChannelManager /// [`ChannelManager::new`]: lightning::ln::channelmanager::ChannelManager::new - pub fn to_best_block(&self) -> BestBlock { - BestBlock::new(self.block_hash, self.inner.height) + pub fn to_block_locator(&self) -> BlockLocator { + BlockLocator::new(self.block_hash, self.inner.height) } } diff --git a/lightning-block-sync/src/test_utils.rs b/lightning-block-sync/src/test_utils.rs index 01da431c243..20ed6f0545e 100644 --- a/lightning-block-sync/src/test_utils.rs +++ b/lightning-block-sync/src/test_utils.rs @@ -12,7 +12,7 @@ use bitcoin::transaction; use bitcoin::Transaction; use lightning::chain; -use lightning::chain::BestBlock; +use lightning::chain::BlockLocator; use std::cell::RefCell; use std::collections::VecDeque; @@ -104,12 +104,12 @@ impl Blockchain { block_header.validate(block_hash).unwrap() } - pub fn best_block_at_height(&self, height: usize) -> BestBlock { + pub fn block_locator_at_height(&self, height: usize) -> BlockLocator { let mut previous_blocks = [None; 12]; for (i, height) in (0..height).rev().take(12).enumerate() { previous_blocks[i] = Some(self.blocks[height].block_hash()); } - BestBlock { + BlockLocator { height: height as u32, block_hash: self.blocks[height].block_hash(), previous_blocks, @@ -135,9 +135,9 @@ impl Blockchain { self.at_height(self.blocks.len() - 1) } - pub fn best_block(&self) -> BestBlock { + pub fn best_block(&self) -> BlockLocator { assert!(!self.blocks.is_empty()); - self.best_block_at_height(self.blocks.len() - 1) + self.block_locator_at_height(self.blocks.len() - 1) } pub fn disconnect_tip(&mut self) -> Option { @@ -223,7 +223,7 @@ impl chain::Listen for NullChainListener { &self, _header: &Header, _txdata: &chain::transaction::TransactionData, _height: u32, ) { } - fn blocks_disconnected(&self, _fork_point: BestBlock) {} + fn blocks_disconnected(&self, _fork_point: BlockLocator) {} } pub struct MockChainListener { @@ -284,7 +284,7 @@ impl chain::Listen for MockChainListener { } } - fn blocks_disconnected(&self, fork_point: BestBlock) { + fn blocks_disconnected(&self, fork_point: BlockLocator) { match self.expected_blocks_disconnected.borrow_mut().pop_front() { None => { panic!( diff --git a/lightning/src/chain/chainmonitor.rs b/lightning/src/chain/chainmonitor.rs index ca01e95c054..b3b69096997 100644 --- a/lightning/src/chain/chainmonitor.rs +++ b/lightning/src/chain/chainmonitor.rs @@ -37,7 +37,7 @@ use crate::chain::channelmonitor::{ WithChannelMonitor, }; use crate::chain::transaction::{OutPoint, TransactionData}; -use crate::chain::{BestBlock, ChannelMonitorUpdateStatus, WatchedOutput}; +use crate::chain::{BlockLocator, ChannelMonitorUpdateStatus, WatchedOutput}; use crate::events::{self, Event, EventHandler, ReplayEvent}; use crate::ln::channel_state::ChannelDetails; #[cfg(peer_storage)] @@ -1473,7 +1473,7 @@ where self.event_notifier.notify(); } - fn blocks_disconnected(&self, fork_point: BestBlock) { + fn blocks_disconnected(&self, fork_point: BlockLocator) { let monitor_states = self.monitors.read().unwrap(); log_debug!( self.logger, diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs index 810de80da95..c3e20ef5e6f 100644 --- a/lightning/src/chain/channelmonitor.rs +++ b/lightning/src/chain/channelmonitor.rs @@ -42,7 +42,7 @@ use crate::chain::package::{ HolderHTLCOutput, PackageSolvingData, PackageTemplate, RevokedHTLCOutput, RevokedOutput, }; use crate::chain::transaction::{OutPoint, TransactionData}; -use crate::chain::{BestBlock, WatchedOutput}; +use crate::chain::{BlockLocator, WatchedOutput}; use crate::events::bump_transaction::{AnchorDescriptor, BumpTransactionEvent}; use crate::events::{ClosureReason, Event, EventHandler, ReplayEvent}; use crate::ln::chan_utils::{ @@ -505,7 +505,7 @@ impl OnchainEventEntry { conf_threshold } - fn has_reached_confirmation_threshold(&self, best_block: &BestBlock) -> bool { + fn has_reached_confirmation_threshold(&self, best_block: &BlockLocator) -> bool { best_block.height >= self.confirmation_threshold() } } @@ -1058,15 +1058,15 @@ impl Readable for IrrevocablyResolvedHTLC { /// You MUST ensure that no ChannelMonitors for a given channel anywhere contain out-of-date /// information and are actively monitoring the chain. /// -/// Like the [`ChannelManager`], deserialization is implemented for `(BestBlock, ChannelMonitor)`, -/// providing you with the last block hash which was connected before shutting down. You must begin -/// syncing the chain from that point, disconnecting and connecting blocks as required to get to -/// the best chain on startup. Note that all [`ChannelMonitor`]s passed to a [`ChainMonitor`] must +/// Like the [`ChannelManager`], deserialization is implemented for `(BlockLocator, ChannelMonitor)`, +/// providing a locator for the best chain as of the last write before shutting down. You must +/// begin syncing the chain from that locator, disconnecting and connecting blocks as required to +/// get to the best chain on startup. Note that all [`ChannelMonitor`]s passed to a [`ChainMonitor`] must /// by synced as of the same block, so syncing must happen prior to [`ChainMonitor`] /// initialization. /// /// For those loading potentially-ancient [`ChannelMonitor`]s, deserialization is also implemented -/// for `Option<(BestBlock, ChannelMonitor)>`. LDK can no longer deserialize a [`ChannelMonitor`] +/// for `Option<(BlockLocator, ChannelMonitor)>`. LDK can no longer deserialize a [`ChannelMonitor`] /// that was first created in LDK prior to 0.0.110 and last updated prior to LDK 0.0.119. In such /// cases, the `Option<(..)>` deserialization option may return `Ok(None)` rather than failing to /// deserialize, allowing you to differentiate between the two cases. @@ -1354,7 +1354,7 @@ pub(crate) struct ChannelMonitorImpl { // (we do *not*, however, update them in update_monitor to ensure any local user copies keep // their best_block from its state and not based on updated copies that didn't run through // the full block_connected). - best_block: BestBlock, + best_block: BlockLocator, /// The node_id of our counterparty counterparty_node_id: PublicKey, @@ -1858,7 +1858,7 @@ impl ChannelMonitor { on_counterparty_tx_csv: u16, destination_script: &Script, channel_parameters: &ChannelTransactionParameters, holder_pays_commitment_tx_fee: bool, commitment_transaction_number_obscure_factor: u64, - initial_holder_commitment_tx: HolderCommitmentTransaction, best_block: BestBlock, + initial_holder_commitment_tx: HolderCommitmentTransaction, best_block: BlockLocator, counterparty_node_id: PublicKey, channel_id: ChannelId, is_manual_broadcast: bool, ) -> ChannelMonitor { @@ -2379,7 +2379,7 @@ impl ChannelMonitor { /// Determines if the disconnected block contained any transactions of interest and updates /// appropriately. pub fn blocks_disconnected( - &self, fork_point: BestBlock, broadcaster: B, fee_estimator: F, logger: &L, + &self, fork_point: BlockLocator, broadcaster: B, fee_estimator: F, logger: &L, ) { let mut inner = self.inner.lock().unwrap(); let logger = WithChannelMonitor::from_impl(logger, &*inner, None); @@ -2472,7 +2472,7 @@ impl ChannelMonitor { /// Gets the latest best block which was connected either via the [`chain::Listen`] or /// [`chain::Confirm`] interfaces. - pub fn current_best_block(&self) -> BestBlock { + pub fn current_best_block(&self) -> BlockLocator { self.inner.lock().unwrap().best_block.clone() } @@ -5414,7 +5414,7 @@ impl ChannelMonitorImpl { log_trace!(logger, "Connecting new block {} at height {}", block_hash, height); self.block_confirmed(height, block_hash, vec![], vec![], vec![], &broadcaster, &fee_estimator, logger) } else if block_hash != self.best_block.block_hash { - self.best_block = BestBlock::new(block_hash, height); + self.best_block = BlockLocator::new(block_hash, height); log_trace!(logger, "Best block re-orged, replaced with new block {} at height {}", block_hash, height); self.onchain_events_awaiting_threshold_conf.retain(|ref entry| entry.height <= height); let conf_target = self.closure_conf_target(); @@ -5931,7 +5931,7 @@ impl ChannelMonitorImpl { #[rustfmt::skip] fn blocks_disconnected( - &mut self, fork_point: BestBlock, broadcaster: B, fee_estimator: F, logger: &WithContext + &mut self, fork_point: BlockLocator, broadcaster: B, fee_estimator: F, logger: &WithContext ) { let new_height = fork_point.height; log_trace!(logger, "Block(s) disconnected to height {}", new_height); @@ -6440,7 +6440,7 @@ impl ReadableArgs<(&'a ES, &'b SP)> - for (BestBlock, ChannelMonitor) + for (BlockLocator, ChannelMonitor) { fn read(reader: &mut R, args: (&'a ES, &'b SP)) -> Result { match >::read(reader, args) { @@ -6482,7 +6482,7 @@ impl<'a, 'b, ES: EntropySource, SP: SignerProvider> ReadableArgs<(&'a ES, &'b SP } impl<'a, 'b, ES: EntropySource, SP: SignerProvider> ReadableArgs<(&'a ES, &'b SP)> - for Option<(BestBlock, ChannelMonitor)> + for Option<(BlockLocator, ChannelMonitor)> { #[rustfmt::skip] fn read(reader: &mut R, args: (&'a ES, &'b SP)) -> Result { @@ -6645,7 +6645,7 @@ impl<'a, 'b, ES: EntropySource, SP: SignerProvider> ReadableArgs<(&'a ES, &'b SP } } - let mut best_block = BestBlock::new(Readable::read(reader)?, Readable::read(reader)?); + let mut best_block = BlockLocator::new(Readable::read(reader)?, Readable::read(reader)?); let waiting_threshold_conf_len: u64 = Readable::read(reader)?; let mut onchain_events_awaiting_threshold_conf = Vec::with_capacity(cmp::min(waiting_threshold_conf_len as usize, MAX_ALLOC_SIZE / 128)); @@ -6966,7 +6966,7 @@ pub(super) fn dummy_monitor( channel_value_satoshis: 0, }; let shutdown_script = crate::ln::script::ShutdownScript::new_p2wpkh_from_pubkey(dummy_key); - let best_block = BestBlock::from_network(Network::Testnet); + let best_block = BlockLocator::from_network(Network::Testnet); let signer = wrap_signer(keys); ChannelMonitor::new( secp_ctx, @@ -7014,7 +7014,7 @@ mod tests { weight_revoked_received_htlc, WEIGHT_REVOKED_OUTPUT, }; use crate::chain::transaction::OutPoint; - use crate::chain::{BestBlock, Confirm}; + use crate::chain::{BlockLocator, Confirm}; use crate::io; use crate::ln::chan_utils::{self, HTLCOutputInCommitment, HolderCommitmentTransaction}; use crate::ln::channel_keys::{ @@ -7081,7 +7081,7 @@ mod tests { nodes[1].chain_monitor.chain_monitor.transactions_confirmed(&new_header, &[(0, broadcast_tx)], conf_height); - let (_, pre_update_monitor) = <(BestBlock, ChannelMonitor<_>)>::read( + let (_, pre_update_monitor) = <(BlockLocator, ChannelMonitor<_>)>::read( &mut io::Cursor::new(&get_monitor!(nodes[1], channel.2).encode()), (&nodes[1].keys_manager.backing, &nodes[1].keys_manager.backing)).unwrap(); diff --git a/lightning/src/chain/mod.rs b/lightning/src/chain/mod.rs index 9692558cf7c..d72d58b3149 100644 --- a/lightning/src/chain/mod.rs +++ b/lightning/src/chain/mod.rs @@ -38,36 +38,38 @@ pub(crate) mod onchaintx; pub(crate) mod package; pub mod transaction; -/// The best known block as identified by its hash and height. +/// Identifies a position in the chain by its block hash and height, along with recent ancestor +/// hashes used to locate the fork point of a reorg. #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] -pub struct BestBlock { - /// The block's hash +pub struct BlockLocator { + /// The block's hash. pub block_hash: BlockHash, - /// The height at which the block was confirmed. + /// The block's height. pub height: u32, - /// Previous blocks immediately before [`Self::block_hash`], in reverse chronological order. + /// Ancestor block hashes immediately before [`Self::block_hash`], in reverse chronological + /// order. /// /// These ensure we can find the fork point of a reorg if our block source no longer has the - /// previous best tip after a restart. + /// previous tip after a restart. pub previous_blocks: [Option; ANTI_REORG_DELAY as usize * 2], } -impl BestBlock { - /// Constructs a `BestBlock` that represents the genesis block at height 0 of the given +impl BlockLocator { + /// Constructs a `BlockLocator` that represents the genesis block at height 0 of the given /// network. pub fn from_network(network: Network) -> Self { let block_hash = genesis_block(network).header.block_hash(); let previous_blocks = [None; ANTI_REORG_DELAY as usize * 2]; - BestBlock { block_hash, height: 0, previous_blocks } + BlockLocator { block_hash, height: 0, previous_blocks } } - /// Returns a `BestBlock` as identified by the given block hash and height. + /// Returns a `BlockLocator` as identified by the given block hash and height. /// /// This is not exported to bindings users directly as the bindings auto-generate an /// equivalent `new`. pub fn new(block_hash: BlockHash, height: u32) -> Self { let previous_blocks = [None; ANTI_REORG_DELAY as usize * 2]; - BestBlock { block_hash, height, previous_blocks } + BlockLocator { block_hash, height, previous_blocks } } /// Advances to a new block at height [`Self::height`] + 1. @@ -85,14 +87,14 @@ impl BestBlock { self.height += 1; } - /// Updates this object for a new best-block, either delegating to [`Self::advance`] if the new + /// Updates this locator for a new chain tip, either delegating to [`Self::advance`] if the new /// block is simply one higher than the current tip and wiping [`Self::previous_blocks`] if a /// few blocks have been skipped. pub fn update_for_new_tip(&mut self, new_tip_hash: BlockHash, new_tip_height: u32) { if new_tip_height == self.height + 1 { self.advance(new_tip_hash); } else { - *self = BestBlock::new(new_tip_hash, new_tip_height); + *self = BlockLocator::new(new_tip_hash, new_tip_height); } } @@ -115,12 +117,12 @@ impl BestBlock { } } - /// Find the most recent common ancestor between two BestBlocks by searching their block hash - /// histories. + /// Finds the most recent common ancestor between two [`BlockLocator`]s by searching their + /// ancestor hash histories. /// /// Returns the common block hash and height, or None if no common block is found in the /// available histories. - pub fn find_common_ancestor(&self, other: &BestBlock) -> Option<(BlockHash, u32)> { + pub fn find_common_ancestor(&self, other: &BlockLocator) -> Option<(BlockHash, u32)> { // First check if either tip matches if self.block_hash == other.block_hash && self.height == other.height { return Some((self.block_hash, self.height)); @@ -141,11 +143,11 @@ impl BestBlock { } } -impl_writeable_tlv_based!(BestBlock, { +impl_writeable_tlv_based!(BlockLocator, { (0, block_hash, required), // Note that any change to the previous_blocks array length will change the serialization // format and thus it is specified without constants here. - (1, previous_blocks_read, (legacy, [Option; 6 * 2], |_| Ok(()), |us: &BestBlock| Some(us.previous_blocks))), + (1, previous_blocks_read, (legacy, [Option; 6 * 2], |_| Ok(()), |us: &BlockLocator| Some(us.previous_blocks))), (2, height, required), (unused, previous_blocks, (static_value, previous_blocks_read.unwrap_or([None; 6 * 2]))), }); @@ -177,8 +179,8 @@ impl_writeable_tlv_based!(BestBlock, { /// /// # Object Birthday /// -/// Note that most implementations take a [`BestBlock`] on construction and blocks only need to be -/// applied starting from that point. +/// Note that most implementations take a [`BlockLocator`] on construction identifying the best +/// block at that time, and blocks only need to be applied starting from that point. pub trait Listen { /// Notifies the listener that a block was added at the given height, with the transaction data /// possibly filtered. @@ -192,11 +194,11 @@ pub trait Listen { /// Notifies the listener that one or more blocks were removed in anticipation of a reorg. /// - /// The provided [`BestBlock`] is the new best block after disconnecting blocks in the reorg - /// but before connecting new ones (i.e. the "fork point" block). For backwards compatibility, - /// you may instead walk the chain backwards, calling `blocks_disconnected` for each block - /// that is disconnected in a reorg. - fn blocks_disconnected(&self, fork_point_block: BestBlock); + /// The provided [`BlockLocator`] identifies the new best block after disconnecting blocks in + /// the reorg but before connecting new ones (i.e. the "fork point" block). For backwards + /// compatibility, you may instead walk the chain backwards, calling `blocks_disconnected` for + /// each block that is disconnected in a reorg. + fn blocks_disconnected(&self, fork_point_block: BlockLocator); } /// The `Confirm` trait is used to notify LDK when relevant transactions have been confirmed on @@ -532,7 +534,7 @@ impl Listen for dyn core::ops::Deref { (**self).filtered_block_connected(header, txdata, height); } - fn blocks_disconnected(&self, fork_point: BestBlock) { + fn blocks_disconnected(&self, fork_point: BlockLocator) { (**self).blocks_disconnected(fork_point); } } @@ -547,7 +549,7 @@ where self.1.filtered_block_connected(header, txdata, height); } - fn blocks_disconnected(&self, fork_point: BestBlock) { + fn blocks_disconnected(&self, fork_point: BlockLocator) { self.0.blocks_disconnected(fork_point); self.1.blocks_disconnected(fork_point); } @@ -584,8 +586,8 @@ mod tests { #[test] fn test_best_block() { let hash1 = BlockHash::from_slice(&[1; 32]).unwrap(); - let mut chain_a = BestBlock::new(hash1, 100); - let mut chain_b = BestBlock::new(hash1, 100); + let mut chain_a = BlockLocator::new(hash1, 100); + let mut chain_b = BlockLocator::new(hash1, 100); // Test get_hash_at_height on initial block assert_eq!(chain_a.get_hash_at_height(100), Some(hash1)); @@ -613,7 +615,7 @@ mod tests { // Test find_common_ancestor with no common history let hash_other = BlockHash::from_slice(&[99; 32]).unwrap(); - let chain_c = BestBlock::new(hash_other, 200); + let chain_c = BlockLocator::new(hash_other, 200); assert_eq!(chain_a.find_common_ancestor(&chain_c), None); } } diff --git a/lightning/src/ln/chanmon_update_fail_tests.rs b/lightning/src/ln/chanmon_update_fail_tests.rs index af4d1569d0c..9633800db08 100644 --- a/lightning/src/ln/chanmon_update_fail_tests.rs +++ b/lightning/src/ln/chanmon_update_fail_tests.rs @@ -16,7 +16,7 @@ use crate::chain::chaininterface::LowerBoundedFeeEstimator; use crate::chain::chainmonitor::ChainMonitor; use crate::chain::channelmonitor::{ChannelMonitor, MonitorEvent, ANTI_REORG_DELAY}; use crate::chain::transaction::OutPoint; -use crate::chain::{BestBlock, ChannelMonitorUpdateStatus, Confirm, Listen, Watch}; +use crate::chain::{BlockLocator, ChannelMonitorUpdateStatus, Confirm, Listen, Watch}; use crate::events::{ClosureReason, Event, HTLCHandlingFailureType, PaymentPurpose}; use crate::ln::channel::AnnouncementSigsState; use crate::ln::channelmanager::{PaymentId, RAACommitmentOrder, TrustedChannelFeatures}; @@ -90,7 +90,7 @@ fn test_monitor_and_persister_update_fail() { let chain_mon = { let new_monitor = { let monitor = nodes[0].chain_monitor.chain_monitor.get_monitor(chan.2).unwrap(); - let (_, new_monitor) = <(BestBlock, ChannelMonitor)>::read( + let (_, new_monitor) = <(BlockLocator, ChannelMonitor)>::read( &mut &monitor.encode()[..], (nodes[0].keys_manager, nodes[0].keys_manager), ) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 10801edef01..fbf2a4caa9f 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -35,7 +35,7 @@ use crate::chain::channelmonitor::{ LATENCY_GRACE_PERIOD_BLOCKS, }; use crate::chain::transaction::{OutPoint, TransactionData}; -use crate::chain::BestBlock; +use crate::chain::BlockLocator; use crate::events::{ClosureReason, FundingInfo}; use crate::ln::chan_utils; use crate::ln::chan_utils::{ @@ -2053,7 +2053,7 @@ where #[rustfmt::skip] pub fn funding_signed( - &mut self, msg: &msgs::FundingSigned, best_block: BestBlock, signer_provider: &SP, logger: &L + &mut self, msg: &msgs::FundingSigned, best_block: BlockLocator, signer_provider: &SP, logger: &L ) -> Result<(&mut FundedChannel, ChannelMonitor), ChannelError> { let phase = core::mem::replace(&mut self.phase, ChannelPhase::Undefined); let result = if let ChannelPhase::UnfundedOutboundV1(chan) = phase { @@ -2326,7 +2326,7 @@ where #[rustfmt::skip] pub fn commitment_signed( - &mut self, msg: &msgs::CommitmentSigned, best_block: BestBlock, signer_provider: &SP, fee_estimator: &LowerBoundedFeeEstimator, logger: &L + &mut self, msg: &msgs::CommitmentSigned, best_block: BlockLocator, signer_provider: &SP, fee_estimator: &LowerBoundedFeeEstimator, logger: &L ) -> Result<(Option>, Option), ChannelError> { let phase = core::mem::replace(&mut self.phase, ChannelPhase::Undefined); match phase { @@ -3542,7 +3542,7 @@ trait InitialRemoteCommitmentReceiver { #[rustfmt::skip] fn initial_commitment_signed( &mut self, channel_id: ChannelId, counterparty_signature: Signature, holder_commitment_point: &mut HolderCommitmentPoint, - best_block: BestBlock, signer_provider: &SP, logger: &L, + best_block: BlockLocator, signer_provider: &SP, logger: &L, ) -> Result<(ChannelMonitor, CommitmentTransaction), ChannelError> { let initial_commitment_tx = match self.check_counterparty_commitment_signature(&counterparty_signature, holder_commitment_point, logger) { Ok(res) => res, @@ -7837,7 +7837,7 @@ where #[rustfmt::skip] pub fn channel_ready( &mut self, msg: &msgs::ChannelReady, node_signer: &NS, chain_hash: ChainHash, - user_config: &UserConfig, best_block: &BestBlock, logger: &L + user_config: &UserConfig, best_block: &BlockLocator, logger: &L ) -> Result, ChannelError> { if self.context.channel_state.is_peer_disconnected() { self.context.workaround_lnd_bug_4006 = Some(msg.clone()); @@ -8244,7 +8244,7 @@ where } pub fn initial_commitment_signed_v2( - &mut self, msg: &msgs::CommitmentSigned, best_block: BestBlock, signer_provider: &SP, + &mut self, msg: &msgs::CommitmentSigned, best_block: BlockLocator, signer_provider: &SP, logger: &L, ) -> Result, ChannelError> { if let Some(signing_session) = self.context.interactive_tx_signing_session.as_ref() { @@ -10237,7 +10237,7 @@ where #[rustfmt::skip] pub fn channel_reestablish( &mut self, msg: &msgs::ChannelReestablish, logger: &L, node_signer: &NS, - chain_hash: ChainHash, user_config: &UserConfig, best_block: &BestBlock, + chain_hash: ChainHash, user_config: &UserConfig, best_block: &BlockLocator, path_for_release_htlc: CBP, ) -> Result where @@ -14572,7 +14572,7 @@ impl OutboundV1Channel { /// Handles a funding_signed message from the remote end. /// If this call is successful, broadcast the funding transaction (and not before!) pub fn funding_signed( - mut self, msg: &msgs::FundingSigned, best_block: BestBlock, signer_provider: &SP, + mut self, msg: &msgs::FundingSigned, best_block: BlockLocator, signer_provider: &SP, logger: &L, ) -> Result< (FundedChannel, ChannelMonitor), @@ -14866,7 +14866,7 @@ impl InboundV1Channel { } pub fn funding_created( - mut self, msg: &msgs::FundingCreated, best_block: BestBlock, signer_provider: &SP, + mut self, msg: &msgs::FundingCreated, best_block: BlockLocator, signer_provider: &SP, logger: &L, ) -> Result< (FundedChannel, Option, ChannelMonitor), @@ -16801,7 +16801,7 @@ pub(crate) fn hold_time_since(send_timestamp: Option) -> Option { mod tests { use crate::chain::chaininterface::LowerBoundedFeeEstimator; use crate::chain::transaction::OutPoint; - use crate::chain::BestBlock; + use crate::chain::BlockLocator; use crate::ln::chan_utils::{self, commit_tx_fee_sat, ChannelTransactionParameters}; use crate::ln::channel::{ AwaitingChannelReadyFlags, ChannelState, FundedChannel, HTLCUpdateAwaitingACK, @@ -17000,7 +17000,7 @@ mod tests { let network = Network::Testnet; let keys_provider = TestKeysInterface::new(&seed, network); let logger = TestLogger::new(); - let best_block = BestBlock::from_network(network); + let best_block = BlockLocator::from_network(network); // Go through the flow of opening a channel between two nodes, making sure // they have different dust limits. @@ -17146,7 +17146,7 @@ mod tests { let secp_ctx = Secp256k1::new(); let seed = [42; 32]; let network = Network::Testnet; - let best_block = BestBlock::from_network(network); + let best_block = BlockLocator::from_network(network); let chain_hash = ChainHash::using_genesis_block(network); let keys_provider = TestKeysInterface::new(&seed, network); @@ -17382,7 +17382,7 @@ mod tests { let secp_ctx = Secp256k1::new(); let seed = [42; 32]; let network = Network::Testnet; - let best_block = BestBlock::from_network(network); + let best_block = BlockLocator::from_network(network); let chain_hash = ChainHash::using_genesis_block(network); let keys_provider = TestKeysInterface::new(&seed, network); @@ -17460,7 +17460,7 @@ mod tests { let secp_ctx = Secp256k1::new(); let seed = [42; 32]; let network = Network::Testnet; - let best_block = BestBlock::from_network(network); + let best_block = BlockLocator::from_network(network); let keys_provider = TestKeysInterface::new(&seed, network); let node_b_node_id = @@ -19115,7 +19115,7 @@ mod tests { let secp_ctx = Secp256k1::new(); let seed = [42; 32]; let network = Network::Testnet; - let best_block = BestBlock::from_network(network); + let best_block = BlockLocator::from_network(network); let chain_hash = ChainHash::using_genesis_block(network); let keys_provider = TestKeysInterface::new(&seed, network); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 73d9a67f50f..570639d8995 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -48,7 +48,7 @@ use crate::chain::channelmonitor::{ LATENCY_GRACE_PERIOD_BLOCKS, MAX_BLOCKS_FOR_CONF, }; use crate::chain::transaction::{OutPoint, TransactionData}; -use crate::chain::{BestBlock, ChannelMonitorUpdateStatus, Confirm, Watch}; +use crate::chain::{BlockLocator, ChannelMonitorUpdateStatus, Confirm, Watch}; use crate::events::{ self, ClosureReason, Event, EventHandler, EventsProvider, HTLCHandlingFailureType, InboundChannelFunds, PaymentFailureReason, ReplayEvent, @@ -2126,7 +2126,7 @@ impl< /// /// ``` /// use bitcoin::network::Network; -/// use lightning::chain::BestBlock; +/// use lightning::chain::BlockLocator; /// # use lightning::chain::channelmonitor::ChannelMonitor; /// use lightning::ln::channelmanager::{ChainParameters, ChannelManager, ChannelManagerReadArgs}; /// # use lightning::routing::gossip::NetworkGraph; @@ -2152,7 +2152,7 @@ impl< /// # entropy_source: &ES, /// # node_signer: &dyn lightning::sign::NodeSigner, /// # signer_provider: &lightning::sign::DynSignerProvider, -/// # best_block: lightning::chain::BestBlock, +/// # best_block: lightning::chain::BlockLocator, /// # current_timestamp: u32, /// # mut reader: R, /// # ) -> Result<(), lightning::ln::msgs::DecodeError> { @@ -2174,7 +2174,7 @@ impl< /// router, message_router, logger, config, channel_monitors.iter().collect(), /// ); /// let (best_block, channel_manager) = -/// <(BestBlock, ChannelManager<_, _, _, _, _, _, _, _, _>)>::read(&mut reader, args)?; +/// <(BlockLocator, ChannelManager<_, _, _, _, _, _, _, _, _>)>::read(&mut reader, args)?; /// /// // Update the ChannelManager and ChannelMonitors with the latest chain data /// // ... @@ -2741,9 +2741,10 @@ impl< /// [`read`], those channels will be force-closed based on the `ChannelMonitor` state and no funds /// will be lost (modulo on-chain transaction fees). /// -/// Note that the deserializer is only implemented for `(`[`BestBlock`]`, `[`ChannelManager`]`)`, which -/// tells you the last block hash which was connected. You should get the best block tip before using the manager. -/// See [`chain::Listen`] and [`chain::Confirm`] for more details. +/// Note that the deserializer is only implemented for `(`[`BlockLocator`]`, `[`ChannelManager`]`)`, +/// which provides a locator for the best chain as of the last write. You should sync to the +/// current best chain tip before using the manager. See [`chain::Listen`] and [`chain::Confirm`] +/// for more details. /// /// # `ChannelUpdate` Messages /// @@ -2835,9 +2836,9 @@ pub struct ChannelManager< flow: OffersMessageFlow, #[cfg(any(test, feature = "_test_utils"))] - pub(super) best_block: RwLock, + pub(super) best_block: RwLock, #[cfg(not(any(test, feature = "_test_utils")))] - best_block: RwLock, + best_block: RwLock, pub(super) secp_ctx: Secp256k1, /// The session_priv bytes and retry metadata of outbound payments which are pending resolution. @@ -3045,7 +3046,7 @@ pub struct ChainParameters { /// The hash and height of the latest block successfully connected. /// /// Used to track on-chain channel funding outputs and send payments with reliable timelocks. - pub best_block: BestBlock, + pub best_block: BlockLocator, } #[derive(Copy, Clone, PartialEq)] @@ -3673,7 +3674,7 @@ impl< /// /// [`block_connected`]: chain::Listen::block_connected /// [`blocks_disconnected`]: chain::Listen::blocks_disconnected - /// [`params.best_block.block_hash`]: chain::BestBlock::block_hash + /// [`params.best_block.block_hash`]: chain::BlockLocator::block_hash #[rustfmt::skip] pub fn new( fee_est: F, chain_monitor: M, tx_broadcaster: T, router: R, message_router: MR, logger: L, @@ -15934,7 +15935,7 @@ impl< self.best_block_updated(header, height); } - fn blocks_disconnected(&self, fork_point: BestBlock) { + fn blocks_disconnected(&self, fork_point: BlockLocator) { let _persistence_guard = PersistenceNotifierGuard::optionally_notify_skipping_background_events( self, @@ -16431,7 +16432,7 @@ impl< /// Gets the latest best block which was connected either via the [`chain::Listen`] or /// [`chain::Confirm`] interfaces. - pub fn current_best_block(&self) -> BestBlock { + pub fn current_best_block(&self) -> BlockLocator { self.best_block.read().unwrap().clone() } @@ -18372,7 +18373,7 @@ impl Readable for AmountlessClaimablePaymentHTLCOnion { // This is an internal DTO used in the two-stage deserialization process. pub(super) struct ChannelManagerData { chain_hash: ChainHash, - best_block: BestBlock, + best_block: BlockLocator, channels: Vec>, claimable_payments: HashMap, peer_init_features: Vec<(PublicKey, InitFeatures)>, @@ -18694,7 +18695,7 @@ impl<'a, ES: EntropySource, SP: SignerProvider, L: Logger> Ok(ChannelManagerData { chain_hash, - best_block: BestBlock { + best_block: BlockLocator { block_hash: best_block_hash, height: best_block_height, previous_blocks: best_block_previous_blocks.unwrap_or([None; 12]), @@ -18731,7 +18732,7 @@ impl<'a, ES: EntropySource, SP: SignerProvider, L: Logger> /// is: /// 1) Deserialize all stored [`ChannelMonitor`]s. /// 2) Deserialize the [`ChannelManager`] by filling in this struct and calling: -/// `<(BestBlock, ChannelManager)>::read(reader, args)` +/// `<(BlockLocator, ChannelManager)>::read(reader, args)` /// This may result in closing some channels if the [`ChannelMonitor`] is newer than the stored /// [`ChannelManager`] state to ensure no loss of funds. Thus, transactions may be broadcasted. /// 3) If you are not fetching full blocks, register all relevant [`ChannelMonitor`] outpoints the @@ -18932,13 +18933,13 @@ impl< MR: MessageRouter, L: Logger + Clone, > ReadableArgs> - for (BestBlock, Arc>) + for (BlockLocator, Arc>) { fn read( reader: &mut Reader, args: ChannelManagerReadArgs<'a, M, T, ES, NS, SP, F, R, MR, L>, ) -> Result { let (best_block, chan_manager) = - <(BestBlock, ChannelManager)>::read(reader, args)?; + <(BlockLocator, ChannelManager)>::read(reader, args)?; Ok((best_block, Arc::new(chan_manager))) } } @@ -18955,7 +18956,7 @@ impl< MR: MessageRouter, L: Logger + Clone, > ReadableArgs> - for (BestBlock, ChannelManager) + for (BlockLocator, ChannelManager) { fn read( reader: &mut Reader, args: ChannelManagerReadArgs<'a, M, T, ES, NS, SP, F, R, MR, L>, @@ -18999,7 +19000,7 @@ impl< pub(super) fn from_channel_manager_data( data: ChannelManagerData, mut args: ChannelManagerReadArgs<'_, M, T, ES, NS, SP, F, R, MR, L>, - ) -> Result<(BestBlock, Self), DecodeError> { + ) -> Result<(BlockLocator, Self), DecodeError> { let ChannelManagerData { chain_hash, best_block, @@ -21804,7 +21805,7 @@ pub mod bench { use crate::chain::Listen; use crate::events::Event; use crate::ln::channelmanager::{ - BestBlock, ChainParameters, ChannelManager, PaymentHash, PaymentId, PaymentPreimage, + BlockLocator, ChainParameters, ChannelManager, PaymentHash, PaymentId, PaymentPreimage, RecipientOnionFields, Retry, }; use crate::ln::functional_test_utils::*; @@ -21891,7 +21892,7 @@ pub mod bench { let chain_monitor_a = ChainMonitor::new(None, &tx_broadcaster, &logger_a, &fee_estimator, &persister_a, &keys_manager_a, keys_manager_a.get_peer_storage_key(), false); let node_a = ChannelManager::new(&fee_estimator, &chain_monitor_a, &tx_broadcaster, &router, &message_router, &logger_a, &keys_manager_a, &keys_manager_a, &keys_manager_a, config.clone(), ChainParameters { network, - best_block: BestBlock::from_network(network), + best_block: BlockLocator::from_network(network), }, genesis_block.header.time); let node_a_holder = ANodeHolder { node: &node_a }; @@ -21901,7 +21902,7 @@ pub mod bench { let chain_monitor_b = ChainMonitor::new(None, &tx_broadcaster, &logger_a, &fee_estimator, &persister_b, &keys_manager_b, keys_manager_b.get_peer_storage_key(), false); let node_b = ChannelManager::new(&fee_estimator, &chain_monitor_b, &tx_broadcaster, &router, &message_router, &logger_b, &keys_manager_b, &keys_manager_b, &keys_manager_b, config.clone(), ChainParameters { network, - best_block: BestBlock::from_network(network), + best_block: BlockLocator::from_network(network), }, genesis_block.header.time); let node_b_holder = ANodeHolder { node: &node_b }; @@ -21955,7 +21956,7 @@ pub mod bench { assert_eq!(&tx_broadcaster.txn_broadcasted.lock().unwrap()[..], &[tx.clone()]); - let block = create_dummy_block(BestBlock::from_network(network).block_hash, 42, vec![tx]); + let block = create_dummy_block(BlockLocator::from_network(network).block_hash, 42, vec![tx]); Listen::block_connected(&node_a, &block, 1); Listen::block_connected(&node_b, &block, 1); diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index c1923730a3d..b48d76d646d 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -15,7 +15,7 @@ use crate::blinded_path::payment::{ }; use crate::chain::channelmonitor::{ChannelMonitor, HTLC_FAIL_BACK_BUFFER}; use crate::chain::transaction::OutPoint; -use crate::chain::{BestBlock, ChannelMonitorUpdateStatus, Confirm, Listen, Watch}; +use crate::chain::{BlockLocator, ChannelMonitorUpdateStatus, Confirm, Listen, Watch}; use crate::events::bump_transaction::sync::BumpTransactionEventHandlerSync; use crate::events::bump_transaction::BumpTransactionEvent; use crate::events::{ @@ -447,13 +447,13 @@ pub fn disconnect_blocks<'a, 'b, 'c, 'd>(node: &'a Node<'b, 'c, 'd>, count: u32) match *node.connect_style.borrow() { ConnectStyle::FullBlockViaListen => { - let best_block = BestBlock::new(orig.0.header.prev_blockhash, orig.1 - 1); + let best_block = BlockLocator::new(orig.0.header.prev_blockhash, orig.1 - 1); node.chain_monitor.chain_monitor.blocks_disconnected(best_block); Listen::blocks_disconnected(node.node, best_block); }, ConnectStyle::FullBlockDisconnectionsSkippingViaListen => { if i == count - 1 { - let best_block = BestBlock::new(orig.0.header.prev_blockhash, orig.1 - 1); + let best_block = BlockLocator::new(orig.0.header.prev_blockhash, orig.1 - 1); node.chain_monitor.chain_monitor.blocks_disconnected(best_block); Listen::blocks_disconnected(node.node, best_block); } @@ -848,7 +848,7 @@ impl<'a, 'b, 'c> Drop for Node<'a, 'b, 'c> { let mon = self.chain_monitor.chain_monitor.get_monitor(channel_id).unwrap(); mon.write(&mut w).unwrap(); let (_, deserialized_monitor) = - <(BestBlock, ChannelMonitor)>::read( + <(BlockLocator, ChannelMonitor)>::read( &mut io::Cursor::new(&w.0), (self.keys_manager, self.keys_manager), ) @@ -877,7 +877,7 @@ impl<'a, 'b, 'c> Drop for Node<'a, 'b, 'c> { let mut w = test_utils::TestVecWriter(Vec::new()); self.node.write(&mut w).unwrap(); <( - BestBlock, + BlockLocator, ChannelManager< &test_utils::TestChainMonitor, &test_utils::TestBroadcaster, @@ -1312,7 +1312,7 @@ pub fn _reload_node<'a, 'b, 'c>( let mut monitors_read = Vec::with_capacity(monitors_encoded.len()); for encoded in monitors_encoded { let mut monitor_read = &encoded[..]; - let (_, monitor) = <(BestBlock, ChannelMonitor)>::read( + let (_, monitor) = <(BlockLocator, ChannelMonitor)>::read( &mut monitor_read, (node.keys_manager, node.keys_manager), ) @@ -1327,7 +1327,7 @@ pub fn _reload_node<'a, 'b, 'c>( for monitor in monitors_read.iter() { assert!(channel_monitors.insert(monitor.channel_id(), monitor).is_none()); } - <(BestBlock, TestChannelManager<'b, 'c>)>::read( + <(BlockLocator, TestChannelManager<'b, 'c>)>::read( &mut node_read, ChannelManagerReadArgs { config, @@ -4716,7 +4716,7 @@ pub fn create_node_chanmgrs<'a, 'b>( for i in 0..node_count { let network = Network::Testnet; let genesis_block = bitcoin::constants::genesis_block(network); - let params = ChainParameters { network, best_block: BestBlock::from_network(network) }; + let params = ChainParameters { network, best_block: BlockLocator::from_network(network) }; let node = ChannelManager::new( cfgs[i].fee_estimator, &cfgs[i].chain_monitor, diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index 8d9df062868..c8ecb40fa6d 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -19,7 +19,7 @@ use crate::chain::channelmonitor::{ LATENCY_GRACE_PERIOD_BLOCKS, }; use crate::chain::transaction::OutPoint; -use crate::chain::BestBlock; +use crate::chain::BlockLocator; use crate::chain::{ChannelMonitorUpdateStatus, Confirm, Listen, Watch}; use crate::events::{ ClosureReason, Event, HTLCHandlingFailureType, PathFailure, PaymentFailureReason, @@ -7378,7 +7378,7 @@ pub fn test_update_err_monitor_lockdown() { let new_monitor = { let monitor = nodes[0].chain_monitor.chain_monitor.get_monitor(chan_1.2).unwrap(); let new_monitor = - <(BestBlock, channelmonitor::ChannelMonitor)>::read( + <(BlockLocator, channelmonitor::ChannelMonitor)>::read( &mut io::Cursor::new(&monitor.encode()), (nodes[0].keys_manager, nodes[0].keys_manager), ) @@ -7486,7 +7486,7 @@ pub fn test_concurrent_monitor_claim() { let new_monitor = { let monitor = nodes[0].chain_monitor.chain_monitor.get_monitor(chan_1.2).unwrap(); let new_monitor = - <(BestBlock, channelmonitor::ChannelMonitor)>::read( + <(BlockLocator, channelmonitor::ChannelMonitor)>::read( &mut io::Cursor::new(&monitor.encode()), (nodes[0].keys_manager, nodes[0].keys_manager), ) @@ -7536,7 +7536,7 @@ pub fn test_concurrent_monitor_claim() { let new_monitor = { let monitor = nodes[0].chain_monitor.chain_monitor.get_monitor(chan_1.2).unwrap(); let new_monitor = - <(BestBlock, channelmonitor::ChannelMonitor)>::read( + <(BlockLocator, channelmonitor::ChannelMonitor)>::read( &mut io::Cursor::new(&monitor.encode()), (nodes[0].keys_manager, nodes[0].keys_manager), ) diff --git a/lightning/src/ln/reload_tests.rs b/lightning/src/ln/reload_tests.rs index 9e992467ecd..16ba896685e 100644 --- a/lightning/src/ln/reload_tests.rs +++ b/lightning/src/ln/reload_tests.rs @@ -11,7 +11,7 @@ //! Functional tests which test for correct behavior across node restarts. -use crate::chain::{BestBlock, ChannelMonitorUpdateStatus, Watch}; +use crate::chain::{BlockLocator, ChannelMonitorUpdateStatus, Watch}; use crate::chain::chaininterface::LowerBoundedFeeEstimator; use crate::chain::channelmonitor::{ChannelMonitor, ChannelMonitorUpdateStep}; use crate::routing::router::{PaymentParameters, RouteParameters}; @@ -411,7 +411,7 @@ fn test_manager_serialize_deserialize_inconsistent_monitor() { let mut node_0_stale_monitors = Vec::new(); for serialized in node_0_stale_monitors_serialized.iter() { let mut read = &serialized[..]; - let (_, monitor) = <(BestBlock, ChannelMonitor)>::read(&mut read, (keys_manager, keys_manager)).unwrap(); + let (_, monitor) = <(BlockLocator, ChannelMonitor)>::read(&mut read, (keys_manager, keys_manager)).unwrap(); assert!(read.is_empty()); node_0_stale_monitors.push(monitor); } @@ -419,14 +419,14 @@ fn test_manager_serialize_deserialize_inconsistent_monitor() { let mut node_0_monitors = Vec::new(); for serialized in node_0_monitors_serialized.iter() { let mut read = &serialized[..]; - let (_, monitor) = <(BestBlock, ChannelMonitor)>::read(&mut read, (keys_manager, keys_manager)).unwrap(); + let (_, monitor) = <(BlockLocator, ChannelMonitor)>::read(&mut read, (keys_manager, keys_manager)).unwrap(); assert!(read.is_empty()); node_0_monitors.push(monitor); } let mut nodes_0_read = &nodes_0_serialized[..]; if let Err(msgs::DecodeError::DangerousValue) = - <(BestBlock, ChannelManager<&test_utils::TestChainMonitor, &test_utils::TestBroadcaster, &test_utils::TestKeysInterface, &test_utils::TestKeysInterface, &test_utils::TestKeysInterface, &test_utils::TestFeeEstimator, &test_utils::TestRouter, &test_utils::TestMessageRouter, &test_utils::TestLogger>)>::read(&mut nodes_0_read, ChannelManagerReadArgs { + <(BlockLocator, ChannelManager<&test_utils::TestChainMonitor, &test_utils::TestBroadcaster, &test_utils::TestKeysInterface, &test_utils::TestKeysInterface, &test_utils::TestKeysInterface, &test_utils::TestFeeEstimator, &test_utils::TestRouter, &test_utils::TestMessageRouter, &test_utils::TestLogger>)>::read(&mut nodes_0_read, ChannelManagerReadArgs { config: UserConfig::default(), entropy_source: keys_manager, node_signer: keys_manager, @@ -445,7 +445,7 @@ fn test_manager_serialize_deserialize_inconsistent_monitor() { let mut nodes_0_read = &nodes_0_serialized[..]; let (_, nodes_0_deserialized_tmp) = - <(BestBlock, ChannelManager<&test_utils::TestChainMonitor, &test_utils::TestBroadcaster, &test_utils::TestKeysInterface, &test_utils::TestKeysInterface, &test_utils::TestKeysInterface, &test_utils::TestFeeEstimator, &test_utils::TestRouter, &test_utils::TestMessageRouter, &test_utils::TestLogger>)>::read(&mut nodes_0_read, ChannelManagerReadArgs { + <(BlockLocator, ChannelManager<&test_utils::TestChainMonitor, &test_utils::TestBroadcaster, &test_utils::TestKeysInterface, &test_utils::TestKeysInterface, &test_utils::TestKeysInterface, &test_utils::TestFeeEstimator, &test_utils::TestRouter, &test_utils::TestMessageRouter, &test_utils::TestLogger>)>::read(&mut nodes_0_read, ChannelManagerReadArgs { config: UserConfig::default(), entropy_source: keys_manager, node_signer: keys_manager, diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 2edcbc8aba8..6c1b7a5befe 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -29,7 +29,7 @@ use crate::chain::channelmonitor::LATENCY_GRACE_PERIOD_BLOCKS; #[allow(unused_imports)] use crate::prelude::*; -use crate::chain::BestBlock; +use crate::chain::BlockLocator; use crate::ln::channel_state::ChannelDetails; use crate::ln::channelmanager::{InterceptId, PaymentId, CLTV_FAR_FAR_AWAY}; use crate::ln::inbound_payment; @@ -69,7 +69,7 @@ use crate::util::ser::Writeable; /// for finding message paths when initiating and retrying onion messages. pub struct OffersMessageFlow { chain_hash: ChainHash, - best_block: RwLock, + best_block: RwLock, our_network_pubkey: PublicKey, highest_seen_timestamp: AtomicUsize, @@ -94,7 +94,7 @@ pub struct OffersMessageFlow { impl OffersMessageFlow { /// Creates a new [`OffersMessageFlow`] pub fn new( - chain_hash: ChainHash, best_block: BestBlock, our_network_pubkey: PublicKey, + chain_hash: ChainHash, best_block: BlockLocator, our_network_pubkey: PublicKey, current_timestamp: u32, inbound_payment_key: inbound_payment::ExpandedKey, receive_auth_key: ReceiveAuthKey, secp_ctx: Secp256k1, message_router: MR, logger: L, @@ -189,7 +189,7 @@ impl OffersMessageFlow { // Note that we deliberately don't use `update_for_new_tip` as we dont rely on receiving // disconnection information instead expecting to simply "jump" to the new tip. - *self.best_block.write().unwrap() = BestBlock::new(header.block_hash(), height); + *self.best_block.write().unwrap() = BlockLocator::new(header.block_hash(), height); loop { // Update timestamp to be the max of its current value and the block diff --git a/lightning/src/util/persist.rs b/lightning/src/util/persist.rs index 68359636f6b..95d6032e130 100644 --- a/lightning/src/util/persist.rs +++ b/lightning/src/util/persist.rs @@ -33,7 +33,7 @@ use crate::chain::chaininterface::{BroadcasterInterface, FeeEstimator}; use crate::chain::chainmonitor::Persist; use crate::chain::channelmonitor::{ChannelMonitor, ChannelMonitorUpdate}; use crate::chain::transaction::OutPoint; -use crate::chain::BestBlock; +use crate::chain::BlockLocator; use crate::ln::types::ChannelId; use crate::sign::{ecdsa::EcdsaChannelSigner, EntropySource, SignerProvider}; use crate::sync::Mutex; @@ -654,7 +654,7 @@ impl Persist( kv_store: K, entropy_source: ES, signer_provider: SP, -) -> Result)>, io::Error> +) -> Result)>, io::Error> where K::Target: KVStoreSync, { @@ -664,7 +664,7 @@ where CHANNEL_MONITOR_PERSISTENCE_PRIMARY_NAMESPACE, CHANNEL_MONITOR_PERSISTENCE_SECONDARY_NAMESPACE, )? { - match )>>::read( + match )>>::read( &mut io::Cursor::new(kv_store.read( CHANNEL_MONITOR_PERSISTENCE_PRIMARY_NAMESPACE, CHANNEL_MONITOR_PERSISTENCE_SECONDARY_NAMESPACE, @@ -857,7 +857,7 @@ where /// Reads all stored channel monitors, along with any stored updates for them. pub fn read_all_channel_monitors_with_updates( &self, - ) -> Result)>, io::Error> { + ) -> Result)>, io::Error> { poll_sync_future(self.0.read_all_channel_monitors_with_updates()) } @@ -878,7 +878,7 @@ where /// function to accomplish this. Take care to limit the number of parallel readers. pub fn read_channel_monitor_with_updates( &self, monitor_key: &str, - ) -> Result<(BestBlock, ChannelMonitor), io::Error> { + ) -> Result<(BlockLocator, ChannelMonitor), io::Error> { poll_sync_future(self.0.read_channel_monitor_with_updates(monitor_key)) } @@ -1045,7 +1045,7 @@ impl< /// deserialization as well. pub async fn read_all_channel_monitors_with_updates( &self, - ) -> Result)>, io::Error> { + ) -> Result)>, io::Error> { let primary = CHANNEL_MONITOR_PERSISTENCE_PRIMARY_NAMESPACE; let secondary = CHANNEL_MONITOR_PERSISTENCE_SECONDARY_NAMESPACE; let monitor_list = self.0.kv_store.list(primary, secondary).await?; @@ -1076,7 +1076,7 @@ impl< /// `Arc` that can live for `'static` and be sent and accessed across threads. pub async fn read_all_channel_monitors_with_updates_parallel( self: &Arc, - ) -> Result)>, io::Error> + ) -> Result)>, io::Error> where K: MaybeSend + MaybeSync + 'static, L: MaybeSend + MaybeSync + 'static, @@ -1126,7 +1126,7 @@ impl< /// function to accomplish this. Take care to limit the number of parallel readers. pub async fn read_channel_monitor_with_updates( &self, monitor_key: &str, - ) -> Result<(BestBlock, ChannelMonitor), io::Error> { + ) -> Result<(BlockLocator, ChannelMonitor), io::Error> { self.0.read_channel_monitor_with_updates(monitor_key).await } @@ -1237,7 +1237,7 @@ impl< { pub async fn read_channel_monitor_with_updates( &self, monitor_key: &str, - ) -> Result<(BestBlock, ChannelMonitor), io::Error> { + ) -> Result<(BlockLocator, ChannelMonitor), io::Error> { match self.maybe_read_channel_monitor_with_updates(monitor_key).await? { Some(res) => Ok(res), None => Err(io::Error::new( @@ -1254,7 +1254,7 @@ impl< async fn maybe_read_channel_monitor_with_updates( &self, monitor_key: &str, - ) -> Result)>, io::Error> { + ) -> Result)>, io::Error> { let monitor_name = MonitorName::from_str(monitor_key)?; let read_future = pin!(self.maybe_read_monitor(&monitor_name, monitor_key)); let list_future = pin!(self @@ -1298,7 +1298,7 @@ impl< /// Read a channel monitor. async fn maybe_read_monitor( &self, monitor_name: &MonitorName, monitor_key: &str, - ) -> Result)>, io::Error> { + ) -> Result)>, io::Error> { let primary = CHANNEL_MONITOR_PERSISTENCE_PRIMARY_NAMESPACE; let secondary = CHANNEL_MONITOR_PERSISTENCE_SECONDARY_NAMESPACE; let monitor_bytes = self.kv_store.read(primary, secondary, monitor_key).await?; @@ -1307,7 +1307,7 @@ impl< if monitor_cursor.get_ref().starts_with(MONITOR_UPDATING_PERSISTER_PREPEND_SENTINEL) { monitor_cursor.set_position(MONITOR_UPDATING_PERSISTER_PREPEND_SENTINEL.len() as u64); } - match )>>::read( + match )>>::read( &mut monitor_cursor, (&self.entropy_source, &self.signer_provider), ) { diff --git a/lightning/src/util/sweep.rs b/lightning/src/util/sweep.rs index bbaaf2905ee..e66cb9c63bd 100644 --- a/lightning/src/util/sweep.rs +++ b/lightning/src/util/sweep.rs @@ -12,7 +12,7 @@ use crate::chain::chaininterface::{ BroadcasterInterface, ConfirmationTarget, FeeEstimator, TransactionType, }; use crate::chain::channelmonitor::{ANTI_REORG_DELAY, ARCHIVAL_DELAY_BLOCKS}; -use crate::chain::{self, BestBlock, Confirm, Filter, Listen, WatchedOutput}; +use crate::chain::{self, BlockLocator, Confirm, Filter, Listen, WatchedOutput}; use crate::io; use crate::ln::msgs::DecodeError; use crate::ln::types::ChannelId; @@ -386,7 +386,7 @@ where /// If chain data is provided via the [`Confirm`] interface or via filtered blocks, users also /// need to register their [`Filter`] implementation via the given `chain_data_source`. pub fn new( - best_block: BestBlock, broadcaster: B, fee_estimator: E, chain_data_source: Option, + best_block: BlockLocator, broadcaster: B, fee_estimator: E, chain_data_source: Option, output_spender: O, change_destination_source: D, kv_store: K, logger: L, ) -> Self { let outputs = Vec::new(); @@ -472,7 +472,7 @@ where /// Gets the latest best block which was connected either via the [`Listen`] or /// [`Confirm`] interfaces. - pub fn current_best_block(&self) -> BestBlock { + pub fn current_best_block(&self) -> BlockLocator { self.sweeper_state.lock().unwrap().best_block } @@ -766,7 +766,7 @@ where self.best_block_updated_internal(&mut state_lock, header, height); } - fn blocks_disconnected(&self, fork_point: BestBlock) { + fn blocks_disconnected(&self, fork_point: BlockLocator) { let mut state_lock = self.sweeper_state.lock().unwrap(); assert!(state_lock.best_block.height > fork_point.height, @@ -854,7 +854,7 @@ where #[derive(Debug, Clone)] struct SweeperState { outputs: Vec, - best_block: BestBlock, + best_block: BlockLocator, dirty: bool, } @@ -889,7 +889,7 @@ impl< K: KVStore, L: Logger, O: OutputSpender, - > ReadableArgs<(B, E, Option, O, D, K, L)> for (BestBlock, OutputSweeper) + > ReadableArgs<(B, E, Option, O, D, K, L)> for (BlockLocator, OutputSweeper) where D::Target: ChangeDestinationSource, { @@ -986,7 +986,7 @@ where /// If chain data is provided via the [`Confirm`] interface or via filtered blocks, users also /// need to register their [`Filter`] implementation via the given `chain_data_source`. pub fn new( - best_block: BestBlock, broadcaster: B, fee_estimator: E, chain_data_source: Option, + best_block: BlockLocator, broadcaster: B, fee_estimator: E, chain_data_source: Option, output_spender: O, change_destination_source: D, kv_store: K, logger: L, ) -> Self { let change_destination_source = @@ -1054,7 +1054,7 @@ where /// Gets the latest best block which was connected either via [`Listen`] or [`Confirm`] /// interfaces. - pub fn current_best_block(&self) -> BestBlock { + pub fn current_best_block(&self) -> BlockLocator { self.sweeper.current_best_block() } @@ -1111,7 +1111,7 @@ where self.sweeper.filtered_block_connected(header, txdata, height); } - fn blocks_disconnected(&self, fork_point: BestBlock) { + fn blocks_disconnected(&self, fork_point: BlockLocator) { self.sweeper.blocks_disconnected(fork_point); } } @@ -1157,7 +1157,7 @@ impl< L: Logger, O: OutputSpender, > ReadableArgs<(B, E, Option, O, D, K, L)> - for (BestBlock, OutputSweeperSync) + for (BlockLocator, OutputSweeperSync) where D::Target: ChangeDestinationSourceSync, K::Target: KVStoreSync, @@ -1172,7 +1172,7 @@ where let kv_store = KVStoreSyncWrapper(kv_store); let args = (a, b, c, d, change_destination_source, kv_store, e); let (best_block, sweeper) = - <(BestBlock, OutputSweeper<_, _, _, _, _, _, _>)>::read(reader, args)?; + <(BlockLocator, OutputSweeper<_, _, _, _, _, _, _>)>::read(reader, args)?; Ok((best_block, OutputSweeperSync { sweeper })) } } diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 57f9ba6b22f..d7320ff2ba9 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -20,7 +20,7 @@ use crate::chain::channelmonitor::{ ChannelMonitor, ChannelMonitorUpdate, ChannelMonitorUpdateStep, MonitorEvent, }; use crate::chain::transaction::OutPoint; -use crate::chain::BestBlock; +use crate::chain::BlockLocator; use crate::chain::WatchedOutput; #[cfg(any(test, feature = "_externalize_tests"))] use crate::ln::chan_utils::CommitmentTransaction; @@ -606,7 +606,7 @@ impl<'a> TestChainMonitor<'a> { // underlying `ChainMonitor`. let mut w = TestVecWriter(Vec::new()); monitor.write(&mut w).unwrap(); - let new_monitor = <(BestBlock, ChannelMonitor)>::read( + let new_monitor = <(BlockLocator, ChannelMonitor)>::read( &mut io::Cursor::new(&w.0), (self.keys_manager, self.keys_manager), ) @@ -643,7 +643,7 @@ impl<'a> chain::Watch for TestChainMonitor<'a> { // monitor to a serialized copy and get he same one back. let mut w = TestVecWriter(Vec::new()); monitor.write(&mut w).unwrap(); - let new_monitor = <(BestBlock, ChannelMonitor)>::read( + let new_monitor = <(BlockLocator, ChannelMonitor)>::read( &mut io::Cursor::new(&w.0), (self.keys_manager, self.keys_manager), ) @@ -699,7 +699,7 @@ impl<'a> chain::Watch for TestChainMonitor<'a> { let monitor = self.chain_monitor.get_monitor(channel_id).unwrap(); w.0.clear(); monitor.write(&mut w).unwrap(); - let new_monitor = <(BestBlock, ChannelMonitor)>::read( + let new_monitor = <(BlockLocator, ChannelMonitor)>::read( &mut io::Cursor::new(&w.0), (self.keys_manager, self.keys_manager), ) From e82d36e45eed07b2fed7b0a3775daa9f783804d2 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 1 Apr 2026 12:32:30 -0500 Subject: [PATCH 328/627] Model RBF splice tx replacement in chanmon_consistency The SplicePending event handler was immediately confirming splice transactions, which caused force-closes when RBF splice replacements were also confirmed for the same channel. Since both transactions spend the same funding UTXO, only one can exist on a real chain. Model this properly by adding a mempool-like pending pool to ChainState. Splice transactions are added to the pool instead of being confirmed immediately. At chain-sync time, pending transactions are sorted by txid and confirmed together in one block; candidates that double-spend an already-confirmed outpoint or another candidate earlier in the sort are dropped. Co-Authored-By: Claude Opus 4.6 (1M context) --- fuzz/src/chanmon_consistency.rs | 111 ++++++++++++++++++++++++++++---- 1 file changed, 99 insertions(+), 12 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index b205120e913..9602fc9511a 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -186,24 +186,42 @@ impl BroadcasterInterface for TestBroadcaster { struct ChainState { blocks: Vec<(Header, Vec)>, confirmed_txids: HashSet, + /// Unconfirmed transactions (e.g., splice txs). Conflicting RBF candidates may coexist; + /// `confirm_pending_txs` determines which one confirms. + pending_txs: Vec<(Txid, Transaction)>, } impl ChainState { fn new() -> Self { let genesis_hash = genesis_block(Network::Bitcoin).block_hash(); let genesis_header = create_dummy_header(genesis_hash, 42); - Self { blocks: vec![(genesis_header, Vec::new())], confirmed_txids: HashSet::new() } + Self { + blocks: vec![(genesis_header, Vec::new())], + confirmed_txids: HashSet::new(), + pending_txs: Vec::new(), + } } fn tip_height(&self) -> u32 { (self.blocks.len() - 1) as u32 } + fn is_outpoint_spent(&self, outpoint: &bitcoin::OutPoint) -> bool { + self.blocks.iter().any(|(_, txs)| { + txs.iter().any(|tx| { + tx.input.iter().any(|input| input.previous_output == *outpoint) + }) + }) + } + fn confirm_tx(&mut self, tx: Transaction) -> bool { let txid = tx.compute_txid(); if self.confirmed_txids.contains(&txid) { return false; } + if tx.input.iter().any(|input| self.is_outpoint_spent(&input.previous_output)) { + return false; + } self.confirmed_txids.insert(txid); let prev_hash = self.blocks.last().unwrap().0.block_hash(); @@ -218,6 +236,53 @@ impl ChainState { true } + /// Add a transaction to the pending pool (mempool). Multiple conflicting transactions (RBF + /// candidates) may coexist; `confirm_pending_txs` selects which one to confirm. + fn add_pending_tx(&mut self, tx: Transaction) { + self.pending_txs.push((tx.compute_txid(), tx)); + } + + /// Confirm pending transactions in a single block, selecting deterministically among + /// conflicting RBF candidates. Sorting by txid ensures the winner is determined by fuzz input + /// content. Transactions that double-spend an already-confirmed outpoint are skipped. + fn confirm_pending_txs(&mut self) { + let mut txs = std::mem::take(&mut self.pending_txs); + txs.sort_by_key(|(txid, _)| *txid); + + let mut confirmed = Vec::new(); + let mut spent_outpoints = Vec::new(); + for (txid, tx) in txs { + if self.confirmed_txids.contains(&txid) { + continue; + } + if tx.input.iter().any(|input| { + self.is_outpoint_spent(&input.previous_output) + || spent_outpoints.contains(&input.previous_output) + }) { + continue; + } + self.confirmed_txids.insert(txid); + for input in &tx.input { + spent_outpoints.push(input.previous_output); + } + confirmed.push(tx); + } + + if confirmed.is_empty() { + return; + } + + let prev_hash = self.blocks.last().unwrap().0.block_hash(); + let header = create_dummy_header(prev_hash, 42); + self.blocks.push((header, confirmed)); + + for _ in 0..5 { + let prev_hash = self.blocks.last().unwrap().0.block_hash(); + let header = create_dummy_header(prev_hash, 42); + self.blocks.push((header, Vec::new())); + } + } + fn block_at(&self, height: u32) -> &(Header, Vec) { &self.blocks[height as usize] } @@ -862,11 +927,15 @@ fn send_mpp_hop_payment( fn assert_action_timeout_awaiting_response(action: &msgs::ErrorAction) { // Since sending/receiving messages may be delayed, `timer_tick_occurred` may cause a node to // disconnect their counterparty if they're expecting a timely response. - assert!(matches!( + assert!( + matches!( + action, + msgs::ErrorAction::DisconnectPeerWithWarning { msg } + if msg.data.contains("Disconnecting due to timeout awaiting response") + ), + "Expected timeout disconnect, got: {:?}", action, - msgs::ErrorAction::DisconnectPeerWithWarning { msg } - if msg.data.contains("Disconnecting due to timeout awaiting response") - )); + ); } enum ChanType { @@ -2033,7 +2102,7 @@ pub fn do_test(data: &[u8], out: Out) { assert!(txs.len() >= 1); let splice_tx = txs.remove(0); assert_eq!(new_funding_txo.txid, splice_tx.compute_txid()); - chain_state.confirm_tx(splice_tx); + chain_state.add_pending_tx(splice_tx); }, events::Event::SpliceFailed { .. } => {}, events::Event::DiscardFunding { @@ -2506,13 +2575,31 @@ pub fn do_test(data: &[u8], out: Out) { }, // Sync node by 1 block to cover confirmation of a transaction. - 0xa8 => sync_with_chain_state(&mut chain_state, &nodes[0], &mut node_height_a, Some(1)), - 0xa9 => sync_with_chain_state(&mut chain_state, &nodes[1], &mut node_height_b, Some(1)), - 0xaa => sync_with_chain_state(&mut chain_state, &nodes[2], &mut node_height_c, Some(1)), + 0xa8 => { + chain_state.confirm_pending_txs(); + sync_with_chain_state(&mut chain_state, &nodes[0], &mut node_height_a, Some(1)); + }, + 0xa9 => { + chain_state.confirm_pending_txs(); + sync_with_chain_state(&mut chain_state, &nodes[1], &mut node_height_b, Some(1)); + }, + 0xaa => { + chain_state.confirm_pending_txs(); + sync_with_chain_state(&mut chain_state, &nodes[2], &mut node_height_c, Some(1)); + }, // Sync node to chain tip to cover confirmation of a transaction post-reorg-risk. - 0xab => sync_with_chain_state(&mut chain_state, &nodes[0], &mut node_height_a, None), - 0xac => sync_with_chain_state(&mut chain_state, &nodes[1], &mut node_height_b, None), - 0xad => sync_with_chain_state(&mut chain_state, &nodes[2], &mut node_height_c, None), + 0xab => { + chain_state.confirm_pending_txs(); + sync_with_chain_state(&mut chain_state, &nodes[0], &mut node_height_a, None); + }, + 0xac => { + chain_state.confirm_pending_txs(); + sync_with_chain_state(&mut chain_state, &nodes[1], &mut node_height_b, None); + }, + 0xad => { + chain_state.confirm_pending_txs(); + sync_with_chain_state(&mut chain_state, &nodes[2], &mut node_height_c, None); + }, 0xb0 | 0xb1 | 0xb2 => { // Restart node A, picking among the in-flight `ChannelMonitor`s to use based on From 21fed17c242bc7764522cd965059a40244ed2a57 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 23 Apr 2026 14:54:22 -0500 Subject: [PATCH 329/627] Skip pre-splice announcement_signatures on reestablish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a splice transaction confirms on both sides while peers are disconnected, each peer's `channel_reestablish` carries `my_current_funding_locked` with the splice txid. In the reestablish handler, `get_announcement_sigs` was called before the inferred `splice_locked` was processed and the splice was promoted, so `self.funding` still pointed to the pre-splice scope. If `announcement_sigs_state` was `NotSent`, the generated `announcement_signatures` carried the pre-splice `short_channel_id` and bitcoin key — which the peer (having already promoted via its own inferred `splice_locked`) would verify against the post-splice `UnsignedChannelAnnouncement`, failing the signature check and force-closing. Skip the pre-promotion call when `my_current_funding_locked` matches the splice we've already confirmed — i.e. `pending_splice.sent_funding_txid` is set and equals the peer's locked txid. `maybe_promote_splice_funding` emits correct post-splice signatures after the inferred `splice_locked` is processed. Co-Authored-By: Claude Opus 4.7 (1M context) --- lightning/src/ln/channel.rs | 18 ++++- lightning/src/ln/splicing_tests.rs | 118 +++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 10801edef01..51e863c4115 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -10325,7 +10325,23 @@ where } } - let announcement_sigs = self.get_announcement_sigs(node_signer, chain_hash, user_config, best_block.height, logger); + // If the counterparty's `my_current_funding_locked` matches the splice we've already + // confirmed and are about to promote, any `announcement_signatures` we'd generate here + // would be for the soon-to-be-superseded pre-splice funding. Skip them; + // `maybe_promote_splice_funding` will emit correct post-splice sigs once + // `inferred_splice_locked` is processed. + let our_splice_txid = + self.pending_splice.as_ref().and_then(|ps| ps.sent_funding_txid); + let splice_promotion_pending = msg + .my_current_funding_locked + .as_ref() + .map(|funding_locked| Some(funding_locked.txid) == our_splice_txid) + .unwrap_or(false); + let announcement_sigs = if splice_promotion_pending { + None + } else { + self.get_announcement_sigs(node_signer, chain_hash, user_config, best_block.height, logger) + }; let mut commitment_update = None; let mut tx_signatures = None; diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index fa22ccb61c7..41903a5b851 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -2183,6 +2183,124 @@ fn do_test_splice_reestablish(reload: bool, async_monitor_update: bool) { .remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script); } +#[test] +fn test_splice_confirms_on_both_sides_while_disconnected() { + // Regression test: when a splice transaction confirms on both sides while peers are + // disconnected, each peer's `channel_reestablish` carries `my_current_funding_locked` with the + // splice txid. The receiving side must not emit `announcement_signatures` for the pre-splice + // funding in that handler — those would be verified against the post-splice channel + // announcement on the peer and force-close the channel. Instead, sigs are generated after the + // inferred `splice_locked` promotes the splice funding. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let prev_funding_outpoint = get_monitor!(nodes[0], channel_id).get_funding_txo(); + let prev_funding_script = get_monitor!(nodes[0], channel_id).get_funding_script(); + + // Capture the pre-splice scid so we can later assert the announcement_sigs each side emits + // on reconnect carry the post-splice scid, not the pre-splice one the bug would emit. + let pre_splice_scid = nodes[0].node.list_channels()[0].short_channel_id.unwrap(); + + let outputs = vec![ + TxOut { + value: Amount::from_sat(initial_channel_value_sat / 4), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }, + TxOut { + value: Amount::from_sat(initial_channel_value_sat / 4), + script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), + }, + ]; + let funding_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); + let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Disconnect before either side confirms the splice. + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + + // Confirm the splice on both sides while disconnected. Each side's `transactions_confirmed` + // runs `check_get_splice_locked`, which sets `pending_splice.sent_funding_txid` so that + // `my_current_funding_locked` will carry the splice txid on reconnect. No `splice_locked` + // messages are queued while disconnected. + confirm_transaction(&nodes[0], &splice_tx); + confirm_transaction(&nodes[1], &splice_tx); + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + + // Reconnect manually so we can inspect each side's emitted `SendAnnouncementSignatures`. + // Each side's `channel_reestablish` carries `my_current_funding_locked` with the splice + // txid, triggering inferred `splice_locked` on the peer. With the fix in place, + // `announcement_signatures` are generated from the post-splice funding (via the promotion + // path) rather than the pre-splice funding (via the reestablish handler). + connect_nodes(&nodes[0], &nodes[1]); + let reestablish_0 = get_chan_reestablish_msgs!(nodes[0], nodes[1]); + let reestablish_1 = get_chan_reestablish_msgs!(nodes[1], nodes[0]); + for msg in &reestablish_0 { + nodes[1].node.handle_channel_reestablish(node_id_0, msg); + } + for msg in &reestablish_1 { + nodes[0].node.handle_channel_reestablish(node_id_1, msg); + } + check_added_monitors(&nodes[0], 1); + check_added_monitors(&nodes[1], 1); + expect_channel_ready_event(&nodes[0], &node_id_1); + expect_channel_ready_event(&nodes[1], &node_id_0); + + // Each side should emit exactly one `SendAnnouncementSignatures` (post-promotion). The + // pre-fix behavior would emit a second, stale pre-splice one — our assertion is that the + // only sigs we send carry the post-splice scid. + let take_announcement_sigs = |events: Vec| -> msgs::AnnouncementSignatures { + let mut sigs = events.into_iter().filter_map(|e| match e { + MessageSendEvent::SendAnnouncementSignatures { msg, .. } => Some(msg), + _ => None, + }); + let only = sigs.next().expect("expected one SendAnnouncementSignatures"); + assert!(sigs.next().is_none(), "expected only one SendAnnouncementSignatures"); + only + }; + let node_0_events = nodes[0].node.get_and_clear_pending_msg_events(); + let node_1_events = nodes[1].node.get_and_clear_pending_msg_events(); + let node_0_sigs = take_announcement_sigs(node_0_events); + let node_1_sigs = take_announcement_sigs(node_1_events); + assert_ne!(node_0_sigs.short_channel_id, pre_splice_scid); + assert_ne!(node_1_sigs.short_channel_id, pre_splice_scid); + + // Cross-deliver to complete the post-splice announcement exchange, then drain the + // resulting `BroadcastChannelAnnouncement` events on each side. + nodes[1].node.handle_announcement_signatures(node_id_0, &node_0_sigs); + nodes[0].node.handle_announcement_signatures(node_id_1, &node_1_sigs); + let _ = nodes[0].node.get_and_clear_pending_msg_events(); + let _ = nodes[1].node.get_and_clear_pending_msg_events(); + + // Channel must still be operational after reconnect — no force-close from mismatched + // announcement signatures. + send_payment(&nodes[0], &[&nodes[1]], 1_000_000); + + // No stray events or messages left over. + assert!(nodes[0].node.get_and_clear_pending_events().is_empty()); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + + // Clean up chain-source state for the retired pre-splice funding so end-of-test checks pass. + nodes[0] + .chain_source + .remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script.clone()); + nodes[1] + .chain_source + .remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script); +} + #[test] fn test_propose_splice_while_disconnected() { do_test_propose_splice_while_disconnected(false); From 80528b15975794cbff8f1f2edad551472948618d Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 23 Apr 2026 15:33:59 -0500 Subject: [PATCH 330/627] Ignore stale announcement_signatures instead of force-closing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A peer may transmit `announcement_signatures` signed over a stale `short_channel_id` — most plausibly a retransmission or a peer implementation whose view hasn't caught up to our post-splice promotion. Verifying such sigs against the current `UnsignedChannelAnnouncement` (built from `self.funding`) always fails the hash check, which previously produced a force-close. BOLT #7 does not require closing in this situation; the mismatch is expected across splice handoffs. Short-circuit with `ChannelError::Ignore` when `msg.short_channel_id` doesn't match the current funding's scid, leaving the genuine invalid-signature paths in place for sigs that actually target our current scid. Co-Authored-By: Claude Opus 4.7 (1M context) --- lightning/src/ln/channel.rs | 11 ++++ lightning/src/ln/splicing_tests.rs | 85 ++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 51e863c4115..e62d36f9e70 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12155,6 +12155,17 @@ where &mut self, node_signer: &NS, chain_hash: ChainHash, best_block_height: u32, msg: &msgs::AnnouncementSignatures, user_config: &UserConfig ) -> Result { + // Ignore sigs signed over a `short_channel_id` other than our current one (e.g. stale + // pre-splice sigs arriving after our side has promoted). Verifying them against the + // current `UnsignedChannelAnnouncement` would always fail the hash check, but per BOLT #7 + // that's not a protocol violation warranting a force-close. + if Some(msg.short_channel_id) != self.funding.get_short_channel_id() { + return Err(ChannelError::Ignore(format!( + "Ignoring announcement_signatures for short_channel_id {} which does not match our current short_channel_id {:?}", + msg.short_channel_id, self.funding.get_short_channel_id(), + ))); + } + let announcement = self.get_channel_announcement(node_signer, chain_hash, user_config)?; let msghash = hash_to_message!(&Sha256d::hash(&announcement.encode()[..])[..]); diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 41903a5b851..a6b91a63c89 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -2301,6 +2301,91 @@ fn test_splice_confirms_on_both_sides_while_disconnected() { .remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script); } +#[test] +fn test_stale_announcement_signatures_ignored_after_splice_lock() { + // Regression test: a peer may transmit `announcement_signatures` signed over a pre-splice + // `short_channel_id` (for example, a stale retransmission or a peer implementation that + // hasn't yet caught up to our post-splice promotion). Verifying those sigs against the + // post-splice `UnsignedChannelAnnouncement` will always fail the hash check, but that is not + // a protocol violation — the spec permits ignoring and the channel should stay open. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut config = test_default_channel_config(); + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + // Use the lower-level helper so we get the signed `ChannelAnnouncement` back — the test + // needs node 1's pre-splice announcement signatures to replay later. + let chan_announcement = + create_chan_between_nodes_with_value(&nodes[0], &nodes[1], initial_channel_value_sat, 0); + let channel_id = chan_announcement.3; + update_nodes_with_chan_announce( + &nodes, + 0, + 1, + &chan_announcement.0, + &chan_announcement.1, + &chan_announcement.2, + ); + + // Extract node 1's pre-splice signatures from the ChannelAnnouncement. `UnsignedChannelAnnouncement` + // orders `node_id_1`/`node_id_2` by serialized pubkey; node 1's sigs are in slot 1 iff node 1's + // pubkey is lexicographically smaller. + let node_1_is_node_one = node_id_1.serialize() < node_id_0.serialize(); + let (stale_node_sig, stale_bitcoin_sig) = if node_1_is_node_one { + (chan_announcement.0.node_signature_1, chan_announcement.0.bitcoin_signature_1) + } else { + (chan_announcement.0.node_signature_2, chan_announcement.0.bitcoin_signature_2) + }; + + // Capture the pre-splice `short_channel_id` — this is the scid the stale sigs sign over. + let pre_splice_scid = nodes[0].node.list_channels()[0].short_channel_id.unwrap(); + + let outputs = vec![ + TxOut { + value: Amount::from_sat(initial_channel_value_sat / 4), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }, + TxOut { + value: Amount::from_sat(initial_channel_value_sat / 4), + script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), + }, + ]; + let funding_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); + let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + mine_transaction(&nodes[0], &splice_tx); + mine_transaction(&nodes[1], &splice_tx); + lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); + + // The post-splice scid is now different; confirm that. + let post_splice_scid = nodes[0].node.list_channels()[0].short_channel_id.unwrap(); + assert_ne!(pre_splice_scid, post_splice_scid); + + // Replay node 1's pre-splice announcement signatures, now stale (the current scid is the + // post-splice one). This is the exact shape of message a peer would send if it retransmitted + // an old `announcement_signatures` across a splice handoff. + let stale_sigs = msgs::AnnouncementSignatures { + channel_id, + short_channel_id: pre_splice_scid, + node_signature: stale_node_sig, + bitcoin_signature: stale_bitcoin_sig, + }; + nodes[0].node.handle_announcement_signatures(node_id_1, &stale_sigs); + + // No force-close, no outbound error, no events. The channel must still be listed and usable. + assert!(nodes[0].node.get_and_clear_pending_events().is_empty()); + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + assert_eq!(nodes[0].node.list_channels().len(), 1); + send_payment(&nodes[0], &[&nodes[1]], 1_000_000); +} + #[test] fn test_propose_splice_while_disconnected() { do_test_propose_splice_while_disconnected(false); From 2294480e8104c87473829fe55a71024908f1c0b6 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Thu, 30 Apr 2026 15:52:11 +0200 Subject: [PATCH 331/627] Check fuzz workspace formatting in CI Extend the rustfmt CI job to check the fuzz workspace. This covers fuzz alongside the root workspace and lightning-tests. --- .github/workflows/build.yml | 2 ++ fuzz/src/chanmon_consistency.rs | 14 +++++++------- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d6a5deda322..1c34d8b3425 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -305,6 +305,8 @@ jobs: run: cargo fmt --check - name: Run rustfmt checks on lightning-tests run: cd lightning-tests && cargo fmt --check + - name: Run rustfmt checks on fuzz + run: cd fuzz && cargo fmt --check tor-connect: runs-on: ubuntu-latest env: diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 655fb76200b..d678d97918f 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -208,9 +208,7 @@ impl ChainState { fn is_outpoint_spent(&self, outpoint: &bitcoin::OutPoint) -> bool { self.blocks.iter().any(|(_, txs)| { - txs.iter().any(|tx| { - tx.input.iter().any(|input| input.previous_output == *outpoint) - }) + txs.iter().any(|tx| tx.input.iter().any(|input| input.previous_output == *outpoint)) }) } @@ -1027,7 +1025,8 @@ pub fn do_test(data: &[u8], out: Out) { } let network = Network::Bitcoin; let best_block_timestamp = genesis_block(network).header.time; - let params = ChainParameters { network, best_block: BlockLocator::from_network(network) }; + let params = + ChainParameters { network, best_block: BlockLocator::from_network(network) }; ( ChannelManager::new( $fee_estimator.clone(), @@ -1142,8 +1141,8 @@ pub fn do_test(data: &[u8], out: Out) { channel_monitors: monitor_refs, }; - let manager = - <(BlockLocator, ChanMan)>::read(&mut &ser[..], read_args).expect("Failed to read manager"); + let manager = <(BlockLocator, ChanMan)>::read(&mut &ser[..], read_args) + .expect("Failed to read manager"); let res = (manager.1, chain_monitor.clone()); for (channel_id, mon) in monitors.drain() { assert_eq!( @@ -2106,7 +2105,8 @@ pub fn do_test(data: &[u8], out: Out) { }, events::Event::SpliceFailed { .. } => {}, events::Event::DiscardFunding { - funding_info: events::FundingInfo::Contribution { .. } + funding_info: + events::FundingInfo::Contribution { .. } | events::FundingInfo::Tx { .. }, .. } => {}, From 8b1c7710959c67742b051a9cfccf75a373ee30f5 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 30 Apr 2026 22:08:49 +0000 Subject: [PATCH 332/627] `as_ref()` before wrapping encoded types in writing `option` TLVs We almost certainly don't want to be moving `option` TLVs during serialization, and while we had logic elsewhere to work around this previously its nice not to have to in the future. --- lightning/src/ln/features.rs | 6 ++++++ lightning/src/ln/msgs.rs | 4 ++-- lightning/src/util/ser.rs | 23 +++++++++++++++++++++++ lightning/src/util/ser_macros.rs | 5 ++--- 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/lightning/src/ln/features.rs b/lightning/src/ln/features.rs index b568d5595a5..a4e7fc15394 100644 --- a/lightning/src/ln/features.rs +++ b/lightning/src/ln/features.rs @@ -81,6 +81,12 @@ macro_rules! impl_feature_write_without_length { } } + impl Writeable for WithoutLength<&&$features> { + fn write(&self, w: &mut W) -> Result<(), io::Error> { + write_be(w, self.0.le_flags()) + } + } + impl Readable for WithoutLength<$features> { fn read(r: &mut R) -> Result { let v = io_extras::read_to_end(r)?; diff --git a/lightning/src/ln/msgs.rs b/lightning/src/ln/msgs.rs index 6210d26893a..5643bfd9498 100644 --- a/lightning/src/ln/msgs.rs +++ b/lightning/src/ln/msgs.rs @@ -763,10 +763,10 @@ pub struct UpdateAddHTLC { struct AccountableBool(T); -impl Writeable for AccountableBool { +impl Writeable for AccountableBool<&bool> { #[inline] fn write(&self, writer: &mut W) -> Result<(), io::Error> { - let wire_value = if self.0 { 7u8 } else { 0u8 }; + let wire_value = if *self.0 { 7u8 } else { 0u8 }; writer.write_all(&[wire_value]) } } diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs index bd2488bd8d1..4c40382517b 100644 --- a/lightning/src/util/ser.rs +++ b/lightning/src/util/ser.rs @@ -610,6 +610,13 @@ macro_rules! impl_writeable_primitive { writer.write_all(&self.0.to_be_bytes()[(self.0.leading_zeros() / 8) as usize..$len]) } } + impl Writeable for HighZeroBytesDroppedBigSize<&$val_type> { + #[inline] + fn write(&self, writer: &mut W) -> Result<(), io::Error> { + // Skip any full leading 0 bytes when writing (in BE): + writer.write_all(&self.0.to_be_bytes()[(self.0.leading_zeros() / 8) as usize..$len]) + } + } impl Readable for $val_type { #[inline] fn read(reader: &mut R) -> Result<$val_type, DecodeError> { @@ -751,12 +758,20 @@ impl_array!(HMAC_LEN * HMAC_COUNT, u8); /// This is not exported to bindings users as manual TLV building is not currently supported in bindings pub struct WithoutLength(pub T); +impl Writeable for WithoutLength<&&String> { + #[inline] + fn write(&self, w: &mut W) -> Result<(), io::Error> { + w.write_all(self.0.as_bytes()) + } +} + impl Writeable for WithoutLength<&String> { #[inline] fn write(&self, w: &mut W) -> Result<(), io::Error> { w.write_all(self.0.as_bytes()) } } + impl LengthReadable for WithoutLength { #[inline] fn read_from_fixed_length_buffer(r: &mut R) -> Result { @@ -808,6 +823,14 @@ impl AsWriteableSlice for &Vec { &self } } + +impl AsWriteableSlice for &&Vec { + type Inner = T; + fn as_slice(&self) -> &[T] { + &self + } +} + impl AsWriteableSlice for &[T] { type Inner = T; fn as_slice(&self) -> &[T] { diff --git a/lightning/src/util/ser_macros.rs b/lightning/src/util/ser_macros.rs index 946be54de65..c023ab4dbc5 100644 --- a/lightning/src/util/ser_macros.rs +++ b/lightning/src/util/ser_macros.rs @@ -81,7 +81,7 @@ macro_rules! _encode_tlv { $crate::_encode_tlv!($stream, $type, $field, option); }; ($stream: expr, $type: expr, $field: expr, (option, encoding: ($fieldty: ty, $encoding: ident) $(, $self: ident)?)) => { - $crate::_encode_tlv!($stream, $type, $field.map(|f| $encoding(f)), option); + $crate::_encode_tlv!($stream, $type, $field.as_ref().map(|f| $encoding(f)), option); }; ($stream: expr, $type: expr, $field: expr, (option, encoding: $fieldty: ty) $(, $self: ident)?) => { $crate::_encode_tlv!($stream, $type, $field, option); @@ -253,8 +253,7 @@ macro_rules! _get_varint_length_prefixed_tlv_length { $crate::_get_varint_length_prefixed_tlv_length!($len, $type, $field, option); }; ($len: expr, $type: expr, $field: expr, (option, encoding: ($fieldty: ty, $encoding: ident)) $(, $self: ident)?) => { - let field = $field.map(|f| $encoding(f)); - $crate::_get_varint_length_prefixed_tlv_length!($len, $type, field, option); + $crate::_get_varint_length_prefixed_tlv_length!($len, $type, $field.as_ref().map(|f| $encoding(f)), option); }; ($len: expr, $type: expr, $field: expr, upgradable_required $(, $self: ident)?) => { $crate::_get_varint_length_prefixed_tlv_length!($len, $type, $field, required); From 05135aeccccbccbdc6062d844784f35ec23b92d6 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Fri, 1 May 2026 00:49:40 +0000 Subject: [PATCH 333/627] Fix typo in `_encode_tlv` leading to confused encoding --- lightning/src/util/ser_macros.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning/src/util/ser_macros.rs b/lightning/src/util/ser_macros.rs index c023ab4dbc5..53777d26130 100644 --- a/lightning/src/util/ser_macros.rs +++ b/lightning/src/util/ser_macros.rs @@ -80,7 +80,7 @@ macro_rules! _encode_tlv { ($stream: expr, $type: expr, $field: expr, upgradable_option $(, $self: ident)?) => { $crate::_encode_tlv!($stream, $type, $field, option); }; - ($stream: expr, $type: expr, $field: expr, (option, encoding: ($fieldty: ty, $encoding: ident) $(, $self: ident)?)) => { + ($stream: expr, $type: expr, $field: expr, (option, encoding: ($fieldty: ty, $encoding: ident)) $(, $self: ident)?) => { $crate::_encode_tlv!($stream, $type, $field.as_ref().map(|f| $encoding(f)), option); }; ($stream: expr, $type: expr, $field: expr, (option, encoding: $fieldty: ty) $(, $self: ident)?) => { From efa95b49e2cad3d069c7b5dcbd0050cc99c8ae86 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 4 May 2026 12:23:05 +0000 Subject: [PATCH 334/627] Switch to ldk-fuzzing-corpus repo rather than CI cache Rather than storing our fuzzing corpus in the CI cache, move it to a new repo which anyone can use for their own local fuzzing and can be updated outside of CI with additional seeds. --- .github/workflows/build.yml | 86 +++++++++++++++--------- .github/workflows/push-fuzz-corpus.yml | 92 ++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/push-fuzz-corpus.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1c34d8b3425..2cad565e82a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -82,11 +82,17 @@ jobs: # Maybe if codecov wasn't broken we wouldn't need to do this... ./codecov --verbose upload-process --disable-search --fail-on-error -f target/codecov.json -t "f421b687-4dc2-4387-ac3d-dc3b2528af57" -F 'tests' cargo clean - - name: Download honggfuzz corpus - uses: actions/download-artifact@v4 - with: - name: hfuzz-corpus - path: fuzz/hfuzz_workspace + - name: Clone fuzzing corpus + run: git clone --depth=1 https://github.com/lightningdevkit/ldk-fuzzing-corpus.git fuzz/ldk-fuzzing-corpus + - name: Symlink corpus into hfuzz_workspace + run: | + set -eu + cd fuzz + for D in ldk-fuzzing-corpus/rust-lightning/*/; do + NAME=$(basename "$D") + mkdir -p "hfuzz_workspace/${NAME}_target" + cp -r "ldk-fuzzing-corpus/rust-lightning/${NAME}" "hfuzz_workspace/${NAME}_target/input" + done - name: Run fuzz coverage generation run: | ./contrib/generate_fuzz_coverage.sh --output-dir `pwd` --output-codecov-json @@ -233,39 +239,59 @@ jobs: - name: Install Rust ${{ env.TOOLCHAIN }} toolchain run: | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ env.TOOLCHAIN }} - # This is read-only for PRs. It seeds the fuzzer for a more effective run. - # NOTE: The `key` is unique and will always miss, forcing a fallback to - # the `restore-keys` to find the latest global cache from the `main` branch. - - name: Restore persistent fuzz corpus (PR) - if: ${{ github.ref != 'refs/heads/main' }} - uses: actions/cache/restore@v4 - with: - path: fuzz/hfuzz_workspace - key: fuzz-corpus-${{ github.ref }}-${{ github.sha }} - restore-keys: | - fuzz-corpus-refs/heads/main- - # The `restore-keys` performs a prefix search to find the most recent - # cache from a previous `main` run. We then save with a new, unique - # `key` (using the SHA) to ensure the cache is always updated, - # as caches are immutable. - - name: Restore/Save persistent honggfuzz corpus (Main) - if: ${{ github.ref == 'refs/heads/main' }} - uses: actions/cache@v4 - with: - path: fuzz/hfuzz_workspace - key: fuzz-corpus-refs/heads/main-${{ github.sha }} - restore-keys: | - fuzz-corpus-refs/heads/main- + - name: Clone fuzzing corpus + run: git clone --depth=1 https://github.com/lightningdevkit/ldk-fuzzing-corpus.git fuzz/ldk-fuzzing-corpus + - name: Symlink corpus into hfuzz_workspace + run: | + set -eu + cd fuzz + for D in ldk-fuzzing-corpus/rust-lightning/*/; do + NAME=$(basename "$D") + mkdir -p "hfuzz_workspace/${NAME}_target" + ln -sfn "../../ldk-fuzzing-corpus/rust-lightning/${NAME}" \ + "hfuzz_workspace/${NAME}_target/input" + done - name: Run fuzzers run: cd fuzz && ./ci-fuzz.sh && cd .. env: FUZZ_MINIMIZE: ${{ contains(github.event.pull_request.labels.*.name, 'fuzz-minimize') }} - - name: Upload honggfuzz corpus + - name: Stage new corpus entries for upload + if: success() || failure() + run: | + set -eu + WORKSPACE="$(pwd)" + rm -rf "$WORKSPACE/new-corpus" + mkdir -p "$WORKSPACE/new-corpus" + + cd fuzz/ldk-fuzzing-corpus + while IFS= read -r F; do + mkdir -p "$WORKSPACE/new-corpus/$(dirname "$F")" + cp -a "$F" "$WORKSPACE/new-corpus/$F" + done < <(git ls-files --others --exclude-standard rust-lightning/) + cd "$WORKSPACE" + + for D in fuzz/hfuzz_workspace/*_target/; do + [ -d "$D" ] || continue + BASE=$(basename "$D") + NAME="${BASE%_target}" + [ -d "$WORKSPACE/new-corpus/$NAME" ] || continue + for F in "$D"/SIG*; do + FILE="$(basename "$F")" + [ -f "$F" -a ! -f "$WORKSPACE/new-corpus/$NAME/$FILE" ] && + cp "$F" "$WORKSPACE/new-corpus/$NAME/$FILE" + done + done + + NEW=$(find new-corpus -type f 2>/dev/null | wc -l) + echo "Staged $NEW new corpus entries (including any SIG* crashes)" + - name: Upload new corpus entries + if: success() || failure() uses: actions/upload-artifact@v4 with: name: hfuzz-corpus - path: fuzz/hfuzz_workspace + path: new-corpus compression-level: 0 + if-no-files-found: ignore linting: runs-on: ubuntu-latest diff --git a/.github/workflows/push-fuzz-corpus.yml b/.github/workflows/push-fuzz-corpus.yml new file mode 100644 index 00000000000..4551de19cc5 --- /dev/null +++ b/.github/workflows/push-fuzz-corpus.yml @@ -0,0 +1,92 @@ +name: Push fuzz corpus + +# Triggered after the main CI workflow finishes. Because `workflow_run` always +# runs in the *base* repo's context (its workflow file as of `main`, with +# full secrets access) it's safe to handle the corpus push here even for fork +# PRs — none of the PR's modified code or scripts execute in this job. +# +# Caveat: GitHub only fires `workflow_run` for workflow files that live on +# the default branch, so this workflow does nothing until it's merged to +# `master`. +on: + # zizmor flags `workflow_run` as a dangerous trigger because it runs with + # repo secrets in base-branch context. That's exactly why we use it here: + # this workflow never touches any PR-supplied code (no checkout, no script + # execution from the artifact — just cp/git on opaque corpus blobs), so + # the warning is a false positive. + workflow_run: # zizmor: ignore[dangerous-triggers] + workflows: ["Continuous Integration Checks"] + types: [completed] + +permissions: + # download-artifact across runs requires `actions: read`. + actions: read + +jobs: + push-corpus: + # Run on either success or fuzzer crash; skip on cancellation. + if: >- + github.event.workflow_run.conclusion == 'success' || + github.event.workflow_run.conclusion == 'failure' + runs-on: ubuntu-latest + steps: + - name: Download fuzz corpus artifact + id: download + # The artifact only exists when the fuzz job got far enough to upload + # it. Don't fail this workflow if the upload was skipped. + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: hfuzz-corpus + path: hfuzz-corpus + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Clone fuzzing corpus + if: steps.download.outcome == 'success' + run: git clone --depth=1 https://github.com/lightningdevkit/ldk-fuzzing-corpus.git + + - name: Copy new corpus entries into the corpus checkout + if: steps.download.outcome == 'success' + run: | + set -eu + if [ -d hfuzz-corpus/rust-lightning ]; then + cp -rn hfuzz-corpus/rust-lightning/. ldk-fuzzing-corpus/rust-lightning/ + fi + + - name: Open PR with new corpus entries + if: steps.download.outcome == 'success' + env: + GH_TOKEN: ${{ secrets.CORPUS_PUSH_TOKEN }} + SOURCE_SHA: ${{ github.event.workflow_run.head_sha }} + RUN_URL: ${{ github.event.workflow_run.html_url }} + RUN_ID: ${{ github.event.workflow_run.id }} + run: | + set -eu + cd ldk-fuzzing-corpus + if [ -z "$(git status --porcelain)" ]; then + echo "No new corpus entries to contribute." + exit 0 + fi + if [ -z "${GH_TOKEN:-}" ]; then + echo "Found new corpus entries but CORPUS_PUSH_TOKEN is unset; skipping PR." + git status --short + exit 0 + fi + BRANCH="ci/new-corpus-${RUN_ID}" + git config user.email "ldk-ci@users.noreply.github.com" + git config user.name "LDK CI" + git checkout -b "$BRANCH" + git add rust-lightning + git commit \ + -m "Add corpus entries from rust-lightning CI" \ + -m "Source commit: ${SOURCE_SHA}" \ + -m "Run: ${RUN_URL}" + REMOTE=$(git config --get remote.origin.url) + PUSH_URL="https://x-access-token:${GH_TOKEN}@${REMOTE#https://}" + git push "$PUSH_URL" "HEAD:$BRANCH" + gh pr create \ + --title "New corpus entries from rust-lightning CI run ${RUN_ID}" \ + --body "Discovered while running fuzz CI against \`${SOURCE_SHA}\`. Source: ${RUN_URL}" \ + --head "$BRANCH" \ + --base master From 33b5166cc172c816b5252c96f3b4f5076bc3fd73 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 9 Apr 2026 06:32:01 +0000 Subject: [PATCH 335/627] Run existing validation code against the candidate funding scope As a result, we now validate that both commitments retain at least one output under the new funding scope, which is crucial for zero-reserve channels. --- lightning/src/ln/channel.rs | 385 +++++++++++++++--------------------- 1 file changed, 155 insertions(+), 230 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index d82f94d5bc5..365e82a2a52 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2744,20 +2744,50 @@ impl FundingScope { prev_funding: &Self, context: &ChannelContext, our_funding_contribution: SignedAmount, their_funding_contribution: SignedAmount, counterparty_funding_pubkey: PublicKey, our_new_holder_keys: ChannelPublicKeys, - ) -> Self { - debug_assert!(our_funding_contribution.unsigned_abs() <= Amount::MAX_MONEY); - debug_assert!(their_funding_contribution.unsigned_abs() <= Amount::MAX_MONEY); + ) -> Result { + if our_funding_contribution.unsigned_abs() > Amount::MAX_MONEY { + return Err(format!( + "Channel {} cannot be spliced; our {} contribution exceeds the total bitcoin supply", + context.channel_id(), + our_funding_contribution, + )); + } - let post_channel_value = prev_funding.compute_post_splice_value( - our_funding_contribution.to_sat(), - their_funding_contribution.to_sat(), - ); + if their_funding_contribution.unsigned_abs() > Amount::MAX_MONEY { + return Err(format!( + "Channel {} cannot be spliced; their {} contribution exceeds the total bitcoin supply", + context.channel_id(), + their_funding_contribution, + )); + } + + let channel_value_satoshis = prev_funding.get_value_satoshis(); + let value_to_self_satoshis = prev_funding.get_value_to_self_msat() / 1000; + let value_to_counterparty_satoshis = channel_value_satoshis + .checked_sub(value_to_self_satoshis) + .expect("value_to_self is greater than channel value"); + let our_funding_contribution_sat = our_funding_contribution.to_sat(); + let their_funding_contribution_sat = their_funding_contribution.to_sat(); let post_value_to_self_msat = prev_funding - .value_to_self_msat - .checked_add_signed(our_funding_contribution.to_sat() * 1000); - debug_assert!(post_value_to_self_msat.is_some()); - let post_value_to_self_msat = post_value_to_self_msat.unwrap(); + .get_value_to_self_msat() + .checked_add_signed(our_funding_contribution_sat * 1000) + .ok_or(format!( + "Our contribution candidate {our_funding_contribution_sat}sat is \ + greater than our total balance in the channel {value_to_self_satoshis}sat" + ))?; + + value_to_counterparty_satoshis.checked_add_signed(their_funding_contribution_sat).ok_or( + format!( + "Their contribution candidate {their_funding_contribution_sat}sat is \ + greater than their total balance in the channel {value_to_counterparty_satoshis}sat" + ), + )?; + + let post_channel_value = prev_funding.get_value_satoshis() + .checked_add_signed(our_funding_contribution.to_sat()) + .and_then(|v| v.checked_add_signed(their_funding_contribution.to_sat())) + .ok_or(format!("The sum of contributions {our_funding_contribution} and {their_funding_contribution} is greater than the channel's value"))?; let channel_parameters = &prev_funding.channel_transaction_parameters; let mut post_channel_transaction_parameters = ChannelTransactionParameters { @@ -2793,7 +2823,7 @@ impl FundingScope { prev_funding.holder_selected_channel_reserve_satoshis == 0, ); - Self { + Ok(Self { channel_transaction_parameters: post_channel_transaction_parameters, value_to_self_msat: post_value_to_self_msat, funding_transaction: None, @@ -2808,12 +2838,6 @@ impl FundingScope { prev.0.saturating_add_signed(our_funding_contribution.to_sat() * 1000); let new_counterparty_balance_msat = prev.1.saturating_add_signed(their_funding_contribution.to_sat() * 1000); - if new_holder_balance_msat < counterparty_selected_channel_reserve_satoshis { - assert_eq!(new_holder_balance_msat, prev.0); - } - if new_counterparty_balance_msat < holder_selected_channel_reserve_satoshis { - assert_eq!(new_counterparty_balance_msat, prev.1); - } Mutex::new((new_holder_balance_msat, new_counterparty_balance_msat)) }, #[cfg(debug_assertions)] @@ -2823,12 +2847,6 @@ impl FundingScope { prev.0.saturating_add_signed(our_funding_contribution.to_sat() * 1000); let new_counterparty_balance_msat = prev.1.saturating_add_signed(their_funding_contribution.to_sat() * 1000); - if new_holder_balance_msat < counterparty_selected_channel_reserve_satoshis { - assert_eq!(new_holder_balance_msat, prev.0); - } - if new_counterparty_balance_msat < holder_selected_channel_reserve_satoshis { - assert_eq!(new_counterparty_balance_msat, prev.1); - } Mutex::new((new_holder_balance_msat, new_counterparty_balance_msat)) }, #[cfg(any(test, fuzzing))] @@ -2839,16 +2857,7 @@ impl FundingScope { funding_tx_confirmed_in: None, minimum_depth_override: None, short_channel_id: None, - } - } - - /// Compute the post-splice channel value from each counterparty's contributions. - pub(super) fn compute_post_splice_value( - &self, our_funding_contribution: i64, their_funding_contribution: i64, - ) -> u64 { - self.get_value_satoshis().saturating_add_signed( - our_funding_contribution.saturating_add(their_funding_contribution), - ) + }) } /// Returns a `SharedOwnedInput` for using this `FundingScope` as the input to a new splice. @@ -12599,9 +12608,12 @@ where let our_funding_contribution = contribution.net_value(); - if let Err(e) = - self.validate_splice_contributions(our_funding_contribution, SignedAmount::ZERO) - { + if let Err(e) = self.validate_splice_contributions( + our_funding_contribution, + SignedAmount::ZERO, + self.funding.get_counterparty_pubkeys().funding_pubkey, + self.funding.get_holder_pubkeys().clone(), + ) { log_error!(logger, "Channel {} cannot be funded: {}", self.context.channel_id(), e); return Err(QuiescentError::FailSplice(self.splice_funding_failed_for(contribution))); } @@ -12791,61 +12803,30 @@ where fn validate_splice_contributions( &self, our_funding_contribution: SignedAmount, their_funding_contribution: SignedAmount, - ) -> Result<(), String> { - if our_funding_contribution.unsigned_abs() > Amount::MAX_MONEY { - return Err(format!( - "Channel {} cannot be spliced; our {} contribution exceeds the total bitcoin supply", - self.context.channel_id(), - our_funding_contribution, - )); - } - - if their_funding_contribution.unsigned_abs() > Amount::MAX_MONEY { - return Err(format!( - "Channel {} cannot be spliced; their {} contribution exceeds the total bitcoin supply", - self.context.channel_id(), - their_funding_contribution, - )); - } + counterparty_funding_pubkey: PublicKey, our_new_holder_keys: ChannelPublicKeys, + ) -> Result { + let candidate_scope = FundingScope::for_splice( + &self.funding, + self.context(), + our_funding_contribution, + their_funding_contribution, + counterparty_funding_pubkey, + our_new_holder_keys, + )?; - let (holder_balance_remaining, counterparty_balance_remaining) = - self.get_holder_counterparty_balances_floor_incl_fee(&self.funding).map_err(|e| { - format!("Channel {} cannot be spliced; {}", self.context.channel_id(), e) - })?; + let (post_splice_holder_balance, post_splice_counterparty_balance) = + self.get_holder_counterparty_balances_floor_incl_fee(&candidate_scope).map_err( + |e| format!("Channel {} cannot be spliced; {}", self.context.channel_id(), e), + )?; - let post_channel_value = self.funding.compute_post_splice_value( - our_funding_contribution.to_sat(), - their_funding_contribution.to_sat(), + let holder_selected_channel_reserve = + Amount::from_sat(candidate_scope.holder_selected_channel_reserve_satoshis); + let counterparty_selected_channel_reserve = Amount::from_sat( + candidate_scope.counterparty_selected_channel_reserve_satoshis.expect("Reserve is set"), ); - let counterparty_selected_channel_reserve = - Amount::from_sat(get_v2_channel_reserve_satoshis( - post_channel_value, - MIN_CHAN_DUST_LIMIT_SATOSHIS, - self.funding - .counterparty_selected_channel_reserve_satoshis - .expect("counterparty reserve is set") - == 0, - )); - let holder_selected_channel_reserve = Amount::from_sat(get_v2_channel_reserve_satoshis( - post_channel_value, - self.context.counterparty_dust_limit_satoshis, - self.funding.holder_selected_channel_reserve_satoshis == 0, - )); // We allow parties to draw from their previous reserve, as long as they satisfy their v2 reserve - if our_funding_contribution != SignedAmount::ZERO { - let post_splice_holder_balance = Amount::from_sat( - holder_balance_remaining.to_sat() - .checked_add_signed(our_funding_contribution.to_sat()) - .ok_or(format!( - "Channel {} cannot be spliced out; our remaining balance {} does not cover our negative funding contribution {}", - self.context.channel_id(), - holder_balance_remaining, - our_funding_contribution, - ))?, - ); - post_splice_holder_balance.checked_sub(counterparty_selected_channel_reserve) .ok_or(format!( "Channel {} cannot be {}; our post-splice channel balance {} is smaller than their selected v2 reserve {}", @@ -12857,17 +12838,6 @@ where } if their_funding_contribution != SignedAmount::ZERO { - let post_splice_counterparty_balance = Amount::from_sat( - counterparty_balance_remaining.to_sat() - .checked_add_signed(their_funding_contribution.to_sat()) - .ok_or(format!( - "Channel {} cannot be spliced out; their remaining balance {} does not cover their negative funding contribution {}", - self.context.channel_id(), - counterparty_balance_remaining, - their_funding_contribution, - ))?, - ); - post_splice_counterparty_balance.checked_sub(holder_selected_channel_reserve) .ok_or(format!( "Channel {} cannot be {}; their post-splice channel balance {} is smaller than our selected v2 reserve {}", @@ -12878,7 +12848,34 @@ where ))?; } - Ok(()) + #[cfg(debug_assertions)] + { + let (old_holder_balance_msat, old_counterparty_balance_msat) = + *self.funding.holder_prev_commitment_tx_balance.lock().unwrap(); + let (new_holder_balance_msat, new_counterparty_balance_msat) = + *candidate_scope.holder_prev_commitment_tx_balance.lock().unwrap(); + if new_holder_balance_msat < counterparty_selected_channel_reserve.to_sat() * 1000 { + debug_assert_eq!(new_holder_balance_msat, old_holder_balance_msat); + } + if new_counterparty_balance_msat < holder_selected_channel_reserve.to_sat() * 1000 { + debug_assert_eq!(new_counterparty_balance_msat, old_counterparty_balance_msat); + } + } + #[cfg(debug_assertions)] + { + let (old_holder_balance_msat, old_counterparty_balance_msat) = + *self.funding.counterparty_prev_commitment_tx_balance.lock().unwrap(); + let (new_holder_balance_msat, new_counterparty_balance_msat) = + *candidate_scope.counterparty_prev_commitment_tx_balance.lock().unwrap(); + if new_holder_balance_msat < counterparty_selected_channel_reserve.to_sat() * 1000 { + debug_assert_eq!(new_holder_balance_msat, old_holder_balance_msat); + } + if new_counterparty_balance_msat < holder_selected_channel_reserve.to_sat() * 1000 { + debug_assert_eq!(new_counterparty_balance_msat, old_counterparty_balance_msat); + } + } + + Ok(candidate_scope) } fn resolve_queued_contribution( @@ -12936,8 +12933,6 @@ where let our_funding_contribution = queued_net_value.unwrap_or(SignedAmount::ZERO); let their_funding_contribution = SignedAmount::from_sat(msg.funding_contribution_satoshis); - self.validate_splice_contributions(our_funding_contribution, their_funding_contribution) - .map_err(|e| self.quiescent_negotiation_err(ChannelError::WarnAndDisconnect(e)))?; // Rotate the pubkeys using the prev_funding_txid as a tweak let prev_funding_txid = self.funding.get_funding_txid(); @@ -12954,14 +12949,14 @@ where let mut holder_pubkeys = self.funding.get_holder_pubkeys().clone(); holder_pubkeys.funding_pubkey = funding_pubkey; - let splice_funding = FundingScope::for_splice( - &self.funding, - &self.context, - our_funding_contribution, - their_funding_contribution, - msg.funding_pubkey, - holder_pubkeys, - ); + let splice_funding = self + .validate_splice_contributions( + our_funding_contribution, + their_funding_contribution, + msg.funding_pubkey, + holder_pubkeys, + ) + .map_err(|e| self.quiescent_negotiation_err(ChannelError::WarnAndDisconnect(e)))?; // Adjust for the feerate and clone so we can store it for future RBF re-use. let (adjusted_contribution, our_funding_inputs, our_funding_outputs) = @@ -13135,17 +13130,15 @@ where Some(value) => SignedAmount::from_sat(value), None => SignedAmount::ZERO, }; - self.validate_splice_contributions(our_funding_contribution, their_funding_contribution) - .map_err(|e| self.quiescent_negotiation_err(ChannelError::WarnAndDisconnect(e)))?; - let rbf_funding = FundingScope::for_splice( - &self.funding, - &self.context, - our_funding_contribution, - their_funding_contribution, - counterparty_funding_pubkey, - holder_pubkeys, - ); + let rbf_funding = self + .validate_splice_contributions( + our_funding_contribution, + their_funding_contribution, + counterparty_funding_pubkey, + holder_pubkeys, + ) + .map_err(|e| self.quiescent_negotiation_err(ChannelError::WarnAndDisconnect(e)))?; // Consume the appropriate contribution source. let (our_funding_inputs, our_funding_outputs) = if queued_net_value.is_some() { @@ -13228,8 +13221,6 @@ where Some(value) => SignedAmount::from_sat(value), None => SignedAmount::ZERO, }; - self.validate_splice_contributions(our_funding_contribution, their_funding_contribution) - .map_err(|e| ChannelError::WarnAndDisconnect(e))?; let last_candidate = pending_splice.negotiated_candidates.last().ok_or_else(|| { ChannelError::WarnAndDisconnect("No negotiated splice candidates for RBF".to_owned()) @@ -13237,14 +13228,16 @@ where let holder_pubkeys = last_candidate.get_holder_pubkeys().clone(); let counterparty_funding_pubkey = *last_candidate.counterparty_funding_pubkey(); - Ok(FundingScope::for_splice( - &self.funding, - &self.context, - our_funding_contribution, - their_funding_contribution, - counterparty_funding_pubkey, - holder_pubkeys, - )) + let new_funding = self + .validate_splice_contributions( + our_funding_contribution, + their_funding_contribution, + counterparty_funding_pubkey, + holder_pubkeys, + ) + .map_err(|e| ChannelError::WarnAndDisconnect(e))?; + + Ok(new_funding) } pub(crate) fn tx_ack_rbf( @@ -13329,22 +13322,32 @@ where let our_funding_contribution = funding_negotiation_context.our_funding_contribution; let their_funding_contribution = SignedAmount::from_sat(msg.funding_contribution_satoshis); - self.validate_splice_contributions(our_funding_contribution, their_funding_contribution) - .map_err(|e| ChannelError::WarnAndDisconnect(e))?; let mut new_keys = self.funding.get_holder_pubkeys().clone(); new_keys.funding_pubkey = *new_holder_funding_key; - Ok(FundingScope::for_splice( - &self.funding, - &self.context, - our_funding_contribution, - their_funding_contribution, - msg.funding_pubkey, - new_keys, - )) + let new_funding = self + .validate_splice_contributions( + our_funding_contribution, + their_funding_contribution, + msg.funding_pubkey, + new_keys, + ) + .map_err(|e| ChannelError::WarnAndDisconnect(e))?; + + Ok(new_funding) } + /// The balances returned here should only be used to check that both parties still hold + /// their respective reserves *after* a splice. This function also checks that both local + /// and remote commitments still have at least one output after the splice, which is + /// particularly relevant for zero-reserve channels. + /// + /// Do NOT use this to determine how much the holder can splice out of the channel. The balance + /// of the holder after a splice is not necessarily equal to the funds they can splice out + /// of the channel due to the v2 reserve, and the zero-reserve-at-least-one-output + /// requirements. Note you cannot simply subtract out the reserve, as splicing funds out + /// of the channel changes the reserve the holder must keep in the channel. fn get_holder_counterparty_balances_floor_incl_fee( &self, funding: &FundingScope, ) -> Result<(Amount, Amount), String> { @@ -13365,6 +13368,16 @@ where self.context.feerate_per_kw }; + // Different dust limits on the local and remote commitments cause the commitment + // transaction fee to be different depending on the commitment, so we grab the floor + // of both balances across both commitments here. + // + // `get_channel_stats` also checks for at least one output on the commitment given + // these parameters. This is particularly relevant for zero-reserve channels. + // + // This "at-least-one-output" check is why we still run both checks on + // zero-fee-commitment channels, even though those channels don't suffer from the + // commitment transaction fee asymmetry. let (local_stats, _local_htlcs) = self .context .get_next_local_commitment_stats( @@ -14170,6 +14183,8 @@ where if let Err(e) = self.validate_splice_contributions( our_funding_contribution, SignedAmount::ZERO, + self.funding.get_counterparty_pubkeys().funding_pubkey, + self.funding.get_holder_pubkeys().clone(), ) { let failed = self.splice_funding_failed_for(contribution); return Err(( @@ -16835,7 +16850,7 @@ mod tests { use crate::chain::chaininterface::LowerBoundedFeeEstimator; use crate::chain::transaction::OutPoint; use crate::chain::BlockLocator; - use crate::ln::chan_utils::{self, commit_tx_fee_sat, ChannelTransactionParameters}; + use crate::ln::chan_utils::{self, commit_tx_fee_sat}; use crate::ln::channel::{ AwaitingChannelReadyFlags, ChannelState, FundedChannel, HTLCUpdateAwaitingACK, InboundHTLCOutput, InboundHTLCState, InboundUpdateAdd, InboundV1Channel, @@ -16853,6 +16868,7 @@ mod tests { use crate::sign::tx_builder::HTLCAmountDirection; #[cfg(ldk_test_vectors)] use crate::sign::{ChannelSigner, EntropySource, InMemorySigner, SignerProvider}; + #[cfg(ldk_test_vectors)] use crate::sync::Mutex; #[cfg(ldk_test_vectors)] use crate::types::features::ChannelTypeFeatures; @@ -19273,95 +19289,4 @@ mod tests { assert_eq!(node_a_chan.context.channel_state, ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::THEIR_CHANNEL_READY)); assert!(node_a_chan.check_get_channel_ready(0, &&logger).is_some()); } - - fn get_pre_and_post( - pre_channel_value: u64, our_funding_contribution: i64, their_funding_contribution: i64, - ) -> (u64, u64) { - use crate::ln::channel::{FundingScope, PredictedNextFee}; - - let funding = FundingScope { - value_to_self_msat: 0, - counterparty_selected_channel_reserve_satoshis: None, - holder_selected_channel_reserve_satoshis: 0, - - #[cfg(debug_assertions)] - holder_prev_commitment_tx_balance: Mutex::new((0, 0)), - #[cfg(debug_assertions)] - counterparty_prev_commitment_tx_balance: Mutex::new((0, 0)), - - #[cfg(any(test, fuzzing))] - next_local_fee: Mutex::new(PredictedNextFee::default()), - #[cfg(any(test, fuzzing))] - next_remote_fee: Mutex::new(PredictedNextFee::default()), - - channel_transaction_parameters: ChannelTransactionParameters::test_dummy( - pre_channel_value, - ), - funding_transaction: None, - funding_tx_confirmed_in: None, - funding_tx_confirmation_height: 0, - short_channel_id: None, - minimum_depth_override: None, - }; - let post_channel_value = - funding.compute_post_splice_value(our_funding_contribution, their_funding_contribution); - (pre_channel_value, post_channel_value) - } - - #[test] - fn test_compute_post_splice_value() { - { - // increase, small amounts - let (pre_channel_value, post_channel_value) = get_pre_and_post(9_000, 6_000, 0); - assert_eq!(pre_channel_value, 9_000); - assert_eq!(post_channel_value, 15_000); - } - { - // increase, small amounts - let (pre_channel_value, post_channel_value) = get_pre_and_post(9_000, 4_000, 2_000); - assert_eq!(pre_channel_value, 9_000); - assert_eq!(post_channel_value, 15_000); - } - { - // increase, small amounts - let (pre_channel_value, post_channel_value) = get_pre_and_post(9_000, 0, 6_000); - assert_eq!(pre_channel_value, 9_000); - assert_eq!(post_channel_value, 15_000); - } - { - // decrease, small amounts - let (pre_channel_value, post_channel_value) = get_pre_and_post(15_000, -6_000, 0); - assert_eq!(pre_channel_value, 15_000); - assert_eq!(post_channel_value, 9_000); - } - { - // decrease, small amounts - let (pre_channel_value, post_channel_value) = get_pre_and_post(15_000, -4_000, -2_000); - assert_eq!(pre_channel_value, 15_000); - assert_eq!(post_channel_value, 9_000); - } - { - // increase and decrease - let (pre_channel_value, post_channel_value) = get_pre_and_post(15_000, 4_000, -2_000); - assert_eq!(pre_channel_value, 15_000); - assert_eq!(post_channel_value, 17_000); - } - let base2: u64 = 2; - let huge63i3 = (base2.pow(63) - 3) as i64; - assert_eq!(huge63i3, 9223372036854775805); - assert_eq!(-huge63i3, -9223372036854775805); - { - // increase, large amount - let (pre_channel_value, post_channel_value) = get_pre_and_post(9_000, huge63i3, 3); - assert_eq!(pre_channel_value, 9_000); - assert_eq!(post_channel_value, 9223372036854784807); - } - { - // increase, large amounts - let (pre_channel_value, post_channel_value) = - get_pre_and_post(9_000, huge63i3, huge63i3); - assert_eq!(pre_channel_value, 9_000); - assert_eq!(post_channel_value, 9223372036854784807); - } - } } From 1d28afcadb158718fbd2bc239f28820e76d9f31c Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 8 Apr 2026 20:53:03 +0000 Subject: [PATCH 336/627] Add `AvailableBalances::next_splice_out_maximum_sat` We previously determined this value by subtracting the htlcs, the anchors, and the commitment transaction fee. This ignored the reserve, as well as the at-least-one-output requirement in zero-reserve channels. This new field now accounts for both of these constraints. It can be seen as the total spliceable balance from the channel. --- lightning/src/ln/channel.rs | 86 ++++++++++++++++---- lightning/src/ln/channel_state.rs | 1 + lightning/src/ln/channelmanager.rs | 1 + lightning/src/ln/funding.rs | 69 ++++++++-------- lightning/src/sign/tx_builder.rs | 121 ++++++++++++++++++++++++++++- 5 files changed, 227 insertions(+), 51 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 365e82a2a52..e850d83e86a 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -131,6 +131,8 @@ pub struct AvailableBalances { /// /// See [`ChannelConfig::max_dust_htlc_exposure`] for more information on the dust calculation and to configure a limit. pub dust_exposure_msat: u64, + /// The maximum value of the next splice-out + pub next_splice_out_maximum_sat: u64, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -6765,7 +6767,7 @@ pub(crate) fn get_legacy_default_holder_selected_channel_reserve_satoshis( /// /// This is used both for outbound and inbound channels and has lower bound /// of `dust_limit_satoshis`. -fn get_v2_channel_reserve_satoshis( +pub(crate) fn get_v2_channel_reserve_satoshis( channel_value_satoshis: u64, dust_limit_satoshis: u64, is_0reserve: bool, ) -> u64 { if is_0reserve { @@ -12400,9 +12402,8 @@ where .as_ref() .and_then(|pending_splice| pending_splice.contributions.last()) { - let holder_balance = self - .get_holder_counterparty_balances_floor_incl_fee(&self.funding) - .map(|(h, _)| h) + let spliceable_balance = self + .get_next_splice_out_maximum(&self.funding) .map_err(|e| APIError::ChannelUnavailable { err: format!( "Channel {} cannot be spliced at this time: {}", @@ -12410,7 +12411,7 @@ where e ), })?; - Some(PriorContribution::new(prior.clone(), holder_balance)) + Some(PriorContribution::new(prior.clone(), spliceable_balance)) } else { None } @@ -12506,16 +12507,13 @@ where return contribution; } - let holder_balance = match self - .get_holder_counterparty_balances_floor_incl_fee(&self.funding) - .map(|(holder, _)| holder) - { + let spliceable_balance = match self.get_next_splice_out_maximum(&self.funding) { Ok(balance) => balance, Err(_) => return contribution, }; if let Err(e) = - contribution.net_value_for_initiator_at_feerate(min_rbf_feerate, holder_balance) + contribution.net_value_for_initiator_at_feerate(min_rbf_feerate, spliceable_balance) { log_info!( logger, @@ -12536,7 +12534,7 @@ where min_rbf_feerate, ); contribution - .for_initiator_at_feerate(min_rbf_feerate, holder_balance) + .for_initiator_at_feerate(min_rbf_feerate, spliceable_balance) .expect("feerate compatibility already checked") } @@ -12881,9 +12879,8 @@ where fn resolve_queued_contribution( &self, feerate: FeeRate, logger: &L, ) -> Result<(Option, Option), ChannelError> { - let holder_balance = self - .get_holder_counterparty_balances_floor_incl_fee(&self.funding) - .map(|(holder, _)| holder) + let spliceable_balance = self + .get_next_splice_out_maximum(&self.funding) .map_err(|e| { log_info!( logger, @@ -12895,9 +12892,9 @@ where }) .ok(); - let net_value = match holder_balance.and_then(|_| self.queued_funding_contribution()) { + let net_value = match spliceable_balance.and_then(|_| self.queued_funding_contribution()) { Some(c) => { - match c.net_value_for_acceptor_at_feerate(feerate, holder_balance.unwrap()) { + match c.net_value_for_acceptor_at_feerate(feerate, spliceable_balance.unwrap()) { Ok(net_value) => Some(net_value), Err(FeeRateAdjustmentError::FeeRateTooHigh { .. }) => { return Err(ChannelError::Abort(AbortReason::FeeRateTooHigh)); @@ -12917,7 +12914,7 @@ where None => None, }; - Ok((net_value, holder_balance)) + Ok((net_value, spliceable_balance)) } pub(crate) fn splice_init( @@ -13348,6 +13345,9 @@ where /// of the channel due to the v2 reserve, and the zero-reserve-at-least-one-output /// requirements. Note you cannot simply subtract out the reserve, as splicing funds out /// of the channel changes the reserve the holder must keep in the channel. + /// + /// See [`FundedChannel::get_next_splice_out_maximum`] for the maximum value of the next + /// splice out of the holder's balance. fn get_holder_counterparty_balances_floor_incl_fee( &self, funding: &FundingScope, ) -> Result<(Amount, Amount), String> { @@ -13418,6 +13418,55 @@ where Ok((holder_balance_floor, counterparty_balance_floor)) } + /// Determines the maximum value that the holder can splice out of the channel, accounting + /// for the updated reserves after said splice. This maximum also makes sure the local + /// commitment retains at least one output after the splice, which is particularly relevant + /// for zero-reserve channels. + fn get_next_splice_out_maximum(&self, funding: &FundingScope) -> Result { + let include_counterparty_unknown_htlcs = true; + // We are not interested in dust exposure + let dust_exposure_limiting_feerate = None; + + // When reading the available balances, we take the remote's view of the pending + // HTLCs, see `tx_builder` for further details + let (remote_stats, _remote_htlcs) = self + .context + .get_next_remote_commitment_stats( + funding, + None, // htlc_candidate + include_counterparty_unknown_htlcs, + 0, + self.context.feerate_per_kw, + dust_exposure_limiting_feerate, + ) + .map_err(|()| "Balance exhausted on remote commitment")?; + + let next_splice_out_maximum_sat = + remote_stats.available_balances.next_splice_out_maximum_sat; + + #[cfg(debug_assertions)] + { + // After this max splice out, validation passes, accounting for the updated reserves + self.validate_splice_contributions( + SignedAmount::from_sat(-(next_splice_out_maximum_sat as i64)), + SignedAmount::ZERO, + funding.counterparty_funding_pubkey().clone(), + funding.get_holder_pubkeys().clone(), + ) + .unwrap(); + // Splice-out an additional satoshi, and validation fails! + self.validate_splice_contributions( + SignedAmount::from_sat(-((next_splice_out_maximum_sat + 1) as i64)), + SignedAmount::ZERO, + funding.counterparty_funding_pubkey().clone(), + funding.get_holder_pubkeys().clone(), + ) + .unwrap_err(); + } + + Ok(Amount::from_sat(next_splice_out_maximum_sat)) + } + pub fn splice_locked( &mut self, msg: &msgs::SpliceLocked, node_signer: &NS, chain_hash: ChainHash, user_config: &UserConfig, block_height: u32, logger: &L, @@ -13644,6 +13693,9 @@ where .next_outbound_htlc_minimum_msat .max(e.next_outbound_htlc_minimum_msat), dust_exposure_msat: acc.dust_exposure_msat.max(e.dust_exposure_msat), + next_splice_out_maximum_sat: acc + .next_splice_out_maximum_sat + .min(e.next_splice_out_maximum_sat), }) }) } diff --git a/lightning/src/ln/channel_state.rs b/lightning/src/ln/channel_state.rs index d59e30f8db1..39e5caeeabc 100644 --- a/lightning/src/ln/channel_state.rs +++ b/lightning/src/ln/channel_state.rs @@ -549,6 +549,7 @@ impl ChannelDetails { next_outbound_htlc_limit_msat: 0, next_outbound_htlc_minimum_msat: u64::MAX, dust_exposure_msat: 0, + next_splice_out_maximum_sat: 0, } }); let (to_remote_reserve_satoshis, to_self_reserve_satoshis) = diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 49d629d5aa3..43a90a1bc7a 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8122,6 +8122,7 @@ impl< next_outbound_htlc_limit_msat: 0, next_outbound_htlc_minimum_msat: u64::MAX, dust_exposure_msat: 0, + next_splice_out_maximum_sat: 0, } }); let is_in_range = (balances.next_outbound_htlc_minimum_msat diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 20366fe772a..386aa3d92a3 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -192,7 +192,7 @@ impl core::fmt::Display for FundingContributionError { #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct PriorContribution { contribution: FundingContribution, - /// The holder's balance, used for feerate adjustment. + /// The holder's spliceable balance, used for feerate adjustment. /// /// This value is captured at [`ChannelManager::splice_channel`] time and may become stale /// if balances change before the contribution is used. Staleness is acceptable here because @@ -203,12 +203,12 @@ pub(super) struct PriorContribution { /// /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel /// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed - holder_balance: Amount, + spliceable_balance: Amount, } impl PriorContribution { - pub(super) fn new(contribution: FundingContribution, holder_balance: Amount) -> Self { - Self { contribution, holder_balance } + pub(super) fn new(contribution: FundingContribution, spliceable_balance: Amount) -> Self { + Self { contribution, spliceable_balance } } } @@ -632,14 +632,14 @@ impl FundingContribution { /// `target_feerate`. If dropping change leaves surplus value, that surplus remains in the /// channel contribution. /// - /// For input-less contributions, `holder_balance` must be provided to cover the outputs and + /// For input-less contributions, `spliceable_balance` must be provided to cover the outputs and /// fees from the channel balance. /// /// Returns `None` if the request would require new wallet inputs or cannot accommodate the /// requested feerate. fn amend_without_coin_selection( self, inputs: FundingInputs, outputs: &[TxOut], target_feerate: FeeRate, - max_feerate: FeeRate, holder_balance: Amount, + max_feerate: FeeRate, spliceable_balance: Amount, ) -> Option { // NOTE: The contribution returned is not guaranteed to be valid. We defer doing so until // `compute_feerate_adjustment`. @@ -717,7 +717,7 @@ impl FundingContribution { let new_contribution_at_current_feerate = adjust_for_inputs_and_outputs(self, inputs, outputs)?; let mut new_contribution_at_target_feerate = new_contribution_at_current_feerate - .at_feerate(target_feerate, holder_balance, true) + .at_feerate(target_feerate, spliceable_balance, true) .ok()?; new_contribution_at_target_feerate.max_feerate = max_feerate; @@ -771,7 +771,7 @@ impl FundingContribution { /// /// Returns `Err` if the contribution cannot accommodate the target feerate. fn compute_feerate_adjustment( - &self, target_feerate: FeeRate, holder_balance: Amount, is_initiator: bool, + &self, target_feerate: FeeRate, spliceable_balance: Amount, is_initiator: bool, ) -> Result<(Amount, Option), FeeRateAdjustmentError> { if target_feerate < self.feerate { return Err(FeeRateAdjustmentError::FeeRateTooLow { @@ -864,10 +864,12 @@ impl FundingContribution { let total_cost = target_fee .checked_add(value_removed) .ok_or(FeeRateAdjustmentError::FeeBufferOverflow)?; - if total_cost > holder_balance { + if total_cost > spliceable_balance { return Err(FeeRateAdjustmentError::FeeBufferInsufficient { source: "channel balance - withdrawal outputs", - available: holder_balance.checked_sub(value_removed).unwrap_or(Amount::ZERO), + available: spliceable_balance + .checked_sub(value_removed) + .unwrap_or(Amount::ZERO), required: target_fee, }); } @@ -879,10 +881,10 @@ impl FundingContribution { /// estimate, and feerate. Returns the adjusted contribution, or an error if the feerate /// can't be accommodated. fn at_feerate( - mut self, feerate: FeeRate, holder_balance: Amount, is_initiator: bool, + mut self, feerate: FeeRate, spliceable_balance: Amount, is_initiator: bool, ) -> Result { let (new_estimated_fee, new_change) = - self.compute_feerate_adjustment(feerate, holder_balance, is_initiator)?; + self.compute_feerate_adjustment(feerate, spliceable_balance, is_initiator)?; match new_change { Some(value) => self.change_output.as_mut().unwrap().value = value, None => self.change_output = None, @@ -899,9 +901,9 @@ impl FundingContribution { /// This adjusts the change output so the acceptor pays their target fee at the target /// feerate. pub(super) fn for_acceptor_at_feerate( - self, feerate: FeeRate, holder_balance: Amount, + self, feerate: FeeRate, spliceable_balance: Amount, ) -> Result { - self.at_feerate(feerate, holder_balance, false) + self.at_feerate(feerate, spliceable_balance, false) } /// Adjusts the contribution's change output for the minimum RBF feerate. @@ -910,9 +912,9 @@ impl FundingContribution { /// below the minimum RBF feerate, this adjusts the change output so the initiator pays fees /// at the minimum RBF feerate. pub(super) fn for_initiator_at_feerate( - self, feerate: FeeRate, holder_balance: Amount, + self, feerate: FeeRate, spliceable_balance: Amount, ) -> Result { - self.at_feerate(feerate, holder_balance, true) + self.at_feerate(feerate, spliceable_balance, true) } /// Returns the net value at the given target feerate without mutating `self`. @@ -921,10 +923,10 @@ impl FundingContribution { /// can't be accommodated) and computes the adjusted net value (returning `Ok` with the value /// accounting for the target feerate). fn net_value_at_feerate( - &self, target_feerate: FeeRate, holder_balance: Amount, is_initiator: bool, + &self, target_feerate: FeeRate, spliceable_balance: Amount, is_initiator: bool, ) -> Result { let (new_estimated_fee, new_change) = - self.compute_feerate_adjustment(target_feerate, holder_balance, is_initiator)?; + self.compute_feerate_adjustment(target_feerate, spliceable_balance, is_initiator)?; let prev_fee = self .estimated_fee @@ -952,17 +954,17 @@ impl FundingContribution { /// Returns the net value at the given target feerate without mutating `self`, /// assuming acceptor fee responsibility. pub(super) fn net_value_for_acceptor_at_feerate( - &self, target_feerate: FeeRate, holder_balance: Amount, + &self, target_feerate: FeeRate, spliceable_balance: Amount, ) -> Result { - self.net_value_at_feerate(target_feerate, holder_balance, false) + self.net_value_at_feerate(target_feerate, spliceable_balance, false) } /// Returns the net value at the given target feerate without mutating `self`, /// assuming initiator fee responsibility. pub(super) fn net_value_for_initiator_at_feerate( - &self, target_feerate: FeeRate, holder_balance: Amount, + &self, target_feerate: FeeRate, spliceable_balance: Amount, ) -> Result { - self.net_value_at_feerate(target_feerate, holder_balance, true) + self.net_value_at_feerate(target_feerate, spliceable_balance, true) } /// The net value contributed to a channel by the splice. @@ -1059,13 +1061,13 @@ impl FundingBuilderInner { fn build_from_prior_contribution( &mut self, contribution: PriorContribution, ) -> Result { - let PriorContribution { contribution, holder_balance } = contribution; + let PriorContribution { contribution, spliceable_balance } = contribution; if self.request_matches_prior(&contribution) { // Same request, but the feerate may have changed. Adjust the prior contribution // to the new feerate if possible. return contribution - .for_initiator_at_feerate(self.feerate, holder_balance) + .for_initiator_at_feerate(self.feerate, spliceable_balance) .map(|mut adjusted| { adjusted.max_feerate = self.max_feerate; adjusted @@ -1084,7 +1086,7 @@ impl FundingBuilderInner { &self.outputs, self.feerate, self.max_feerate, - holder_balance, + spliceable_balance, ) .ok_or_else(|| FundingContributionError::MissingCoinSelectionSource); } @@ -2181,8 +2183,8 @@ mod tests { }; // Balance of 55,000 sats can't cover outputs (50,000) + target_fee at 50k sat/kwu. - let holder_balance = Amount::from_sat(55_000); - let result = contribution.for_acceptor_at_feerate(target_feerate, holder_balance); + let spliceable_balance = Amount::from_sat(55_000); + let result = contribution.for_acceptor_at_feerate(target_feerate, spliceable_balance); assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. }))); } @@ -2601,8 +2603,8 @@ mod tests { }; // Balance of 40,000 sats is less than outputs (50,000) + target_fee. - let holder_balance = Amount::from_sat(40_000); - let result = contribution.for_acceptor_at_feerate(target_feerate, holder_balance); + let spliceable_balance = Amount::from_sat(40_000); + let result = contribution.for_acceptor_at_feerate(target_feerate, spliceable_balance); assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. }))); } @@ -2627,9 +2629,9 @@ mod tests { }; // Balance of 100,000 sats is more than outputs (50,000) + target_fee. - let holder_balance = Amount::from_sat(100_000); + let spliceable_balance = Amount::from_sat(100_000); let contribution = - contribution.for_acceptor_at_feerate(target_feerate, holder_balance).unwrap(); + contribution.for_acceptor_at_feerate(target_feerate, spliceable_balance).unwrap(); let expected_target_fee = estimate_transaction_fee(&[], &outputs, None, false, true, target_feerate); assert_eq!(contribution.estimated_fee, expected_target_fee); @@ -2657,8 +2659,9 @@ mod tests { }; // Balance of 40,000 sats is less than outputs (50,000) + target_fee. - let holder_balance = Amount::from_sat(40_000); - let result = contribution.net_value_for_acceptor_at_feerate(target_feerate, holder_balance); + let spliceable_balance = Amount::from_sat(40_000); + let result = + contribution.net_value_for_acceptor_at_feerate(target_feerate, spliceable_balance); assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. }))); } diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index 400d2cbfc82..986cb9e844c 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -9,7 +9,9 @@ use crate::ln::chan_utils::{ second_stage_tx_fees_sat, ChannelTransactionParameters, CommitmentTransaction, HTLCOutputInCommitment, }; -use crate::ln::channel::{CommitmentStats, ANCHOR_OUTPUT_VALUE_SATOSHI}; +use crate::ln::channel::{ + get_v2_channel_reserve_satoshis, CommitmentStats, ANCHOR_OUTPUT_VALUE_SATOSHI, +}; use crate::prelude::*; use crate::types::features::ChannelTypeFeatures; use crate::util::logger::Logger; @@ -315,6 +317,108 @@ fn get_next_commitment_stats( }) } +/// Determines the maximum value that the holder can splice out of the channel, accounting +/// for the updated reserves after said splice. This maximum also makes sure the local commitment +/// retains at least one output after the splice, which is particularly relevant for +/// zero-reserve channels. +// +// The equation to determine `max_splice_percentage_constraint_sat` is: +// 1) floor((c - s) / 100) == h - s - d +// We want the maximum value of s that will satisfy equation 1, therefore, we solve: +// 2) (c - s) / 100 < h - s - d + 1 +// where c: `channel_value_satoshis` +// s: `max_splice_percentage_constraint_sat` +// h: `local_balance_before_fee_sat` +// d: `post_splice_delta_above_reserve_sat` +// This results in: +// 3) s < (100h + 100 - 100d - c) / 99 +fn get_next_splice_out_maximum_sat( + is_outbound_from_holder: bool, channel_value_satoshis: u64, local_balance_before_fee_msat: u64, + remote_balance_before_fee_msat: u64, spiked_feerate: u32, + spiked_feerate_nondust_htlc_count: usize, post_splice_delta_above_reserve_sat: u64, + channel_constraints: &ChannelConstraints, channel_type: &ChannelTypeFeatures, +) -> u64 { + let local_balance_before_fee_sat = local_balance_before_fee_msat / 1000; + let mut next_splice_out_maximum_sat = if channel_constraints + .counterparty_selected_channel_reserve_satoshis + != 0 + { + let dividend_sat = local_balance_before_fee_sat + .saturating_mul(100) + .saturating_add(100) + .saturating_sub(post_splice_delta_above_reserve_sat.saturating_mul(100)) + .saturating_sub(channel_value_satoshis); + // Calculate the greatest integer that is strictly less than the RHS of inequality 3 above + let max_splice_percentage_constraint_sat = dividend_sat.saturating_sub(1) / 99; + let max_splice_dust_limit_constraint_sat = local_balance_before_fee_sat + .saturating_sub(channel_constraints.holder_dust_limit_satoshis) + .saturating_sub(post_splice_delta_above_reserve_sat); + // Both constraints must be satisfied, so take the minimum of the two maximums + let max_splice_out_sat = + cmp::min(max_splice_percentage_constraint_sat, max_splice_dust_limit_constraint_sat); + #[cfg(debug_assertions)] + if max_splice_out_sat == 0 { + let current_balance_sat = + local_balance_before_fee_sat.saturating_sub(post_splice_delta_above_reserve_sat); + let v2_reserve_sat = get_v2_channel_reserve_satoshis( + channel_value_satoshis, + channel_constraints.holder_dust_limit_satoshis, + false, + ); + // If the holder cannot splice out anything, they must be at or + // below the v2 reserve + debug_assert!(current_balance_sat <= v2_reserve_sat); + } else { + let post_splice_reserve_sat = get_v2_channel_reserve_satoshis( + channel_value_satoshis.saturating_sub(max_splice_out_sat), + channel_constraints.holder_dust_limit_satoshis, + false, + ); + // If the holder can splice out some maximum, splicing out that + // maximum lands them at exactly the new v2 reserve + the + // `post_splice_delta_above_reserve_sat` + debug_assert_eq!( + local_balance_before_fee_sat.saturating_sub(max_splice_out_sat), + post_splice_reserve_sat.saturating_add(post_splice_delta_above_reserve_sat) + ); + } + max_splice_out_sat + } else { + // In a zero-reserve channel, the holder is free to withdraw up to its `post_splice_delta_above_reserve_sat` + local_balance_before_fee_sat.saturating_sub(post_splice_delta_above_reserve_sat) + }; + + // We only bother to check the local commitment here, the counterparty will check its own commitment. + // + // If the current `next_splice_out_maximum_sat` would produce a local commitment with no + // outputs, bump this maximum such that, after the splice, the holder's balance covers at + // least `dust_limit_satoshis` and, if they are the funder, `current_spiked_tx_fee_sat`. + // We don't include an additional non-dust inbound HTLC in the `current_spiked_tx_fee_sat`, + // because we don't mind if the holder dips below their dust limit to cover the fee for that + // inbound non-dust HTLC. + if !has_output( + is_outbound_from_holder, + local_balance_before_fee_msat.saturating_sub(next_splice_out_maximum_sat * 1000), + remote_balance_before_fee_msat, + spiked_feerate, + spiked_feerate_nondust_htlc_count, + channel_constraints.holder_dust_limit_satoshis, + channel_type, + ) { + let dust_limit_satoshis = channel_constraints.holder_dust_limit_satoshis; + let current_spiked_tx_fee_sat = commit_tx_fee_sat(spiked_feerate, 0, channel_type); + let min_balance_sat = if is_outbound_from_holder { + dust_limit_satoshis.saturating_add(current_spiked_tx_fee_sat) + } else { + dust_limit_satoshis + }; + next_splice_out_maximum_sat = + (local_balance_before_fee_msat / 1000).saturating_sub(min_balance_sat); + } + + next_splice_out_maximum_sat +} + fn get_available_balances( is_outbound_from_holder: bool, channel_value_satoshis: u64, value_to_holder_msat: u64, pending_htlcs: &[HTLCAmountDirection], feerate_per_kw: u32, @@ -411,6 +515,20 @@ fn get_available_balances( total_anchors_sat.saturating_mul(1000), ); + let next_splice_out_maximum_sat = get_next_splice_out_maximum_sat( + is_outbound_from_holder, + channel_value_satoshis, + local_balance_before_fee_msat, + remote_balance_before_fee_msat, + spiked_feerate, + // The number of non-dust HTLCs on the local commitment at the spiked feerate + local_nondust_htlc_count, + // The post-splice minimum balance of the holder + if is_outbound_from_holder { local_min_commit_tx_fee_sat } else { 0 }, + &channel_constraints, + channel_type, + ); + let outbound_capacity_msat = local_balance_before_fee_msat .saturating_sub(channel_constraints.counterparty_selected_channel_reserve_satoshis * 1000); @@ -585,6 +703,7 @@ fn get_available_balances( next_outbound_htlc_limit_msat: available_capacity_msat, next_outbound_htlc_minimum_msat, dust_exposure_msat, + next_splice_out_maximum_sat, } } From f86b2eb16a6947a52a0a46774c96680435328599 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 9 Apr 2026 00:22:21 +0000 Subject: [PATCH 337/627] Use `next_splice_out_maximum_sat` to validate `funding_contributed` This is equivalent to the previous commit, see the debug assertions added in the previous commit. We now also get to communicate the exact maximum back to the user, instead of some "balance is lower than our reserve" message, which is hard to react to. --- lightning/src/ln/channel.rs | 29 ++++++++++++++++------------- lightning/src/ln/splicing_tests.rs | 4 ++-- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index e850d83e86a..f7c4ca26dd1 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12605,13 +12605,14 @@ where } let our_funding_contribution = contribution.net_value(); - - if let Err(e) = self.validate_splice_contributions( - our_funding_contribution, - SignedAmount::ZERO, - self.funding.get_counterparty_pubkeys().funding_pubkey, - self.funding.get_holder_pubkeys().clone(), - ) { + let unsigned_contribution = our_funding_contribution.unsigned_abs(); + if let Err(e) = self.get_next_splice_out_maximum(&self.funding) + .and_then(|splice_max| splice_max + .to_sat() + .checked_add_signed(our_funding_contribution.to_sat()) + .ok_or(format!("Our splice-out value of {unsigned_contribution} is greater than the maximum {splice_max}")) + ) + { log_error!(logger, "Channel {} cannot be funded: {}", self.context.channel_id(), e); return Err(QuiescentError::FailSplice(self.splice_funding_failed_for(contribution))); } @@ -14232,12 +14233,14 @@ where // balance. If invalid, disconnect and return the contribution so // the user can reclaim their inputs. let our_funding_contribution = contribution.net_value(); - if let Err(e) = self.validate_splice_contributions( - our_funding_contribution, - SignedAmount::ZERO, - self.funding.get_counterparty_pubkeys().funding_pubkey, - self.funding.get_holder_pubkeys().clone(), - ) { + let unsigned_contribution = our_funding_contribution.unsigned_abs(); + if let Err(e) = self.get_next_splice_out_maximum(&self.funding) + .and_then(|splice_max| splice_max + .to_sat() + .checked_add_signed(our_funding_contribution.to_sat()) + .ok_or(format!("Our splice-out value of {unsigned_contribution} is greater than the maximum {splice_max}")) + ) + { let failed = self.splice_funding_failed_for(contribution); return Err(( ChannelError::WarnAndDisconnect(format!( diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index b2cb1eda375..33483e4cc54 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -4264,8 +4264,8 @@ fn do_test_splice_pending_htlcs(config: UserConfig) { format!("Channel {} cannot accept funding contribution", channel_id); assert_eq!(error, APIError::APIMisuseError { err: cannot_accept_contribution }); let cannot_be_funded = format!( - "Channel {} cannot be funded: Channel {} cannot be spliced out; our post-splice channel balance {} is smaller than their selected v2 reserve {}", - channel_id, channel_id, post_splice_reserve - Amount::ONE_SAT, post_splice_reserve + "Channel {} cannot be funded: Our splice-out value of {} is greater than the maximum {}", + channel_id, splice_out_incl_fees + Amount::ONE_SAT, splice_out_incl_fees, ); initiator.logger.assert_log("lightning::ln::channel", cannot_be_funded, 1); From 9bc7b1943b51e1cd716434dd0ea2f40e3d6ef864 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 9 Apr 2026 07:06:07 +0000 Subject: [PATCH 338/627] Add `ChannelDetails::next_splice_out_maximum_sat` --- fuzz/src/router.rs | 1 + lightning/src/ln/channel_state.rs | 5 +++++ lightning/src/routing/router.rs | 2 ++ 3 files changed, 8 insertions(+) diff --git a/fuzz/src/router.rs b/fuzz/src/router.rs index 7c62b3ac5a0..2295ae3d7ff 100644 --- a/fuzz/src/router.rs +++ b/fuzz/src/router.rs @@ -248,6 +248,7 @@ pub fn do_test(data: &[u8], out: Out) { outbound_capacity_msat: capacity.saturating_mul(1000), next_outbound_htlc_limit_msat: capacity.saturating_mul(1000), next_outbound_htlc_minimum_msat: 0, + next_splice_out_maximum_sat: capacity, inbound_htlc_minimum_msat: None, inbound_htlc_maximum_msat: None, config: None, diff --git a/lightning/src/ln/channel_state.rs b/lightning/src/ln/channel_state.rs index 39e5caeeabc..28e8bedf41b 100644 --- a/lightning/src/ln/channel_state.rs +++ b/lightning/src/ln/channel_state.rs @@ -399,6 +399,8 @@ pub struct ChannelDetails { /// an upper-bound. This is intended for use when routing, allowing us to ensure we pick a /// route which is valid. pub next_outbound_htlc_minimum_msat: u64, + /// The maximum value of the next splice out from our channel balance. + pub next_splice_out_maximum_sat: u64, /// The available inbound capacity for the remote peer to send HTLCs to us. This does not /// include any pending HTLCs which are not yet fully resolved (and, thus, whose balance is not /// available for inclusion in new inbound HTLCs). @@ -599,6 +601,7 @@ impl ChannelDetails { outbound_capacity_msat: balance.outbound_capacity_msat, next_outbound_htlc_limit_msat: balance.next_outbound_htlc_limit_msat, next_outbound_htlc_minimum_msat: balance.next_outbound_htlc_minimum_msat, + next_splice_out_maximum_sat: balance.next_splice_out_maximum_sat, user_channel_id: context.get_user_id(), confirmations_required: channel.minimum_depth(), confirmations: Some(funding.get_funding_tx_confirmations(best_block_height)), @@ -639,6 +642,7 @@ impl_writeable_tlv_based!(ChannelDetails, { (20, inbound_capacity_msat, required), (21, next_outbound_htlc_minimum_msat, (default_value, 0)), (22, confirmations_required, option), + (23, next_splice_out_maximum_sat, (default_value, u64::from(outbound_capacity_msat.0.unwrap()) / 1000)), (24, force_close_spend_delay, option), (26, is_outbound, required), (28, is_channel_ready, required), @@ -744,6 +748,7 @@ mod tests { outbound_capacity_msat: 24_300, next_outbound_htlc_limit_msat: 20_000, next_outbound_htlc_minimum_msat: 132, + next_splice_out_maximum_sat: 20, inbound_capacity_msat: 42, unspendable_punishment_reserve: Some(8273), confirmations_required: Some(5), diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 18b78dd647b..6db5761207c 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -4150,6 +4150,7 @@ mod tests { outbound_capacity_msat, next_outbound_htlc_limit_msat: outbound_capacity_msat, next_outbound_htlc_minimum_msat: 0, + next_splice_out_maximum_sat: outbound_capacity_msat / 1000, inbound_capacity_msat: 42, unspendable_punishment_reserve: None, confirmations_required: None, @@ -9650,6 +9651,7 @@ pub(crate) mod bench_utils { outbound_capacity_msat: 10_000_000_000, next_outbound_htlc_minimum_msat: 0, next_outbound_htlc_limit_msat: 10_000_000_000, + next_splice_out_maximum_sat: 10_000_000, inbound_capacity_msat: 0, unspendable_punishment_reserve: None, confirmations_required: None, From b96c2dbec5cb54aea15dae5bdf14b47c65c8a61a Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 9 Apr 2026 06:33:02 +0000 Subject: [PATCH 339/627] Add `test_0reserve_splice` --- lightning/src/ln/htlc_reserve_unit_tests.rs | 2 +- lightning/src/ln/splicing_tests.rs | 354 +++++++++++++++++++- 2 files changed, 353 insertions(+), 3 deletions(-) diff --git a/lightning/src/ln/htlc_reserve_unit_tests.rs b/lightning/src/ln/htlc_reserve_unit_tests.rs index aaf81b87be7..45d3cf5950f 100644 --- a/lightning/src/ln/htlc_reserve_unit_tests.rs +++ b/lightning/src/ln/htlc_reserve_unit_tests.rs @@ -2581,7 +2581,7 @@ fn test_0reserve_no_outputs() { do_test_0reserve_no_outputs_p2a_anchor(); } -fn setup_0reserve_no_outputs_channels<'a, 'b, 'c, 'd>( +pub(crate) fn setup_0reserve_no_outputs_channels<'a, 'b, 'c, 'd>( nodes: &'a Vec>, channel_value_sat: u64, dust_limit_satoshis: u64, ) -> (ChannelId, Transaction) { let node_a_id = nodes[0].node.get_our_node_id(); diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 33483e4cc54..986af7901dd 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -16,8 +16,8 @@ use crate::chain::ChannelMonitorUpdateStatus; use crate::events::{ClosureReason, Event, FundingInfo, HTLCHandlingFailureType}; use crate::ln::chan_utils; use crate::ln::channel::{ - CHANNEL_ANNOUNCEMENT_PROPAGATION_DELAY, DISCONNECT_PEER_AWAITING_RESPONSE_TICKS, - FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE, + ANCHOR_OUTPUT_VALUE_SATOSHI, CHANNEL_ANNOUNCEMENT_PROPAGATION_DELAY, + DISCONNECT_PEER_AWAITING_RESPONSE_TICKS, FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE, }; use crate::ln::channelmanager::{provided_init_features, PaymentId, BREAKDOWN_TIMEOUT}; use crate::ln::functional_test_utils::*; @@ -7477,3 +7477,353 @@ fn test_no_disconnect_after_quiescence_on_reconnect() { assert!(!has_disconnect(&nodes[0].node.get_and_clear_pending_msg_events())); assert!(!has_disconnect(&nodes[1].node.get_and_clear_pending_msg_events())); } + +#[test] +fn test_0reserve_splice() { + let mut config = test_default_channel_config(); + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + let a = do_test_0reserve_splice_holder_validation(false, false, false, config.clone()); + let _b = do_test_0reserve_splice_holder_validation(true, false, false, config.clone()); + let _c = do_test_0reserve_splice_holder_validation(false, true, false, config.clone()); + let _d = do_test_0reserve_splice_holder_validation(true, true, false, config.clone()); + + let _e = do_test_0reserve_splice_holder_validation(false, false, true, config.clone()); + let _f = do_test_0reserve_splice_holder_validation(true, false, true, config.clone()); + let _g = do_test_0reserve_splice_holder_validation(false, true, true, config.clone()); + let _h = do_test_0reserve_splice_holder_validation(true, true, true, config.clone()); + + assert_eq!(a, ChannelTypeFeatures::only_static_remote_key()); + + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + let a = do_test_0reserve_splice_holder_validation(false, false, false, config.clone()); + let _b = do_test_0reserve_splice_holder_validation(true, false, false, config.clone()); + let _c = do_test_0reserve_splice_holder_validation(false, true, false, config.clone()); + let _d = do_test_0reserve_splice_holder_validation(true, true, false, config.clone()); + + let _e = do_test_0reserve_splice_holder_validation(false, false, true, config.clone()); + let _f = do_test_0reserve_splice_holder_validation(true, false, true, config.clone()); + let _g = do_test_0reserve_splice_holder_validation(false, true, true, config.clone()); + let _h = do_test_0reserve_splice_holder_validation(true, true, true, config.clone()); + + assert_eq!(a, ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies()); + + let mut config = test_default_channel_config(); + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + let a = do_test_0reserve_splice_counterparty_validation(false, false, false, config.clone()); + let _b = do_test_0reserve_splice_counterparty_validation(true, false, false, config.clone()); + let _c = do_test_0reserve_splice_counterparty_validation(false, true, false, config.clone()); + let _d = do_test_0reserve_splice_counterparty_validation(true, true, false, config.clone()); + + let _e = do_test_0reserve_splice_counterparty_validation(false, false, true, config.clone()); + let _f = do_test_0reserve_splice_counterparty_validation(true, false, true, config.clone()); + let _g = do_test_0reserve_splice_counterparty_validation(false, true, true, config.clone()); + let _h = do_test_0reserve_splice_counterparty_validation(true, true, true, config.clone()); + assert_eq!(a, ChannelTypeFeatures::only_static_remote_key()); + + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + let a = do_test_0reserve_splice_counterparty_validation(false, false, false, config.clone()); + let _b = do_test_0reserve_splice_counterparty_validation(true, false, false, config.clone()); + let _c = do_test_0reserve_splice_counterparty_validation(false, true, false, config.clone()); + let _d = do_test_0reserve_splice_counterparty_validation(true, true, false, config.clone()); + + let _e = do_test_0reserve_splice_counterparty_validation(false, false, true, config.clone()); + let _f = do_test_0reserve_splice_counterparty_validation(true, false, true, config.clone()); + let _g = do_test_0reserve_splice_counterparty_validation(false, true, true, config.clone()); + let _h = do_test_0reserve_splice_counterparty_validation(true, true, true, config.clone()); + assert_eq!(a, ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies()); + + // TODO: Skip 0FC channels for now as these always have an output on the commitment, the P2A + // output. We will be able to withdraw up to the dust limit of the funding script, which + // is checked in interactivetx. Still need to double check whether that's what we actually + // want. +} + +#[cfg(test)] +fn do_test_0reserve_splice_holder_validation( + splice_passes: bool, counterparty_has_output: bool, node_0_is_initiator: bool, + mut config: UserConfig, +) -> ChannelTypeFeatures { + use crate::ln::htlc_reserve_unit_tests::setup_0reserve_no_outputs_channels; + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; + let node_chanmgrs = + create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config.clone())]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let _node_id_0 = nodes[0].node.get_our_node_id(); + let _node_id_1 = nodes[1].node.get_our_node_id(); + + let channel_value_sat = 100_000; + // Some dust limit, does not matter + let dust_limit_satoshis = 546; + + let (channel_id, _tx) = + setup_0reserve_no_outputs_channels(&nodes, channel_value_sat, dust_limit_satoshis); + let details = &nodes[0].node.list_channels()[0]; + let channel_type = details.channel_type.clone().unwrap(); + + let feerate = 253; + let spiked_feerate = if channel_type == ChannelTypeFeatures::only_static_remote_key() { + feerate * FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 + } else if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() { + feerate + } else { + panic!("Unexpected channel type"); + }; + let anchors_sat = + if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() { + ANCHOR_OUTPUT_VALUE_SATOSHI * 2 + } else { + 0 + }; + + let initiator_value_to_self_sat = if counterparty_has_output { + send_payment(&nodes[0], &[&nodes[1]], channel_value_sat / 2 * 1_000); + channel_value_sat / 2 + } else if !node_0_is_initiator { + let tx_fee_msat = chan_utils::commit_tx_fee_sat(spiked_feerate, 2, &channel_type) * 1000; + let node_0_details = &nodes[0].node.list_channels()[0]; + let outbound_capacity_msat = node_0_details.outbound_capacity_msat; + let available_capacity_msat = node_0_details.next_outbound_htlc_limit_msat; + assert_eq!(outbound_capacity_msat, (channel_value_sat - anchors_sat) * 1000); + assert_eq!(available_capacity_msat, outbound_capacity_msat - tx_fee_msat); + send_payment(&nodes[0], &[&nodes[1]], available_capacity_msat); + + // Make sure node 0 has no output on the commitment at this point + let node_0_to_local_output_msat = channel_value_sat * 1000 + - available_capacity_msat + - anchors_sat * 1000 + - chan_utils::commit_tx_fee_sat(feerate, 0, &channel_type) * 1000; + assert!(node_0_to_local_output_msat / 1000 < dust_limit_satoshis); + let commit_tx = &get_local_commitment_txn!(nodes[0], channel_id)[0]; + assert_eq!(commit_tx.output.len(), if anchors_sat == 0 { 1 } else { 2 }); + assert_eq!( + commit_tx.output.last().unwrap().value, + Amount::from_sat(available_capacity_msat / 1000) + ); + if anchors_sat != 0 { + assert_eq!(commit_tx.output[0].value, Amount::from_sat(330)); + } + + available_capacity_msat / 1000 + } else { + channel_value_sat + }; + + // The estimated fees to splice out a single output at 253sat/kw + let estimated_fees = 183; + let splice_out_max_value = if counterparty_has_output && node_0_is_initiator { + let commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(spiked_feerate, 1, &channel_type); + Amount::from_sat( + initiator_value_to_self_sat - commit_tx_fee_sat - anchors_sat - estimated_fees, + ) + } else if !counterparty_has_output && node_0_is_initiator { + let commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(spiked_feerate, 0, &channel_type); + Amount::from_sat( + initiator_value_to_self_sat + - commit_tx_fee_sat + - anchors_sat - estimated_fees + - dust_limit_satoshis, + ) + } else if counterparty_has_output && !node_0_is_initiator { + Amount::from_sat(initiator_value_to_self_sat - estimated_fees) + } else if !counterparty_has_output && !node_0_is_initiator { + Amount::from_sat(initiator_value_to_self_sat - estimated_fees - dust_limit_satoshis) + } else { + panic!("unexpected case!"); + }; + let outputs = vec![TxOut { + value: splice_out_max_value + if splice_passes { Amount::ZERO } else { Amount::ONE_SAT }, + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]; + + let (initiator, acceptor) = + if node_0_is_initiator { (&nodes[0], &nodes[1]) } else { (&nodes[1], &nodes[0]) }; + + let initiator_details = &initiator.node.list_channels()[0]; + assert_eq!( + initiator_details.next_splice_out_maximum_sat, + splice_out_max_value.to_sat() + estimated_fees + ); + + if splice_passes { + let contribution = initiate_splice_out(initiator, acceptor, channel_id, outputs).unwrap(); + + let (splice_tx, _) = splice_channel(initiator, acceptor, channel_id, contribution); + mine_transaction(initiator, &splice_tx); + mine_transaction(acceptor, &splice_tx); + lock_splice_after_blocks(initiator, acceptor, ANTI_REORG_DELAY - 1); + } else { + assert!(initiate_splice_out(initiator, acceptor, channel_id, outputs).is_err()); + let splice_out_value = + splice_out_max_value + Amount::from_sat(estimated_fees) + Amount::ONE_SAT; + let splice_out_max_value = splice_out_max_value + Amount::from_sat(estimated_fees); + let cannot_be_funded = format!( + "Channel {channel_id} cannot be funded: Our \ + splice-out value of {splice_out_value} is greater than the maximum \ + {splice_out_max_value}" + ); + initiator.logger.assert_log("lightning::ln::channel", cannot_be_funded, 1); + } + + channel_type +} + +#[cfg(test)] +fn do_test_0reserve_splice_counterparty_validation( + splice_passes: bool, counterparty_has_output: bool, node_0_is_initiator: bool, + mut config: UserConfig, +) -> ChannelTypeFeatures { + use crate::ln::htlc_reserve_unit_tests::setup_0reserve_no_outputs_channels; + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; + let node_chanmgrs = + create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config.clone())]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let _node_id_0 = nodes[0].node.get_our_node_id(); + let _node_id_1 = nodes[1].node.get_our_node_id(); + + let channel_value_sat = 100_000; + // Some dust limit, does not matter + let dust_limit_satoshis = 546; + + let (channel_id, _tx) = + setup_0reserve_no_outputs_channels(&nodes, channel_value_sat, dust_limit_satoshis); + let details = &nodes[0].node.list_channels()[0]; + let channel_type = details.channel_type.clone().unwrap(); + + let feerate = 253; + let spiked_feerate = if channel_type == ChannelTypeFeatures::only_static_remote_key() { + feerate * FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 + } else if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() { + feerate + } else { + panic!("Unexpected channel type"); + }; + let anchors_sat = + if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() { + ANCHOR_OUTPUT_VALUE_SATOSHI * 2 + } else { + 0 + }; + + let initiator_value_to_self_sat = if counterparty_has_output { + send_payment(&nodes[0], &[&nodes[1]], channel_value_sat / 2 * 1_000); + channel_value_sat / 2 + } else if !node_0_is_initiator { + let tx_fee_msat = chan_utils::commit_tx_fee_sat(spiked_feerate, 2, &channel_type) * 1000; + let node_0_details = &nodes[0].node.list_channels()[0]; + let outbound_capacity_msat = node_0_details.outbound_capacity_msat; + let available_capacity_msat = node_0_details.next_outbound_htlc_limit_msat; + assert_eq!(outbound_capacity_msat, (channel_value_sat - anchors_sat) * 1000); + assert_eq!(available_capacity_msat, outbound_capacity_msat - tx_fee_msat); + send_payment(&nodes[0], &[&nodes[1]], available_capacity_msat); + + // Make sure node 0 has no output on the commitment at this point + let node_0_to_local_output_msat = channel_value_sat * 1000 + - available_capacity_msat + - anchors_sat * 1000 + - chan_utils::commit_tx_fee_sat(spiked_feerate, 0, &channel_type) * 1000; + assert!(node_0_to_local_output_msat / 1000 < dust_limit_satoshis); + let commit_tx = &get_local_commitment_txn!(nodes[0], channel_id)[0]; + assert_eq!(commit_tx.output.len(), if anchors_sat == 0 { 1 } else { 2 }); + assert_eq!( + commit_tx.output.last().unwrap().value, + Amount::from_sat(available_capacity_msat / 1000) + ); + if anchors_sat != 0 { + assert_eq!(commit_tx.output[0].value, Amount::from_sat(330)); + } + + available_capacity_msat / 1000 + } else { + channel_value_sat + }; + + let splice_out_value_incl_fees = if counterparty_has_output && node_0_is_initiator { + let commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(spiked_feerate, 1, &channel_type); + Amount::from_sat(initiator_value_to_self_sat - commit_tx_fee_sat - anchors_sat) + } else if !counterparty_has_output && node_0_is_initiator { + let commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(spiked_feerate, 0, &channel_type); + Amount::from_sat( + initiator_value_to_self_sat - commit_tx_fee_sat - anchors_sat - dust_limit_satoshis, + ) + } else if counterparty_has_output && !node_0_is_initiator { + Amount::from_sat(initiator_value_to_self_sat) + } else if !counterparty_has_output && !node_0_is_initiator { + Amount::from_sat(initiator_value_to_self_sat - dust_limit_satoshis) + } else { + panic!("unexpected case!"); + }; + + let (initiator, acceptor) = + if node_0_is_initiator { (&nodes[0], &nodes[1]) } else { (&nodes[1], &nodes[0]) }; + + let initiator_details = &initiator.node.list_channels()[0]; + assert_eq!(initiator_details.next_splice_out_maximum_sat, splice_out_value_incl_fees.to_sat()); + + let funding_contribution_sat = + -(splice_out_value_incl_fees.to_sat() as i64) - if splice_passes { 0 } else { 1 }; + let outputs = vec![TxOut { + // Splice out some dummy amount to get past the initiator's validation, + // we'll modify the message in-flight. + value: Amount::from_sat(1_000), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]; + let _contribution = initiate_splice_out(initiator, acceptor, channel_id, outputs).unwrap(); + + let node_id_initiator = initiator.node.get_our_node_id(); + let node_id_acceptor = acceptor.node.get_our_node_id(); + + let stfu_init = get_event_msg!(initiator, MessageSendEvent::SendStfu, node_id_acceptor); + acceptor.node.handle_stfu(node_id_initiator, &stfu_init); + let stfu_ack = get_event_msg!(acceptor, MessageSendEvent::SendStfu, node_id_initiator); + initiator.node.handle_stfu(node_id_acceptor, &stfu_ack); + + let mut splice_init = + get_event_msg!(initiator, MessageSendEvent::SendSpliceInit, node_id_acceptor); + // Make the modification here + splice_init.funding_contribution_satoshis = funding_contribution_sat; + + if splice_passes { + acceptor.node.handle_splice_init(node_id_initiator, &splice_init); + let _splice_ack = + get_event_msg!(acceptor, MessageSendEvent::SendSpliceAck, node_id_initiator); + } else { + acceptor.node.handle_splice_init(node_id_initiator, &splice_init); + let msg_events = acceptor.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + if let MessageSendEvent::HandleError { action, .. } = &msg_events[0] { + assert!(matches!(action, msgs::ErrorAction::DisconnectPeerWithWarning { .. })); + } else { + panic!("Expected MessageSendEvent::HandleError"); + } + let cannot_splice_out = if u64::try_from(funding_contribution_sat.abs()).unwrap() + > initiator_value_to_self_sat + { + format!( + "Got non-closing error: Their contribution candidate {funding_contribution_sat}sat \ + is greater than their total balance in the channel {initiator_value_to_self_sat}sat" + ) + } else { + format!( + "Got non-closing error: Channel {channel_id} cannot \ + be spliced; Balance exhausted on local commitment" + ) + }; + acceptor.logger.assert_log("lightning::ln::channelmanager", cannot_splice_out, 1); + } + + channel_type +} From be6cf5b89e9e1381ea452fab6eac7dfeb81e11d4 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Tue, 28 Apr 2026 19:39:26 +0000 Subject: [PATCH 340/627] Always enforce the 1000sat min channel value in zero-reserve channels We did not enforce this minimum when accepting 0-reserve channels. This is because we depended on the `MIN_THEIR_CHAN_RESERVE_SATOSHIS` constant to guarantee this minimum channel value, but this value is no longer read in 0-reserve channels. Note that the user's `min_funding_satoshis` value would still be respected in this case. When splicing 0-reserve channels, we only enforced that the commitment transaction retained at least one output after the splice, which could produce a channel value lower than 1000sats. Along the way, we also now enforce this 1000sat minimum when splicing reserve-enabled channels. We previously correctly enforced the reserves after the splice, but this could still result in a channel value smaller than 1000sats. This case is now rejected during splice validation. Note that the user's `min_funding_satoshis` is not respected when validating splice contributions, we leave this for follow-up work. --- lightning/src/ln/channel.rs | 25 +++++-- lightning/src/ln/channelmanager.rs | 2 +- lightning/src/ln/splicing_tests.rs | 113 ++++++++++++++++++++++------- lightning/src/sign/tx_builder.rs | 6 ++ lightning/src/util/config.rs | 3 +- 5 files changed, 115 insertions(+), 34 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index f7c4ca26dd1..e07ee7fceab 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -1012,6 +1012,9 @@ pub const MIN_CHAN_DUST_LIMIT_SATOSHIS: u64 = 354; // Just a reasonable implementation-specific safe lower bound, higher than the dust limit. pub const MIN_THEIR_CHAN_RESERVE_SATOSHIS: u64 = 1000; +// Just a reasonable implementation-specific safe lower bound. +pub const MIN_CHANNEL_VALUE_SATOSHIS: u64 = 1000; + /// Used to return a simple Error back to ChannelManager. Will get converted to a /// msgs::ErrorAction::SendErrorMessage or msgs::ErrorAction::IgnoreError as appropriate with our /// channel_id in ChannelManager. @@ -2786,10 +2789,15 @@ impl FundingScope { ), )?; - let post_channel_value = prev_funding.get_value_satoshis() + let post_channel_value_sat = prev_funding.get_value_satoshis() .checked_add_signed(our_funding_contribution.to_sat()) .and_then(|v| v.checked_add_signed(their_funding_contribution.to_sat())) .ok_or(format!("The sum of contributions {our_funding_contribution} and {their_funding_contribution} is greater than the channel's value"))?; + if post_channel_value_sat < MIN_CHANNEL_VALUE_SATOSHIS { + return Err(format!( + "Spliced channel value must be at least 1000 satoshis. It would be {post_channel_value_sat}", + )); + } let channel_parameters = &prev_funding.channel_transaction_parameters; let mut post_channel_transaction_parameters = ChannelTransactionParameters { @@ -2801,7 +2809,7 @@ impl FundingScope { funding_outpoint: None, // filled later splice_parent_funding_txid: prev_funding.get_funding_txid(), channel_type_features: channel_parameters.channel_type_features.clone(), - channel_value_satoshis: post_channel_value, + channel_value_satoshis: post_channel_value_sat, }; post_channel_transaction_parameters .counterparty_parameters @@ -2812,7 +2820,7 @@ impl FundingScope { // New reserve values are based on the new channel value and are v2-specific let counterparty_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis( - post_channel_value, + post_channel_value_sat, MIN_CHAN_DUST_LIMIT_SATOSHIS, prev_funding .counterparty_selected_channel_reserve_satoshis @@ -2820,7 +2828,7 @@ impl FundingScope { == 0, ); let holder_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis( - post_channel_value, + post_channel_value_sat, context.counterparty_dust_limit_satoshis, prev_funding.holder_selected_channel_reserve_satoshis == 0, ); @@ -3748,6 +3756,11 @@ impl ChannelContext { let channel_value_satoshis = our_funding_satoshis.saturating_add(open_channel_fields.funding_satoshis); + if channel_value_satoshis < MIN_CHANNEL_VALUE_SATOSHIS { + return Err(ChannelError::close(format!( + "Channel value must be at least 1000 satoshis. It was {channel_value_satoshis}", + ))); + } let channel_keys_id = signer_provider.generate_channel_keys_id(true, user_id); let holder_signer = signer_provider.derive_channel_signer(channel_keys_id); @@ -3896,7 +3909,7 @@ impl ChannelContext { && holder_selected_channel_reserve_satoshis != 0 { // Protocol level safety check in place, although it should never happen because - // of `MIN_THEIR_CHAN_RESERVE_SATOSHIS` + // of `MIN_THEIR_CHAN_RESERVE_SATOSHIS` and `MIN_CHANNEL_VALUE_SATOSHIS` return Err(ChannelError::close(format!( "Suitable channel reserve not found. remote_channel_reserve was ({holder_selected_channel_reserve_satoshis}). dust_limit_satoshis is ({MIN_CHAN_DUST_LIMIT_SATOSHIS})." ))); @@ -14453,7 +14466,7 @@ impl OutboundV1Channel { ); if holder_selected_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS && !is_0reserve { // Protocol level safety check in place, although it should never happen because - // of `MIN_THEIR_CHAN_RESERVE_SATOSHIS` + // of `MIN_THEIR_CHAN_RESERVE_SATOSHIS` and `MIN_CHANNEL_VALUE_SATOSHIS` return Err(APIError::APIMisuseError { err: format!( "Holder selected channel reserve below implementation limit dust_limit_satoshis {holder_selected_channel_reserve_satoshis}" diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 43a90a1bc7a..a7a0942f0c8 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -3887,7 +3887,7 @@ impl< override_config: Option, trusted_channel_features: Option, ) -> Result { - if channel_value_satoshis < 1000 { + if channel_value_satoshis < crate::ln::channel::MIN_CHANNEL_VALUE_SATOSHIS { return Err(APIError::APIMisuseError { err: format!( "Channel value must be at least 1000 satoshis. It was {channel_value_satoshis}" diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 986af7901dd..a5361358653 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -18,6 +18,7 @@ use crate::ln::chan_utils; use crate::ln::channel::{ ANCHOR_OUTPUT_VALUE_SATOSHI, CHANNEL_ANNOUNCEMENT_PROPAGATION_DELAY, DISCONNECT_PEER_AWAITING_RESPONSE_TICKS, FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE, + MIN_CHANNEL_VALUE_SATOSHIS, }; use crate::ln::channelmanager::{provided_init_features, PaymentId, BREAKDOWN_TIMEOUT}; use crate::ln::functional_test_utils::*; @@ -7509,6 +7510,20 @@ fn test_0reserve_splice() { assert_eq!(a, ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies()); + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = true; + let a = do_test_0reserve_splice_holder_validation(false, false, false, config.clone()); + let _b = do_test_0reserve_splice_holder_validation(true, false, false, config.clone()); + let _c = do_test_0reserve_splice_holder_validation(false, true, false, config.clone()); + let _d = do_test_0reserve_splice_holder_validation(true, true, false, config.clone()); + + let _e = do_test_0reserve_splice_holder_validation(false, false, true, config.clone()); + let _f = do_test_0reserve_splice_holder_validation(true, false, true, config.clone()); + let _g = do_test_0reserve_splice_holder_validation(false, true, true, config.clone()); + let _h = do_test_0reserve_splice_holder_validation(true, true, true, config.clone()); + + assert_eq!(a, ChannelTypeFeatures::anchors_zero_fee_commitments()); + let mut config = test_default_channel_config(); config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; @@ -7521,6 +7536,7 @@ fn test_0reserve_splice() { let _f = do_test_0reserve_splice_counterparty_validation(true, false, true, config.clone()); let _g = do_test_0reserve_splice_counterparty_validation(false, true, true, config.clone()); let _h = do_test_0reserve_splice_counterparty_validation(true, true, true, config.clone()); + assert_eq!(a, ChannelTypeFeatures::only_static_remote_key()); config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; @@ -7534,12 +7550,22 @@ fn test_0reserve_splice() { let _f = do_test_0reserve_splice_counterparty_validation(true, false, true, config.clone()); let _g = do_test_0reserve_splice_counterparty_validation(false, true, true, config.clone()); let _h = do_test_0reserve_splice_counterparty_validation(true, true, true, config.clone()); + assert_eq!(a, ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies()); - // TODO: Skip 0FC channels for now as these always have an output on the commitment, the P2A - // output. We will be able to withdraw up to the dust limit of the funding script, which - // is checked in interactivetx. Still need to double check whether that's what we actually - // want. + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = true; + let a = do_test_0reserve_splice_counterparty_validation(false, false, false, config.clone()); + let _b = do_test_0reserve_splice_counterparty_validation(true, false, false, config.clone()); + let _c = do_test_0reserve_splice_counterparty_validation(false, true, false, config.clone()); + let _d = do_test_0reserve_splice_counterparty_validation(true, true, false, config.clone()); + + let _e = do_test_0reserve_splice_counterparty_validation(false, false, true, config.clone()); + let _f = do_test_0reserve_splice_counterparty_validation(true, false, true, config.clone()); + let _g = do_test_0reserve_splice_counterparty_validation(false, true, true, config.clone()); + let _h = do_test_0reserve_splice_counterparty_validation(true, true, true, config.clone()); + + assert_eq!(a, ChannelTypeFeatures::anchors_zero_fee_commitments()); } #[cfg(test)] @@ -7569,13 +7595,12 @@ fn do_test_0reserve_splice_holder_validation( let details = &nodes[0].node.list_channels()[0]; let channel_type = details.channel_type.clone().unwrap(); - let feerate = 253; + let feerate = + if channel_type == ChannelTypeFeatures::anchors_zero_fee_commitments() { 0 } else { 253 }; let spiked_feerate = if channel_type == ChannelTypeFeatures::only_static_remote_key() { feerate * FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 - } else if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() { - feerate } else { - panic!("Unexpected channel type"); + feerate }; let anchors_sat = if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() { @@ -7603,13 +7628,18 @@ fn do_test_0reserve_splice_holder_validation( - chan_utils::commit_tx_fee_sat(feerate, 0, &channel_type) * 1000; assert!(node_0_to_local_output_msat / 1000 < dust_limit_satoshis); let commit_tx = &get_local_commitment_txn!(nodes[0], channel_id)[0]; - assert_eq!(commit_tx.output.len(), if anchors_sat == 0 { 1 } else { 2 }); + assert_eq!( + commit_tx.output.len(), + if channel_type == ChannelTypeFeatures::only_static_remote_key() { 1 } else { 2 } + ); assert_eq!( commit_tx.output.last().unwrap().value, Amount::from_sat(available_capacity_msat / 1000) ); - if anchors_sat != 0 { + if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() { assert_eq!(commit_tx.output[0].value, Amount::from_sat(330)); + } else if channel_type == ChannelTypeFeatures::anchors_zero_fee_commitments() { + assert_eq!(commit_tx.output[0].value, Amount::ZERO); } available_capacity_msat / 1000 @@ -7618,27 +7648,36 @@ fn do_test_0reserve_splice_holder_validation( }; // The estimated fees to splice out a single output at 253sat/kw - let estimated_fees = 183; - let splice_out_max_value = if counterparty_has_output && node_0_is_initiator { + let estimated_fees_sat = 183; + let mut splice_out_max_value = if counterparty_has_output && node_0_is_initiator { let commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(spiked_feerate, 1, &channel_type); Amount::from_sat( - initiator_value_to_self_sat - commit_tx_fee_sat - anchors_sat - estimated_fees, + initiator_value_to_self_sat - commit_tx_fee_sat - anchors_sat - estimated_fees_sat, ) } else if !counterparty_has_output && node_0_is_initiator { let commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(spiked_feerate, 0, &channel_type); Amount::from_sat( initiator_value_to_self_sat - commit_tx_fee_sat - - anchors_sat - estimated_fees + - anchors_sat - estimated_fees_sat - dust_limit_satoshis, ) } else if counterparty_has_output && !node_0_is_initiator { - Amount::from_sat(initiator_value_to_self_sat - estimated_fees) + Amount::from_sat(initiator_value_to_self_sat - estimated_fees_sat) } else if !counterparty_has_output && !node_0_is_initiator { - Amount::from_sat(initiator_value_to_self_sat - estimated_fees - dust_limit_satoshis) + Amount::from_sat(initiator_value_to_self_sat - estimated_fees_sat - dust_limit_satoshis) } else { panic!("unexpected case!"); }; + + if channel_value_sat + < splice_out_max_value.to_sat() + estimated_fees_sat + MIN_CHANNEL_VALUE_SATOSHIS + { + splice_out_max_value = Amount::from_sat( + channel_value_sat.saturating_sub(estimated_fees_sat + MIN_CHANNEL_VALUE_SATOSHIS), + ); + } + let outputs = vec![TxOut { value: splice_out_max_value + if splice_passes { Amount::ZERO } else { Amount::ONE_SAT }, script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), @@ -7650,7 +7689,7 @@ fn do_test_0reserve_splice_holder_validation( let initiator_details = &initiator.node.list_channels()[0]; assert_eq!( initiator_details.next_splice_out_maximum_sat, - splice_out_max_value.to_sat() + estimated_fees + splice_out_max_value.to_sat() + estimated_fees_sat ); if splice_passes { @@ -7663,8 +7702,8 @@ fn do_test_0reserve_splice_holder_validation( } else { assert!(initiate_splice_out(initiator, acceptor, channel_id, outputs).is_err()); let splice_out_value = - splice_out_max_value + Amount::from_sat(estimated_fees) + Amount::ONE_SAT; - let splice_out_max_value = splice_out_max_value + Amount::from_sat(estimated_fees); + splice_out_max_value + Amount::from_sat(estimated_fees_sat) + Amount::ONE_SAT; + let splice_out_max_value = splice_out_max_value + Amount::from_sat(estimated_fees_sat); let cannot_be_funded = format!( "Channel {channel_id} cannot be funded: Our \ splice-out value of {splice_out_value} is greater than the maximum \ @@ -7703,13 +7742,12 @@ fn do_test_0reserve_splice_counterparty_validation( let details = &nodes[0].node.list_channels()[0]; let channel_type = details.channel_type.clone().unwrap(); - let feerate = 253; + let feerate = + if channel_type == ChannelTypeFeatures::anchors_zero_fee_commitments() { 0 } else { 253 }; let spiked_feerate = if channel_type == ChannelTypeFeatures::only_static_remote_key() { feerate * FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 - } else if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() { - feerate } else { - panic!("Unexpected channel type"); + feerate }; let anchors_sat = if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() { @@ -7737,13 +7775,18 @@ fn do_test_0reserve_splice_counterparty_validation( - chan_utils::commit_tx_fee_sat(spiked_feerate, 0, &channel_type) * 1000; assert!(node_0_to_local_output_msat / 1000 < dust_limit_satoshis); let commit_tx = &get_local_commitment_txn!(nodes[0], channel_id)[0]; - assert_eq!(commit_tx.output.len(), if anchors_sat == 0 { 1 } else { 2 }); + assert_eq!( + commit_tx.output.len(), + if channel_type == ChannelTypeFeatures::only_static_remote_key() { 1 } else { 2 } + ); assert_eq!( commit_tx.output.last().unwrap().value, Amount::from_sat(available_capacity_msat / 1000) ); - if anchors_sat != 0 { + if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() { assert_eq!(commit_tx.output[0].value, Amount::from_sat(330)); + } else if channel_type == ChannelTypeFeatures::anchors_zero_fee_commitments() { + assert_eq!(commit_tx.output[0].value, Amount::ZERO); } available_capacity_msat / 1000 @@ -7751,7 +7794,7 @@ fn do_test_0reserve_splice_counterparty_validation( channel_value_sat }; - let splice_out_value_incl_fees = if counterparty_has_output && node_0_is_initiator { + let mut splice_out_value_incl_fees = if counterparty_has_output && node_0_is_initiator { let commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(spiked_feerate, 1, &channel_type); Amount::from_sat(initiator_value_to_self_sat - commit_tx_fee_sat - anchors_sat) } else if !counterparty_has_output && node_0_is_initiator { @@ -7767,6 +7810,10 @@ fn do_test_0reserve_splice_counterparty_validation( panic!("unexpected case!"); }; + if channel_value_sat < splice_out_value_incl_fees.to_sat() + MIN_CHANNEL_VALUE_SATOSHIS { + splice_out_value_incl_fees = + Amount::from_sat(channel_value_sat.saturating_sub(MIN_CHANNEL_VALUE_SATOSHIS)); + } let (initiator, acceptor) = if node_0_is_initiator { (&nodes[0], &nodes[1]) } else { (&nodes[1], &nodes[0]) }; @@ -7775,6 +7822,9 @@ fn do_test_0reserve_splice_counterparty_validation( let funding_contribution_sat = -(splice_out_value_incl_fees.to_sat() as i64) - if splice_passes { 0 } else { 1 }; + let post_channel_value_sat = + channel_value_sat.checked_add_signed(funding_contribution_sat).unwrap(); + let outputs = vec![TxOut { // Splice out some dummy amount to get past the initiator's validation, // we'll modify the message in-flight. @@ -7812,11 +7862,22 @@ fn do_test_0reserve_splice_counterparty_validation( let cannot_splice_out = if u64::try_from(funding_contribution_sat.abs()).unwrap() > initiator_value_to_self_sat { + // They obviously can't afford their contribution, so we fail before even + // querying `TxBuilder` format!( "Got non-closing error: Their contribution candidate {funding_contribution_sat}sat \ is greater than their total balance in the channel {initiator_value_to_self_sat}sat" ) + } else if post_channel_value_sat < MIN_CHANNEL_VALUE_SATOSHIS { + // We require all spliced channels to have a value of at least 1000 satoshis after the splice + format!( + "Got non-closing error: Spliced channel value must be at least {MIN_CHANNEL_VALUE_SATOSHIS} satoshis. \ + It would be {post_channel_value_sat}" + ) } else { + // Last but not least, `TxBuilder` decides whether all parties can afford + // HTLCs, anchors, and transaction fees while retaining at least one + // output on the commitments format!( "Got non-closing error: Channel {channel_id} cannot \ be spliced; Balance exhausted on local commitment" diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index 986cb9e844c..ffb01c571b7 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -11,6 +11,7 @@ use crate::ln::chan_utils::{ }; use crate::ln::channel::{ get_v2_channel_reserve_satoshis, CommitmentStats, ANCHOR_OUTPUT_VALUE_SATOSHI, + MIN_CHANNEL_VALUE_SATOSHIS, }; use crate::prelude::*; use crate::types::features::ChannelTypeFeatures; @@ -416,6 +417,11 @@ fn get_next_splice_out_maximum_sat( (local_balance_before_fee_msat / 1000).saturating_sub(min_balance_sat); } + if channel_value_satoshis < next_splice_out_maximum_sat + MIN_CHANNEL_VALUE_SATOSHIS { + next_splice_out_maximum_sat = + channel_value_satoshis.saturating_sub(MIN_CHANNEL_VALUE_SATOSHIS); + } + next_splice_out_maximum_sat } diff --git a/lightning/src/util/config.rs b/lightning/src/util/config.rs index c83eb697461..78ab45d58c2 100644 --- a/lightning/src/util/config.rs +++ b/lightning/src/util/config.rs @@ -326,7 +326,8 @@ pub struct ChannelHandshakeLimits { /// only applies to inbound channels. /// /// Default value: `1000` - /// (Minimum of [`ChannelHandshakeConfig::their_channel_reserve_proportional_millionths`]) + /// + /// Minimum value: `1000` (Any values less will be treated as `1000` instead.) pub min_funding_satoshis: u64, /// The remote node sets a limit on the minimum size of HTLCs we can send to them. This allows /// you to limit the maximum minimum-size they can require. From 8b383bb8d7586f151ba9cde8bfbe5f991473199c Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 5 May 2026 19:45:38 +0200 Subject: [PATCH 341/627] Fix signed comparison in `ElectrumClient` `GetHistoryRes::height` from electrum-client is a *signed* integer. Here we first check for `<= 0` *before* casting to `u32`. Signed-off-by: Elias Rohrer --- lightning-transaction-sync/src/electrum.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lightning-transaction-sync/src/electrum.rs b/lightning-transaction-sync/src/electrum.rs index 1905456d281..9d643f48511 100644 --- a/lightning-transaction-sync/src/electrum.rs +++ b/lightning-transaction-sync/src/electrum.rs @@ -329,11 +329,11 @@ impl ElectrumSyncClient { let mut filtered_history = script_history.iter().filter(|h| h.tx_hash == **txid); if let Some(history) = filtered_history.next() { - let prob_conf_height = history.height as u32; - if prob_conf_height <= 0 { + if history.height <= 0 { // Skip if it's a an unconfirmed entry. continue; } + let prob_conf_height = history.height as u32; let confirmed_tx = self.get_confirmed_tx(tx, prob_conf_height)?; confirmed_txs.push(confirmed_tx); } From fb4103d7788414b2b462911dfb988c70380b5f1d Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 5 May 2026 19:50:17 +0200 Subject: [PATCH 342/627] Free pending_query_count slot when DNS proof build fails `OMDomainResolver` rate-limits in-flight DNSSEC proof builds via a `pending_query_count` counter capped at `MAX_PENDING_RESPONSES` (1024). The counter was only released when the proof build succeeded, so any failure mode -- NXDOMAIN, insecure zones, unreachable resolvers, I/O timeouts, malformed names -- permanently consumed a slot. Because the queried name is attacker-controlled (it travels in over a `DNSSECQuery` onion message from any LN peer, given DNS resolution is an opt-in network-advertised feature), an adversary could exhaust the counter with ~1025 failing queries and persistently DoS the resolver for any subsequent legitimate BIP-353 lookups, until the process is restarted. Always release the slot once the proof build completes, regardless of outcome, and add a regression test which points the resolver at a TCP-refusing local port and asserts the counter returns to zero. Co-Authored-By: HAL 9000 --- lightning-dns-resolver/src/lib.rs | 93 ++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/lightning-dns-resolver/src/lib.rs b/lightning-dns-resolver/src/lib.rs index c6d583ab745..90f1ac01f02 100644 --- a/lightning-dns-resolver/src/lib.rs +++ b/lightning-dns-resolver/src/lib.rs @@ -125,8 +125,8 @@ impl DNSResolverMessageHandler for OMDomainResolv let contents = DNSResolverMessage::DNSSECProof(DNSSECProof { name: q.0, proof }); let instructions = responder.respond().into_instructions(); us.pending_replies.lock().unwrap().push((contents, instructions)); - us.pending_query_count.fetch_sub(1, Ordering::Relaxed); } + us.pending_query_count.fetch_sub(1, Ordering::Relaxed); }); None } @@ -337,4 +337,95 @@ mod test { assert_eq!(resolution.1, payment_id); assert!(resolution.2[.."bitcoin:".len()].eq_ignore_ascii_case("bitcoin:")); } + + #[tokio::test] + async fn failed_query_does_not_leak_pending_counter() { + use std::sync::atomic::Ordering; + + let secp_ctx = Secp256k1::new(); + + // Resolver points at a port that should refuse TCP, so build_txt_proof_async + // returns Err quickly. + let resolver_keys = Arc::new(KeysManager::new(&[99; 32], 42, 43, true)); + let resolver_logger = TestLogger { node: "resolver" }; + let resolver = + Arc::new(OMDomainResolver::::ignoring_incoming_proofs( + "127.0.0.1:1".parse().unwrap(), + )); + let resolver_state = Arc::clone(&resolver.state); + let resolver_messenger = OnionMessenger::new( + Arc::clone(&resolver_keys), + Arc::clone(&resolver_keys), + resolver_logger, + DummyNodeLookup {}, + DirectlyConnectedRouter {}, + IgnoringMessageHandler {}, + IgnoringMessageHandler {}, + Arc::clone(&resolver), + IgnoringMessageHandler {}, + ); + let resolver_id = resolver_keys.get_node_id(Recipient::Node).unwrap(); + + let resolver_dest = Destination::Node(resolver_id); + let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs(); + + let payment_id = PaymentId([42; 32]); + let name = HumanReadableName::from_encoded("matt@mattcorallo.com").unwrap(); + + let payer_keys = Arc::new(KeysManager::new(&[2; 32], 42, 43, true)); + let payer_logger = TestLogger { node: "payer" }; + let payer_id = payer_keys.get_node_id(Recipient::Node).unwrap(); + let payer = Arc::new(URIResolver { + resolved_uri: Mutex::new(None), + resolver: OMNameResolver::new(now as u32, 1), + pending_messages: Mutex::new(Vec::new()), + }); + let payer_messenger = Arc::new(OnionMessenger::new( + Arc::clone(&payer_keys), + Arc::clone(&payer_keys), + payer_logger, + DummyNodeLookup {}, + DirectlyConnectedRouter {}, + IgnoringMessageHandler {}, + IgnoringMessageHandler {}, + Arc::clone(&payer), + IgnoringMessageHandler {}, + )); + + let init_msg = get_om_init(); + payer_messenger.peer_connected(resolver_id, &init_msg, true).unwrap(); + resolver_messenger.peer_connected(payer_id, &init_msg, false).unwrap(); + + let (msg, context) = + payer.resolver.resolve_name(payment_id, name.clone(), &*payer_keys).unwrap(); + let query_context = MessageContext::DNSResolver(context); + let receive_key = payer_keys.get_receive_auth_key(); + let reply_path = BlindedMessagePath::one_hop( + payer_id, + receive_key, + query_context, + false, + &*payer_keys, + &secp_ctx, + ); + payer.pending_messages.lock().unwrap().push(( + DNSResolverMessage::DNSSECQuery(msg), + MessageSendInstructions::WithSpecifiedReplyPath { + destination: resolver_dest, + reply_path, + }, + )); + + let query = payer_messenger.next_onion_message_for_peer(resolver_id).unwrap(); + resolver_messenger.handle_onion_message(payer_id, &query); + + let start = Instant::now(); + while resolver_state.pending_query_count.load(Ordering::Relaxed) != 0 { + tokio::time::sleep(Duration::from_millis(50)).await; + assert!( + start.elapsed() < Duration::from_secs(10), + "pending_query_count not decremented after failed proof: counter leaks" + ); + } + } } From 33987e869f0511aa515048737fdeaa4c0a612b9b Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 5 May 2026 20:01:08 +0200 Subject: [PATCH 343/627] Count zero-fee-commitments channels in anchor reserve check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `can_support_additional_anchor_channel` decides whether the wallet has enough on-chain reserve to back another anchor channel by counting the node's existing anchor channels. The classification only checked the `anchors_zero_fee_htlc_tx` feature, so channels negotiated with the `anchor_zero_fee_commitments` (TRUC / 0FC, option 41) variant — which require the same on-chain reserve to fund commitment / HTLC fee bumps on force-close — were silently dropped from the count. A node enabling `negotiate_anchor_zero_fee_commitments` would therefore be green-lit to open more anchor channels than its wallet can actually back, risking unfunded fee bumps and HTLC loss on simultaneous force-closes. Treat both feature flags as marking a channel as an anchor channel for reserve-accounting purposes (factored into a small `is_anchor_channel_type` helper, used in both the chain-monitor and channel-manager loops), and add a regression test that opens a single 0FC channel with reserves sized for exactly one channel and asserts the function refuses to authorize a second. Co-Authored-By: HAL 9000 --- lightning/src/util/anchor_channel_reserves.rs | 56 ++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/lightning/src/util/anchor_channel_reserves.rs b/lightning/src/util/anchor_channel_reserves.rs index 2c09ddd70a6..000f5432529 100644 --- a/lightning/src/util/anchor_channel_reserves.rs +++ b/lightning/src/util/anchor_channel_reserves.rs @@ -260,6 +260,13 @@ pub fn get_supportable_anchor_channels( num_whole_utxos + total_fractional_amount.to_sat() / reserve_per_channel.to_sat() / 2 } +/// Returns whether a channel of the given type requires an on-chain anchor reserve, i.e. uses +/// either the `anchors_zero_fee_htlc_tx` or `anchor_zero_fee_commitments` (TRUC / 0FC) variant. +fn is_anchor_channel_type(channel_type: &ChannelTypeFeatures) -> bool { + channel_type.supports_anchors_zero_fee_htlc_tx() + || channel_type.supports_anchor_zero_fee_commitments() +} + /// Verifies whether the anchor channel reserve provided by `utxos` is sufficient to support /// an additional anchor channel. /// @@ -296,7 +303,7 @@ where } else { continue; }; - if channel_monitor.channel_type_features().supports_anchors_zero_fee_htlc_tx() + if is_anchor_channel_type(&channel_monitor.channel_type_features()) && !channel_monitor.get_claimable_balances().is_empty() { anchor_channels.insert(channel_id); @@ -305,7 +312,7 @@ where // Also include channels that are in the middle of negotiation or anchor channels that don't have // a ChannelMonitor yet. for channel in a_channel_manager.get_cm().list_channels() { - if channel.channel_type.map_or(true, |ct| ct.supports_anchors_zero_fee_htlc_tx()) { + if channel.channel_type.map_or(true, |ct| is_anchor_channel_type(&ct)) { anchor_channels.insert(channel.channel_id); } } @@ -315,6 +322,7 @@ where #[cfg(test)] mod test { use super::*; + use crate::ln::functional_test_utils::*; use bitcoin::{OutPoint, ScriptBuf, Sequence, TxOut, Txid}; use std::str::FromStr; @@ -425,4 +433,48 @@ mod test { 1068 ); } + + #[test] + fn test_can_support_additional_anchor_channel_zero_fee_commitments() { + // Regression test: a channel that uses the `anchor_zero_fee_commitments` + // (option 41) variant is just as much an anchor channel — and requires + // the same on-chain reserve — as one using `anchors_zero_fee_htlc_tx`. + // The reserve check must therefore count it as an existing anchor + // channel when deciding whether the wallet can safely support an + // additional one. Currently `can_support_additional_anchor_channel` + // only counts channels whose features set `anchors_zero_fee_htlc_tx`, + // so a node whose reserves are exhausted by zero-fee-commitment + // channels is incorrectly told it can open another anchor channel. + let mut cfg = test_default_channel_config(); + cfg.channel_handshake_config.negotiate_anchor_zero_fee_commitments = true; + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(cfg.clone()), Some(cfg)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + create_chan_between_nodes(&nodes[0], &nodes[1]); + + let channels = nodes[0].node.list_channels(); + assert_eq!(channels.len(), 1); + let channel_type = channels[0].channel_type.as_ref().unwrap(); + assert!(channel_type.supports_anchor_zero_fee_commitments()); + // Sanity check: a zero-fee-commitments channel does not also set the + // older anchors_zero_fee_htlc_tx feature. + assert!(!channel_type.supports_anchors_zero_fee_htlc_tx()); + + let context = AnchorChannelReserveContext::default(); + let reserve = get_reserve_per_channel(&context); + // Provide a single UTXO with enough value to cover one channel reserve. + let utxos = vec![make_p2wpkh_utxo(reserve * 2)]; + + // We already have one TRUC anchor channel and only enough reserve for + // a single channel; we must not authorize an additional one. + assert!(!can_support_additional_anchor_channel( + &context, + &utxos, + nodes[0].node, + &nodes[0].chain_monitor.chain_monitor, + )); + } } From c005b11dc36d3d48f1916d24a21da742d37709d6 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 5 May 2026 20:58:22 +0200 Subject: [PATCH 344/627] Fix `StaticInvoice::is_offer_expired` to check the offer's expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The std-only `StaticInvoice::is_offer_expired` accessor delegated to `InvoiceContents::is_expired`, which compares `created_at + relative_expiry` against the current time — that is the *invoice*'s expiry, not the offer's. The `_no_std` sibling and `flow.rs:: enqueue_static_invoice` already treat the two as distinct checks. A payer or forwarder using the std API to decide whether to honor a static invoice would therefore get the wrong answer in either direction: forwarding offers the issuer has already retired (when the invoice is still fresh), or refusing offers that are still valid (when the invoice has aged past its `relative_expiry` but the offer itself has no `absolute_expiry`). Route the std accessor through `InvoiceContents::is_offer_expired` so both the std and no-std paths consult the offer's expiry. Co-Authored-By: HAL 9000 --- lightning/src/offers/static_invoice.rs | 39 +++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/lightning/src/offers/static_invoice.rs b/lightning/src/offers/static_invoice.rs index c8afb7cfc12..860835912a1 100644 --- a/lightning/src/offers/static_invoice.rs +++ b/lightning/src/offers/static_invoice.rs @@ -408,7 +408,7 @@ impl StaticInvoice { /// Whether the [`Offer`] that this invoice is based on is expired. #[cfg(feature = "std")] pub fn is_offer_expired(&self) -> bool { - self.contents.is_expired() + self.contents.is_offer_expired() } /// Whether the [`Offer`] that this invoice is based on is expired, given the current time as @@ -1003,6 +1003,43 @@ mod tests { } } + #[cfg(feature = "std")] + #[test] + fn is_offer_expired_does_not_check_invoice_expiry() { + // Regression test: `StaticInvoice::is_offer_expired` must reflect the offer's expiry, + // not the invoice's own expiry. Build an invoice whose offer has no absolute expiry + // (so the offer never expires) but whose own `created_at + relative_expiry` lies in + // the past (so the invoice itself is expired). + let node_id = recipient_pubkey(); + let payment_paths = payment_paths(); + let expanded_key = ExpandedKey::new([42; 32]); + let entropy = FixedEntropy {}; + let nonce = Nonce::from_entropy_source(&entropy); + let secp_ctx = Secp256k1::new(); + + let offer = OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx) + .path(blinded_path()) + .build() + .unwrap(); + + let invoice = StaticInvoiceBuilder::for_offer_using_derived_keys( + &offer, + payment_paths.clone(), + vec![blinded_path()], + Duration::from_secs(0), + &expanded_key, + nonce, + &secp_ctx, + ) + .unwrap() + .relative_expiry(1) + .build_and_sign(&secp_ctx) + .unwrap(); + + assert!(invoice.is_expired()); + assert!(!invoice.is_offer_expired()); + } + #[test] fn builds_invoice_from_offer_using_derived_key() { let node_id = recipient_pubkey(); From b64efcda8835c2b1aed3e8f20d186657b00b5ed9 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 5 May 2026 21:22:51 +0200 Subject: [PATCH 345/627] Validate Esplora merkle proof against the block header's merkle root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `EsploraSyncClient::get_confirmed_tx` parsed the SPV proof returned by the Esplora server but threw away the security check: the merkle root computed by `PartialMerkleTree::extract_matches` was discarded (`let _ = …`), and only the leaf-equality check (`matches[0] == txid`) remained. Anyone can construct a single-leaf partial tree advertising an arbitrary txid via `PartialMerkleTree::from_txids(&[txid], &[true])`, so this gate was vacuous. A malicious or compromised Esplora server could therefore convince `EsploraSyncClient` that any transaction was confirmed in any block by returning `MerkleBlock { header: real_header, txn: forged_partial_tree }`, causing LDK to feed a synthesized `ConfirmedTx` into `Confirm` implementations such as `ChannelManager` / `ChainMonitor`. From there, the channel-funding / closing / HTLC flows would treat the transaction as confirmed at an attacker-chosen height, with consequences ranging from premature state transitions to force-close races. Capture the merkle root returned by `extract_matches` and require it to equal `block_header.merkle_root`, matching the validation the Electrum sibling already performs via `validate_merkle_proof`. Co-Authored-By: HAL 9000 --- lightning-transaction-sync/src/esplora.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lightning-transaction-sync/src/esplora.rs b/lightning-transaction-sync/src/esplora.rs index 6caf7a6a7ee..7d3550d65b1 100644 --- a/lightning-transaction-sync/src/esplora.rs +++ b/lightning-transaction-sync/src/esplora.rs @@ -361,8 +361,13 @@ impl EsploraSyncClient { let mut matches = Vec::new(); let mut indexes = Vec::new(); - let _ = merkle_block.txn.extract_matches(&mut matches, &mut indexes); - if indexes.len() != 1 || matches.len() != 1 || matches[0] != txid { + let computed_merkle_root = + merkle_block.txn.extract_matches(&mut matches, &mut indexes).ok(); + if computed_merkle_root != Some(block_header.merkle_root) + || indexes.len() != 1 + || matches.len() != 1 + || matches[0] != txid + { log_error!(self.logger, "Retrieved Merkle block for txid {} doesn't match expectations. This should not happen. Please verify server integrity.", txid); return Err(InternalError::Failed); } From 6394d18bf088bc27c05f23696ec22f5ef44f5b3c Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 5 May 2026 22:17:31 +0200 Subject: [PATCH 346/627] Release `OutputSweeper::pending_sweep` flag on future drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `regenerate_and_broadcast_spend_if_necessary` used `pending_sweep: AtomicBool` as a single-runner gate but only cleared the flag with an unconditional `store(false)` *after* the inner future resolved. If the caller's future was dropped while the inner await was `Pending` — which `tokio::time::timeout`, `futures::select!`, manual `JoinHandle::abort`, etc. all do — the reset never ran, leaving the flag stuck `true` and every subsequent call to the function short-circuiting with `Ok(())`. Because `OutputSweeper` is what claims `SpendableOutputDescriptor`s back to the user's wallet after channel closure (including HTLC outputs with time-bounded recovery deadlines), a stuck flag turns into fund-loss exposure: time-sensitive HTLC sweeps simply stop happening, while every other code path keeps queueing new outputs to sweep, until the process is restarted. Replace the trailing `store(false)` with an RAII `PendingSweepGuard` whose `Drop` impl always releases the flag — covering normal return, error, and cancellation alike. Co-Authored-By: HAL 9000 --- lightning/src/util/sweep.rs | 164 ++++++++++++++++++++++++++++++++++-- 1 file changed, 159 insertions(+), 5 deletions(-) diff --git a/lightning/src/util/sweep.rs b/lightning/src/util/sweep.rs index e66cb9c63bd..8d539b0a5e6 100644 --- a/lightning/src/util/sweep.rs +++ b/lightning/src/util/sweep.rs @@ -488,12 +488,18 @@ where return Ok(()); } - let result = self.regenerate_and_broadcast_spend_if_necessary_internal().await; - - // Release the pending sweep flag again, regardless of result. - self.pending_sweep.store(false, Ordering::Release); + // Use an RAII guard so the flag is released even if this future is dropped mid-await + // (e.g. cancelled by `tokio::time::timeout` or `select!`). A bare `store(false)` after + // the await would never run on cancellation, leaving the sweeper permanently disabled. + struct PendingSweepGuard<'a>(&'a AtomicBool); + impl<'a> Drop for PendingSweepGuard<'a> { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } + } + let _guard = PendingSweepGuard(&self.pending_sweep); - result + self.regenerate_and_broadcast_spend_if_necessary_internal().await } /// Regenerates and broadcasts the spending transaction for any outputs that are pending @@ -1176,3 +1182,151 @@ where Ok((best_block, OutputSweeperSync { sweeper })) } } + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + use crate::chain::transaction::OutPoint; + use crate::sign::{ChangeDestinationSource, OutputSpender}; + use crate::util::async_poll::dummy_waker; + use crate::util::logger::Record; + use crate::util::native_async::MaybeSend; + + use bitcoin::hashes::Hash as _; + use bitcoin::secp256k1::All; + use bitcoin::transaction::Version; + use bitcoin::{Amount, BlockHash, ScriptBuf, Transaction, TxOut, Txid}; + + use core::future as core_future; + use core::pin::pin; + use core::sync::atomic::Ordering; + use core::task::Poll; + + struct DummyBroadcaster; + impl BroadcasterInterface for DummyBroadcaster { + fn broadcast_transactions(&self, _: &[(&Transaction, TransactionType)]) {} + } + + struct DummyFeeEstimator; + impl FeeEstimator for DummyFeeEstimator { + fn get_est_sat_per_1000_weight(&self, _: ConfirmationTarget) -> u32 { + 1000 + } + } + + struct DummyFilter; + impl Filter for DummyFilter { + fn register_tx(&self, _: &Txid, _: &bitcoin::Script) {} + fn register_output(&self, _: WatchedOutput) {} + } + + struct DummyLogger; + impl Logger for DummyLogger { + fn log(&self, _: Record) {} + } + + struct DummyOutputSpender; + impl OutputSpender for DummyOutputSpender { + fn spend_spendable_outputs( + &self, _: &[&SpendableOutputDescriptor], _: Vec, _: ScriptBuf, _: u32, + _: Option, _: &Secp256k1, + ) -> Result { + Ok(Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: Vec::new(), + output: Vec::new(), + }) + } + } + + struct DummyChangeDestSource; + impl ChangeDestinationSource for DummyChangeDestSource { + fn get_change_destination_script<'a>( + &'a self, + ) -> impl Future> + MaybeSend + 'a { + core_future::ready(Ok(ScriptBuf::new())) + } + } + + struct PendingKVStore; + impl KVStore for PendingKVStore { + fn read( + &self, _: &str, _: &str, _: &str, + ) -> impl Future, io::Error>> + 'static + MaybeSend { + core_future::ready(Err(io::Error::new(io::ErrorKind::NotFound, ""))) + } + fn write( + &self, _: &str, _: &str, _: &str, _: Vec, + ) -> impl Future> + 'static + MaybeSend { + core_future::pending() + } + fn remove( + &self, _: &str, _: &str, _: &str, _: bool, + ) -> impl Future> + 'static + MaybeSend { + core_future::ready(Ok(())) + } + fn list( + &self, _: &str, _: &str, + ) -> impl Future, io::Error>> + 'static + MaybeSend { + core_future::ready(Ok(Vec::new())) + } + } + + #[test] + fn pending_sweep_flag_resets_after_future_drop() { + let best_block = BlockLocator::new(BlockHash::all_zeros(), 1_000); + + let sweeper: OutputSweeper< + DummyBroadcaster, + Box, + DummyFeeEstimator, + DummyFilter, + PendingKVStore, + DummyLogger, + DummyOutputSpender, + > = OutputSweeper::new( + best_block, + DummyBroadcaster, + DummyFeeEstimator, + None, + DummyOutputSpender, + Box::new(DummyChangeDestSource), + PendingKVStore, + DummyLogger, + ); + + // Inject a tracked output directly so the sweep loop has work to do. + let descriptor = SpendableOutputDescriptor::StaticOutput { + outpoint: OutPoint { txid: Txid::all_zeros(), index: 0 }, + output: TxOut { value: Amount::from_sat(100_000), script_pubkey: ScriptBuf::new() }, + channel_keys_id: None, + }; + sweeper.sweeper_state.lock().unwrap().outputs.push(TrackedSpendableOutput { + descriptor, + channel_id: None, + counterparty_node_id: None, + status: OutputSpendStatus::PendingInitialBroadcast { delayed_until_height: None }, + }); + + // Start a sweep, poll once (the persist step stays Pending because our KVStore's + // `write` future is `future::pending()`), then drop the future to mimic + // cancellation - the sort of thing a `tokio::time::timeout` wrapper produces. + { + let mut fut = pin!(sweeper.regenerate_and_broadcast_spend_if_necessary()); + let waker = dummy_waker(); + let mut ctx = task::Context::from_waker(&waker); + assert!(matches!(fut.as_mut().poll(&mut ctx), Poll::Pending)); + } + + // Once the future has been dropped, `pending_sweep` must be cleared. The bug + // is that the flag is only ever cleared after the inner future returns, so a + // dropped future leaves it stuck `true` and every subsequent call to + // `regenerate_and_broadcast_spend_if_necessary` short-circuits with `Ok(())`, + // permanently disabling the sweeper. + assert!( + !sweeper.pending_sweep.load(Ordering::Acquire), + "pending_sweep flag was not reset when the future was dropped", + ); + } +} From a2cc67e7993b5c7b91290cdd678a1759ab7143c8 Mon Sep 17 00:00:00 2001 From: Abeeujah Date: Tue, 5 May 2026 18:25:38 +0100 Subject: [PATCH 347/627] Replace local ChaCha20-Poly1305 with external crate Migrates ChaCha20-Poly1305 encryption from the local crypto module to rust-bitcoin's `chacha20-poly1305` crate. Integrated the crate across all modules (Router, PeerStorage, Onion Utils, etc.). Add the chacha20_poly1305_fuzz flag to fuzz config to after implementing the fuzz logic upstream. --- .github/workflows/build.yml | 6 +- Cargo.toml | 1 + ci/check-compiles.sh | 4 +- fuzz/Cargo.toml | 1 + lightning/Cargo.toml | 1 + lightning/src/crypto/streams.rs | 137 +++++++++++++++------ lightning/src/ln/inbound_payment.rs | 40 +++--- lightning/src/ln/onion_utils.rs | 48 ++++---- lightning/src/ln/our_peer_storage.rs | 15 +-- lightning/src/ln/peer_channel_encryptor.rs | 28 +++-- lightning/src/routing/router.rs | 15 +-- lightning/src/sign/mod.rs | 11 +- lightning/src/util/scid_utils.rs | 12 +- 13 files changed, 200 insertions(+), 119 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2cad565e82a..5862302bd00 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -225,9 +225,9 @@ jobs: - name: Sanity check fuzz targets on Rust ${{ env.TOOLCHAIN }} run: | cd fuzz - RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz" cargo test --quiet --color always --lib -j8 - RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz" cargo test --manifest-path fuzz-fake-hashes/Cargo.toml --quiet --color always --bins -j8 - RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz" cargo test --manifest-path fuzz-real-hashes/Cargo.toml --quiet --color always --bins -j8 + RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz --cfg=chacha20_poly1305_fuzz" cargo test --quiet --color always --lib -j8 + RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz --cfg=chacha20_poly1305_fuzz" cargo test --manifest-path fuzz-fake-hashes/Cargo.toml --quiet --color always --bins -j8 + RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=chacha20_poly1305_fuzz" cargo test --manifest-path fuzz-real-hashes/Cargo.toml --quiet --color always --bins -j8 fuzz: runs-on: self-hosted diff --git a/Cargo.toml b/Cargo.toml index 7978d9de6a0..98bf30683bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,7 @@ check-cfg = [ "cfg(fuzzing)", "cfg(secp256k1_fuzz)", "cfg(hashes_fuzz)", + "cfg(chacha20_poly1305_fuzz)", "cfg(test)", "cfg(debug_assertions)", "cfg(c_bindings)", diff --git a/ci/check-compiles.sh b/ci/check-compiles.sh index cd1e0759c63..30f7518c727 100755 --- a/ci/check-compiles.sh +++ b/ci/check-compiles.sh @@ -6,9 +6,9 @@ cargo check cargo doc cargo doc --document-private-items cd fuzz -RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz" \ +RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz --cfg=chacha20_poly1305_fuzz" \ cargo check --manifest-path fuzz-fake-hashes/Cargo.toml --features=stdin_fuzz -RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz" \ +RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=chacha20_poly1305_fuzz" \ cargo check --manifest-path fuzz-real-hashes/Cargo.toml --features=stdin_fuzz cd ../lightning && cargo check --no-default-features cd .. && RUSTC_BOOTSTRAP=1 RUSTFLAGS="--cfg=c_bindings" cargo check -Z avoid-dev-deps diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 8cafdd1f2fb..274b19d8ee4 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -44,4 +44,5 @@ check-cfg = [ "cfg(secp256k1_fuzz)", "cfg(hashes_fuzz)", "cfg(splicing)", + "cfg(chacha20_poly1305_fuzz)" ] diff --git a/lightning/Cargo.toml b/lightning/Cargo.toml index 2f2f01bc401..661f8854f89 100644 --- a/lightning/Cargo.toml +++ b/lightning/Cargo.toml @@ -40,6 +40,7 @@ lightning-macros = { version = "0.2", path = "../lightning-macros" } bech32 = { version = "0.11.0", default-features = false } bitcoin = { version = "0.32.4", default-features = false, features = ["secp-recovery"] } +chacha20-poly1305 = { version = "0.2.0", default-features = false } dnssec-prover = { version = "0.6", default-features = false } hashbrown = { version = "0.13", default-features = false } diff --git a/lightning/src/crypto/streams.rs b/lightning/src/crypto/streams.rs index 23a23154307..8d46a8d8422 100644 --- a/lightning/src/crypto/streams.rs +++ b/lightning/src/crypto/streams.rs @@ -1,7 +1,4 @@ -use crate::crypto::chacha20::ChaCha20; -use crate::crypto::chacha20poly1305rfc::ChaCha20Poly1305RFC; use crate::crypto::fixed_time_eq; -use crate::crypto::poly1305::Poly1305; use crate::io::{self, Read, Write}; use crate::ln::msgs::DecodeError; @@ -10,6 +7,10 @@ use crate::util::ser::{ }; use alloc::vec::Vec; +use chacha20_poly1305::{ + chacha20::{ChaCha20, Key, Nonce}, + poly1305::Poly1305, +}; pub(crate) struct ChaChaReader<'a, R: io::Read> { pub chacha: &'a mut ChaCha20, @@ -19,7 +20,7 @@ impl<'a, R: io::Read> io::Read for ChaChaReader<'a, R> { fn read(&mut self, dest: &mut [u8]) -> Result { let res = self.read.read(dest)?; if res > 0 { - self.chacha.process_in_place(&mut dest[0..res]); + self.chacha.apply_keystream(&mut dest[..res]); } Ok(res) } @@ -42,11 +43,20 @@ impl<'a, W: Writeable> ChaChaPolyWriteAdapter<'a, W> { impl<'a, T: Writeable> Writeable for ChaChaPolyWriteAdapter<'a, T> { // Simultaneously write and encrypt Self::writeable. fn write(&self, w: &mut W) -> Result<(), io::Error> { - let mut chacha = ChaCha20Poly1305RFC::new(&self.rho, &[0; 12], &[]); - let mut chacha_stream = ChaChaPolyWriter { chacha: &mut chacha, write: w }; + let mut chacha = ChaCha20::new(Key::new(self.rho), Nonce::new([0; 12]), 0); + let mut mac_key = [0u8; 64]; + chacha.apply_keystream(&mut mac_key); + + #[cfg(not(fuzzing))] + let mac = Poly1305::new(mac_key[..32].try_into().unwrap()); + #[cfg(fuzzing)] + let mac = Poly1305::new(self.rho); + + let mut chacha_stream = + ChaChaPolyWriter { chacha: &mut chacha, poly: mac, write_len: 0, write: w }; self.writeable.write(&mut chacha_stream)?; - let mut tag = [0 as u8; 16]; - chacha.finish_and_get_tag(&mut tag); + + let tag = chacha_stream.finish_and_get_tag(); tag.write(w)?; Ok(()) @@ -62,12 +72,15 @@ impl<'a, T: Writeable> Writeable for ChaChaPolyWriteAdapter<'a, T> { pub(crate) fn chachapoly_encrypt_with_swapped_aad( mut plaintext: Vec, key: [u8; 32], aad: [u8; 32], ) -> Vec { - let mut chacha = ChaCha20::new(&key[..], &[0; 12]); + let mut chacha = ChaCha20::new(Key::new(key), Nonce::new([0; 12]), 0); let mut mac_key = [0u8; 64]; - chacha.process_in_place(&mut mac_key); + chacha.apply_keystream(&mut mac_key); - let mut mac = Poly1305::new(&mac_key[..32]); - chacha.process_in_place(&mut plaintext[..]); + #[cfg(not(fuzzing))] + let mut mac = Poly1305::new(mac_key[..32].try_into().unwrap()); + #[cfg(fuzzing)] + let mut mac = Poly1305::new(key); + chacha.apply_keystream(&mut plaintext[..]); mac.input(&plaintext[..]); if plaintext.len() % 16 != 0 { @@ -80,7 +93,7 @@ pub(crate) fn chachapoly_encrypt_with_swapped_aad( mac.input(&(plaintext.len() as u64).to_le_bytes()); mac.input(&32u64.to_le_bytes()); - plaintext.extend_from_slice(&mac.result()); + plaintext.extend_from_slice(&mac.tag()); plaintext } @@ -105,7 +118,7 @@ pub(crate) enum TriPolyAADUsed { /// /// Note that we do *not* use the provided AADs as the standard ChaCha20Poly1305 AAD as that would /// require placing it first and prevent us from avoiding redundant Poly1305 rounds. Instead, the -/// ChaCha20Poly1305 MAC check is tweaked to move the AAD to *after* the the contents being +/// ChaCha20Poly1305 MAC check is tweaked to move the AAD to *after* the contents being /// checked, effectively treating the contents as the AAD for the AAD-containing MAC but behaving /// like classic ChaCha20Poly1305 for the non-AAD-containing MAC. pub(crate) struct ChaChaTriPolyReadAdapter { @@ -127,14 +140,14 @@ impl LengthReadableArgs<([u8; 32], [u8; 32], [u8; 32])> } let (key, aad_a, aad_b) = params; - let mut chacha = ChaCha20::new(&key[..], &[0; 12]); + let mut chacha = ChaCha20::new(Key::new(key), Nonce::new([0; 12]), 0); let mut mac_key = [0u8; 64]; - chacha.process_in_place(&mut mac_key); + chacha.apply_keystream(&mut mac_key); #[cfg(not(fuzzing))] - let mut mac = Poly1305::new(&mac_key[..32]); + let mut mac = Poly1305::new(mac_key[..32].try_into().unwrap()); #[cfg(fuzzing)] - let mut mac = Poly1305::new(&key); + let mut mac = Poly1305::new(key); let decrypted_len = r.remaining_bytes() - 16; let s = FixedLengthReader::new(r, decrypted_len); @@ -145,7 +158,6 @@ impl LengthReadableArgs<([u8; 32], [u8; 32], [u8; 32])> while chacha_stream.read.bytes_remain() { let mut buf = [0; 256]; if chacha_stream.read(&mut buf)? == 0 { - // Reached EOF return Err(DecodeError::ShortRead); } } @@ -173,13 +185,13 @@ impl LengthReadableArgs<([u8; 32], [u8; 32], [u8; 32])> mac.input(&0u64.to_le_bytes()); mac.input(&(read_len as u64).to_le_bytes()); - let mut tag = [0 as u8; 16]; + let mut tag = [0u8; 16]; r.read_exact(&mut tag)?; - if fixed_time_eq(&mac.result(), &tag) { + if fixed_time_eq(&mac.tag(), &tag) { Ok(Self { readable, used_aad: TriPolyAADUsed::None }) - } else if fixed_time_eq(&mac_aad_a.result(), &tag) { + } else if fixed_time_eq(&mac_aad_a.tag(), &tag) { Ok(Self { readable, used_aad: TriPolyAADUsed::First }) - } else if fixed_time_eq(&mac_aad_b.result(), &tag) { + } else if fixed_time_eq(&mac_aad_b.tag(), &tag) { Ok(Self { readable, used_aad: TriPolyAADUsed::Second }) } else { return Err(DecodeError::InvalidValue); @@ -197,12 +209,12 @@ struct ChaChaTriPolyReader<'a, R: Read> { impl<'a, R: Read> Read for ChaChaTriPolyReader<'a, R> { // Decrypts bytes from Self::read into `dest`. // After all reads complete, the caller must compare the expected tag with - // the result of `Poly1305::result()`. + // the result of `Poly1305::tag()` fn read(&mut self, dest: &mut [u8]) -> Result { let res = self.read.read(dest)?; if res > 0 { - self.poly.input(&dest[0..res]); - self.chacha.process_in_place(&mut dest[0..res]); + self.poly.input(&dest[..res]); + self.chacha.apply_keystream(&mut dest[..res]); self.read_len += res; } Ok(res) @@ -224,19 +236,38 @@ impl LengthReadableArgs<[u8; 32]> for ChaChaPolyReadAdapter { return Err(DecodeError::InvalidValue); } - let mut chacha = ChaCha20Poly1305RFC::new(&secret, &[0; 12], &[]); + let mut chacha = ChaCha20::new(Key::new(secret), Nonce::new([0; 12]), 0); + let mut mac_key = [0u8; 64]; + chacha.apply_keystream(&mut mac_key); + + #[cfg(not(fuzzing))] + let mut mac = Poly1305::new(mac_key[..32].try_into().unwrap()); + #[cfg(fuzzing)] + let mut mac = Poly1305::new(secret); + let decrypted_len = r.remaining_bytes() - 16; let s = FixedLengthReader::new(r, decrypted_len); - let mut chacha_stream = ChaChaPolyReader { chacha: &mut chacha, read: s }; + let mut chacha_stream = ChaChaPolyReader::new(&mut chacha, &mut mac, s); let readable: T = Readable::read(&mut chacha_stream)?; while chacha_stream.read.bytes_remain() { let mut buf = [0; 256]; - chacha_stream.read(&mut buf)?; + if chacha_stream.read(&mut buf)? == 0 { + return Err(DecodeError::ShortRead); + } } - let mut tag = [0 as u8; 16]; + let read_len = chacha_stream.read_len(); + drop(chacha_stream); + + if read_len % 16 != 0 { + mac.input(&[0; 16][0..16 - (read_len % 16)]); + } + mac.input(&0u64.to_le_bytes()); + mac.input(&(read_len as u64).to_le_bytes()); + + let mut tag = [0u8; 16]; r.read_exact(&mut tag)?; - if !chacha.finish_and_check_tag(&tag) { + if !fixed_time_eq(&mac.tag(), &tag) { return Err(DecodeError::InvalidValue); } @@ -244,20 +275,32 @@ impl LengthReadableArgs<[u8; 32]> for ChaChaPolyReadAdapter { } } -/// Enables simultaneously reading and decrypting a ChaCha20Poly1305RFC stream from a std::io::Read. +/// Enables simultaneously reading and decrypting a ChaCha20Poly1305 stream from a std::io::Read. struct ChaChaPolyReader<'a, R: Read> { - pub chacha: &'a mut ChaCha20Poly1305RFC, + chacha: &'a mut ChaCha20, + poly: &'a mut Poly1305, + read_len: usize, pub read: R, } +impl<'a, R: Read> ChaChaPolyReader<'a, R> { + fn new(chacha: &'a mut ChaCha20, poly: &'a mut Poly1305, read: R) -> Self { + Self { chacha, poly, read_len: 0, read } + } + + fn read_len(&self) -> usize { + self.read_len + } +} + impl<'a, R: Read> Read for ChaChaPolyReader<'a, R> { // Decrypt bytes from Self::read into `dest`. - // `ChaCha20Poly1305RFC::finish_and_check_tag` must be called to check the tag after all reads - // complete. fn read(&mut self, dest: &mut [u8]) -> Result { let res = self.read.read(dest)?; if res > 0 { - self.chacha.decrypt_in_place(&mut dest[0..res]); + self.poly.input(&dest[..res]); + self.chacha.apply_keystream(&mut dest[..res]); + self.read_len += res; } Ok(res) } @@ -265,14 +308,26 @@ impl<'a, R: Read> Read for ChaChaPolyReader<'a, R> { /// Enables simultaneously writing and encrypting a byte stream into a Writer. struct ChaChaPolyWriter<'a, W: Writer> { - pub chacha: &'a mut ChaCha20Poly1305RFC, + chacha: &'a mut ChaCha20, + poly: Poly1305, + write_len: usize, pub write: &'a mut W, } +impl<'a, W: Writer> ChaChaPolyWriter<'a, W> { + /// Finish encrypting and return the 16-byte authentication tag. + fn finish_and_get_tag(mut self) -> [u8; 16] { + if self.write_len % 16 != 0 { + self.poly.input(&[0; 16][0..16 - (self.write_len % 16)]); + } + self.poly.input(&0u64.to_le_bytes()); + self.poly.input(&(self.write_len as u64).to_le_bytes()); + self.poly.tag() + } +} + impl<'a, W: Writer> Writer for ChaChaPolyWriter<'a, W> { // Encrypt then write bytes from `src` into Self::write. - // `ChaCha20Poly1305RFC::finish_and_get_tag` can be called to retrieve the tag after all writes - // complete. fn write_all(&mut self, src: &[u8]) -> Result<(), io::Error> { let mut src_idx = 0; while src_idx < src.len() { @@ -280,8 +335,10 @@ impl<'a, W: Writer> Writer for ChaChaPolyWriter<'a, W> { let bytes_written = (&mut write_buffer[..]) .write(&src[src_idx..]) .expect("In-memory writes can't fail"); - self.chacha.encrypt_in_place(&mut write_buffer[..bytes_written]); + self.chacha.apply_keystream(&mut write_buffer[..bytes_written]); + self.poly.input(&write_buffer[..bytes_written]); self.write.write_all(&write_buffer[..bytes_written])?; + self.write_len += bytes_written; src_idx += bytes_written; } Ok(()) diff --git a/lightning/src/ln/inbound_payment.rs b/lightning/src/ln/inbound_payment.rs index d70a20eaf44..a7597701768 100644 --- a/lightning/src/ln/inbound_payment.rs +++ b/lightning/src/ln/inbound_payment.rs @@ -13,12 +13,12 @@ use bitcoin::hashes::cmp::fixed_time_eq; use bitcoin::hashes::hmac::{Hmac, HmacEngine}; use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::{Hash, HashEngine}; +use chacha20_poly1305::chacha20::{ChaCha20, Key, Nonce}; -use crate::crypto::chacha20::ChaCha20; use crate::crypto::utils::hkdf_extract_expand_7x; use crate::ln::msgs; use crate::ln::msgs::MAX_VALUE_MSAT; -use crate::offers::nonce::Nonce; +use crate::offers::nonce::Nonce as LocalNonce; use crate::sign::EntropySource; use crate::types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; use crate::util::errors::APIError; @@ -96,8 +96,13 @@ impl ExpandedKey { /// Encrypts or decrypts the given `bytes`. Used for data included in an offer message's /// metadata (e.g., payment id). - pub(crate) fn crypt_for_offer(&self, mut bytes: [u8; 32], nonce: Nonce) -> [u8; 32] { - ChaCha20::encrypt_single_block_in_place(&self.offers_encryption_key, &nonce.0, &mut bytes); + pub(crate) fn crypt_for_offer(&self, mut bytes: [u8; 32], nonce: LocalNonce) -> [u8; 32] { + ChaCha20::new_from_block( + Key::new(self.offers_encryption_key), + Nonce::new(nonce.0[4..].try_into().unwrap()), + u32::from_le_bytes(nonce.0[..4].try_into().unwrap()), + ) + .apply_keystream(&mut bytes); bytes } } @@ -301,12 +306,14 @@ fn construct_payment_secret( let (iv_slice, encrypted_metadata_slice) = payment_secret_bytes.split_at_mut(IV_LEN); iv_slice.copy_from_slice(iv_bytes); - ChaCha20::encrypt_single_block( - metadata_key, - iv_bytes, - encrypted_metadata_slice, - metadata_bytes, - ); + encrypted_metadata_slice.copy_from_slice(metadata_bytes); + ChaCha20::new_from_block( + Key::new(*metadata_key), + Nonce::new(iv_bytes[4..].try_into().unwrap()), + u32::from_le_bytes(iv_bytes[..4].try_into().unwrap()), + ) + .apply_keystream(encrypted_metadata_slice); + PaymentSecret(payment_secret_bytes) } @@ -485,12 +492,13 @@ fn decrypt_metadata( iv_bytes.copy_from_slice(iv_slice); let mut metadata_bytes: [u8; METADATA_LEN] = [0; METADATA_LEN]; - ChaCha20::encrypt_single_block( - &keys.metadata_key, - &iv_bytes, - &mut metadata_bytes, - encrypted_metadata_bytes, - ); + metadata_bytes.copy_from_slice(encrypted_metadata_bytes); + ChaCha20::new_from_block( + Key::new(keys.metadata_key), + Nonce::new(iv_bytes[4..].try_into().unwrap()), + u32::from_le_bytes(iv_bytes[..4].try_into().unwrap()), + ) + .apply_keystream(&mut metadata_bytes); (iv_bytes, metadata_bytes) } diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 602d731bac6..fe41bc1c6dc 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -11,7 +11,6 @@ use super::msgs::OnionErrorPacket; use crate::blinded_path::BlindedHop; -use crate::crypto::chacha20::ChaCha20; use crate::crypto::streams::ChaChaReader; use crate::events::HTLCHandlingFailureReason; use crate::ln::channel::TOTAL_BITCOIN_SUPPLY_SATOSHIS; @@ -40,6 +39,8 @@ use bitcoin::secp256k1; use bitcoin::secp256k1::ecdh::SharedSecret; use bitcoin::secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey}; +use chacha20_poly1305::chacha20::{ChaCha20, Key, Nonce}; + use crate::io::{Cursor, Read}; #[allow(unused_imports)] @@ -725,8 +726,8 @@ pub(super) fn construct_onion_packet( ) -> Result { let mut packet_data = [0; ONION_DATA_LEN]; - let mut chacha = ChaCha20::new(&prng_seed, &[0; 8]); - chacha.process(&[0; ONION_DATA_LEN], &mut packet_data); + let mut chacha = ChaCha20::new(Key::new(prng_seed), Nonce::new([0; 12]), 0); + chacha.apply_keystream(&mut packet_data); debug_assert_eq!(payloads.len(), onion_keys.len(), "Payloads and keys must have equal lengths"); @@ -763,8 +764,8 @@ pub(super) fn construct_trampoline_onion_packet( } let mut packet_data = vec![0u8; packet_length]; - let mut chacha = ChaCha20::new(&prng_seed, &[0; 8]); - chacha.process_in_place(&mut packet_data); + let mut chacha = ChaCha20::new(Key::new(prng_seed), Nonce::new([0; 12]), 0); + chacha.apply_keystream(&mut packet_data); construct_onion_packet_with_init_noise::<_, _>( payloads, @@ -783,8 +784,8 @@ pub(super) fn construct_onion_packet_with_writable_hopdata( ) -> Result { let mut packet_data = [0; ONION_DATA_LEN]; - let mut chacha = ChaCha20::new(&prng_seed, &[0; 8]); - chacha.process(&[0; ONION_DATA_LEN], &mut packet_data); + let mut chacha = ChaCha20::new(Key::new(prng_seed), Nonce::new([0; 12]), 0); + chacha.apply_keystream(&mut packet_data); let packet = FixedSizeOnionPacket(packet_data); construct_onion_packet_with_init_noise::<_, _>( @@ -822,8 +823,8 @@ pub(crate) fn construct_onion_message_packet Result { let mut packet_data = vec![0; packet_data_len]; - let mut chacha = ChaCha20::new(&prng_seed, &[0; 8]); - chacha.process_in_place(&mut packet_data); + let mut chacha = ChaCha20::new(Key::new(prng_seed), Nonce::new([0; 12]), 0); + chacha.apply_keystream(&mut packet_data); construct_onion_packet_with_init_noise::<_, _>(payloads, onion_keys, packet_data, None) } @@ -843,12 +844,9 @@ fn construct_onion_packet_with_init_noise( let mut pos = 0; for (i, (payload, keys)) in payloads.iter().zip(onion_keys.iter()).enumerate() { - let mut chacha = ChaCha20::new(&keys.rho, &[0u8; 8]); - // TODO: Batch this. - for _ in 0..(packet_data.len() - pos) { - let mut dummy = [0; 1]; - chacha.process_in_place(&mut dummy); // We don't have a seek function :( - } + // Seek to the position in the keystream where we want to start encrypting + let seek_pos = (packet_data.len() - pos) as u32; + let mut chacha = ChaCha20::new(Key::new(keys.rho), Nonce::new([0; 12]), seek_pos); let mut payload_len = LengthCalculatingWriter(0); payload.write(&mut payload_len).expect("Failed to calculate length"); @@ -862,7 +860,7 @@ fn construct_onion_packet_with_init_noise( } res.resize(pos, 0u8); - chacha.process_in_place(&mut res); + chacha.apply_keystream(&mut res); } res }; @@ -877,8 +875,8 @@ fn construct_onion_packet_with_init_noise( packet_data[0..payload_len.0].copy_from_slice(&payload.encode()[..]); packet_data[payload_len.0..(payload_len.0 + 32)].copy_from_slice(&hmac_res); - let mut chacha = ChaCha20::new(&keys.rho, &[0u8; 8]); - chacha.process_in_place(packet_data); + let mut chacha = ChaCha20::new(Key::new(keys.rho), Nonce::new([0; 12]), 0); + chacha.apply_keystream(packet_data); if i == 0 { let stop_index = packet_data.len(); @@ -900,8 +898,8 @@ fn construct_onion_packet_with_init_noise( /// Encrypts/decrypts a failure packet. fn crypt_failure_packet(shared_secret: &[u8], packet: &mut OnionErrorPacket) { let ammag = gen_ammag_from_shared_secret(&shared_secret); - let mut chacha = ChaCha20::new(&ammag, &[0u8; 8]); - chacha.process_in_place(&mut packet.data); + let mut chacha = ChaCha20::new(Key::new(ammag), Nonce::new([0; 12]), 0); + chacha.apply_keystream(&mut packet.data); if let Some(ref mut attribution_data) = packet.attribution_data { attribution_data.crypt(shared_secret); @@ -2738,7 +2736,7 @@ fn decode_next_hop, N: NextPacketBytes>( }); } - let mut chacha = ChaCha20::new(&rho, &[0u8; 8]); + let mut chacha = ChaCha20::new(Key::new(rho), Nonce::new([0; 12]), 0); let mut chacha_stream = ChaChaReader { chacha: &mut chacha, read: Cursor::new(&hop_data[..]) }; match R::read(&mut chacha_stream, read_args) { Err(err) => { @@ -2803,7 +2801,7 @@ fn decode_next_hop, N: NextPacketBytes>( } // Once we've emptied the set of bytes our peer gave us, encrypt 0 bytes until we // fill the onion hop data we'll forward to our next-hop peer. - chacha_stream.chacha.process_in_place(&mut new_packet_bytes.as_mut()[read_pos..]); + chacha_stream.chacha.apply_keystream(&mut new_packet_bytes.as_mut()[read_pos..]); return Ok((msg, Some((hmac, new_packet_bytes)))); // This packet needs forwarding } }, @@ -2845,9 +2843,9 @@ impl AttributionData { /// Encrypts or decrypts the attribution data using the provided shared secret. pub(crate) fn crypt(&mut self, shared_secret: &[u8]) { let ammagext = gen_ammagext_from_shared_secret(&shared_secret); - let mut chacha = ChaCha20::new(&ammagext, &[0u8; 8]); - chacha.process_in_place(&mut self.hold_times); - chacha.process_in_place(&mut self.hmacs); + let mut chacha = ChaCha20::new(Key::new(ammagext), Nonce::new([0; 12]), 0); + chacha.apply_keystream(&mut self.hold_times); + chacha.apply_keystream(&mut self.hmacs); } /// Adds the current node's HMACs for all possible positions to this packet. diff --git a/lightning/src/ln/our_peer_storage.rs b/lightning/src/ln/our_peer_storage.rs index ab0e9783ffa..937e446bcff 100644 --- a/lightning/src/ln/our_peer_storage.rs +++ b/lightning/src/ln/our_peer_storage.rs @@ -14,18 +14,18 @@ use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::{Hash, HashEngine, Hmac, HmacEngine}; use bitcoin::secp256k1::PublicKey; +use chacha20_poly1305::{ChaCha20Poly1305, Key, Nonce}; use crate::ln::types::ChannelId; use crate::sign::PeerStorageKey; -use crate::crypto::chacha20poly1305rfc::ChaCha20Poly1305RFC; use crate::prelude::*; /// [`DecryptedOurPeerStorage`] is used to store serialised channel information that allows for the creation of a /// `peer_storage` backup. /// /// This structure is designed to serialize channel data for backup and supports encryption -/// using `ChaCha20Poly1305RFC` for transmission. +/// using `ChaCha20Poly1305` for transmission. /// /// # Key Methods /// - [`DecryptedOurPeerStorage::new`]: Returns [`DecryptedOurPeerStorage`] with the given data. @@ -66,9 +66,8 @@ impl DecryptedOurPeerStorage { let plaintext_len = data.len(); let nonce = derive_nonce(key, random_bytes); - let mut chacha = ChaCha20Poly1305RFC::new(&key.inner, &nonce, b""); - let mut tag = [0; 16]; - chacha.encrypt_full_message_in_place(&mut data[0..plaintext_len], &mut tag); + let chacha = ChaCha20Poly1305::new(Key::new(key.inner), Nonce::new(nonce)); + let tag = chacha.encrypt(&mut data[0..plaintext_len], None); data.extend_from_slice(&tag); @@ -122,9 +121,11 @@ impl EncryptedOurPeerStorage { let nonce = derive_nonce(key, random_bytes); - let mut chacha = ChaCha20Poly1305RFC::new(&key.inner, &nonce, b""); + let chacha = ChaCha20Poly1305::new(Key::new(key.inner), Nonce::new(nonce)); - if chacha.check_decrypt_in_place(encrypted_data, tag).is_err() { + let mut decrypt_tag = [0; 16]; + decrypt_tag.copy_from_slice(tag); + if chacha.decrypt(encrypted_data, decrypt_tag, None).is_err() { return Err(()); } diff --git a/lightning/src/ln/peer_channel_encryptor.rs b/lightning/src/ln/peer_channel_encryptor.rs index 5554c5a8c19..d9fc6dd2c6a 100644 --- a/lightning/src/ln/peer_channel_encryptor.rs +++ b/lightning/src/ln/peer_channel_encryptor.rs @@ -25,8 +25,8 @@ use bitcoin::secp256k1; use bitcoin::secp256k1::ecdh::SharedSecret; use bitcoin::secp256k1::Secp256k1; use bitcoin::secp256k1::{PublicKey, SecretKey}; +use chacha20_poly1305::{ChaCha20Poly1305, Key, Nonce}; -use crate::crypto::chacha20poly1305rfc::ChaCha20Poly1305RFC; use crate::crypto::utils::hkdf_extract_expand_twice; use crate::util::ser::VecWriter; @@ -150,10 +150,11 @@ impl PeerChannelEncryptor { fn encrypt_with_ad(res: &mut [u8], n: u64, key: &[u8; 32], h: &[u8], plaintext: &[u8]) { let mut nonce = [0; 12]; nonce[4..].copy_from_slice(&n.to_le_bytes()[..]); + res[0..plaintext.len()].copy_from_slice(plaintext); + + let chacha = ChaCha20Poly1305::new(Key::new(*key), Nonce::new(nonce)); + let tag = chacha.encrypt(&mut res[0..plaintext.len()], Some(h)); - let mut chacha = ChaCha20Poly1305RFC::new(key, &nonce, h); - let mut tag = [0; 16]; - chacha.encrypt(plaintext, &mut res[0..plaintext.len()], &mut tag); res[plaintext.len()..].copy_from_slice(&tag); } @@ -166,9 +167,8 @@ impl PeerChannelEncryptor { let mut nonce = [0; 12]; nonce[4..].copy_from_slice(&n.to_le_bytes()[..]); - let mut chacha = ChaCha20Poly1305RFC::new(key, &nonce, h); - let mut tag = [0; 16]; - chacha.encrypt_full_message_in_place(&mut res[offset..], &mut tag); + let chacha = ChaCha20Poly1305::new(Key::new(*key), Nonce::new(nonce)); + let tag = chacha.encrypt(&mut res[offset..], Some(h)); res.extend_from_slice(&tag); } @@ -178,9 +178,11 @@ impl PeerChannelEncryptor { let mut nonce = [0; 12]; nonce[4..].copy_from_slice(&n.to_le_bytes()[..]); - let mut chacha = ChaCha20Poly1305RFC::new(key, &nonce, h); + let chacha = ChaCha20Poly1305::new(Key::new(*key), Nonce::new(nonce)); let (inout, tag) = inout.split_at_mut(inout.len() - 16); - if chacha.check_decrypt_in_place(inout, tag).is_err() { + let mut decrypt_tag = [0; 16]; + decrypt_tag.copy_from_slice(tag); + if chacha.decrypt(inout, decrypt_tag, Some(h)).is_err() { return Err(LightningError { err: "Bad MAC".to_owned(), action: msgs::ErrorAction::DisconnectPeer { msg: None }, @@ -197,9 +199,13 @@ impl PeerChannelEncryptor { nonce[4..].copy_from_slice(&n.to_le_bytes()[..]); let (data, hmac) = cyphertext.split_at(cyphertext.len() - 16); + let mut tag = [0; 16]; + tag.copy_from_slice(hmac); + res.copy_from_slice(data); + let mac_check = - ChaCha20Poly1305RFC::new(key, &nonce, h).variable_time_decrypt(&data, res, hmac); - mac_check.map_err(|()| LightningError { + ChaCha20Poly1305::new(Key::new(*key), Nonce::new(nonce)).decrypt(res, tag, Some(h)); + mac_check.map_err(|_| LightningError { err: "Bad MAC".to_owned(), action: msgs::ErrorAction::DisconnectPeer { msg: None }, }) diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 18b78dd647b..a3e5ad41992 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -10,6 +10,7 @@ //! The router finds paths within a [`NetworkGraph`] for a payment. use bitcoin::secp256k1::{self, PublicKey, Secp256k1}; +use chacha20_poly1305::chacha20::{ChaCha20, Key, Nonce}; use lightning_invoice::Bolt11Invoice; use crate::blinded_path::payment::{ @@ -17,7 +18,6 @@ use crate::blinded_path::payment::{ PaymentRelay, ReceiveTlvs, }; use crate::blinded_path::{BlindedHop, Direction, IntroductionNode}; -use crate::crypto::chacha20::ChaCha20; use crate::ln::channel_state::ChannelDetails; use crate::ln::channelmanager::{PaymentId, MIN_FINAL_CLTV_EXPIRY_DELTA}; use crate::ln::msgs::{DecodeError, MAX_VALUE_MSAT}; @@ -3944,11 +3944,11 @@ fn add_random_cltv_offset(route: &mut Route, payment_params: &PaymentParameters, } // Init PRNG with the path-dependant nonce, which is static for private paths. - let mut prng = ChaCha20::new(random_seed_bytes, &path_nonce); + let mut prng = ChaCha20::new(Key::new(*random_seed_bytes), Nonce::new(path_nonce), 0); let mut random_path_bytes = [0u8; ::core::mem::size_of::()]; // Pick a random path length in [1 .. 3] - prng.process_in_place(&mut random_path_bytes); + prng.apply_keystream(&mut random_path_bytes); let random_walk_length = usize::from_be_bytes(random_path_bytes).wrapping_rem(3).wrapping_add(1); for random_hop in 0..random_walk_length { @@ -3959,7 +3959,7 @@ fn add_random_cltv_offset(route: &mut Route, payment_params: &PaymentParameters, if let Some(cur_node_id) = cur_hop { if let Some(cur_node) = network_nodes.get(&cur_node_id) { // Randomly choose the next unvisited hop. - prng.process_in_place(&mut random_path_bytes); + prng.apply_keystream(&mut random_path_bytes); if let Some(random_channel) = usize::from_be_bytes(random_path_bytes) .checked_rem(cur_node.channels.len()) .and_then(|index| cur_node.channels.get(index)) @@ -4080,7 +4080,6 @@ mod tests { use crate::blinded_path::payment::{BlindedPayInfo, BlindedPaymentPath}; use crate::blinded_path::BlindedHop; use crate::chain::transaction::OutPoint; - use crate::crypto::chacha20::ChaCha20; use crate::ln::chan_utils::make_funding_redeemscript; use crate::ln::channel_state::{ChannelCounterparty, ChannelDetails, ChannelShutdownState}; use crate::ln::channelmanager; @@ -4117,6 +4116,8 @@ mod tests { use bitcoin::secp256k1::Secp256k1; use bitcoin::secp256k1::{PublicKey, SecretKey}; use bitcoin::transaction::TxOut; + use chacha20_poly1305::chacha20::ChaCha20; + use chacha20_poly1305::{Key, Nonce}; use crate::io::Cursor; use crate::prelude::*; @@ -7709,10 +7710,10 @@ mod tests { for p in route.paths { // 1. Select random observation point - let mut prng = ChaCha20::new(&random_seed_bytes, &[0u8; 12]); + let mut prng = ChaCha20::new(Key::new(random_seed_bytes), Nonce::new([0; 12]),0); let mut random_bytes = [0u8; ::core::mem::size_of::()]; - prng.process_in_place(&mut random_bytes); + prng.apply_keystream(&mut random_bytes); let random_path_index = usize::from_be_bytes(random_bytes).wrapping_rem(p.hops.len()); let observation_point = NodeId::from_pubkey(&p.hops.get(random_path_index).unwrap().pubkey); diff --git a/lightning/src/sign/mod.rs b/lightning/src/sign/mod.rs index 3237149338b..374ad38b2ce 100644 --- a/lightning/src/sign/mod.rs +++ b/lightning/src/sign/mod.rs @@ -34,6 +34,7 @@ use bitcoin::secp256k1::schnorr; use bitcoin::secp256k1::All; use bitcoin::secp256k1::{Keypair, PublicKey, Scalar, Secp256k1, SecretKey, Signing}; use bitcoin::{secp256k1, Psbt, Sequence, Txid, WPubkeyHash, Witness}; +use chacha20_poly1305::chacha20::{ChaCha20, Key, Nonce}; use lightning_invoice::RawBolt11Invoice; @@ -60,7 +61,6 @@ use crate::util::native_async::MaybeSend; use crate::util::ser::{ReadableArgs, Writeable}; use crate::util::transaction_utils; -use crate::crypto::chacha20::ChaCha20; use crate::prelude::*; use crate::sign::ecdsa::EcdsaChannelSigner; use crate::util::atomic_counter::AtomicCounter; @@ -2703,7 +2703,14 @@ impl EntropySource for RandomBytes { let index = self.index.next(); let mut nonce = [0u8; 16]; nonce[..8].copy_from_slice(&index.to_be_bytes()); - ChaCha20::get_single_block(&self.seed, &nonce) + let mut chacha_bytes = [0; 32]; + ChaCha20::new_from_block( + Key::new(self.seed), + Nonce::new(nonce[4..].try_into().unwrap()), + u32::from_le_bytes(nonce[..4].try_into().unwrap()), + ) + .apply_keystream(&mut chacha_bytes); + chacha_bytes } } diff --git a/lightning/src/util/scid_utils.rs b/lightning/src/util/scid_utils.rs index d57c529a41a..342c062d7f1 100644 --- a/lightning/src/util/scid_utils.rs +++ b/lightning/src/util/scid_utils.rs @@ -73,12 +73,12 @@ pub fn scid_from_parts( /// 3) payments intended to be intercepted will route using a fake scid (this is typically used so /// the forwarding node can open a JIT channel to the next hop) pub(crate) mod fake_scid { - use crate::crypto::chacha20::ChaCha20; use crate::prelude::*; use crate::sign::EntropySource; use crate::util::scid_utils; use bitcoin::constants::ChainHash; use bitcoin::Network; + use chacha20_poly1305::chacha20::{ChaCha20, Key, Nonce}; const TEST_SEGWIT_ACTIVATION_HEIGHT: u32 = 1; const MAINNET_SEGWIT_ACTIVATION_HEIGHT: u32 = 481_824; @@ -150,15 +150,15 @@ pub(crate) mod fake_scid { fn get_encrypted_vout( &self, block_height: u32, tx_index: u32, fake_scid_rand_bytes: &[u8; 32], ) -> u8 { - let mut salt = [0 as u8; 8]; + let mut salt = [0 as u8; 12]; let block_height_bytes = block_height.to_be_bytes(); - salt[0..4].copy_from_slice(&block_height_bytes); + salt[4..8].copy_from_slice(&block_height_bytes); let tx_index_bytes = tx_index.to_be_bytes(); - salt[4..8].copy_from_slice(&tx_index_bytes); + salt[8..12].copy_from_slice(&tx_index_bytes); - let mut chacha = ChaCha20::new(fake_scid_rand_bytes, &salt); + let mut chacha = ChaCha20::new(Key::new(*fake_scid_rand_bytes), Nonce::new(salt), 0); let mut vout_byte = [*self as u8]; - chacha.process_in_place(&mut vout_byte); + chacha.apply_keystream(&mut vout_byte); vout_byte[0] & NAMESPACE_ID_BITMASK } } From 964a84fcb07ca33517d7e1e748686d44e7b1f9fe Mon Sep 17 00:00:00 2001 From: Abeeujah Date: Tue, 5 May 2026 18:40:45 +0100 Subject: [PATCH 348/627] Drop local chacha20poly1305 implementation Complete the migration process from the local chacha20poly1305 to the rust-bitcoin chacha20-poly1305 crate. --- lightning/src/crypto/chacha20.rs | 639 -------------------- lightning/src/crypto/chacha20poly1305rfc.rs | 157 ----- lightning/src/crypto/mod.rs | 3 - lightning/src/crypto/poly1305.rs | 434 ------------- 4 files changed, 1233 deletions(-) delete mode 100644 lightning/src/crypto/chacha20.rs delete mode 100644 lightning/src/crypto/chacha20poly1305rfc.rs delete mode 100644 lightning/src/crypto/poly1305.rs diff --git a/lightning/src/crypto/chacha20.rs b/lightning/src/crypto/chacha20.rs deleted file mode 100644 index 67f9e93c480..00000000000 --- a/lightning/src/crypto/chacha20.rs +++ /dev/null @@ -1,639 +0,0 @@ -// This file was stolen from rust-crypto. -// Copyright 2012-2013 The Rust Project Developers. See the COPYRIGHT -// file at the top-level directory of this distribution and at -// http://rust-lang.org/COPYRIGHT. -// -// This file is licensed under the Apache License, Version 2.0 or the MIT license -// , at your option. -// You may not use this file except in accordance with one or both of these -// licenses. - -#[cfg(not(fuzzing))] -mod real_chacha { - use core::cmp; - - #[derive(Clone, Copy, PartialEq, Eq)] - #[allow(non_camel_case_types)] - struct u32x4(pub u32, pub u32, pub u32, pub u32); - impl ::core::ops::Add for u32x4 { - type Output = u32x4; - #[inline] - fn add(self, rhs: u32x4) -> u32x4 { - u32x4( - self.0.wrapping_add(rhs.0), - self.1.wrapping_add(rhs.1), - self.2.wrapping_add(rhs.2), - self.3.wrapping_add(rhs.3), - ) - } - } - impl ::core::ops::Sub for u32x4 { - type Output = u32x4; - #[inline] - fn sub(self, rhs: u32x4) -> u32x4 { - u32x4( - self.0.wrapping_sub(rhs.0), - self.1.wrapping_sub(rhs.1), - self.2.wrapping_sub(rhs.2), - self.3.wrapping_sub(rhs.3), - ) - } - } - impl ::core::ops::BitXor for u32x4 { - type Output = u32x4; - #[inline] - fn bitxor(self, rhs: u32x4) -> u32x4 { - u32x4(self.0 ^ rhs.0, self.1 ^ rhs.1, self.2 ^ rhs.2, self.3 ^ rhs.3) - } - } - impl ::core::ops::Shr for u32x4 { - type Output = u32x4; - #[inline] - fn shr(self, shr: u8) -> u32x4 { - u32x4(self.0 >> shr, self.1 >> shr, self.2 >> shr, self.3 >> shr) - } - } - impl ::core::ops::Shl for u32x4 { - type Output = u32x4; - #[inline] - fn shl(self, shl: u8) -> u32x4 { - u32x4(self.0 << shl, self.1 << shl, self.2 << shl, self.3 << shl) - } - } - impl u32x4 { - #[inline] - fn from_bytes(bytes: &[u8]) -> Self { - assert_eq!(bytes.len(), 4 * 4); - Self( - u32::from_le_bytes(bytes[0 * 4..1 * 4].try_into().expect("len is 4")), - u32::from_le_bytes(bytes[1 * 4..2 * 4].try_into().expect("len is 4")), - u32::from_le_bytes(bytes[2 * 4..3 * 4].try_into().expect("len is 4")), - u32::from_le_bytes(bytes[3 * 4..4 * 4].try_into().expect("len is 4")), - ) - } - } - - const BLOCK_SIZE: usize = 64; - - #[derive(Clone, Copy)] - struct ChaChaState { - a: u32x4, - b: u32x4, - c: u32x4, - d: u32x4, - } - - #[derive(Copy)] - pub struct ChaCha20 { - state: ChaChaState, - output: [u8; BLOCK_SIZE], - offset: usize, - } - - impl Clone for ChaCha20 { - fn clone(&self) -> ChaCha20 { - *self - } - } - - macro_rules! swizzle { - ($b: expr, $c: expr, $d: expr) => {{ - let u32x4(b10, b11, b12, b13) = $b; - $b = u32x4(b11, b12, b13, b10); - let u32x4(c10, c11, c12, c13) = $c; - $c = u32x4(c12, c13, c10, c11); - let u32x4(d10, d11, d12, d13) = $d; - $d = u32x4(d13, d10, d11, d12); - }}; - } - - macro_rules! state_to_buffer { - ($state: expr, $output: expr) => {{ - let u32x4(a1, a2, a3, a4) = $state.a; - let u32x4(b1, b2, b3, b4) = $state.b; - let u32x4(c1, c2, c3, c4) = $state.c; - let u32x4(d1, d2, d3, d4) = $state.d; - let lens = [a1, a2, a3, a4, b1, b2, b3, b4, c1, c2, c3, c4, d1, d2, d3, d4]; - for i in 0..lens.len() { - $output[i * 4..(i + 1) * 4].copy_from_slice(&lens[i].to_le_bytes()); - } - }}; - } - - macro_rules! round { - ($state: expr) => {{ - $state.a = $state.a + $state.b; - rotate!($state.d, $state.a, 16); - $state.c = $state.c + $state.d; - rotate!($state.b, $state.c, 12); - $state.a = $state.a + $state.b; - rotate!($state.d, $state.a, 8); - $state.c = $state.c + $state.d; - rotate!($state.b, $state.c, 7); - }}; - } - - macro_rules! rotate { - ($a: expr, $b: expr, $rot: expr) => {{ - let v = $a ^ $b; - let r = 32 - $rot; - let right = v >> r; - $a = (v << $rot) ^ right - }}; - } - - impl ChaCha20 { - pub fn new(key: &[u8], nonce: &[u8]) -> ChaCha20 { - assert!(key.len() == 16 || key.len() == 32); - assert!(nonce.len() == 8 || nonce.len() == 12); - - ChaCha20 { state: ChaCha20::expand(key, nonce), output: [0u8; BLOCK_SIZE], offset: 64 } - } - - /// Get one block from a ChaCha stream. - pub fn get_single_block(key: &[u8; 32], nonce: &[u8; 16]) -> [u8; 32] { - let mut chacha = ChaCha20 { - state: ChaCha20::expand(key, nonce), - output: [0u8; BLOCK_SIZE], - offset: 64, - }; - let mut chacha_bytes = [0; 32]; - chacha.process_in_place(&mut chacha_bytes); - chacha_bytes - } - - /// Encrypts `src` into `dest` using a single block from a ChaCha stream. Passing `dest` as - /// `src` in a second call will decrypt it. - pub fn encrypt_single_block(key: &[u8; 32], nonce: &[u8; 16], dest: &mut [u8], src: &[u8]) { - debug_assert_eq!(dest.len(), src.len()); - debug_assert!(dest.len() <= 32); - - let block = ChaCha20::get_single_block(key, nonce); - for i in 0..dest.len() { - dest[i] = block[i] ^ src[i]; - } - } - - /// Same as `encrypt_single_block` only operates on a fixed-size input in-place. - pub fn encrypt_single_block_in_place( - key: &[u8; 32], nonce: &[u8; 16], bytes: &mut [u8; 32], - ) { - let block = ChaCha20::get_single_block(key, nonce); - for i in 0..bytes.len() { - bytes[i] ^= block[i]; - } - } - - fn expand(key: &[u8], nonce: &[u8]) -> ChaChaState { - let constant = match key.len() { - 16 => b"expand 16-byte k", - 32 => b"expand 32-byte k", - _ => unreachable!(), - }; - ChaChaState { - a: u32x4::from_bytes(&constant[0..16]), - b: u32x4::from_bytes(&key[0..16]), - c: if key.len() == 16 { - u32x4::from_bytes(&key[0..16]) - } else { - u32x4::from_bytes(&key[16..32]) - }, - d: if nonce.len() == 16 { - u32x4::from_bytes(&nonce[0..16]) - } else if nonce.len() == 12 { - let mut nonce4 = [0; 4 * 4]; - nonce4[4..].copy_from_slice(nonce); - u32x4::from_bytes(&nonce4) - } else { - let mut nonce4 = [0; 4 * 4]; - nonce4[8..].copy_from_slice(nonce); - u32x4::from_bytes(&nonce4) - }, - } - } - - // put the the next BLOCK_SIZE keystream bytes into self.output - fn update(&mut self) { - let mut state = self.state; - - for _ in 0..10 { - round!(state); - swizzle!(state.b, state.c, state.d); - round!(state); - swizzle!(state.d, state.c, state.b); - } - state.a = state.a + self.state.a; - state.b = state.b + self.state.b; - state.c = state.c + self.state.c; - state.d = state.d + self.state.d; - - state_to_buffer!(state, self.output); - - self.state.d = self.state.d + u32x4(1, 0, 0, 0); - let u32x4(c12, _, _, _) = self.state.d; - if c12 == 0 { - // we could increment the other counter word with an 8 byte nonce - // but other implementations like boringssl have this same - // limitation - panic!("counter is exhausted"); - } - - self.offset = 0; - } - - #[inline] // Useful cause input may be 0s on stack that should be optimized out - pub fn process(&mut self, input: &[u8], output: &mut [u8]) { - assert!(input.len() == output.len()); - let len = input.len(); - let mut i = 0; - while i < len { - // If there is no keystream available in the output buffer, - // generate the next block. - if self.offset == BLOCK_SIZE { - self.update(); - } - - // Process the min(available keystream, remaining input length). - let count = cmp::min(BLOCK_SIZE - self.offset, len - i); - // explicitly assert lengths to avoid bounds checks: - assert!(output.len() >= i + count); - assert!(input.len() >= i + count); - assert!(self.output.len() >= self.offset + count); - for j in 0..count { - output[i + j] = input[i + j] ^ self.output[self.offset + j]; - } - i += count; - self.offset += count; - } - } - - pub fn process_in_place(&mut self, input_output: &mut [u8]) { - let len = input_output.len(); - let mut i = 0; - while i < len { - // If there is no keystream available in the output buffer, - // generate the next block. - if self.offset == BLOCK_SIZE { - self.update(); - } - - // Process the min(available keystream, remaining input length). - let count = cmp::min(BLOCK_SIZE - self.offset, len - i); - // explicitly assert lengths to avoid bounds checks: - assert!(input_output.len() >= i + count); - assert!(self.output.len() >= self.offset + count); - for j in 0..count { - input_output[i + j] ^= self.output[self.offset + j]; - } - i += count; - self.offset += count; - } - } - - #[cfg(test)] - pub fn seek_to_block(&mut self, block_offset: u32) { - self.state.d.0 = block_offset; - self.update(); - } - } -} -#[cfg(not(fuzzing))] -pub use self::real_chacha::ChaCha20; - -#[cfg(fuzzing)] -mod fuzzy_chacha { - pub struct ChaCha20 {} - - impl ChaCha20 { - pub fn new(key: &[u8], nonce: &[u8]) -> ChaCha20 { - assert!(key.len() == 16 || key.len() == 32); - assert!(nonce.len() == 8 || nonce.len() == 12); - Self {} - } - - pub fn get_single_block(_key: &[u8; 32], _nonce: &[u8; 16]) -> [u8; 32] { - [0; 32] - } - - pub fn encrypt_single_block( - _key: &[u8; 32], _nonce: &[u8; 16], dest: &mut [u8], src: &[u8], - ) { - debug_assert_eq!(dest.len(), src.len()); - debug_assert!(dest.len() <= 32); - dest.copy_from_slice(src); - } - - pub fn encrypt_single_block_in_place( - _key: &[u8; 32], _nonce: &[u8; 16], _bytes: &mut [u8; 32], - ) { - } - - pub fn process(&mut self, input: &[u8], output: &mut [u8]) { - output.copy_from_slice(input); - } - - pub fn process_in_place(&mut self, _input_output: &mut [u8]) {} - } -} -#[cfg(fuzzing)] -pub use self::fuzzy_chacha::ChaCha20; - -#[cfg(test)] -mod test { - use core::iter::repeat; - - use crate::prelude::*; - - use super::ChaCha20; - - #[test] - fn test_chacha20_256_tls_vectors() { - struct TestVector { - key: [u8; 32], - nonce: [u8; 8], - keystream: Vec, - } - // taken from http://tools.ietf.org/html/draft-agl-tls-chacha20poly1305-04 - let test_vectors = [ - TestVector { - key: [ - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - ], - nonce: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], - keystream: vec![ - 0x76, 0xb8, 0xe0, 0xad, 0xa0, 0xf1, 0x3d, 0x90, 0x40, 0x5d, 0x6a, 0xe5, 0x53, - 0x86, 0xbd, 0x28, 0xbd, 0xd2, 0x19, 0xb8, 0xa0, 0x8d, 0xed, 0x1a, 0xa8, 0x36, - 0xef, 0xcc, 0x8b, 0x77, 0x0d, 0xc7, 0xda, 0x41, 0x59, 0x7c, 0x51, 0x57, 0x48, - 0x8d, 0x77, 0x24, 0xe0, 0x3f, 0xb8, 0xd8, 0x4a, 0x37, 0x6a, 0x43, 0xb8, 0xf4, - 0x15, 0x18, 0xa1, 0x1c, 0xc3, 0x87, 0xb6, 0x69, 0xb2, 0xee, 0x65, 0x86, - ], - }, - TestVector { - key: [ - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, - ], - nonce: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], - keystream: vec![ - 0x45, 0x40, 0xf0, 0x5a, 0x9f, 0x1f, 0xb2, 0x96, 0xd7, 0x73, 0x6e, 0x7b, 0x20, - 0x8e, 0x3c, 0x96, 0xeb, 0x4f, 0xe1, 0x83, 0x46, 0x88, 0xd2, 0x60, 0x4f, 0x45, - 0x09, 0x52, 0xed, 0x43, 0x2d, 0x41, 0xbb, 0xe2, 0xa0, 0xb6, 0xea, 0x75, 0x66, - 0xd2, 0xa5, 0xd1, 0xe7, 0xe2, 0x0d, 0x42, 0xaf, 0x2c, 0x53, 0xd7, 0x92, 0xb1, - 0xc4, 0x3f, 0xea, 0x81, 0x7e, 0x9a, 0xd2, 0x75, 0xae, 0x54, 0x69, 0x63, - ], - }, - TestVector { - key: [ - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - ], - nonce: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01], - keystream: vec![ - 0xde, 0x9c, 0xba, 0x7b, 0xf3, 0xd6, 0x9e, 0xf5, 0xe7, 0x86, 0xdc, 0x63, 0x97, - 0x3f, 0x65, 0x3a, 0x0b, 0x49, 0xe0, 0x15, 0xad, 0xbf, 0xf7, 0x13, 0x4f, 0xcb, - 0x7d, 0xf1, 0x37, 0x82, 0x10, 0x31, 0xe8, 0x5a, 0x05, 0x02, 0x78, 0xa7, 0x08, - 0x45, 0x27, 0x21, 0x4f, 0x73, 0xef, 0xc7, 0xfa, 0x5b, 0x52, 0x77, 0x06, 0x2e, - 0xb7, 0xa0, 0x43, 0x3e, 0x44, 0x5f, 0x41, 0xe3, - ], - }, - TestVector { - key: [ - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - ], - nonce: [0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], - keystream: vec![ - 0xef, 0x3f, 0xdf, 0xd6, 0xc6, 0x15, 0x78, 0xfb, 0xf5, 0xcf, 0x35, 0xbd, 0x3d, - 0xd3, 0x3b, 0x80, 0x09, 0x63, 0x16, 0x34, 0xd2, 0x1e, 0x42, 0xac, 0x33, 0x96, - 0x0b, 0xd1, 0x38, 0xe5, 0x0d, 0x32, 0x11, 0x1e, 0x4c, 0xaf, 0x23, 0x7e, 0xe5, - 0x3c, 0xa8, 0xad, 0x64, 0x26, 0x19, 0x4a, 0x88, 0x54, 0x5d, 0xdc, 0x49, 0x7a, - 0x0b, 0x46, 0x6e, 0x7d, 0x6b, 0xbd, 0xb0, 0x04, 0x1b, 0x2f, 0x58, 0x6b, - ], - }, - TestVector { - key: [ - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, - 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, - 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, - ], - nonce: [0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07], - keystream: vec![ - 0xf7, 0x98, 0xa1, 0x89, 0xf1, 0x95, 0xe6, 0x69, 0x82, 0x10, 0x5f, 0xfb, 0x64, - 0x0b, 0xb7, 0x75, 0x7f, 0x57, 0x9d, 0xa3, 0x16, 0x02, 0xfc, 0x93, 0xec, 0x01, - 0xac, 0x56, 0xf8, 0x5a, 0xc3, 0xc1, 0x34, 0xa4, 0x54, 0x7b, 0x73, 0x3b, 0x46, - 0x41, 0x30, 0x42, 0xc9, 0x44, 0x00, 0x49, 0x17, 0x69, 0x05, 0xd3, 0xbe, 0x59, - 0xea, 0x1c, 0x53, 0xf1, 0x59, 0x16, 0x15, 0x5c, 0x2b, 0xe8, 0x24, 0x1a, 0x38, - 0x00, 0x8b, 0x9a, 0x26, 0xbc, 0x35, 0x94, 0x1e, 0x24, 0x44, 0x17, 0x7c, 0x8a, - 0xde, 0x66, 0x89, 0xde, 0x95, 0x26, 0x49, 0x86, 0xd9, 0x58, 0x89, 0xfb, 0x60, - 0xe8, 0x46, 0x29, 0xc9, 0xbd, 0x9a, 0x5a, 0xcb, 0x1c, 0xc1, 0x18, 0xbe, 0x56, - 0x3e, 0xb9, 0xb3, 0xa4, 0xa4, 0x72, 0xf8, 0x2e, 0x09, 0xa7, 0xe7, 0x78, 0x49, - 0x2b, 0x56, 0x2e, 0xf7, 0x13, 0x0e, 0x88, 0xdf, 0xe0, 0x31, 0xc7, 0x9d, 0xb9, - 0xd4, 0xf7, 0xc7, 0xa8, 0x99, 0x15, 0x1b, 0x9a, 0x47, 0x50, 0x32, 0xb6, 0x3f, - 0xc3, 0x85, 0x24, 0x5f, 0xe0, 0x54, 0xe3, 0xdd, 0x5a, 0x97, 0xa5, 0xf5, 0x76, - 0xfe, 0x06, 0x40, 0x25, 0xd3, 0xce, 0x04, 0x2c, 0x56, 0x6a, 0xb2, 0xc5, 0x07, - 0xb1, 0x38, 0xdb, 0x85, 0x3e, 0x3d, 0x69, 0x59, 0x66, 0x09, 0x96, 0x54, 0x6c, - 0xc9, 0xc4, 0xa6, 0xea, 0xfd, 0xc7, 0x77, 0xc0, 0x40, 0xd7, 0x0e, 0xaf, 0x46, - 0xf7, 0x6d, 0xad, 0x39, 0x79, 0xe5, 0xc5, 0x36, 0x0c, 0x33, 0x17, 0x16, 0x6a, - 0x1c, 0x89, 0x4c, 0x94, 0xa3, 0x71, 0x87, 0x6a, 0x94, 0xdf, 0x76, 0x28, 0xfe, - 0x4e, 0xaa, 0xf2, 0xcc, 0xb2, 0x7d, 0x5a, 0xaa, 0xe0, 0xad, 0x7a, 0xd0, 0xf9, - 0xd4, 0xb6, 0xad, 0x3b, 0x54, 0x09, 0x87, 0x46, 0xd4, 0x52, 0x4d, 0x38, 0x40, - 0x7a, 0x6d, 0xeb, 0x3a, 0xb7, 0x8f, 0xab, 0x78, 0xc9, - ], - }, - ]; - - for tv in test_vectors.iter() { - let mut c = ChaCha20::new(&tv.key, &tv.nonce); - let input: Vec = repeat(0).take(tv.keystream.len()).collect(); - let mut output: Vec = repeat(0).take(input.len()).collect(); - c.process(&input[..], &mut output[..]); - assert_eq!(output, tv.keystream); - } - } - - #[test] - fn test_chacha20_256_tls_vectors_96_nonce() { - struct TestVector { - key: [u8; 32], - nonce: [u8; 12], - keystream: Vec, - } - // taken from http://tools.ietf.org/html/draft-agl-tls-chacha20poly1305-04 - let test_vectors = [ - TestVector { - key: [ - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - ], - nonce: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], - keystream: vec![ - 0x76, 0xb8, 0xe0, 0xad, 0xa0, 0xf1, 0x3d, 0x90, 0x40, 0x5d, 0x6a, 0xe5, 0x53, - 0x86, 0xbd, 0x28, 0xbd, 0xd2, 0x19, 0xb8, 0xa0, 0x8d, 0xed, 0x1a, 0xa8, 0x36, - 0xef, 0xcc, 0x8b, 0x77, 0x0d, 0xc7, 0xda, 0x41, 0x59, 0x7c, 0x51, 0x57, 0x48, - 0x8d, 0x77, 0x24, 0xe0, 0x3f, 0xb8, 0xd8, 0x4a, 0x37, 0x6a, 0x43, 0xb8, 0xf4, - 0x15, 0x18, 0xa1, 0x1c, 0xc3, 0x87, 0xb6, 0x69, 0xb2, 0xee, 0x65, 0x86, - ], - }, - TestVector { - key: [ - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, - ], - nonce: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], - keystream: vec![ - 0x45, 0x40, 0xf0, 0x5a, 0x9f, 0x1f, 0xb2, 0x96, 0xd7, 0x73, 0x6e, 0x7b, 0x20, - 0x8e, 0x3c, 0x96, 0xeb, 0x4f, 0xe1, 0x83, 0x46, 0x88, 0xd2, 0x60, 0x4f, 0x45, - 0x09, 0x52, 0xed, 0x43, 0x2d, 0x41, 0xbb, 0xe2, 0xa0, 0xb6, 0xea, 0x75, 0x66, - 0xd2, 0xa5, 0xd1, 0xe7, 0xe2, 0x0d, 0x42, 0xaf, 0x2c, 0x53, 0xd7, 0x92, 0xb1, - 0xc4, 0x3f, 0xea, 0x81, 0x7e, 0x9a, 0xd2, 0x75, 0xae, 0x54, 0x69, 0x63, - ], - }, - TestVector { - key: [ - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - ], - nonce: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01], - keystream: vec![ - 0xde, 0x9c, 0xba, 0x7b, 0xf3, 0xd6, 0x9e, 0xf5, 0xe7, 0x86, 0xdc, 0x63, 0x97, - 0x3f, 0x65, 0x3a, 0x0b, 0x49, 0xe0, 0x15, 0xad, 0xbf, 0xf7, 0x13, 0x4f, 0xcb, - 0x7d, 0xf1, 0x37, 0x82, 0x10, 0x31, 0xe8, 0x5a, 0x05, 0x02, 0x78, 0xa7, 0x08, - 0x45, 0x27, 0x21, 0x4f, 0x73, 0xef, 0xc7, 0xfa, 0x5b, 0x52, 0x77, 0x06, 0x2e, - 0xb7, 0xa0, 0x43, 0x3e, 0x44, 0x5f, 0x41, 0xe3, - ], - }, - TestVector { - key: [ - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - ], - nonce: [0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], - keystream: vec![ - 0xef, 0x3f, 0xdf, 0xd6, 0xc6, 0x15, 0x78, 0xfb, 0xf5, 0xcf, 0x35, 0xbd, 0x3d, - 0xd3, 0x3b, 0x80, 0x09, 0x63, 0x16, 0x34, 0xd2, 0x1e, 0x42, 0xac, 0x33, 0x96, - 0x0b, 0xd1, 0x38, 0xe5, 0x0d, 0x32, 0x11, 0x1e, 0x4c, 0xaf, 0x23, 0x7e, 0xe5, - 0x3c, 0xa8, 0xad, 0x64, 0x26, 0x19, 0x4a, 0x88, 0x54, 0x5d, 0xdc, 0x49, 0x7a, - 0x0b, 0x46, 0x6e, 0x7d, 0x6b, 0xbd, 0xb0, 0x04, 0x1b, 0x2f, 0x58, 0x6b, - ], - }, - TestVector { - key: [ - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, - 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, - 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, - ], - nonce: [0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07], - keystream: vec![ - 0xf7, 0x98, 0xa1, 0x89, 0xf1, 0x95, 0xe6, 0x69, 0x82, 0x10, 0x5f, 0xfb, 0x64, - 0x0b, 0xb7, 0x75, 0x7f, 0x57, 0x9d, 0xa3, 0x16, 0x02, 0xfc, 0x93, 0xec, 0x01, - 0xac, 0x56, 0xf8, 0x5a, 0xc3, 0xc1, 0x34, 0xa4, 0x54, 0x7b, 0x73, 0x3b, 0x46, - 0x41, 0x30, 0x42, 0xc9, 0x44, 0x00, 0x49, 0x17, 0x69, 0x05, 0xd3, 0xbe, 0x59, - 0xea, 0x1c, 0x53, 0xf1, 0x59, 0x16, 0x15, 0x5c, 0x2b, 0xe8, 0x24, 0x1a, 0x38, - 0x00, 0x8b, 0x9a, 0x26, 0xbc, 0x35, 0x94, 0x1e, 0x24, 0x44, 0x17, 0x7c, 0x8a, - 0xde, 0x66, 0x89, 0xde, 0x95, 0x26, 0x49, 0x86, 0xd9, 0x58, 0x89, 0xfb, 0x60, - 0xe8, 0x46, 0x29, 0xc9, 0xbd, 0x9a, 0x5a, 0xcb, 0x1c, 0xc1, 0x18, 0xbe, 0x56, - 0x3e, 0xb9, 0xb3, 0xa4, 0xa4, 0x72, 0xf8, 0x2e, 0x09, 0xa7, 0xe7, 0x78, 0x49, - 0x2b, 0x56, 0x2e, 0xf7, 0x13, 0x0e, 0x88, 0xdf, 0xe0, 0x31, 0xc7, 0x9d, 0xb9, - 0xd4, 0xf7, 0xc7, 0xa8, 0x99, 0x15, 0x1b, 0x9a, 0x47, 0x50, 0x32, 0xb6, 0x3f, - 0xc3, 0x85, 0x24, 0x5f, 0xe0, 0x54, 0xe3, 0xdd, 0x5a, 0x97, 0xa5, 0xf5, 0x76, - 0xfe, 0x06, 0x40, 0x25, 0xd3, 0xce, 0x04, 0x2c, 0x56, 0x6a, 0xb2, 0xc5, 0x07, - 0xb1, 0x38, 0xdb, 0x85, 0x3e, 0x3d, 0x69, 0x59, 0x66, 0x09, 0x96, 0x54, 0x6c, - 0xc9, 0xc4, 0xa6, 0xea, 0xfd, 0xc7, 0x77, 0xc0, 0x40, 0xd7, 0x0e, 0xaf, 0x46, - 0xf7, 0x6d, 0xad, 0x39, 0x79, 0xe5, 0xc5, 0x36, 0x0c, 0x33, 0x17, 0x16, 0x6a, - 0x1c, 0x89, 0x4c, 0x94, 0xa3, 0x71, 0x87, 0x6a, 0x94, 0xdf, 0x76, 0x28, 0xfe, - 0x4e, 0xaa, 0xf2, 0xcc, 0xb2, 0x7d, 0x5a, 0xaa, 0xe0, 0xad, 0x7a, 0xd0, 0xf9, - 0xd4, 0xb6, 0xad, 0x3b, 0x54, 0x09, 0x87, 0x46, 0xd4, 0x52, 0x4d, 0x38, 0x40, - 0x7a, 0x6d, 0xeb, 0x3a, 0xb7, 0x8f, 0xab, 0x78, 0xc9, - ], - }, - ]; - - for tv in test_vectors.iter() { - let mut c = ChaCha20::new(&tv.key, &tv.nonce); - let input: Vec = repeat(0).take(tv.keystream.len()).collect(); - let mut output: Vec = repeat(0).take(input.len()).collect(); - c.process(&input[..], &mut output[..]); - assert_eq!(output, tv.keystream); - } - } - - #[test] - fn get_single_block() { - // Test that `get_single_block` (which takes a 16-byte nonce) is equivalent to getting a block - // using a 12-byte nonce, with the block starting at the counter offset given by the remaining 4 - // bytes. - let key = [ - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, - 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, - 0x1c, 0x1d, 0x1e, 0x1f, - ]; - let nonce_16bytes = [ - 0x00, 0x01, 0x02, 0x03, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, - 0x0a, 0x0b, - ]; - let counter_pos = &nonce_16bytes[..4]; - let nonce_12bytes = &nonce_16bytes[4..]; - - // Initialize a ChaCha20 instance with its counter starting at 0. - let mut chacha20 = ChaCha20::new(&key, nonce_12bytes); - // Seek its counter to the block at counter_pos. - chacha20.seek_to_block(u32::from_le_bytes(counter_pos.try_into().unwrap())); - let mut block_bytes = [0; 32]; - chacha20.process_in_place(&mut block_bytes); - - assert_eq!(ChaCha20::get_single_block(&key, &nonce_16bytes), block_bytes); - } - - #[test] - fn encrypt_single_block() { - let key = [ - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, - 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, - 0x1c, 0x1d, 0x1e, 0x1f, - ]; - let nonce = [ - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, - 0x0e, 0x0f, - ]; - let bytes = [1; 32]; - - let mut encrypted_bytes = [0; 32]; - ChaCha20::encrypt_single_block(&key, &nonce, &mut encrypted_bytes, &bytes); - - let mut decrypted_bytes = [0; 32]; - ChaCha20::encrypt_single_block(&key, &nonce, &mut decrypted_bytes, &encrypted_bytes); - - assert_eq!(bytes, decrypted_bytes); - } - - #[test] - fn encrypt_single_block_in_place() { - let key = [ - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, - 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, - 0x1c, 0x1d, 0x1e, 0x1f, - ]; - let nonce = [ - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, - 0x0e, 0x0f, - ]; - let unencrypted_bytes = [1; 32]; - let mut bytes = unencrypted_bytes; - - ChaCha20::encrypt_single_block_in_place(&key, &nonce, &mut bytes); - assert_ne!(bytes, unencrypted_bytes); - - ChaCha20::encrypt_single_block_in_place(&key, &nonce, &mut bytes); - assert_eq!(bytes, unencrypted_bytes); - } -} diff --git a/lightning/src/crypto/chacha20poly1305rfc.rs b/lightning/src/crypto/chacha20poly1305rfc.rs deleted file mode 100644 index 839fad9ce6c..00000000000 --- a/lightning/src/crypto/chacha20poly1305rfc.rs +++ /dev/null @@ -1,157 +0,0 @@ -// ring has a garbage API so its use is avoided, but rust-crypto doesn't have RFC-variant poly1305 -// Instead, we steal rust-crypto's implementation and tweak it to match the RFC. -// -// This file is licensed under the Apache License, Version 2.0 or the MIT license -// , at your option. -// You may not use this file except in accordance with one or both of these -// licenses. -// -// This is a port of Andrew Moons poly1305-donna -// https://github.com/floodyberry/poly1305-donna - -use super::chacha20::ChaCha20; -use super::fixed_time_eq; -use super::poly1305::Poly1305; - -pub struct ChaCha20Poly1305RFC { - cipher: ChaCha20, - mac: Poly1305, - finished: bool, - data_len: usize, - aad_len: u64, -} - -impl ChaCha20Poly1305RFC { - #[inline] - fn pad_mac_16(mac: &mut Poly1305, len: usize) { - if len % 16 != 0 { - mac.input(&[0; 16][0..16 - (len % 16)]); - } - } - pub fn new(key: &[u8], nonce: &[u8], aad: &[u8]) -> ChaCha20Poly1305RFC { - assert!(key.len() == 16 || key.len() == 32); - assert!(nonce.len() == 12); - - // Ehh, I'm too lazy to *also* tweak ChaCha20 to make it RFC-compliant - assert!(nonce[0] == 0 && nonce[1] == 0 && nonce[2] == 0 && nonce[3] == 0); - - let mut cipher = ChaCha20::new(key, &nonce[4..]); - let mut mac_key = [0u8; 64]; - let zero_key = [0u8; 64]; - cipher.process(&zero_key, &mut mac_key); - - #[cfg(not(fuzzing))] - let mut mac = Poly1305::new(&mac_key[..32]); - #[cfg(fuzzing)] - let mut mac = Poly1305::new(&key); - mac.input(aad); - ChaCha20Poly1305RFC::pad_mac_16(&mut mac, aad.len()); - - ChaCha20Poly1305RFC { cipher, mac, finished: false, data_len: 0, aad_len: aad.len() as u64 } - } - - pub fn encrypt(&mut self, input: &[u8], output: &mut [u8], out_tag: &mut [u8]) { - assert!(input.len() == output.len()); - assert!(!self.finished); - self.cipher.process(input, output); - self.data_len += input.len(); - self.mac.input(output); - ChaCha20Poly1305RFC::pad_mac_16(&mut self.mac, self.data_len); - self.finished = true; - self.mac.input(&self.aad_len.to_le_bytes()); - self.mac.input(&(self.data_len as u64).to_le_bytes()); - out_tag.copy_from_slice(&self.mac.result()); - } - - pub fn encrypt_full_message_in_place(&mut self, input_output: &mut [u8], out_tag: &mut [u8]) { - self.encrypt_in_place(input_output); - self.finish_and_get_tag(out_tag); - } - - // Encrypt `input_output` in-place. To finish and calculate the tag, use `finish_and_get_tag` - // below. - pub(in super::super) fn encrypt_in_place(&mut self, input_output: &mut [u8]) { - debug_assert!(!self.finished); - self.cipher.process_in_place(input_output); - self.data_len += input_output.len(); - self.mac.input(input_output); - } - - // If we were previously encrypting with `encrypt_in_place`, this method can be used to finish - // encrypting and calculate the tag. - pub(in super::super) fn finish_and_get_tag(&mut self, out_tag: &mut [u8]) { - debug_assert!(!self.finished); - ChaCha20Poly1305RFC::pad_mac_16(&mut self.mac, self.data_len); - self.finished = true; - self.mac.input(&self.aad_len.to_le_bytes()); - self.mac.input(&(self.data_len as u64).to_le_bytes()); - out_tag.copy_from_slice(&self.mac.result()); - } - - /// Decrypt the `input`, checking the given `tag` prior to writing the decrypted contents - /// into `output`. Note that, because `output` is not touched until the `tag` is checked, - /// this decryption is *variable time*. - pub fn variable_time_decrypt( - &mut self, input: &[u8], output: &mut [u8], tag: &[u8], - ) -> Result<(), ()> { - assert!(input.len() == output.len()); - assert!(!self.finished); - - self.finished = true; - - self.mac.input(input); - - self.data_len += input.len(); - ChaCha20Poly1305RFC::pad_mac_16(&mut self.mac, self.data_len); - self.mac.input(&self.aad_len.to_le_bytes()); - self.mac.input(&(self.data_len as u64).to_le_bytes()); - - let calc_tag = self.mac.result(); - if fixed_time_eq(&calc_tag, tag) { - self.cipher.process(input, output); - Ok(()) - } else { - Err(()) - } - } - - pub fn check_decrypt_in_place( - &mut self, input_output: &mut [u8], tag: &[u8], - ) -> Result<(), ()> { - self.decrypt_in_place(input_output); - if self.finish_and_check_tag(tag) { - Ok(()) - } else { - Err(()) - } - } - - /// Decrypt in place, without checking the tag. Use `finish_and_check_tag` to check it - /// later when decryption finishes. - /// - /// Should never be `pub` because the public API should always enforce tag checking. - pub(in super::super) fn decrypt_in_place(&mut self, input_output: &mut [u8]) { - debug_assert!(!self.finished); - self.mac.input(input_output); - self.data_len += input_output.len(); - self.cipher.process_in_place(input_output); - } - - /// If we were previously decrypting with `just_decrypt_in_place`, this method must be used - /// to check the tag. Returns whether or not the tag is valid. - pub(in super::super) fn finish_and_check_tag(&mut self, tag: &[u8]) -> bool { - debug_assert!(!self.finished); - self.finished = true; - ChaCha20Poly1305RFC::pad_mac_16(&mut self.mac, self.data_len); - self.mac.input(&self.aad_len.to_le_bytes()); - self.mac.input(&(self.data_len as u64).to_le_bytes()); - - let calc_tag = self.mac.result(); - if fixed_time_eq(&calc_tag, tag) { - true - } else { - false - } - } -} diff --git a/lightning/src/crypto/mod.rs b/lightning/src/crypto/mod.rs index 478918a49a8..73d7ad64685 100644 --- a/lightning/src/crypto/mod.rs +++ b/lightning/src/crypto/mod.rs @@ -7,8 +7,5 @@ fn fixed_time_eq(a: &[u8], b: &[u8]) -> bool { a == b } -pub(crate) mod chacha20; -pub(crate) mod chacha20poly1305rfc; -pub(crate) mod poly1305; pub(crate) mod streams; pub(crate) mod utils; diff --git a/lightning/src/crypto/poly1305.rs b/lightning/src/crypto/poly1305.rs deleted file mode 100644 index a71e39ed773..00000000000 --- a/lightning/src/crypto/poly1305.rs +++ /dev/null @@ -1,434 +0,0 @@ -// This file is licensed under the Apache License, Version 2.0 or the MIT license -// , at your option. -// You may not use this file except in accordance with one or both of these -// licenses. - -// This is a port of Andrew Moons poly1305-donna -// https://github.com/floodyberry/poly1305-donna - -#[cfg(not(fuzzing))] -mod real_poly1305 { - use core::cmp::min; - - #[derive(Clone, Copy)] - pub struct Poly1305 { - r: [u32; 5], - h: [u32; 5], - pad: [u32; 4], - leftover: usize, - buffer: [u8; 16], - finalized: bool, - } - - impl Poly1305 { - pub fn new(key: &[u8]) -> Poly1305 { - assert!(key.len() == 32); - let mut poly = Poly1305 { - r: [0u32; 5], - h: [0u32; 5], - pad: [0u32; 4], - leftover: 0, - buffer: [0u8; 16], - finalized: false, - }; - - // r &= 0xffffffc0ffffffc0ffffffc0fffffff - poly.r[0] = (u32::from_le_bytes(key[0..4].try_into().expect("len is 4"))) & 0x3ffffff; - poly.r[1] = - (u32::from_le_bytes(key[3..7].try_into().expect("len is 4")) >> 2) & 0x3ffff03; - poly.r[2] = - (u32::from_le_bytes(key[6..10].try_into().expect("len is 4")) >> 4) & 0x3ffc0ff; - poly.r[3] = - (u32::from_le_bytes(key[9..13].try_into().expect("len is 4")) >> 6) & 0x3f03fff; - poly.r[4] = - (u32::from_le_bytes(key[12..16].try_into().expect("len is 4")) >> 8) & 0x00fffff; - - poly.pad[0] = u32::from_le_bytes(key[16..20].try_into().expect("len is 4")); - poly.pad[1] = u32::from_le_bytes(key[20..24].try_into().expect("len is 4")); - poly.pad[2] = u32::from_le_bytes(key[24..28].try_into().expect("len is 4")); - poly.pad[3] = u32::from_le_bytes(key[28..32].try_into().expect("len is 4")); - - poly - } - - fn block(&mut self, m: &[u8]) { - let hibit: u32 = if self.finalized { 0 } else { 1 << 24 }; - - let r0 = self.r[0]; - let r1 = self.r[1]; - let r2 = self.r[2]; - let r3 = self.r[3]; - let r4 = self.r[4]; - - let s1 = r1 * 5; - let s2 = r2 * 5; - let s3 = r3 * 5; - let s4 = r4 * 5; - - let mut h0 = self.h[0]; - let mut h1 = self.h[1]; - let mut h2 = self.h[2]; - let mut h3 = self.h[3]; - let mut h4 = self.h[4]; - - // h += m - h0 += (u32::from_le_bytes(m[0..4].try_into().expect("len is 4"))) & 0x3ffffff; - h1 += (u32::from_le_bytes(m[3..7].try_into().expect("len is 4")) >> 2) & 0x3ffffff; - h2 += (u32::from_le_bytes(m[6..10].try_into().expect("len is 4")) >> 4) & 0x3ffffff; - h3 += (u32::from_le_bytes(m[9..13].try_into().expect("len is 4")) >> 6) & 0x3ffffff; - h4 += (u32::from_le_bytes(m[12..16].try_into().expect("len is 4")) >> 8) | hibit; - - // h *= r - let d0 = (h0 as u64 * r0 as u64) - + (h1 as u64 * s4 as u64) - + (h2 as u64 * s3 as u64) - + (h3 as u64 * s2 as u64) - + (h4 as u64 * s1 as u64); - let mut d1 = (h0 as u64 * r1 as u64) - + (h1 as u64 * r0 as u64) - + (h2 as u64 * s4 as u64) - + (h3 as u64 * s3 as u64) - + (h4 as u64 * s2 as u64); - let mut d2 = (h0 as u64 * r2 as u64) - + (h1 as u64 * r1 as u64) - + (h2 as u64 * r0 as u64) - + (h3 as u64 * s4 as u64) - + (h4 as u64 * s3 as u64); - let mut d3 = (h0 as u64 * r3 as u64) - + (h1 as u64 * r2 as u64) - + (h2 as u64 * r1 as u64) - + (h3 as u64 * r0 as u64) - + (h4 as u64 * s4 as u64); - let mut d4 = (h0 as u64 * r4 as u64) - + (h1 as u64 * r3 as u64) - + (h2 as u64 * r2 as u64) - + (h3 as u64 * r1 as u64) - + (h4 as u64 * r0 as u64); - - // (partial) h %= p - let mut c: u32; - c = (d0 >> 26) as u32; - h0 = d0 as u32 & 0x3ffffff; - d1 += c as u64; - c = (d1 >> 26) as u32; - h1 = d1 as u32 & 0x3ffffff; - d2 += c as u64; - c = (d2 >> 26) as u32; - h2 = d2 as u32 & 0x3ffffff; - d3 += c as u64; - c = (d3 >> 26) as u32; - h3 = d3 as u32 & 0x3ffffff; - d4 += c as u64; - c = (d4 >> 26) as u32; - h4 = d4 as u32 & 0x3ffffff; - h0 += c * 5; - c = h0 >> 26; - h0 &= 0x3ffffff; - h1 += c; - - self.h[0] = h0; - self.h[1] = h1; - self.h[2] = h2; - self.h[3] = h3; - self.h[4] = h4; - } - - pub fn finish(&mut self) { - if self.leftover > 0 { - self.buffer[self.leftover] = 1; - for i in self.leftover + 1..16 { - self.buffer[i] = 0; - } - self.finalized = true; - let tmp = self.buffer; - self.block(&tmp); - } - - // fully carry h - let mut h0 = self.h[0]; - let mut h1 = self.h[1]; - let mut h2 = self.h[2]; - let mut h3 = self.h[3]; - let mut h4 = self.h[4]; - - let mut c: u32; - c = h1 >> 26; - h1 &= 0x3ffffff; - h2 += c; - c = h2 >> 26; - h2 &= 0x3ffffff; - h3 += c; - c = h3 >> 26; - h3 &= 0x3ffffff; - h4 += c; - c = h4 >> 26; - h4 &= 0x3ffffff; - h0 += c * 5; - c = h0 >> 26; - h0 &= 0x3ffffff; - h1 += c; - - // compute h + -p - let mut g0 = h0.wrapping_add(5); - c = g0 >> 26; - g0 &= 0x3ffffff; - let mut g1 = h1.wrapping_add(c); - c = g1 >> 26; - g1 &= 0x3ffffff; - let mut g2 = h2.wrapping_add(c); - c = g2 >> 26; - g2 &= 0x3ffffff; - let mut g3 = h3.wrapping_add(c); - c = g3 >> 26; - g3 &= 0x3ffffff; - let mut g4 = h4.wrapping_add(c).wrapping_sub(1 << 26); - - // select h if h < p, or h + -p if h >= p - let mut mask = (g4 >> (32 - 1)).wrapping_sub(1); - g0 &= mask; - g1 &= mask; - g2 &= mask; - g3 &= mask; - g4 &= mask; - mask = !mask; - h0 = (h0 & mask) | g0; - h1 = (h1 & mask) | g1; - h2 = (h2 & mask) | g2; - h3 = (h3 & mask) | g3; - h4 = (h4 & mask) | g4; - - // h = h % (2^128) - h0 = ((h0) | (h1 << 26)) & 0xffffffff; - h1 = ((h1 >> 6) | (h2 << 20)) & 0xffffffff; - h2 = ((h2 >> 12) | (h3 << 14)) & 0xffffffff; - h3 = ((h3 >> 18) | (h4 << 8)) & 0xffffffff; - - // h = mac = (h + pad) % (2^128) - let mut f: u64; - f = h0 as u64 + self.pad[0] as u64; - h0 = f as u32; - f = h1 as u64 + self.pad[1] as u64 + (f >> 32); - h1 = f as u32; - f = h2 as u64 + self.pad[2] as u64 + (f >> 32); - h2 = f as u32; - f = h3 as u64 + self.pad[3] as u64 + (f >> 32); - h3 = f as u32; - - self.h[0] = h0; - self.h[1] = h1; - self.h[2] = h2; - self.h[3] = h3; - } - - pub fn input(&mut self, data: &[u8]) { - assert!(!self.finalized); - let mut m = data; - - if self.leftover > 0 { - let want = min(16 - self.leftover, m.len()); - for i in 0..want { - self.buffer[self.leftover + i] = m[i]; - } - m = &m[want..]; - self.leftover += want; - - if self.leftover < 16 { - return; - } - - // self.block(self.buffer[..]); - let tmp = self.buffer; - self.block(&tmp); - - self.leftover = 0; - } - - while m.len() >= 16 { - self.block(&m[0..16]); - m = &m[16..]; - } - - for i in 0..m.len() { - self.buffer[i] = m[i]; - } - self.leftover = m.len(); - } - - pub fn result(&mut self) -> [u8; 16] { - if !self.finalized { - self.finish(); - } - let mut output = [0; 16]; - output[0..4].copy_from_slice(&self.h[0].to_le_bytes()); - output[4..8].copy_from_slice(&self.h[1].to_le_bytes()); - output[8..12].copy_from_slice(&self.h[2].to_le_bytes()); - output[12..16].copy_from_slice(&self.h[3].to_le_bytes()); - output - } - } - - #[cfg(test)] - mod test { - use core::iter::repeat; - - use super::Poly1305; - - fn poly1305(key: &[u8], msg: &[u8], mac: &mut [u8; 16]) { - let mut poly = Poly1305::new(key); - poly.input(msg); - *mac = poly.result(); - } - - #[test] - fn test_nacl_vector() { - let key = [ - 0xee, 0xa6, 0xa7, 0x25, 0x1c, 0x1e, 0x72, 0x91, 0x6d, 0x11, 0xc2, 0xcb, 0x21, 0x4d, - 0x3c, 0x25, 0x25, 0x39, 0x12, 0x1d, 0x8e, 0x23, 0x4e, 0x65, 0x2d, 0x65, 0x1f, 0xa4, - 0xc8, 0xcf, 0xf8, 0x80, - ]; - - let msg = [ - 0x8e, 0x99, 0x3b, 0x9f, 0x48, 0x68, 0x12, 0x73, 0xc2, 0x96, 0x50, 0xba, 0x32, 0xfc, - 0x76, 0xce, 0x48, 0x33, 0x2e, 0xa7, 0x16, 0x4d, 0x96, 0xa4, 0x47, 0x6f, 0xb8, 0xc5, - 0x31, 0xa1, 0x18, 0x6a, 0xc0, 0xdf, 0xc1, 0x7c, 0x98, 0xdc, 0xe8, 0x7b, 0x4d, 0xa7, - 0xf0, 0x11, 0xec, 0x48, 0xc9, 0x72, 0x71, 0xd2, 0xc2, 0x0f, 0x9b, 0x92, 0x8f, 0xe2, - 0x27, 0x0d, 0x6f, 0xb8, 0x63, 0xd5, 0x17, 0x38, 0xb4, 0x8e, 0xee, 0xe3, 0x14, 0xa7, - 0xcc, 0x8a, 0xb9, 0x32, 0x16, 0x45, 0x48, 0xe5, 0x26, 0xae, 0x90, 0x22, 0x43, 0x68, - 0x51, 0x7a, 0xcf, 0xea, 0xbd, 0x6b, 0xb3, 0x73, 0x2b, 0xc0, 0xe9, 0xda, 0x99, 0x83, - 0x2b, 0x61, 0xca, 0x01, 0xb6, 0xde, 0x56, 0x24, 0x4a, 0x9e, 0x88, 0xd5, 0xf9, 0xb3, - 0x79, 0x73, 0xf6, 0x22, 0xa4, 0x3d, 0x14, 0xa6, 0x59, 0x9b, 0x1f, 0x65, 0x4c, 0xb4, - 0x5a, 0x74, 0xe3, 0x55, 0xa5, - ]; - - let expected = [ - 0xf3, 0xff, 0xc7, 0x70, 0x3f, 0x94, 0x00, 0xe5, 0x2a, 0x7d, 0xfb, 0x4b, 0x3d, 0x33, - 0x05, 0xd9, - ]; - - let mut mac = [0u8; 16]; - poly1305(&key, &msg, &mut mac); - assert_eq!(&mac[..], &expected[..]); - - let mut poly = Poly1305::new(&key); - poly.input(&msg[0..32]); - poly.input(&msg[32..96]); - poly.input(&msg[96..112]); - poly.input(&msg[112..120]); - poly.input(&msg[120..124]); - poly.input(&msg[124..126]); - poly.input(&msg[126..127]); - poly.input(&msg[127..128]); - poly.input(&msg[128..129]); - poly.input(&msg[129..130]); - poly.input(&msg[130..131]); - let mac = poly.result(); - assert_eq!(&mac[..], &expected[..]); - } - - #[test] - fn donna_self_test() { - let wrap_key = [ - 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - ]; - - let wrap_msg = [ - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, - ]; - - let wrap_mac = [ - 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, - ]; - - let mut mac = [0u8; 16]; - poly1305(&wrap_key, &wrap_msg, &mut mac); - assert_eq!(&mac[..], &wrap_mac[..]); - - let total_key = [ - 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0xff, 0xfe, 0xfd, 0xfc, 0xfb, 0xfa, 0xf9, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0x00, 0x00, 0x00, 0x00, - ]; - - let total_mac = [ - 0x64, 0xaf, 0xe2, 0xe8, 0xd6, 0xad, 0x7b, 0xbd, 0xd2, 0x87, 0xf9, 0x7c, 0x44, 0x62, - 0x3d, 0x39, - ]; - - let mut tpoly = Poly1305::new(&total_key); - for i in 0..256 { - let key: Vec = repeat(i as u8).take(32).collect(); - let msg: Vec = repeat(i as u8).take(256).collect(); - let mut mac = [0u8; 16]; - poly1305(&key[..], &msg[0..i], &mut mac); - tpoly.input(&mac); - } - let mac = tpoly.result(); - assert_eq!(&mac[..], &total_mac[..]); - } - - #[test] - fn test_tls_vectors() { - // from http://tools.ietf.org/html/draft-agl-tls-chacha20poly1305-04 - let key = b"this is 32-byte key for Poly1305"; - let msg = [0u8; 32]; - let expected = [ - 0x49, 0xec, 0x78, 0x09, 0x0e, 0x48, 0x1e, 0xc6, 0xc2, 0x6b, 0x33, 0xb9, 0x1c, 0xcc, - 0x03, 0x07, - ]; - let mut mac = [0u8; 16]; - poly1305(key, &msg, &mut mac); - assert_eq!(&mac[..], &expected[..]); - - let msg = b"Hello world!"; - let expected = [ - 0xa6, 0xf7, 0x45, 0x00, 0x8f, 0x81, 0xc9, 0x16, 0xa2, 0x0d, 0xcc, 0x74, 0xee, 0xf2, - 0xb2, 0xf0, - ]; - poly1305(key, msg, &mut mac); - assert_eq!(&mac[..], &expected[..]); - } - } -} -#[cfg(not(fuzzing))] -pub use real_poly1305::*; - -#[cfg(fuzzing)] -mod fuzzy_poly1305 { - #[derive(Clone, Copy)] - pub struct Poly1305 { - tag: [u8; 16], - finalized: bool, - } - - impl Poly1305 { - pub fn new(key: &[u8]) -> Poly1305 { - assert_eq!(key.len(), 32); - let mut poly = Poly1305 { tag: [0; 16], finalized: false }; - poly.tag.copy_from_slice(&key[..16]); - - poly - } - - pub fn finish(&mut self) { - self.finalized = true; - } - - pub fn input(&mut self, _data: &[u8]) { - assert!(!self.finalized); - } - - pub fn result(&mut self) -> [u8; 16] { - if !self.finalized { - self.finish(); - } - self.tag - } - } -} -#[cfg(fuzzing)] -pub use fuzzy_poly1305::*; From 5455058ef2ec7994e4f19311477ecc662354dc52 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 5 May 2026 21:15:50 +0200 Subject: [PATCH 349/627] Roll back composite sub-handlers when one rejects `peer_connected` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `composite_custom_message_handler!` expanded `peer_connected` to call every sub-handler and remember the last error, but never undo the already-succeeded ones. The `CustomMessageHandler::peer_connected` contract is that `PeerManager` will *not* invoke `peer_disconnected` when `peer_connected` returns `Err` — so any per-peer state allocated by an earlier sub-handler that returned `Ok` was leaked permanently once a later sub-handler returned `Err`. A peer who can elicit `Err` from any sub-handler in the composite (feature-bit gate, banlist, etc.) could repeatedly reconnect to grow that leaked state without bound (slow resource DoS), and "currently connected" predicates in the leaking sub-handler would lie about peers that were actually rejected. Mirror the rollback pattern `PeerManager` already uses for the four built-in handlers (`peer_handler.rs:2149-2188`): record each sub-handler's `peer_connected` result, and if any returned `Err`, call `peer_disconnected` on the ones that succeeded before propagating the failure. Co-Authored-By: HAL 9000 Signed-off-by: Elias Rohrer --- lightning-custom-message/src/lib.rs | 162 +++++++++++++++++++++++++++- 1 file changed, 157 insertions(+), 5 deletions(-) diff --git a/lightning-custom-message/src/lib.rs b/lightning-custom-message/src/lib.rs index 32d5a9e4389..0d70ba06385 100644 --- a/lightning-custom-message/src/lib.rs +++ b/lightning-custom-message/src/lib.rs @@ -312,13 +312,25 @@ macro_rules! composite_custom_message_handler { } fn peer_connected(&self, their_node_id: $crate::bitcoin::secp256k1::PublicKey, msg: &$crate::lightning::ln::msgs::Init, inbound: bool) -> Result<(), ()> { - let mut result = Ok(()); + // Per the `CustomMessageHandler::peer_connected` contract, `peer_disconnected` + // will not be called by `PeerManager` if we return `Err`. To avoid leaking + // per-peer state in sub-handlers that already returned `Ok` when a later one + // errors, record each sub-handler's result and roll back the successful ones + // ourselves before propagating the failure. $( - if let Err(e) = self.$field.peer_connected(their_node_id, msg, inbound) { - result = Err(e); - } + let $field = self.$field.peer_connected(their_node_id, msg, inbound); )* - result + let any_err = false $( || $field.is_err() )*; + if any_err { + $( + if $field.is_ok() { + self.$field.peer_disconnected(their_node_id); + } + )* + Err(()) + } else { + Ok(()) + } } fn provided_node_features(&self) -> $crate::lightning::types::features::NodeFeatures { @@ -376,3 +388,143 @@ macro_rules! composite_custom_message_handler { } } } + +#[cfg(test)] +mod tests { + use bitcoin::secp256k1::PublicKey; + use core::sync::atomic::{AtomicUsize, Ordering}; + use lightning::io; + use lightning::ln::msgs::{DecodeError, Init, LightningError}; + use lightning::ln::peer_handler::CustomMessageHandler; + use lightning::ln::wire::{CustomMessageReader, Type}; + use lightning::types::features::{InitFeatures, NodeFeatures}; + use lightning::util::ser::{LengthLimitedRead, Writeable, Writer}; + + #[derive(Debug)] + pub struct Foo; + impl Type for Foo { + fn type_id(&self) -> u16 { + 32768 + } + } + impl Writeable for Foo { + fn write(&self, _: &mut W) -> Result<(), io::Error> { + Ok(()) + } + } + + pub struct CountingHandler { + pub connect_count: AtomicUsize, + } + impl CustomMessageReader for CountingHandler { + type CustomMessage = Foo; + fn read( + &self, _t: u16, _b: &mut R, + ) -> Result, DecodeError> { + Ok(None) + } + } + impl CustomMessageHandler for CountingHandler { + fn handle_custom_message(&self, _msg: Foo, _: PublicKey) -> Result<(), LightningError> { + Ok(()) + } + fn get_and_clear_pending_msg(&self) -> Vec<(PublicKey, Foo)> { + vec![] + } + fn peer_disconnected(&self, _: PublicKey) { + self.connect_count.fetch_sub(1, Ordering::SeqCst); + } + fn peer_connected(&self, _: PublicKey, _: &Init, _: bool) -> Result<(), ()> { + self.connect_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + fn provided_node_features(&self) -> NodeFeatures { + NodeFeatures::empty() + } + fn provided_init_features(&self, _: PublicKey) -> InitFeatures { + InitFeatures::empty() + } + } + + #[derive(Debug)] + pub struct Bar; + impl Type for Bar { + fn type_id(&self) -> u16 { + 32769 + } + } + impl Writeable for Bar { + fn write(&self, _: &mut W) -> Result<(), io::Error> { + Ok(()) + } + } + + pub struct ErroringHandler; + impl CustomMessageReader for ErroringHandler { + type CustomMessage = Bar; + fn read( + &self, _t: u16, _b: &mut R, + ) -> Result, DecodeError> { + Ok(None) + } + } + impl CustomMessageHandler for ErroringHandler { + fn handle_custom_message(&self, _msg: Bar, _: PublicKey) -> Result<(), LightningError> { + Ok(()) + } + fn get_and_clear_pending_msg(&self) -> Vec<(PublicKey, Bar)> { + vec![] + } + fn peer_disconnected(&self, _: PublicKey) { + debug_assert!(false); + } + fn peer_connected(&self, _: PublicKey, _: &Init, _: bool) -> Result<(), ()> { + Err(()) + } + fn provided_node_features(&self) -> NodeFeatures { + NodeFeatures::empty() + } + fn provided_init_features(&self, _: PublicKey) -> InitFeatures { + InitFeatures::empty() + } + } + + composite_custom_message_handler!( + pub struct CompositeHandler { + counting: CountingHandler, + erroring: ErroringHandler, + } + + pub enum CompositeMessage { + Foo(32768), + Bar(32769), + } + ); + + #[test] + fn peer_connected_failure_does_not_leak_subhandler_state() { + let composite = CompositeHandler { + counting: CountingHandler { connect_count: AtomicUsize::new(0) }, + erroring: ErroringHandler, + }; + let pk_bytes = [ + 0x02, 0x79, 0xBE, 0x66, 0x7E, 0xF9, 0xDC, 0xBB, 0xAC, 0x55, 0xA0, 0x62, 0x95, 0xCE, + 0x87, 0x0B, 0x07, 0x02, 0x9B, 0xFC, 0xDB, 0x2D, 0xCE, 0x28, 0xD9, 0x59, 0xF2, 0x81, + 0x5B, 0x16, 0xF8, 0x17, 0x98, + ]; + let pk = PublicKey::from_slice(&pk_bytes).unwrap(); + let init = + Init { features: InitFeatures::empty(), networks: None, remote_network_address: None }; + + let result = composite.peer_connected(pk, &init, true); + assert!(result.is_err(), "Composite must propagate the inner Err"); + + let leaked = composite.counting.connect_count.load(Ordering::SeqCst); + assert_eq!( + leaked, 0, + "CountingHandler tracked {leaked} connected peer(s) after the composite \ + returned Err; this state will never be cleaned up because per the trait \ + contract peer_disconnected won't be called when peer_connected returns Err.", + ); + } +} From 0d2ac33ef883beafb3418c8d986dd3bcbfb45c4c Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 17 Apr 2026 10:03:38 -0500 Subject: [PATCH 350/627] Expose interactive funding candidates on broadcast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace TransactionType::Splice with TransactionType::InteractiveFunding so downstream consumers can update their own state tracking from the broadcast callback. The local contribution data isn't recoverable from the on-chain transaction, so the broadcast must surface it directly. Each candidate carries the participating channels and their local contributions; the broadcast lists every negotiated candidate — original first, then each RBF replacement — letting downstream reconcile any historical txid, not just the immediate predecessor. The new variant is structured to be forward-compatible with batches and V2 (dual-funded) channel establishment, neither of which is implemented today. The new types are Writeable/Readable so downstream can persist them directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- lightning/src/chain/chaininterface.rs | 73 +++++++++- lightning/src/ln/channel.rs | 35 ++++- lightning/src/ln/channelmanager.rs | 2 +- lightning/src/ln/funding.rs | 2 +- lightning/src/ln/splicing_tests.rs | 184 ++++++++++++++++++-------- lightning/src/util/wallet_utils.rs | 2 +- 6 files changed, 225 insertions(+), 73 deletions(-) diff --git a/lightning/src/chain/chaininterface.rs b/lightning/src/chain/chaininterface.rs index 806e947c153..bb5f6de95ab 100644 --- a/lightning/src/chain/chaininterface.rs +++ b/lightning/src/chain/chaininterface.rs @@ -15,9 +15,11 @@ use core::{cmp, ops::Deref}; +use crate::ln::funding::FundingContribution; use crate::ln::types::ChannelId; use crate::prelude::*; +use bitcoin::hash_types::Txid; use bitcoin::secp256k1::PublicKey; use bitcoin::transaction::Transaction; @@ -104,19 +106,76 @@ pub enum TransactionType { /// A single sweep transaction may aggregate outputs from multiple channels. channels: Vec<(PublicKey, ChannelId)>, }, - /// A splice transaction modifying an existing channel's funding. + /// An interactively-negotiated funding transaction. /// - /// A transaction of this type will be broadcast as a result of a [`ChannelManager::splice_channel`] operation. + /// A transaction of this type will be broadcast as a result of a + /// [`ChannelManager::splice_channel`] operation, or (once supported) V2 (dual-funded) channel + /// establishment. The same variant is used for batches of either or both. /// /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel - Splice { - /// The `node_id` of the channel counterparty. - counterparty_node_id: PublicKey, - /// The ID of the channel being spliced. - channel_id: ChannelId, + InteractiveFunding { + /// Every negotiated candidate for this funding in order: the original negotiation + /// followed by any RBF replacements. The last entry is the candidate being broadcast. + candidates: Vec, }, } +/// A single negotiated candidate within a [`TransactionType::InteractiveFunding`] broadcast. +/// +/// The candidate is identified by its [`Txid`] and lists the channels participating in it. A +/// single candidate funds more than one channel only when batching splices and/or V2 channel +/// openings (not yet implemented). +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct FundingCandidate { + /// The txid of this candidate. + pub txid: Txid, + /// The channels participating in this candidate. + pub channels: Vec, +} + +/// Information about a single channel's participation in a [`FundingCandidate`]. +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct ChannelFunding { + /// The `node_id` of the channel counterparty. + pub counterparty_node_id: PublicKey, + /// The ID of the channel. + pub channel_id: ChannelId, + /// Whether this channel is being newly established or is an existing channel being spliced. + pub purpose: FundingPurpose, + /// The local node's contribution to this channel in this candidate, or `None` if we did + /// not contribute (e.g., a pure acceptor with zero value added, or a leading RBF round + /// before we began contributing). + pub contribution: Option, +} + +/// The role of a channel within a [`FundingCandidate`]. +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub enum FundingPurpose { + /// The channel is being newly established (V2 dual-funded open). + Establishment, + /// An existing channel is being spliced. + Splice, +} + +// Needed so downstream consumers can persist these without needing to define wrapper types +// mirroring the type structure. +impl_writeable_tlv_based!(FundingCandidate, { + (1, txid, required), + (3, channels, required_vec), +}); + +impl_writeable_tlv_based!(ChannelFunding, { + (1, counterparty_node_id, required), + (3, channel_id, required), + (5, purpose, required), + (7, contribution, option), +}); + +impl_writeable_tlv_based_enum!(FundingPurpose, + (0, Establishment) => {}, + (2, Splice) => {}, +); + // TODO: Define typed abstraction over feerates to handle their conversions. pub(crate) fn compute_feerate_sat_per_1000_weight(fee_sat: u64, weight: u64) -> u32 { (fee_sat * 1000 / weight).try_into().unwrap_or(u32::max_value()) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index e6397aefbcb..5b6d04a7e93 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -28,7 +28,8 @@ use bitcoin::{secp256k1, sighash, FeeRate, Sequence, TxIn}; use crate::blinded_path::message::BlindedMessagePath; use crate::chain::chaininterface::{ - ConfirmationTarget, FeeEstimator, LowerBoundedFeeEstimator, TransactionType, + ChannelFunding, ConfirmationTarget, FeeEstimator, FundingCandidate, FundingPurpose, + LowerBoundedFeeEstimator, TransactionType, }; use crate::chain::channelmonitor::{ ChannelMonitor, ChannelMonitorUpdate, ChannelMonitorUpdateStep, CommitmentHTLCData, @@ -9382,10 +9383,34 @@ where ); } - let tx_type = TransactionType::Splice { - counterparty_node_id: self.context.counterparty_node_id, - channel_id: self.context.channel_id, - }; + let contrib_offset = pending_splice + .negotiated_candidates + .len() + .saturating_sub(pending_splice.contributions.len()); + let candidates = pending_splice + .negotiated_candidates + .iter() + .enumerate() + .map(|(i, funding)| { + let txid = funding + .get_funding_txid() + .expect("negotiated candidates should have a funding txid"); + let contribution = i + .checked_sub(contrib_offset) + .and_then(|j| pending_splice.contributions.get(j)) + .cloned(); + FundingCandidate { + txid, + channels: vec![ChannelFunding { + counterparty_node_id: self.context.counterparty_node_id, + channel_id: self.context.channel_id, + purpose: FundingPurpose::Splice, + contribution, + }], + } + }) + .collect(); + let tx_type = TransactionType::InteractiveFunding { candidates }; funding_tx_signed.funding_tx = Some((funding_tx, tx_type)); funding_tx_signed.splice_negotiated = Some(splice_negotiated); funding_tx_signed.splice_locked = splice_locked; diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 1f32423507f..7aa82015f40 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -11124,7 +11124,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } else if let Some((splice_tx, tx_type)) = funding_tx_signed .as_mut() .and_then(|v| v.funding_tx.take()) - .filter(|(_, tx_type)| matches!(tx_type, TransactionType::Splice { .. })) + .filter(|(_, tx_type)| matches!(tx_type, TransactionType::InteractiveFunding { .. })) { log_info!(logger, "Broadcasting signed splice transaction with txid {}", splice_tx.compute_txid()); self.tx_broadcaster.broadcast_transactions(&[(&splice_tx, tx_type)]); diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 20366fe772a..aa5a8540c57 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -539,7 +539,7 @@ enum FundingInputs { } /// The components of a funding transaction contributed by one party. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Hash, PartialEq, Eq)] pub struct FundingContribution { /// The estimate fees responsible to be paid for the contribution. estimated_fee: Amount, diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index b2cb1eda375..635ad31ed53 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -9,7 +9,7 @@ #![cfg_attr(not(test), allow(unused_imports))] -use crate::chain::chaininterface::{TransactionType, FEERATE_FLOOR_SATS_PER_KW}; +use crate::chain::chaininterface::{FundingPurpose, TransactionType, FEERATE_FLOOR_SATS_PER_KW}; use crate::chain::channelmonitor::{ANTI_REORG_DELAY, LATENCY_GRACE_PERIOD_BLOCKS}; use crate::chain::transaction::OutPoint; use crate::chain::ChannelMonitorUpdateStatus; @@ -493,13 +493,23 @@ pub fn complete_interactive_funding_negotiation_for_both<'a, 'b, 'c, 'd>( pub fn sign_interactive_funding_tx<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, is_0conf: bool, + expected_replaced_txid: Option, ) -> (Transaction, Option<(msgs::SpliceLocked, PublicKey)>) { - sign_interactive_funding_tx_with_acceptor_contribution(initiator, acceptor, is_0conf, false) + sign_interactive_funding_tx_with_acceptor_contribution( + initiator, + acceptor, + is_0conf, + false, + expected_replaced_txid, + ) } +/// `expected_replaced_txid` is the expected txid of the prior negotiated candidate in the +/// `TransactionType::InteractiveFunding` broadcast: `None` for a first splice attempt; `Some(txid)` +/// for an RBF replacing that prior negotiated candidate. pub fn sign_interactive_funding_tx_with_acceptor_contribution<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, is_0conf: bool, - acceptor_has_contribution: bool, + acceptor_has_contribution: bool, expected_replaced_txid: Option, ) -> (Transaction, Option<(msgs::SpliceLocked, PublicKey)>) { let node_id_initiator = initiator.node.get_our_node_id(); let node_id_acceptor = acceptor.node.get_our_node_id(); @@ -599,17 +609,29 @@ pub fn sign_interactive_funding_tx_with_acceptor_contribution<'a, 'b, 'c, 'd>( assert_eq!(initiator_txn[0].0, acceptor_txn[0].0); let (tx, initiator_tx_type) = initiator_txn.remove(0); let (_, acceptor_tx_type) = acceptor_txn.remove(0); - // Verify transaction types are Splice for both nodes - assert!( - matches!(initiator_tx_type, TransactionType::Splice { .. }), - "Expected TransactionType::Splice, got {:?}", - initiator_tx_type - ); - assert!( - matches!(acceptor_tx_type, TransactionType::Splice { .. }), - "Expected TransactionType::Splice, got {:?}", - acceptor_tx_type - ); + // Verify transaction types are InteractiveFunding for both nodes. The initiator always + // contributes; the acceptor contributes iff the flag says so. Both parties must observe + // the same prior candidate txid as the caller declares. + let assert_broadcast = + |label: &str, tx_type: &TransactionType, contribution_expected: bool| { + let candidates = match tx_type { + TransactionType::InteractiveFunding { candidates } => candidates, + other => panic!("Expected TransactionType::InteractiveFunding, got {other:?}"), + }; + let last = candidates.last().expect("at least one candidate"); + assert_eq!(last.txid, tx.compute_txid(), "{label} last candidate txid mismatch"); + let last_channel = last.channels.first().expect("at least one channel"); + assert!(matches!(last_channel.purpose, FundingPurpose::Splice)); + assert_eq!( + last_channel.contribution.is_some(), + contribution_expected, + "{label} contribution presence mismatch", + ); + let prior_txid = candidates.len().checked_sub(2).map(|i| candidates[i].txid); + assert_eq!(prior_txid, expected_replaced_txid, "{label} replaced_txid mismatch"); + }; + assert_broadcast("initiator", &initiator_tx_type, true); + assert_broadcast("acceptor", &acceptor_tx_type, acceptor_has_contribution); tx }; (tx, splice_locked) @@ -631,7 +653,7 @@ pub fn splice_channel<'a, 'b, 'c, 'd>( funding_contribution, new_funding_script.clone(), ); - let (splice_tx, splice_locked) = sign_interactive_funding_tx(initiator, acceptor, false); + let (splice_tx, splice_locked) = sign_interactive_funding_tx(initiator, acceptor, false, None); assert!(splice_locked.is_none()); expect_splice_pending_event(initiator, &node_id_acceptor); @@ -1312,7 +1334,7 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { }), ); - let (splice_tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + let (splice_tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false, None); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_1_id); @@ -1517,7 +1539,7 @@ fn do_test_splice_tiebreak( // Sign (acceptor has contribution) and broadcast. let (tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( - &nodes[0], &nodes[1], false, true, + &nodes[0], &nodes[1], false, true, None, ); assert!(splice_locked.is_none()); @@ -1585,7 +1607,7 @@ fn do_test_splice_tiebreak( // Sign (no acceptor contribution) and broadcast. let (tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( - &nodes[0], &nodes[1], false, false, + &nodes[0], &nodes[1], false, false, None, ); assert!(splice_locked.is_none()); @@ -1633,7 +1655,7 @@ fn do_test_splice_tiebreak( ); let (new_splice_tx, splice_locked) = - sign_interactive_funding_tx(&nodes[1], &nodes[0], false); + sign_interactive_funding_tx(&nodes[1], &nodes[0], false, None); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[1], &node_id_0); @@ -2513,7 +2535,7 @@ fn do_test_propose_splice_while_disconnected(use_0conf: bool) { new_funding_script, ); let (splice_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( - &nodes[0], &nodes[1], use_0conf, true, + &nodes[0], &nodes[1], use_0conf, true, None, ); expect_splice_pending_event(&nodes[0], &node_id_1); expect_splice_pending_event(&nodes[1], &node_id_0); @@ -4568,8 +4590,14 @@ fn test_splice_rbf_acceptor_basic() { new_funding_script.clone(), ); - // Step 10: Sign and broadcast. - let (rbf_tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + // Step 10: Sign and broadcast. The prior candidate in the broadcast's + // `TransactionType::InteractiveFunding` must point at the first splice tx it is replacing. + let (rbf_tx, splice_locked) = sign_interactive_funding_tx( + &nodes[0], + &nodes[1], + false, + Some(first_splice_tx.compute_txid()), + ); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); @@ -4606,7 +4634,7 @@ fn test_splice_rbf_at_high_feerate() { // Step 1: Complete a splice-in at floor feerate. let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); - let (_first_splice_tx, new_funding_script) = + let (first_splice_tx, new_funding_script) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); // Step 2: RBF to a high feerate (1000 sat/kwu, well above the 600 crossover point). @@ -4622,7 +4650,12 @@ fn test_splice_rbf_at_high_feerate() { contribution, new_funding_script.clone(), ); - let (_, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + let (rbf_tx_1, splice_locked) = sign_interactive_funding_tx( + &nodes[0], + &nodes[1], + false, + Some(first_splice_tx.compute_txid()), + ); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); expect_splice_pending_event(&nodes[1], &node_id_0); @@ -4643,7 +4676,8 @@ fn test_splice_rbf_at_high_feerate() { contribution, new_funding_script, ); - let (_, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + let (_, splice_locked) = + sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(rbf_tx_1.compute_txid())); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); expect_splice_pending_event(&nodes[1], &node_id_0); @@ -4835,7 +4869,7 @@ fn test_splice_rbf_insufficient_feerate_high() { // Complete a splice-in at floor feerate, then RBF to 1000 sat/kwu. let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); - let (_splice_tx, new_funding_script) = + let (splice_tx, new_funding_script) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); provide_utxo_reserves(&nodes, 2, added_value * 2); @@ -4850,7 +4884,8 @@ fn test_splice_rbf_insufficient_feerate_high() { contribution, new_funding_script, ); - let (_, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + let (_, splice_locked) = + sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(splice_tx.compute_txid())); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); expect_splice_pending_event(&nodes[1], &node_id_0); @@ -5378,7 +5413,11 @@ pub fn do_test_splice_rbf_tiebreak( // Sign (acceptor has contribution) and broadcast. let (rbf_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( - &nodes[0], &nodes[1], false, true, + &nodes[0], + &nodes[1], + false, + true, + Some(first_splice_tx.compute_txid()), ); assert!(splice_locked.is_none()); @@ -5450,7 +5489,11 @@ pub fn do_test_splice_rbf_tiebreak( // Sign (acceptor has no contribution) and broadcast. let (rbf_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( - &nodes[0], &nodes[1], false, false, + &nodes[0], + &nodes[1], + false, + false, + Some(first_splice_tx.compute_txid()), ); assert!(splice_locked.is_none()); @@ -5514,7 +5557,7 @@ pub fn do_test_splice_rbf_tiebreak( // Sign (no acceptor contribution) and broadcast. let (new_splice_tx, splice_locked) = - sign_interactive_funding_tx(&nodes[1], &nodes[0], false); + sign_interactive_funding_tx(&nodes[1], &nodes[0], false, None); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[1], &node_id_0); @@ -5696,8 +5739,9 @@ fn test_splice_rbf_acceptor_recontributes() { new_funding_script.clone(), ); - let (first_splice_tx, splice_locked) = - sign_interactive_funding_tx_with_acceptor_contribution(&nodes[0], &nodes[1], false, true); + let (first_splice_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( + &nodes[0], &nodes[1], false, true, None, + ); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); @@ -5733,8 +5777,13 @@ fn test_splice_rbf_acceptor_recontributes() { ); // Step 11: Sign (acceptor has contribution) and broadcast. - let (rbf_tx, splice_locked) = - sign_interactive_funding_tx_with_acceptor_contribution(&nodes[0], &nodes[1], false, true); + let (rbf_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( + &nodes[0], + &nodes[1], + false, + true, + Some(first_splice_tx.compute_txid()), + ); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); @@ -5820,8 +5869,9 @@ fn test_splice_rbf_after_counterparty_rbf_aborted() { new_funding_script, ); - let (_first_splice_tx, splice_locked) = - sign_interactive_funding_tx_with_acceptor_contribution(&nodes[0], &nodes[1], false, true); + let (_first_splice_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( + &nodes[0], &nodes[1], false, true, None, + ); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); @@ -5952,8 +6002,9 @@ fn test_splice_rbf_recontributes_feerate_too_high() { new_funding_script.clone(), ); - let (_first_splice_tx, splice_locked) = - sign_interactive_funding_tx_with_acceptor_contribution(&nodes[0], &nodes[1], false, true); + let (_first_splice_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( + &nodes[0], &nodes[1], false, true, None, + ); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); @@ -6038,7 +6089,8 @@ fn test_splice_rbf_sequential() { funding_contribution_1, new_funding_script.clone(), ); - let (splice_tx_1, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + let (splice_tx_1, splice_locked) = + sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(splice_tx_0.compute_txid())); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); expect_splice_pending_event(&nodes[1], &node_id_0); @@ -6058,7 +6110,8 @@ fn test_splice_rbf_sequential() { funding_contribution_2, new_funding_script.clone(), ); - let (rbf_tx_final, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + let (rbf_tx_final, splice_locked) = + sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(splice_tx_1.compute_txid())); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); expect_splice_pending_event(&nodes[1], &node_id_0); @@ -6108,7 +6161,7 @@ fn test_splice_rbf_amends_prior_net_positive_contribution_request() { script_pubkey: ScriptBuf::new_p2wsh(&WScriptHash::all_zeros()), }; - let run_rbf_round = |contribution: FundingContribution| { + let run_rbf_round = |contribution: FundingContribution, replaced_txid: Txid| { nodes[0] .node .funding_contributed(&channel_id, &node_id_1, contribution.clone(), None) @@ -6121,7 +6174,8 @@ fn test_splice_rbf_amends_prior_net_positive_contribution_request() { contribution, new_funding_script.clone(), ); - let (tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + let (tx, splice_locked) = + sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(replaced_txid)); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); expect_splice_pending_event(&nodes[1], &node_id_0); @@ -6142,7 +6196,7 @@ fn test_splice_rbf_amends_prior_net_positive_contribution_request() { contribution_1.change_output().unwrap().value < initial_contribution.change_output().unwrap().value ); - let splice_tx_1 = run_rbf_round(contribution_1.clone()); + let splice_tx_1 = run_rbf_round(contribution_1.clone(), splice_tx_0.compute_txid()); let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); assert_eq!(funding_template.prior_contribution().unwrap().outputs(), contribution_1.outputs()); @@ -6157,7 +6211,7 @@ fn test_splice_rbf_amends_prior_net_positive_contribution_request() { assert_eq!(inputs_2, initial_inputs); assert_eq!(contribution_2.outputs(), contribution_1.outputs()); assert!(contribution_2.net_value() < contribution_1.net_value()); - let splice_tx_2 = run_rbf_round(contribution_2.clone()); + let splice_tx_2 = run_rbf_round(contribution_2.clone(), splice_tx_1.compute_txid()); let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); assert_eq!(funding_template.prior_contribution().unwrap().outputs(), contribution_2.outputs()); @@ -6175,7 +6229,7 @@ fn test_splice_rbf_amends_prior_net_positive_contribution_request() { contribution_3.change_output().unwrap().value > contribution_2.change_output().unwrap().value ); - let splice_tx_3 = run_rbf_round(contribution_3.clone()); + let splice_tx_3 = run_rbf_round(contribution_3.clone(), splice_tx_2.compute_txid()); let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); assert_eq!(funding_template.prior_contribution().unwrap().outputs(), contribution_3.outputs()); @@ -6189,7 +6243,7 @@ fn test_splice_rbf_amends_prior_net_positive_contribution_request() { contribution_4.change_output().unwrap().value < contribution_3.change_output().unwrap().value ); - let rbf_tx_final = run_rbf_round(contribution_4); + let rbf_tx_final = run_rbf_round(contribution_4, splice_tx_3.compute_txid()); lock_rbf_splice_after_blocks( &nodes[0], @@ -6235,7 +6289,7 @@ fn test_splice_rbf_amends_prior_net_negative_contribution_request() { let (splice_tx_0, new_funding_script) = splice_channel(&nodes[0], &nodes[1], channel_id, initial_contribution.clone()); - let run_rbf_round = |contribution: FundingContribution| { + let run_rbf_round = |contribution: FundingContribution, replaced_txid: Txid| { nodes[0] .node .funding_contributed(&channel_id, &node_id_1, contribution.clone(), None) @@ -6248,7 +6302,8 @@ fn test_splice_rbf_amends_prior_net_negative_contribution_request() { contribution, new_funding_script.clone(), ); - let (tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + let (tx, splice_locked) = + sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(replaced_txid)); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); expect_splice_pending_event(&nodes[1], &node_id_0); @@ -6268,7 +6323,7 @@ fn test_splice_rbf_amends_prior_net_negative_contribution_request() { assert!(inputs_1.is_empty()); assert_eq!(contribution_1.outputs(), &[first_output.clone(), second_output.clone()]); assert!(contribution_1.net_value() < initial_contribution.net_value()); - let splice_tx_1 = run_rbf_round(contribution_1.clone()); + let splice_tx_1 = run_rbf_round(contribution_1.clone(), splice_tx_0.compute_txid()); let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); assert_eq!(funding_template.prior_contribution().unwrap().outputs(), contribution_1.outputs()); @@ -6282,7 +6337,7 @@ fn test_splice_rbf_amends_prior_net_negative_contribution_request() { assert!(inputs_2.is_empty()); assert_eq!(contribution_2.outputs(), std::slice::from_ref(&second_output)); assert!(contribution_2.net_value() > contribution_1.net_value()); - let splice_tx_2 = run_rbf_round(contribution_2.clone()); + let splice_tx_2 = run_rbf_round(contribution_2.clone(), splice_tx_1.compute_txid()); let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); assert_eq!(funding_template.prior_contribution().unwrap().outputs(), contribution_2.outputs()); @@ -6293,7 +6348,7 @@ fn test_splice_rbf_amends_prior_net_negative_contribution_request() { assert_eq!(contribution_3.outputs(), contribution_2.outputs()); assert!(contribution_3.net_value() < contribution_2.net_value()); assert!(contribution_3.change_output().is_none()); - let rbf_tx_final = run_rbf_round(contribution_3); + let rbf_tx_final = run_rbf_round(contribution_3, splice_tx_2.compute_txid()); lock_rbf_splice_after_blocks( &nodes[0], @@ -6358,8 +6413,9 @@ fn test_splice_rbf_acceptor_contributes_then_disconnects() { new_funding_script.clone(), ); - let (_first_splice_tx, splice_locked) = - sign_interactive_funding_tx_with_acceptor_contribution(&nodes[0], &nodes[1], false, true); + let (_first_splice_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( + &nodes[0], &nodes[1], false, true, None, + ); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); @@ -7160,7 +7216,7 @@ fn test_splice_rbf_rejects_low_feerate_after_several_attempts() { // Round 0: Initial splice-in at floor feerate (253). let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); - let (_, new_funding_script) = + let (mut prev_splice_tx, new_funding_script) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); // Bump the fee estimator on node 1 (the RBF receiver) early so the feerate check @@ -7184,11 +7240,17 @@ fn test_splice_rbf_rejects_low_feerate_after_several_attempts() { contribution, new_funding_script.clone(), ); - let (_, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + let (rbf_tx, splice_locked) = sign_interactive_funding_tx( + &nodes[0], + &nodes[1], + false, + Some(prev_splice_tx.compute_txid()), + ); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); expect_splice_pending_event(&nodes[1], &node_id_0); prev_feerate = feerate; + prev_splice_tx = rbf_tx; } // Round 11: RBF at minimum bump. Should be rejected because feerate < fee estimator. @@ -7231,7 +7293,7 @@ fn test_splice_rbf_rejects_own_low_feerate_after_several_attempts() { // Round 0: Initial splice-in at floor feerate (253). let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); - let (_, new_funding_script) = + let (mut prev_splice_tx, new_funding_script) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); // Bump node 0's fee estimator early so the feerate check would reject once the @@ -7255,11 +7317,17 @@ fn test_splice_rbf_rejects_own_low_feerate_after_several_attempts() { contribution, new_funding_script.clone(), ); - let (_, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + let (rbf_tx, splice_locked) = sign_interactive_funding_tx( + &nodes[0], + &nodes[1], + false, + Some(prev_splice_tx.compute_txid()), + ); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); expect_splice_pending_event(&nodes[1], &node_id_0); prev_feerate = feerate; + prev_splice_tx = rbf_tx; } // Round 11: Our own RBF at minimum bump. funding_contributed should reject it. @@ -7319,7 +7387,7 @@ fn test_no_disconnect_after_splice_completes() { funding_contribution, new_funding_script, ); - let (_, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + let (_, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false, None); assert!(splice_locked.is_none()); let node_id_0 = nodes[0].node.get_our_node_id(); diff --git a/lightning/src/util/wallet_utils.rs b/lightning/src/util/wallet_utils.rs index b0fdb60aa7e..cd79b3615c7 100644 --- a/lightning/src/util/wallet_utils.rs +++ b/lightning/src/util/wallet_utils.rs @@ -149,7 +149,7 @@ impl Utxo { /// /// Can be used as an input to contribute to a channel's funding transaction either when using the /// v2 channel establishment protocol or when splicing. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Hash, PartialEq, Eq)] pub struct ConfirmedUtxo { /// The unspent [`TxOut`] found in [`prevtx`]. /// From 1d36f7bce6b893427e74cdb835e14c2952cd13d2 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 17 Apr 2026 10:35:05 -0500 Subject: [PATCH 351/627] Expose additional FundingContribution accessors Add public getters for `estimated_fee`, `inputs`, and `max_feerate`, and elevate `feerate` from `pub(super)` to `pub`. Together with the existing `value_added`, `outputs`, and `change_output`, this gives downstream consumers of `TransactionType::Splice` (notably LDK Node, which updates `PaymentDetails` from the broadcast callback) the data they need without reaching into the raw transaction. Co-Authored-By: Claude Opus 4.7 (1M context) --- lightning/src/ln/funding.rs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index aa5a8540c57..a4a5eb2b15a 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -578,10 +578,6 @@ impl_writeable_tlv_based!(FundingContribution, { }); impl FundingContribution { - pub(super) fn feerate(&self) -> FeeRate { - self.feerate - } - pub(super) fn is_splice(&self) -> bool { self.is_splice } @@ -610,6 +606,16 @@ impl FundingContribution { .unwrap_or(Amount::ZERO) } + /// Returns the estimated on-chain fee this contribution is responsible for paying. + pub fn estimated_fee(&self) -> Amount { + self.estimated_fee + } + + /// Returns the inputs included in this contribution. + pub fn inputs(&self) -> &[FundingTxInput] { + &self.inputs + } + /// Returns the outputs (e.g., withdrawal destinations) included in this contribution. /// /// This does not include the change output; see [`FundingContribution::change_output`]. @@ -625,6 +631,17 @@ impl FundingContribution { self.change_output.as_ref() } + /// Returns the fee rate used to select `inputs` (the minimum feerate). + pub fn feerate(&self) -> FeeRate { + self.feerate + } + + /// Returns the maximum fee rate this contribution will accept as acceptor before rejecting + /// the splice. + pub fn max_feerate(&self) -> FeeRate { + self.max_feerate + } + /// Tries to satisfy a new request using only this contribution's existing inputs. /// /// For input-backed contributions, this reuses the current inputs, adjusts the explicit From b0c312dbd25816af70dc16685eec5584bd6a5822 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Sun, 29 Mar 2026 16:23:35 +0000 Subject: [PATCH 352/627] Attempt to unblock blocked monitor updates on startup When we make an MPP claim we push RAA blockers for each chanel to ensure we don't allow any single channel to make too much progress until all channels have the preimage durably on disk. We don't have to store those RAA blockers on disk in the ChannelManager as there's no point - if the ChannelManager gets to disk with the RAA blockers it also brought with it the pending ChannelMonitorUpdates that contain the preimages and will now be replayed, ensuring the preimage makes it to all ChannelMonitors. However, just because those RAA blockers dissapear on reload doesn't mean the implications of them does too - if a later ChannelMonitorUpdate was blocked in the channel we don't have logic to unblock it on startup. Here we add such logic, simply attempting to unblock all blocked `ChannelMonitorUpdate`s that existed on startup. Code written by Claude. Fixes #4518 --- lightning/src/ln/channelmanager.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index a7a0942f0c8..980325ac912 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -1473,6 +1473,11 @@ enum BackgroundEvent { channel_id: ChannelId, highest_update_id_completed: u64, }, + /// A channel had blocked monitor updates waiting on startup. If the updates were blocked on + /// an MPP claim blocker not written to disk, we may be able to unblock them now. + /// + /// This event is never written to disk. + AttemptUnblockMonitorUpdates { counterparty_node_id: PublicKey, channel_id: ChannelId }, } /// A pointer to a channel that is unblocked when an event is surfaced @@ -8795,6 +8800,12 @@ impl< &counterparty_node_id, ); }, + BackgroundEvent::AttemptUnblockMonitorUpdates { + counterparty_node_id, + channel_id, + } => { + self.handle_monitor_update_release(counterparty_node_id, channel_id, None); + }, } } NotifyOption::DoPersist @@ -9751,6 +9762,7 @@ impl< BackgroundEvent::MonitorUpdatesComplete { channel_id, .. } => *channel_id == _prev_channel_id, + BackgroundEvent::AttemptUnblockMonitorUpdates { .. } => false, } }); assert!(channel_closed || matching_bg_event, "{:?}", *background_events); @@ -19456,6 +19468,14 @@ impl< log_error!(logger, " Please ensure the chain::Watch API requirements are met and file a bug report at https://github.com/lightningdevkit/rust-lightning"); return Err(DecodeError::DangerousValue); } + if funded_chan.blocked_monitor_updates_pending() > 0 { + pending_background_events.push( + BackgroundEvent::AttemptUnblockMonitorUpdates { + counterparty_node_id: *counterparty_id, + channel_id: *chan_id, + }, + ); + } } else { // We shouldn't have persisted (or read) any unfunded channel types so none should have been // created in this `channel_by_id` map. From f0a8cebbc1e879f892012b7cfabbed80d376bc1b Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 25 Mar 2026 17:56:42 -0500 Subject: [PATCH 353/627] Add NegotiationFailureReason to SpliceFailed event Each splice negotiation round can fail for different reasons, but Event::SpliceFailed previously gave no indication of what went wrong. Add a NegotiationFailureReason enum so users can distinguish failures and take appropriate action (e.g., retry with a higher feerate vs. wait for the channel to become usable). The reason is determined at each channelmanager emission site based on context rather than threaded through channel.rs internals, since the channelmanager knows the triggering context (disconnect, tx_abort, shutdown, etc.) while channel.rs functions like abandon_quiescent_action handle both splice and non-splice quiescent actions. The one exception is QuiescentError::FailSplice, which carries a reason alongside the SpliceFundingFailed. This is appropriate because FailSplice is already splice-specific, and the channel.rs code that constructs it (e.g., contribution validation, feerate checks) knows the specific failure cause. A with_negotiation_failure_reason method on QuiescentError allows callers to override the default when needed. Older serializations that lack the reason field default to Unknown via default_value in deserialization. The persistence reload path uses PeerDisconnected since a reload implies the peer connection was lost. Co-Authored-By: Claude Opus 4.6 (1M context) --- lightning/src/events/mod.rs | 110 ++++++++++++++++ lightning/src/ln/channel.rs | 47 +++++-- lightning/src/ln/channelmanager.rs | 32 ++++- lightning/src/ln/functional_test_utils.rs | 9 +- lightning/src/ln/splicing_tests.rs | 154 ++++++++++++++++++---- 5 files changed, 308 insertions(+), 44 deletions(-) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 73c4a39c76f..0c99ee02c79 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -99,6 +99,110 @@ impl_writeable_tlv_based_enum!(FundingInfo, } ); +/// The reason a funding negotiation round failed. +/// +/// Each negotiation attempt (initial or RBF) resolves to either success or failure. This enum +/// indicates what caused the failure. Use [`is_retriable`] to determine whether the splice can +/// be reattempted on this channel by calling [`ChannelManager::splice_channel`]. +/// +/// [`is_retriable`]: Self::is_retriable +/// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum NegotiationFailureReason { + /// The reason was not available (e.g., from an older serialization). + Unknown, + /// The peer disconnected during negotiation. Wait for the peer to reconnect, then retry. + PeerDisconnected, + /// The counterparty explicitly aborted the negotiation by sending `tx_abort`. Retrying with + /// the same parameters is unlikely to succeed — consider adjusting the contribution or + /// waiting for the counterparty to initiate. + CounterpartyAborted { + /// The counterparty's abort message. + /// + /// This is counterparty-provided data. Use `Display` on [`UntrustedString`] for safe + /// logging. + msg: UntrustedString, + }, + /// An error occurred during interactive transaction negotiation (e.g., the counterparty sent + /// an invalid message). The negotiation was aborted. + NegotiationError { + /// A developer-readable error message. + msg: String, + }, + /// The funding contribution was invalid (e.g., insufficient balance for the splice amount). + /// Call [`ChannelManager::splice_channel`] for a fresh [`FundingTemplate`] and build a new + /// contribution with adjusted parameters. + /// + /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel + /// [`FundingTemplate`]: crate::ln::funding::FundingTemplate + ContributionInvalid, + /// The negotiation was locally abandoned via `ChannelManager::abandon_splice`. + LocallyAbandoned, + /// The channel is closing, so the negotiation cannot continue. See [`Event::ChannelClosed`] + /// for the closure reason. + ChannelClosing, + /// The contribution's feerate was too low for RBF. Call [`ChannelManager::splice_channel`] + /// for a fresh [`FundingTemplate`] (which includes the updated minimum feerate) and build a + /// new contribution with a higher feerate. + /// + /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel + /// [`FundingTemplate`]: crate::ln::funding::FundingTemplate + FeeRateTooLow, +} + +impl NegotiationFailureReason { + /// Whether the splice negotiation is likely to succeed if retried on this channel. When `true`, + /// call [`ChannelManager::splice_channel`] to obtain a fresh [`FundingTemplate`] and retry. + /// + /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel + /// [`FundingTemplate`]: crate::ln::funding::FundingTemplate + pub fn is_retriable(&self) -> bool { + match self { + Self::Unknown + | Self::PeerDisconnected + | Self::ContributionInvalid + | Self::FeeRateTooLow => true, + Self::CounterpartyAborted { .. } + | Self::NegotiationError { .. } + | Self::LocallyAbandoned + | Self::ChannelClosing => false, + } + } +} + +impl core::fmt::Display for NegotiationFailureReason { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Unknown => f.write_str("unknown reason"), + Self::PeerDisconnected => f.write_str("peer disconnected during negotiation"), + Self::CounterpartyAborted { msg } => { + write!(f, "counterparty aborted: {}", msg) + }, + Self::NegotiationError { msg } => write!(f, "negotiation error: {}", msg), + Self::ContributionInvalid => f.write_str("funding contribution was invalid"), + Self::LocallyAbandoned => f.write_str("splice locally abandoned"), + + Self::ChannelClosing => f.write_str("channel is closing"), + Self::FeeRateTooLow => f.write_str("feerate too low for RBF"), + } + } +} + +impl_writeable_tlv_based_enum_upgradable!(NegotiationFailureReason, + (1, Unknown) => {}, + (3, PeerDisconnected) => {}, + (5, CounterpartyAborted) => { + (1, msg, required), + }, + (7, NegotiationError) => { + (1, msg, required), + }, + (9, ContributionInvalid) => {}, + (11, LocallyAbandoned) => {}, + (13, ChannelClosing) => {}, + (15, FeeRateTooLow) => {}, +); + /// Some information provided on receipt of payment depends on whether the payment received is a /// spontaneous payment or a "conventional" lightning payment that's paying an invoice. #[derive(Clone, Debug, PartialEq, Eq)] @@ -1586,6 +1690,8 @@ pub enum Event { abandoned_funding_txo: Option, /// The features that this channel will operate with, if available. channel_type: Option, + /// The reason the splice negotiation failed. + reason: NegotiationFailureReason, }, /// Used to indicate to the user that they can abandon the funding transaction and recycle the /// inputs for another purpose. @@ -2379,6 +2485,7 @@ impl Writeable for Event { ref counterparty_node_id, ref abandoned_funding_txo, ref channel_type, + ref reason, } => { 52u8.write(writer)?; write_tlv_fields!(writer, { @@ -2387,6 +2494,7 @@ impl Writeable for Event { (5, user_channel_id, required), (7, counterparty_node_id, required), (9, abandoned_funding_txo, option), + (11, reason, required), }); }, // Note that, going forward, all new events must only write data inside of @@ -3031,6 +3139,7 @@ impl MaybeReadable for Event { (5, user_channel_id, required), (7, counterparty_node_id, required), (9, abandoned_funding_txo, option), + (11, reason, upgradable_option), }); Ok(Some(Event::SpliceFailed { @@ -3039,6 +3148,7 @@ impl MaybeReadable for Event { counterparty_node_id: counterparty_node_id.0.unwrap(), abandoned_funding_txo, channel_type, + reason: reason.unwrap_or(NegotiationFailureReason::Unknown), })) }; f() diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 8c74fa6753a..ad643a192ad 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -37,7 +37,7 @@ use crate::chain::channelmonitor::{ }; use crate::chain::transaction::{OutPoint, TransactionData}; use crate::chain::BlockLocator; -use crate::events::{ClosureReason, FundingInfo}; +use crate::events::{ClosureReason, FundingInfo, NegotiationFailureReason}; use crate::ln::chan_utils; use crate::ln::chan_utils::{ get_commitment_transaction_number_obscure_factor, max_htlcs, second_stage_tx_fees_sat, @@ -3192,7 +3192,17 @@ pub(crate) enum QuiescentAction { pub(super) enum QuiescentError { DoNothing, DiscardFunding { inputs: Vec, outputs: Vec }, - FailSplice(SpliceFundingFailed), + FailSplice(SpliceFundingFailed, NegotiationFailureReason), +} + +impl QuiescentError { + fn with_negotiation_failure_reason(mut self, reason: NegotiationFailureReason) -> Self { + match self { + QuiescentError::FailSplice(_, ref mut r) => *r = reason, + _ => debug_assert!(false, "Expected FailSplice variant"), + } + self + } } pub(crate) enum StfuResponse { @@ -7155,9 +7165,10 @@ where fn quiescent_action_into_error(&self, action: QuiescentAction) -> QuiescentError { match action { - QuiescentAction::Splice { contribution, .. } => { - QuiescentError::FailSplice(self.splice_funding_failed_for(contribution)) - }, + QuiescentAction::Splice { contribution, .. } => QuiescentError::FailSplice( + self.splice_funding_failed_for(contribution), + NegotiationFailureReason::Unknown, + ), #[cfg(any(test, fuzzing, feature = "_test_utils"))] QuiescentAction::DoNothing => QuiescentError::DoNothing, } @@ -7166,7 +7177,7 @@ where fn abandon_quiescent_action(&mut self) -> Option { let action = self.quiescent_action.take()?; match self.quiescent_action_into_error(action) { - QuiescentError::FailSplice(failed) => Some(failed), + QuiescentError::FailSplice(failed, _) => Some(failed), #[cfg(any(test, fuzzing, feature = "_test_utils"))] QuiescentError::DoNothing => None, _ => { @@ -10446,7 +10457,7 @@ where tx_abort = Some(msgs::TxAbort { channel_id: self.context.channel_id(), data: - "No active signing session. The associated funding transaction may have already been broadcast.".as_bytes().to_vec() }); + "Signing was not completed for this funding transaction; it may be forgotten.".as_bytes().to_vec() }); } } if let Some(funding_txid) = retransmit_funding_commit_sig { @@ -12652,7 +12663,10 @@ where ) { log_error!(logger, "Channel {} cannot be funded: {}", self.context.channel_id(), e); - return Err(QuiescentError::FailSplice(self.splice_funding_failed_for(contribution))); + return Err(QuiescentError::FailSplice( + self.splice_funding_failed_for(contribution), + NegotiationFailureReason::ContributionInvalid, + )); } if let Some(pending_splice) = self.pending_splice.as_ref() { @@ -12668,6 +12682,7 @@ where ); return Err(QuiescentError::FailSplice( self.splice_funding_failed_for(contribution), + NegotiationFailureReason::FeeRateTooLow, )); } } @@ -14165,9 +14180,18 @@ where ) -> Result, QuiescentError> { log_debug!(logger, "Attempting to initiate quiescence"); + // TODO: NegotiationFailureReason is splice-specific, but propose_quiescence is + // generic. The reason should be selected by the caller, but it currently can't + // distinguish why quiescence failed. Revisit when a second quiescent protocol is added. if !self.context.is_usable() { + debug_assert!( + self.context.channel_state.is_local_shutdown_sent() + || self.context.channel_state.is_remote_shutdown_sent(), + "splice_channel should have prevented reaching propose_quiescence on a non-ready channel" + ); log_debug!(logger, "Channel is not in a usable state to propose quiescence"); - return Err(self.quiescent_action_into_error(action)); + return Err(self.quiescent_action_into_error(action) + .with_negotiation_failure_reason(NegotiationFailureReason::ChannelClosing)); } if self.quiescent_action.is_some() { log_debug!( @@ -14286,7 +14310,10 @@ where self.context.channel_id(), e, )), - QuiescentError::FailSplice(failed), + QuiescentError::FailSplice( + failed, + NegotiationFailureReason::ContributionInvalid, + ), )); } let prior_contribution = contribution.clone(); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 64486598005..ef2ce9a7d14 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -4169,6 +4169,7 @@ impl< user_channel_id: chan.context().get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type, + reason: events::NegotiationFailureReason::ChannelClosing, }, None, )); @@ -4475,6 +4476,7 @@ impl< user_channel_id: shutdown_res.user_channel_id, abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type, + reason: events::NegotiationFailureReason::ChannelClosing, }, None, )); @@ -4981,6 +4983,7 @@ impl< user_channel_id: chan.context.get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type, + reason: events::NegotiationFailureReason::LocallyAbandoned, }, None, )); @@ -6673,12 +6676,15 @@ impl< )); } }, - QuiescentError::FailSplice(SpliceFundingFailed { - funding_txo, - channel_type, - contributed_inputs, - contributed_outputs, - }) => { + QuiescentError::FailSplice( + SpliceFundingFailed { + funding_txo, + channel_type, + contributed_inputs, + contributed_outputs, + }, + reason, + ) => { let pending_events = &mut self.pending_events.lock().unwrap(); pending_events.push_back(( events::Event::SpliceFailed { @@ -6687,6 +6693,7 @@ impl< user_channel_id, abandoned_funding_txo: funding_txo, channel_type, + reason, }, None, )); @@ -6840,7 +6847,7 @@ impl< "Channel {} already has a pending funding contribution", channel_id, ), - QuiescentError::FailSplice(_) => format!( + QuiescentError::FailSplice(..) => format!( "Channel {} cannot accept funding contribution", channel_id, ), @@ -11983,6 +11990,9 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ user_channel_id, abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type.clone(), + reason: events::NegotiationFailureReason::NegotiationError { + msg: format!("{:?}", err.err), + }, }, None, )); @@ -12319,6 +12329,11 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ user_channel_id: chan_entry.get().context().get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type, + reason: events::NegotiationFailureReason::CounterpartyAborted { + msg: UntrustedString( + String::from_utf8_lossy(&msg.data).to_string(), + ), + }, }, None, )); @@ -12467,6 +12482,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ user_channel_id: chan.context().get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type, + reason: events::NegotiationFailureReason::ChannelClosing, }, None, )); @@ -15543,6 +15559,7 @@ impl< user_channel_id: chan.context().get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type, + reason: events::NegotiationFailureReason::PeerDisconnected, }); splice_failed_events.push(events::Event::DiscardFunding { channel_id: chan.context().channel_id(), @@ -18171,6 +18188,7 @@ impl< user_channel_id: chan.context.get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type, + reason: events::NegotiationFailureReason::PeerDisconnected, }, None, )); diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index b48d76d646d..b8ef5890899 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -19,8 +19,8 @@ use crate::chain::{BlockLocator, ChannelMonitorUpdateStatus, Confirm, Listen, Wa use crate::events::bump_transaction::sync::BumpTransactionEventHandlerSync; use crate::events::bump_transaction::BumpTransactionEvent; use crate::events::{ - ClaimedHTLC, ClosureReason, Event, FundingInfo, HTLCHandlingFailureType, PaidBolt12Invoice, - PathFailure, PaymentFailureReason, PaymentPurpose, + ClaimedHTLC, ClosureReason, Event, FundingInfo, HTLCHandlingFailureType, + NegotiationFailureReason, PaidBolt12Invoice, PathFailure, PaymentFailureReason, PaymentPurpose, }; use crate::ln::chan_utils::{ commitment_tx_base_weight, COMMITMENT_TX_WEIGHT_PER_HTLC, TRUC_MAX_WEIGHT, @@ -3232,13 +3232,14 @@ pub fn expect_splice_pending_event<'a, 'b, 'c, 'd>( #[cfg(any(test, ldk_bench, feature = "_test_utils"))] pub fn expect_splice_failed_events<'a, 'b, 'c, 'd>( node: &'a Node<'b, 'c, 'd>, expected_channel_id: &ChannelId, - funding_contribution: FundingContribution, + funding_contribution: FundingContribution, expected_reason: NegotiationFailureReason, ) { let events = node.node.get_and_clear_pending_events(); assert_eq!(events.len(), 2); match &events[0] { - Event::SpliceFailed { channel_id, .. } => { + Event::SpliceFailed { channel_id, reason, .. } => { assert_eq!(*expected_channel_id, *channel_id); + assert_eq!(expected_reason, *reason); }, _ => panic!("Unexpected event"), } diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 9a3813904e1..34b51e2696c 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -13,7 +13,9 @@ use crate::chain::chaininterface::{FundingPurpose, TransactionType, FEERATE_FLOO use crate::chain::channelmonitor::{ANTI_REORG_DELAY, LATENCY_GRACE_PERIOD_BLOCKS}; use crate::chain::transaction::OutPoint; use crate::chain::ChannelMonitorUpdateStatus; -use crate::events::{ClosureReason, Event, FundingInfo, HTLCHandlingFailureType}; +use crate::events::{ + ClosureReason, Event, FundingInfo, HTLCHandlingFailureType, NegotiationFailureReason, +}; use crate::ln::chan_utils; use crate::ln::channel::{ ANCHOR_OUTPUT_VALUE_SATOSHI, CHANNEL_ANNOUNCEMENT_PROPAGATION_DELAY, @@ -28,6 +30,7 @@ use crate::ln::outbound_payment::RecipientOnionFields; use crate::ln::types::ChannelId; use crate::routing::router::{PaymentParameters, RouteParameters}; use crate::types::features::ChannelTypeFeatures; +use crate::types::string::UntrustedString; use crate::util::config::UserConfig; use crate::util::errors::APIError; use crate::util::ser::Writeable; @@ -252,7 +255,12 @@ pub fn initiate_splice_out<'a, 'b, 'c, 'd>( ) { Ok(()) => Ok(funding_contribution), Err(e) => { - expect_splice_failed_events(initiator, &channel_id, funding_contribution); + expect_splice_failed_events( + initiator, + &channel_id, + funding_contribution, + NegotiationFailureReason::ContributionInvalid, + ); Err(e) }, } @@ -884,7 +892,12 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { nodes[1].node.peer_disconnected(node_id_0); } - expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); + expect_splice_failed_events( + &nodes[0], + &channel_id, + funding_contribution, + NegotiationFailureReason::PeerDisconnected, + ); let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); reconnect_args.send_channel_ready = (true, true); @@ -935,7 +948,12 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { nodes[1].node.peer_disconnected(node_id_0); } - expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); + expect_splice_failed_events( + &nodes[0], + &channel_id, + funding_contribution, + NegotiationFailureReason::PeerDisconnected, + ); let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); reconnect_args.send_channel_ready = (true, true); @@ -1017,7 +1035,17 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { let tx_abort = get_event_msg!(nodes[0], MessageSendEvent::SendTxAbort, node_id_1); nodes[1].node.handle_tx_abort(node_id_0, &tx_abort); - expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); + expect_splice_failed_events( + &nodes[0], + &channel_id, + funding_contribution, + NegotiationFailureReason::CounterpartyAborted { + msg: UntrustedString( + "Signing was not completed for this funding transaction; it may be forgotten." + .to_string(), + ), + }, + ); // Attempt a splice negotiation that completes, (i.e. `tx_signatures` are exchanged). Reconnecting // should not abort the negotiation or reset the splice state. @@ -1101,7 +1129,12 @@ fn test_config_reject_inbound_splices() { nodes[0].node.peer_disconnected(node_id_1); nodes[1].node.peer_disconnected(node_id_0); - expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); + expect_splice_failed_events( + &nodes[0], + &channel_id, + funding_contribution, + NegotiationFailureReason::PeerDisconnected, + ); let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); reconnect_args.send_channel_ready = (true, true); @@ -2696,7 +2729,14 @@ fn fail_splice_on_interactive_tx_error() { get_event_msg!(acceptor, MessageSendEvent::SendTxComplete, node_id_initiator); initiator.node.handle_tx_add_input(node_id_acceptor, &tx_add_input); - expect_splice_failed_events(initiator, &channel_id, funding_contribution); + expect_splice_failed_events( + initiator, + &channel_id, + funding_contribution, + NegotiationFailureReason::NegotiationError { + msg: "Abort: Parity for `serial_id` was incorrect".to_string(), + }, + ); // We exit quiescence upon sending `tx_abort`, so we should see the holding cell be immediately // freed. @@ -2767,7 +2807,12 @@ fn fail_splice_on_tx_abort() { let tx_abort = get_event_msg!(acceptor, MessageSendEvent::SendTxAbort, node_id_initiator); initiator.node.handle_tx_abort(node_id_acceptor, &tx_abort); - expect_splice_failed_events(initiator, &channel_id, funding_contribution); + expect_splice_failed_events( + initiator, + &channel_id, + funding_contribution, + NegotiationFailureReason::CounterpartyAborted { msg: UntrustedString(String::new()) }, + ); // We exit quiescence upon receiving `tx_abort`, so we should see our `tx_abort` echo and the // holding cell be immediately freed. @@ -2863,7 +2908,16 @@ fn fail_splice_on_tx_complete_error() { }; initiator.node.handle_tx_abort(node_id_acceptor, tx_abort); - expect_splice_failed_events(initiator, &channel_id, funding_contribution); + expect_splice_failed_events( + initiator, + &channel_id, + funding_contribution, + NegotiationFailureReason::CounterpartyAborted { + msg: UntrustedString( + "Total value of outputs exceeds total value of inputs".to_string(), + ), + }, + ); let tx_abort = get_event_msg!(initiator, MessageSendEvent::SendTxAbort, node_id_acceptor); acceptor.node.handle_tx_abort(node_id_initiator, &tx_abort); @@ -3153,8 +3207,9 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool, pending_ let events = nodes[0].node.get_and_clear_pending_events(); assert_eq!(events.len(), 2, "{events:?}"); match &events[0] { - Event::SpliceFailed { channel_id: cid, .. } => { + Event::SpliceFailed { channel_id: cid, reason, .. } => { assert_eq!(*cid, channel_id); + assert_eq!(*reason, NegotiationFailureReason::ChannelClosing); }, other => panic!("Expected SpliceFailed, got {:?}", other), } @@ -3173,7 +3228,12 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool, pending_ other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), } } else { - expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); + expect_splice_failed_events( + &nodes[0], + &channel_id, + funding_contribution, + NegotiationFailureReason::ChannelClosing, + ); } let _ = get_event_msg!(closee_node, MessageSendEvent::SendShutdown, closer_node_id); } @@ -4146,7 +4206,12 @@ fn test_funding_contributed_channel_shutdown() { }) ); - expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); + expect_splice_failed_events( + &nodes[0], + &channel_id, + funding_contribution, + NegotiationFailureReason::ChannelClosing, + ); } #[test] @@ -4327,7 +4392,12 @@ fn do_test_splice_pending_htlcs(config: UserConfig) { let reconnect_args = ReconnectArgs::new(initiator, acceptor); reconnect_nodes(reconnect_args); - expect_splice_failed_events(initiator, &channel_id, contribution); + expect_splice_failed_events( + initiator, + &channel_id, + contribution, + NegotiationFailureReason::PeerDisconnected, + ); // 4) Try again with the additional satoshi removed from the splice-out message, and check that it passes // validation on the receiver's side. @@ -4362,7 +4432,12 @@ fn do_test_splice_pending_htlcs(config: UserConfig) { nodes[1].node.peer_disconnected(node_id_0); let reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); reconnect_nodes(reconnect_args); - expect_splice_failed_events(&nodes[1], &channel_id, contribution); + expect_splice_failed_events( + &nodes[1], + &channel_id, + contribution, + NegotiationFailureReason::PeerDisconnected, + ); let details = &nodes[1].node.list_channels()[0]; let expected_outbound_htlc_max = (pre_splice_balance.to_sat() - details.unspendable_punishment_reserve.unwrap()) * 1000; @@ -4513,7 +4588,12 @@ fn test_splice_acceptor_disconnect_emits_events() { nodes[1].node.peer_disconnected(node_id_0); // The initiator should get SpliceFailed + DiscardFunding. - expect_splice_failed_events(&nodes[0], &channel_id, node_0_funding_contribution); + expect_splice_failed_events( + &nodes[0], + &channel_id, + node_0_funding_contribution, + NegotiationFailureReason::PeerDisconnected, + ); // The acceptor should also get SpliceFailed + DiscardFunding with its contributions // so it can reclaim its UTXOs. The contribution is feerate-adjusted by handle_splice_init, @@ -4521,7 +4601,10 @@ fn test_splice_acceptor_disconnect_emits_events() { let events = nodes[1].node.get_and_clear_pending_events(); assert_eq!(events.len(), 2, "{events:?}"); match &events[0] { - Event::SpliceFailed { channel_id: cid, .. } => assert_eq!(*cid, channel_id), + Event::SpliceFailed { channel_id: cid, reason, .. } => { + assert_eq!(*cid, channel_id); + assert_eq!(*reason, NegotiationFailureReason::PeerDisconnected); + }, other => panic!("Expected SpliceFailed, got {:?}", other), } match &events[1] { @@ -6444,7 +6527,10 @@ fn test_splice_rbf_acceptor_contributes_then_disconnects() { let events = nodes[0].node.get_and_clear_pending_events(); assert_eq!(events.len(), 2, "{events:?}"); match &events[0] { - Event::SpliceFailed { channel_id: cid, .. } => assert_eq!(*cid, channel_id), + Event::SpliceFailed { channel_id: cid, reason, .. } => { + assert_eq!(*cid, channel_id); + assert_eq!(*reason, NegotiationFailureReason::PeerDisconnected); + }, other => panic!("Expected SpliceFailed, got {:?}", other), } match &events[1] { @@ -6523,8 +6609,9 @@ fn test_splice_rbf_disconnect_filters_prior_contributions() { let events = nodes[0].node.get_and_clear_pending_events(); assert_eq!(events.len(), 2, "{events:?}"); match &events[0] { - Event::SpliceFailed { channel_id: cid, .. } => { + Event::SpliceFailed { channel_id: cid, reason, .. } => { assert_eq!(*cid, channel_id); + assert_eq!(*reason, NegotiationFailureReason::PeerDisconnected); }, other => panic!("Expected SpliceFailed, got {:?}", other), } @@ -6564,7 +6651,10 @@ fn test_splice_rbf_disconnect_filters_prior_contributions() { let events = nodes[0].node.get_and_clear_pending_events(); assert_eq!(events.len(), 2, "{events:?}"); match &events[0] { - Event::SpliceFailed { channel_id: cid, .. } => assert_eq!(*cid, channel_id), + Event::SpliceFailed { channel_id: cid, reason, .. } => { + assert_eq!(*cid, channel_id); + assert_eq!(*reason, NegotiationFailureReason::PeerDisconnected); + }, other => panic!("Expected SpliceFailed, got {:?}", other), } match &events[1] { @@ -7101,7 +7191,12 @@ fn test_splice_revalidation_at_quiescence() { assert_eq!(msg_events.len(), 1, "{msg_events:?}"); assert!(matches!(msg_events[0], MessageSendEvent::HandleError { .. })); - expect_splice_failed_events(&nodes[0], &channel_id, contribution); + expect_splice_failed_events( + &nodes[0], + &channel_id, + contribution, + NegotiationFailureReason::ContributionInvalid, + ); } #[test] @@ -7351,7 +7446,10 @@ fn test_splice_rbf_rejects_own_low_feerate_after_several_attempts() { let events = nodes[0].node.get_and_clear_pending_events(); assert_eq!(events.len(), 1, "{events:?}"); match &events[0] { - Event::SpliceFailed { channel_id: cid, .. } => assert_eq!(*cid, channel_id), + Event::SpliceFailed { channel_id: cid, reason, .. } => { + assert_eq!(*cid, channel_id); + assert_eq!(*reason, NegotiationFailureReason::FeeRateTooLow); + }, other => panic!("Expected SpliceFailed, got {:?}", other), } } @@ -7447,7 +7545,12 @@ fn test_no_disconnect_after_splice_aborted() { // Abort the splice, which should clear the timer when exiting quiescence. nodes[0].node.abandon_splice(&channel_id, &node_id_1).unwrap(); - expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); + expect_splice_failed_events( + &nodes[0], + &channel_id, + funding_contribution, + NegotiationFailureReason::LocallyAbandoned, + ); let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); let tx_abort = msg_events @@ -7519,7 +7622,12 @@ fn test_no_disconnect_after_quiescence_on_reconnect() { nodes[0].node.peer_disconnected(node_id_1); nodes[1].node.peer_disconnected(node_id_0); - expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); + expect_splice_failed_events( + &nodes[0], + &channel_id, + funding_contribution, + NegotiationFailureReason::PeerDisconnected, + ); let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); reconnect_args.send_channel_ready = (true, true); From 450eb6453106a90419323d189467fdaa56249dfe Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 25 Mar 2026 18:20:35 -0500 Subject: [PATCH 354/627] Add FundingContribution to SpliceFailed event Replace the abandoned_funding_txo and channel_type fields on Event::SpliceFailed with an Option from the failed round. Users can feed this back to funding_contributed to retry or use it to inform a fresh attempt via splice_channel. Also makes FundingContribution::feerate() public so users can inspect the feerate when deciding whether to retry or bump. Co-Authored-By: Claude Opus 4.6 (1M context) --- lightning/src/events/mod.rs | 39 ++--- lightning/src/ln/channel.rs | 55 ++++--- lightning/src/ln/channelmanager.rs | 189 ++++++++++------------ lightning/src/ln/functional_test_utils.rs | 3 +- lightning/src/ln/splicing_tests.rs | 149 ++++++++--------- 5 files changed, 201 insertions(+), 234 deletions(-) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 0c99ee02c79..5a52be026fb 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -25,6 +25,7 @@ use crate::blinded_path::payment::{ use crate::chain::transaction; use crate::ln::channel::FUNDING_CONF_DEADLINE_BLOCKS; use crate::ln::channelmanager::{InterceptId, PaymentId}; +use crate::ln::funding::FundingContribution; use crate::ln::msgs; use crate::ln::onion_utils::LocalHTLCFailureReason; use crate::ln::outbound_payment::RecipientOnionFields; @@ -1664,19 +1665,20 @@ pub enum Event { /// The witness script that is used to lock the channel's funding output to commitment transactions. new_funding_redeem_script: ScriptBuf, }, - /// Used to indicate that a splice for the given `channel_id` has failed. + /// Used to indicate that a splice negotiation round for the given `channel_id` has failed. /// - /// This event may be emitted if a splice fails after it has been initiated but prior to signing - /// any negotiated funding transaction. + /// Each splice attempt (initial or RBF) resolves to either [`Event::SplicePending`] on + /// success or this event on failure. Prior successfully negotiated splice transactions are + /// unaffected. /// - /// Any UTXOs contributed to be spent by the funding transaction may be reused and will be - /// given in `contributed_inputs`. + /// Any UTXOs contributed to the failed round that are not committed to a prior negotiated + /// splice transaction will be returned via a preceding [`Event::DiscardFunding`]. /// /// # Failure Behavior and Persistence /// This event will eventually be replayed after failures-to-handle (i.e., the event handler /// returning `Err(ReplayEvent ())`) and will be persisted across restarts. SpliceFailed { - /// The `channel_id` of the channel for which the splice failed. + /// The `channel_id` of the channel for which the splice negotiation round failed. channel_id: ChannelId, /// The `user_channel_id` value passed in to [`ChannelManager::create_channel`] for outbound /// channels, or to [`ChannelManager::accept_inbound_channel`] for inbound channels. @@ -1686,12 +1688,17 @@ pub enum Event { user_channel_id: u128, /// The `node_id` of the channel counterparty. counterparty_node_id: PublicKey, - /// The outpoint of the channel's splice funding transaction, if one was created. - abandoned_funding_txo: Option, - /// The features that this channel will operate with, if available. - channel_type: Option, /// The reason the splice negotiation failed. reason: NegotiationFailureReason, + /// The funding contribution from the failed negotiation round, if available. This can be + /// fed back to [`ChannelManager::funding_contributed`] to retry with the same parameters. + /// Alternatively, call [`ChannelManager::splice_channel`] to obtain a fresh + /// [`FundingTemplate`] and build a new contribution. + /// + /// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed + /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel + /// [`FundingTemplate`]: crate::ln::funding::FundingTemplate + contribution: Option, }, /// Used to indicate to the user that they can abandon the funding transaction and recycle the /// inputs for another purpose. @@ -2483,18 +2490,16 @@ impl Writeable for Event { ref channel_id, ref user_channel_id, ref counterparty_node_id, - ref abandoned_funding_txo, - ref channel_type, ref reason, + ref contribution, } => { 52u8.write(writer)?; write_tlv_fields!(writer, { (1, channel_id, required), - (3, channel_type, option), (5, user_channel_id, required), (7, counterparty_node_id, required), - (9, abandoned_funding_txo, option), (11, reason, required), + (13, contribution, option), }); }, // Note that, going forward, all new events must only write data inside of @@ -3135,20 +3140,18 @@ impl MaybeReadable for Event { let mut f = || { _init_and_read_len_prefixed_tlv_fields!(reader, { (1, channel_id, required), - (3, channel_type, option), (5, user_channel_id, required), (7, counterparty_node_id, required), - (9, abandoned_funding_txo, option), (11, reason, upgradable_option), + (13, contribution, option), }); Ok(Some(Event::SpliceFailed { channel_id: channel_id.0.unwrap(), user_channel_id: user_channel_id.0.unwrap(), counterparty_node_id: counterparty_node_id.0.unwrap(), - abandoned_funding_txo, - channel_type, reason: reason.unwrap_or(NegotiationFailureReason::Unknown), + contribution, })) }; f() diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index ad643a192ad..473fc6b46f3 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -7051,17 +7051,33 @@ pub struct SpliceFundingNegotiated { /// Information about a splice funding negotiation that has failed. pub struct SpliceFundingFailed { - /// The outpoint of the channel's splice funding transaction, if one was created. - pub funding_txo: Option, + /// UTXOs spent as inputs contributed to the splice transaction. Excludes inputs already + /// contributed in prior rounds, which may be included in `contribution`. + contributed_inputs: Vec, - /// The features that this channel will operate with, if available. - pub channel_type: Option, + /// Outputs contributed to the splice transaction. Excludes outputs already contributed + /// in prior rounds, which may be included in `contribution`. + contributed_outputs: Vec, - /// UTXOs spent as inputs contributed to the splice transaction. - pub contributed_inputs: Vec, + /// The funding contribution from the failed round, if available. + contribution: Option, +} - /// Outputs contributed to the splice transaction. - pub contributed_outputs: Vec, +impl SpliceFundingFailed { + /// Splits into the funding info for `DiscardFunding` (if there are inputs or outputs to + /// discard) and the contribution for `SpliceFailed`. + pub(super) fn into_parts(self) -> (Option, Option) { + let funding_info = + if !self.contributed_inputs.is_empty() || !self.contributed_outputs.is_empty() { + Some(FundingInfo::Contribution { + inputs: self.contributed_inputs, + outputs: self.contributed_outputs, + }) + } else { + None + }; + (funding_info, self.contribution) + } } macro_rules! maybe_create_splice_funding_failed { @@ -7071,15 +7087,6 @@ macro_rules! maybe_create_splice_funding_failed { .and_then(|funding_negotiation| { let is_initiator = funding_negotiation.is_initiator(); - let funding_txo = funding_negotiation - .as_funding() - .and_then(|funding| funding.get_funding_txo()) - .map(|txo| txo.into_bitcoin_outpoint()); - - let channel_type = funding_negotiation - .as_funding() - .map(|funding| funding.get_channel_type().clone()); - let (mut contributed_inputs, mut contributed_outputs) = match funding_negotiation { FundingNegotiation::AwaitingAck { context, .. } => { context.$contributed_inputs_and_outputs() @@ -7110,12 +7117,10 @@ macro_rules! maybe_create_splice_funding_failed { return None; } - Some(SpliceFundingFailed { - funding_txo, - channel_type, - contributed_inputs, - contributed_outputs, - }) + let contribution = + $pending_splice_ref.and_then(|ps| ps.contributions.last().cloned()); + + Some(SpliceFundingFailed { contributed_inputs, contributed_outputs, contribution }) }) }}; } @@ -7146,6 +7151,7 @@ where /// Builds a [`SpliceFundingFailed`] from a contribution, filtering out inputs/outputs /// that are still committed to a prior splice round. fn splice_funding_failed_for(&self, contribution: FundingContribution) -> SpliceFundingFailed { + let cloned_contribution = contribution.clone(); let (mut inputs, mut outputs) = contribution.into_contributed_inputs_and_outputs(); if let Some(ref pending_splice) = self.pending_splice { for input in pending_splice.contributed_inputs() { @@ -7156,10 +7162,9 @@ where } } SpliceFundingFailed { - funding_txo: None, - channel_type: None, contributed_inputs: inputs, contributed_outputs: outputs, + contribution: Some(cloned_contribution), } } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index ef2ce9a7d14..2d6aaa56c5f 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -61,8 +61,8 @@ use crate::ln::channel::QuiescentError; use crate::ln::channel::{ self, hold_time_since, Channel, ChannelError, ChannelUpdateStatus, DisconnectResult, FundedChannel, FundingTxSigned, InboundV1Channel, InteractiveTxMsgError, OutboundHop, - OutboundV1Channel, PendingV2Channel, ReconnectionMsg, ShutdownResult, SpliceFundingFailed, - StfuResponse, UpdateFulfillCommitFetch, WithChannelContext, + OutboundV1Channel, PendingV2Channel, ReconnectionMsg, ShutdownResult, StfuResponse, + UpdateFulfillCommitFetch, WithChannelContext, }; use crate::ln::channel_state::ChannelDetails; use crate::ln::funding::{FundingContribution, FundingTemplate}; @@ -4161,28 +4161,27 @@ impl< failed_htlcs = htlcs; if let Some(splice_funding_failed) = splice_funding_failed { + let (funding_info, contribution) = splice_funding_failed.into_parts(); let mut pending_events = self.pending_events.lock().unwrap(); pending_events.push_back(( events::Event::SpliceFailed { channel_id: *chan_id, counterparty_node_id: *counterparty_node_id, user_channel_id: chan.context().get_user_id(), - abandoned_funding_txo: splice_funding_failed.funding_txo, - channel_type: splice_funding_failed.channel_type, + contribution, reason: events::NegotiationFailureReason::ChannelClosing, }, None, )); - pending_events.push_back(( - events::Event::DiscardFunding { - channel_id: *chan_id, - funding_info: FundingInfo::Contribution { - inputs: splice_funding_failed.contributed_inputs, - outputs: splice_funding_failed.contributed_outputs, + if let Some(funding_info) = funding_info { + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: *chan_id, + funding_info, }, - }, - None, - )); + None, + )); + } } // We can send the `shutdown` message before updating the `ChannelMonitor` @@ -4469,27 +4468,26 @@ impl< )); if let Some(splice_funding_failed) = shutdown_res.splice_funding_failed.take() { + let (funding_info, contribution) = splice_funding_failed.into_parts(); pending_events.push_back(( events::Event::SpliceFailed { channel_id: shutdown_res.channel_id, counterparty_node_id: shutdown_res.counterparty_node_id, user_channel_id: shutdown_res.user_channel_id, - abandoned_funding_txo: splice_funding_failed.funding_txo, - channel_type: splice_funding_failed.channel_type, + contribution, reason: events::NegotiationFailureReason::ChannelClosing, }, None, )); - pending_events.push_back(( - events::Event::DiscardFunding { - channel_id: shutdown_res.channel_id, - funding_info: FundingInfo::Contribution { - inputs: splice_funding_failed.contributed_inputs, - outputs: splice_funding_failed.contributed_outputs, + if let Some(funding_info) = funding_info { + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: shutdown_res.channel_id, + funding_info, }, - }, - None, - )); + None, + )); + } } if let Some(transaction) = shutdown_res.unbroadcasted_funding_tx { @@ -4975,28 +4973,27 @@ impl< }); if let Some(splice_funding_failed) = splice_funding_failed { + let (funding_info, contribution) = splice_funding_failed.into_parts(); let pending_events = &mut self.pending_events.lock().unwrap(); pending_events.push_back(( events::Event::SpliceFailed { channel_id: *channel_id, counterparty_node_id: *counterparty_node_id, user_channel_id: chan.context.get_user_id(), - abandoned_funding_txo: splice_funding_failed.funding_txo, - channel_type: splice_funding_failed.channel_type, + contribution, reason: events::NegotiationFailureReason::LocallyAbandoned, }, None, )); - pending_events.push_back(( - events::Event::DiscardFunding { - channel_id: *channel_id, - funding_info: FundingInfo::Contribution { - inputs: splice_funding_failed.contributed_inputs, - outputs: splice_funding_failed.contributed_outputs, + if let Some(funding_info) = funding_info { + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: *channel_id, + funding_info, }, - }, - None, - )); + None, + )); + } } Ok(()) @@ -6676,36 +6673,22 @@ impl< )); } }, - QuiescentError::FailSplice( - SpliceFundingFailed { - funding_txo, - channel_type, - contributed_inputs, - contributed_outputs, - }, - reason, - ) => { + QuiescentError::FailSplice(splice_funding_failed, reason) => { + let (funding_info, contribution) = splice_funding_failed.into_parts(); let pending_events = &mut self.pending_events.lock().unwrap(); pending_events.push_back(( events::Event::SpliceFailed { channel_id, counterparty_node_id, user_channel_id, - abandoned_funding_txo: funding_txo, - channel_type, reason, + contribution, }, None, )); - if !contributed_inputs.is_empty() || !contributed_outputs.is_empty() { + if let Some(funding_info) = funding_info { pending_events.push_back(( - events::Event::DiscardFunding { - channel_id, - funding_info: FundingInfo::Contribution { - inputs: contributed_inputs, - outputs: contributed_outputs, - }, - }, + events::Event::DiscardFunding { channel_id, funding_info }, None, )); } @@ -11982,30 +11965,24 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ user_channel_id: u128, ) -> MsgHandleErrInternal { if let Some(splice_funding_failed) = err.splice_funding_failed { + let (funding_info, contribution) = splice_funding_failed.into_parts(); let pending_events = &mut self.pending_events.lock().unwrap(); pending_events.push_back(( events::Event::SpliceFailed { channel_id, counterparty_node_id: *counterparty_node_id, user_channel_id, - abandoned_funding_txo: splice_funding_failed.funding_txo, - channel_type: splice_funding_failed.channel_type.clone(), + contribution, reason: events::NegotiationFailureReason::NegotiationError { msg: format!("{:?}", err.err), }, }, None, )); - pending_events.push_back(( - events::Event::DiscardFunding { - channel_id, - funding_info: FundingInfo::Contribution { - inputs: splice_funding_failed.contributed_inputs, - outputs: splice_funding_failed.contributed_outputs, - }, - }, - None, - )); + if let Some(funding_info) = funding_info { + pending_events + .push_back((events::Event::DiscardFunding { channel_id, funding_info }, None)); + } } MsgHandleErrInternal::from_chan_no_close(err.err, channel_id) } @@ -12321,14 +12298,14 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } if let Some(splice_funding_failed) = splice_failed { + let (funding_info, contribution) = splice_funding_failed.into_parts(); let pending_events = &mut self.pending_events.lock().unwrap(); pending_events.push_back(( events::Event::SpliceFailed { channel_id: msg.channel_id, counterparty_node_id: *counterparty_node_id, user_channel_id: chan_entry.get().context().get_user_id(), - abandoned_funding_txo: splice_funding_failed.funding_txo, - channel_type: splice_funding_failed.channel_type, + contribution, reason: events::NegotiationFailureReason::CounterpartyAborted { msg: UntrustedString( String::from_utf8_lossy(&msg.data).to_string(), @@ -12337,16 +12314,15 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }, None, )); - pending_events.push_back(( - events::Event::DiscardFunding { - channel_id: msg.channel_id, - funding_info: FundingInfo::Contribution { - inputs: splice_funding_failed.contributed_inputs, - outputs: splice_funding_failed.contributed_outputs, + if let Some(funding_info) = funding_info { + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: msg.channel_id, + funding_info, }, - }, - None, - )); + None, + )); + } } let holding_cell_res = if needs_holding_cell_release { @@ -12474,28 +12450,27 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ dropped_htlcs = htlcs; if let Some(splice_funding_failed) = splice_funding_failed { + let (funding_info, contribution) = splice_funding_failed.into_parts(); let mut pending_events = self.pending_events.lock().unwrap(); pending_events.push_back(( events::Event::SpliceFailed { channel_id: msg.channel_id, counterparty_node_id: *counterparty_node_id, user_channel_id: chan.context().get_user_id(), - abandoned_funding_txo: splice_funding_failed.funding_txo, - channel_type: splice_funding_failed.channel_type, + contribution, reason: events::NegotiationFailureReason::ChannelClosing, }, None, )); - pending_events.push_back(( - events::Event::DiscardFunding { - channel_id: msg.channel_id, - funding_info: FundingInfo::Contribution { - inputs: splice_funding_failed.contributed_inputs, - outputs: splice_funding_failed.contributed_outputs, + if let Some(funding_info) = funding_info { + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: msg.channel_id, + funding_info, }, - }, - None, - )); + None, + )); + } } if let Some(msg) = shutdown { @@ -15553,21 +15528,20 @@ impl< chan.peer_disconnected_is_resumable(&&logger); if let Some(splice_funding_failed) = splice_funding_failed { + let (funding_info, contribution) = splice_funding_failed.into_parts(); splice_failed_events.push(events::Event::SpliceFailed { channel_id: chan.context().channel_id(), counterparty_node_id, user_channel_id: chan.context().get_user_id(), - abandoned_funding_txo: splice_funding_failed.funding_txo, - channel_type: splice_funding_failed.channel_type, + contribution, reason: events::NegotiationFailureReason::PeerDisconnected, }); - splice_failed_events.push(events::Event::DiscardFunding { - channel_id: chan.context().channel_id(), - funding_info: FundingInfo::Contribution { - inputs: splice_funding_failed.contributed_inputs, - outputs: splice_funding_failed.contributed_outputs, - }, - }); + if let Some(funding_info) = funding_info { + splice_failed_events.push(events::Event::DiscardFunding { + channel_id: chan.context().channel_id(), + funding_info, + }); + } } if is_resumable { @@ -18181,27 +18155,26 @@ impl< for peer_state in peer_states.iter() { for chan in peer_state.channel_by_id.values().filter_map(Channel::as_funded) { if let Some(splice_funding_failed) = chan.maybe_splice_funding_failed() { + let (funding_info, contribution) = splice_funding_failed.into_parts(); events.push_back(( events::Event::SpliceFailed { channel_id: chan.context.channel_id(), counterparty_node_id: chan.context.get_counterparty_node_id(), user_channel_id: chan.context.get_user_id(), - abandoned_funding_txo: splice_funding_failed.funding_txo, - channel_type: splice_funding_failed.channel_type, reason: events::NegotiationFailureReason::PeerDisconnected, + contribution, }, None, )); - events.push_back(( - events::Event::DiscardFunding { - channel_id: chan.context().channel_id(), - funding_info: FundingInfo::Contribution { - inputs: splice_funding_failed.contributed_inputs, - outputs: splice_funding_failed.contributed_outputs, + if let Some(funding_info) = funding_info { + events.push_back(( + events::Event::DiscardFunding { + channel_id: chan.context().channel_id(), + funding_info, }, - }, - None, - )); + None, + )); + } } } } diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index b8ef5890899..df161715152 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -3237,9 +3237,10 @@ pub fn expect_splice_failed_events<'a, 'b, 'c, 'd>( let events = node.node.get_and_clear_pending_events(); assert_eq!(events.len(), 2); match &events[0] { - Event::SpliceFailed { channel_id, reason, .. } => { + Event::SpliceFailed { channel_id, reason, contribution, .. } => { assert_eq!(*expected_channel_id, *channel_id); assert_eq!(expected_reason, *reason); + assert_eq!(contribution.as_ref(), Some(&funding_contribution)); }, _ => panic!("Unexpected event"), } diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 34b51e2696c..63d0b32f1fd 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -205,13 +205,12 @@ pub fn do_initiate_splice_in<'a, 'b, 'c, 'd>( pub fn do_initiate_rbf_splice_in<'a, 'b, 'c, 'd>( node: &'a Node<'b, 'c, 'd>, counterparty: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, - value_added: Amount, feerate: FeeRate, + feerate: FeeRate, ) -> FundingContribution { let node_id_counterparty = counterparty.node.get_our_node_id(); let funding_template = node.node.splice_channel(&channel_id, &node_id_counterparty).unwrap(); - let wallet = WalletSync::new(Arc::clone(&node.wallet_source), node.logger); let funding_contribution = - funding_template.splice_in_sync(value_added, feerate, FeeRate::MAX, &wallet).unwrap(); + funding_template.with_prior_contribution(feerate, FeeRate::MAX).build().unwrap(); node.node .funding_contributed(&channel_id, &node_id_counterparty, funding_contribution.clone(), None) .unwrap(); @@ -220,15 +219,12 @@ pub fn do_initiate_rbf_splice_in<'a, 'b, 'c, 'd>( pub fn do_initiate_rbf_splice_in_and_out<'a, 'b, 'c, 'd>( node: &'a Node<'b, 'c, 'd>, counterparty: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, - value_added: Amount, outputs: Vec, feerate: FeeRate, + outputs: Vec, feerate: FeeRate, ) -> FundingContribution { let node_id_counterparty = counterparty.node.get_our_node_id(); let funding_template = node.node.splice_channel(&channel_id, &node_id_counterparty).unwrap(); - let wallet = WalletSync::new(Arc::clone(&node.wallet_source), node.logger); let funding_contribution = funding_template - .without_prior_contribution(feerate, FeeRate::MAX) - .with_coin_selection_source_sync(&wallet) - .add_value(value_added) + .with_prior_contribution(feerate, FeeRate::MAX) .add_outputs(outputs) .build() .unwrap(); @@ -238,6 +234,22 @@ pub fn do_initiate_rbf_splice_in_and_out<'a, 'b, 'c, 'd>( funding_contribution } +pub fn do_initiate_splice_in_at_feerate<'a, 'b, 'c, 'd>( + initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, + value_added: Amount, feerate: FeeRate, +) -> FundingContribution { + let node_id_acceptor = acceptor.node.get_our_node_id(); + let funding_template = initiator.node.splice_channel(&channel_id, &node_id_acceptor).unwrap(); + let wallet = WalletSync::new(Arc::clone(&initiator.wallet_source), initiator.logger); + let funding_contribution = + funding_template.splice_in_sync(value_added, feerate, FeeRate::MAX, &wallet).unwrap(); + initiator + .node + .funding_contributed(&channel_id, &node_id_acceptor, funding_contribution.clone(), None) + .unwrap(); + funding_contribution +} + pub fn initiate_splice_out<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, outputs: Vec, @@ -3207,9 +3219,10 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool, pending_ let events = nodes[0].node.get_and_clear_pending_events(); assert_eq!(events.len(), 2, "{events:?}"); match &events[0] { - Event::SpliceFailed { channel_id: cid, reason, .. } => { + Event::SpliceFailed { channel_id: cid, reason, contribution, .. } => { assert_eq!(*cid, channel_id); assert_eq!(*reason, NegotiationFailureReason::ChannelClosing); + assert!(contribution.is_some()); }, other => panic!("Expected SpliceFailed, got {:?}", other), } @@ -4601,9 +4614,10 @@ fn test_splice_acceptor_disconnect_emits_events() { let events = nodes[1].node.get_and_clear_pending_events(); assert_eq!(events.len(), 2, "{events:?}"); match &events[0] { - Event::SpliceFailed { channel_id: cid, reason, .. } => { + Event::SpliceFailed { channel_id: cid, reason, contribution, .. } => { assert_eq!(*cid, channel_id); assert_eq!(*reason, NegotiationFailureReason::PeerDisconnected); + assert!(contribution.is_some()); }, other => panic!("Expected SpliceFailed, got {:?}", other), } @@ -4660,7 +4674,7 @@ fn test_splice_rbf_acceptor_basic() { let rbf_feerate_sat_per_kwu = FEERATE_FLOOR_SATS_PER_KW as u64 + 25; let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); let funding_contribution = - do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, rbf_feerate); // Steps 4-8: STFU exchange → tx_init_rbf → tx_ack_rbf. complete_rbf_handshake(&nodes[0], &nodes[1]); @@ -4724,8 +4738,7 @@ fn test_splice_rbf_at_high_feerate() { // Step 2: RBF to a high feerate (1000 sat/kwu, well above the 600 crossover point). provide_utxo_reserves(&nodes, 2, added_value * 2); let high_feerate = FeeRate::from_sat_per_kwu(1000); - let contribution = - do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, high_feerate); + let contribution = do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, high_feerate); complete_rbf_handshake(&nodes[0], &nodes[1]); complete_interactive_funding_negotiation( &nodes[0], @@ -4750,8 +4763,7 @@ fn test_splice_rbf_at_high_feerate() { let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); funding_template.min_rbf_feerate().unwrap() }; - let contribution = - do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + let contribution = do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, rbf_feerate); complete_rbf_handshake(&nodes[0], &nodes[1]); complete_interactive_funding_negotiation( &nodes[0], @@ -4816,7 +4828,7 @@ fn test_splice_rbf_insufficient_feerate() { // Node 0 initiates a proper RBF but we tamper the feerate to be insufficient. provide_utxo_reserves(&nodes, 2, added_value * 2); let _funding_contribution = - do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, min_rbf_feerate); + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, min_rbf_feerate); let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); nodes[1].node.handle_stfu(node_id_0, &stfu_0); @@ -4842,17 +4854,14 @@ fn test_splice_rbf_insufficient_feerate() { // Node 0 echoes tx_abort and exits quiescence, freeing the holding cell. nodes[0].node.handle_tx_abort(node_id_1, &tx_abort); - // TODO: the RBF round's inputs are partially filtered against the prior round's committed - // UTXOs, so the DiscardFunding carries coin-selection-dependent residue. Revisit once - // #4514 lands to see if its semantics change what DiscardFunding contains here. + // The RBF round contributed the same inputs and outputs as the prior round, so after + // filtering against the prior round's committed UTXOs nothing remains to discard and + // `DiscardFunding` is suppressed; only `SpliceFailed` is emitted. let events = nodes[0].node.get_and_clear_pending_events(); - assert_eq!(events.len(), 2, "{events:?}"); + assert_eq!(events.len(), 1, "{events:?}"); assert!( matches!(&events[0], Event::SpliceFailed { channel_id: cid, .. } if *cid == channel_id) ); - assert!( - matches!(&events[1], Event::DiscardFunding { channel_id: cid, .. } if *cid == channel_id) - ); let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); assert_eq!(msg_events.len(), 2, "{msg_events:?}"); @@ -4885,7 +4894,7 @@ fn test_splice_rbf_insufficient_feerate() { // Node 0 initiates another proper RBF but we tamper the feerate to the 25/24 value. provide_utxo_reserves(&nodes, 2, added_value * 2); let _funding_contribution = - do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, min_rbf_feerate); + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, min_rbf_feerate); let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); nodes[1].node.handle_stfu(node_id_0, &stfu_0); @@ -4903,22 +4912,19 @@ fn test_splice_rbf_insufficient_feerate() { nodes[0].node.handle_tx_abort(node_id_1, &tx_abort); let tx_abort_echo = get_event_msg!(nodes[0], MessageSendEvent::SendTxAbort, node_id_1); - // TODO: same as above — revisit once #4514 lands. + // As above: nothing remains after filtering, so `DiscardFunding` is suppressed. let events = nodes[0].node.get_and_clear_pending_events(); - assert_eq!(events.len(), 2); + assert_eq!(events.len(), 1); assert!( matches!(&events[0], Event::SpliceFailed { channel_id: cid, .. } if *cid == channel_id) ); - assert!( - matches!(&events[1], Event::DiscardFunding { channel_id: cid, .. } if *cid == channel_id) - ); nodes[1].node.handle_tx_abort(node_id_0, &tx_abort_echo); // Acceptor-side: prev + 25 = 278 satisfies the combined BIP125 rule and is accepted. provide_utxo_reserves(&nodes, 2, added_value * 2); let _funding_contribution = - do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, min_rbf_feerate); + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, min_rbf_feerate); let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); nodes[1].node.handle_stfu(node_id_0, &stfu_0); @@ -4958,8 +4964,7 @@ fn test_splice_rbf_insufficient_feerate_high() { provide_utxo_reserves(&nodes, 2, added_value * 2); let high_feerate = FeeRate::from_sat_per_kwu(1000); - let contribution = - do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, high_feerate); + let contribution = do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, high_feerate); complete_rbf_handshake(&nodes[0], &nodes[1]); complete_interactive_funding_negotiation( &nodes[0], @@ -4980,7 +4985,7 @@ fn test_splice_rbf_insufficient_feerate_high() { provide_utxo_reserves(&nodes, 2, added_value * 2); let min_rbf_feerate = FeeRate::from_sat_per_kwu(1041); let _funding_contribution = - do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, min_rbf_feerate); + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, min_rbf_feerate); let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); nodes[1].node.handle_stfu(node_id_0, &stfu_0); @@ -4997,33 +5002,20 @@ fn test_splice_rbf_insufficient_feerate_high() { nodes[0].node.handle_tx_abort(node_id_1, &tx_abort); let tx_abort_echo = get_event_msg!(nodes[0], MessageSendEvent::SendTxAbort, node_id_1); - // TODO: the RBF round's inputs are fully filtered against the prior round's committed - // UTXOs, so this DiscardFunding is emitted with empty inputs and outputs. Once #4514 - // lands, a fully-drained DiscardFunding should be suppressed entirely — expect - // `events.len() == 1`. + // The RBF round's inputs and outputs are fully filtered against the prior round's + // committed UTXOs, so `DiscardFunding` is suppressed. let events = nodes[0].node.get_and_clear_pending_events(); - assert_eq!(events.len(), 2); + assert_eq!(events.len(), 1); assert!( matches!(&events[0], Event::SpliceFailed { channel_id: cid, .. } if *cid == channel_id) ); - match &events[1] { - Event::DiscardFunding { - channel_id: cid, - funding_info: FundingInfo::Contribution { inputs, outputs }, - } => { - assert_eq!(*cid, channel_id); - assert!(inputs.is_empty(), "Expected inputs filtered, got {inputs:?}"); - assert!(outputs.is_empty(), "Expected outputs filtered, got {outputs:?}"); - }, - other => panic!("Expected DiscardFunding with Contribution, got {other:?}"), - } nodes[1].node.handle_tx_abort(node_id_0, &tx_abort_echo); // Feerate 1041 satisfies both rules — accepted. provide_utxo_reserves(&nodes, 2, added_value * 2); let _funding_contribution = - do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, min_rbf_feerate); + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, min_rbf_feerate); let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); nodes[1].node.handle_stfu(node_id_0, &stfu_0); @@ -5315,7 +5307,7 @@ fn test_splice_rbf_not_quiescence_initiator() { let rbf_feerate_sat_per_kwu = FEERATE_FLOOR_SATS_PER_KW as u64 + 25; let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); let _funding_contribution = - do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, rbf_feerate); // STFU exchange: node 0 initiates quiescence. let stfu_init = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); @@ -5425,10 +5417,10 @@ pub fn do_test_splice_rbf_tiebreak( // Node 0 calls splice_channel + funding_contributed. let node_0_funding_contribution = - do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate_0); + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, rbf_feerate_0); // Node 1 calls splice_channel + funding_contributed. - let node_1_funding_contribution = do_initiate_rbf_splice_in( + let node_1_funding_contribution = do_initiate_splice_in_at_feerate( &nodes[1], &nodes[0], channel_id, @@ -5838,7 +5830,7 @@ fn test_splice_rbf_acceptor_recontributes() { let rbf_feerate_sat_per_kwu = FEERATE_FLOOR_SATS_PER_KW as u64 + 25; let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); let rbf_funding_contribution = - do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, rbf_feerate); // Steps 6-9: STFU exchange → tx_init_rbf → tx_ack_rbf. // Node 1 should re-contribute via our_prior_contribution. @@ -5967,7 +5959,7 @@ fn test_splice_rbf_after_counterparty_rbf_aborted() { let rbf_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25); let _rbf_funding_contribution = - do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, rbf_feerate); let tx_ack_rbf = complete_rbf_handshake(&nodes[0], &nodes[1]); assert!(tx_ack_rbf.funding_output_contribution.is_some()); @@ -6163,7 +6155,7 @@ fn test_splice_rbf_sequential() { let rbf_feerate_1 = FeeRate::from_sat_per_kwu(feerate_1_sat_per_kwu); let funding_contribution_1 = - do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate_1); + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, rbf_feerate_1); complete_rbf_handshake(&nodes[0], &nodes[1]); complete_interactive_funding_negotiation( @@ -6184,7 +6176,7 @@ fn test_splice_rbf_sequential() { let rbf_feerate_2 = FeeRate::from_sat_per_kwu(feerate_2_sat_per_kwu); let funding_contribution_2 = - do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate_2); + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, rbf_feerate_2); complete_rbf_handshake(&nodes[0], &nodes[1]); complete_interactive_funding_negotiation( @@ -6511,7 +6503,7 @@ fn test_splice_rbf_acceptor_contributes_then_disconnects() { let rbf_feerate_sat_per_kwu = FEERATE_FLOOR_SATS_PER_KW as u64 + 25; let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); let _rbf_funding_contribution = - do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, rbf_feerate); let tx_ack_rbf = complete_rbf_handshake(&nodes[0], &nodes[1]); assert!( @@ -6523,24 +6515,22 @@ fn test_splice_rbf_acceptor_contributes_then_disconnects() { nodes[0].node.peer_disconnected(node_id_1); nodes[1].node.peer_disconnected(node_id_0); - // The initiator should get SpliceFailed + DiscardFunding. + // The initiator re-used the same UTXOs as round 0. Since those UTXOs are still committed + // to round 0's splice, they are filtered and no DiscardFunding is emitted. let events = nodes[0].node.get_and_clear_pending_events(); - assert_eq!(events.len(), 2, "{events:?}"); + assert_eq!(events.len(), 1, "{events:?}"); match &events[0] { - Event::SpliceFailed { channel_id: cid, reason, .. } => { + Event::SpliceFailed { channel_id: cid, reason, contribution, .. } => { assert_eq!(*cid, channel_id); assert_eq!(*reason, NegotiationFailureReason::PeerDisconnected); + assert!(contribution.is_some()); }, other => panic!("Expected SpliceFailed, got {:?}", other), } - match &events[1] { - Event::DiscardFunding { funding_info: FundingInfo::Contribution { .. }, .. } => {}, - other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), - } // The acceptor re-contributed the same UTXOs as round 0 (via prior contribution // adjustment). Since those UTXOs are still committed to round 0's splice, they are - // filtered from the DiscardFunding event. With all inputs/outputs filtered, no events + // filtered and no DiscardFunding is emitted. With all inputs/outputs filtered, no events // are emitted for the acceptor. let events = nodes[1].node.get_and_clear_pending_events(); assert_eq!(events.len(), 0, "{events:?}"); @@ -6593,7 +6583,6 @@ fn test_splice_rbf_disconnect_filters_prior_contributions() { &nodes[0], &nodes[1], channel_id, - added_value, vec![splice_out_output.clone()], rbf_feerate, ); @@ -6609,9 +6598,10 @@ fn test_splice_rbf_disconnect_filters_prior_contributions() { let events = nodes[0].node.get_and_clear_pending_events(); assert_eq!(events.len(), 2, "{events:?}"); match &events[0] { - Event::SpliceFailed { channel_id: cid, reason, .. } => { + Event::SpliceFailed { channel_id: cid, reason, contribution, .. } => { assert_eq!(*cid, channel_id); assert_eq!(*reason, NegotiationFailureReason::PeerDisconnected); + assert!(contribution.is_some()); }, other => panic!("Expected SpliceFailed, got {:?}", other), } @@ -6641,7 +6631,7 @@ fn test_splice_rbf_disconnect_filters_prior_contributions() { let rbf_feerate_2 = FeeRate::from_sat_per_kwu(feerate_1_sat_per_kwu); let _funding_contribution_2 = - do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate_2); + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, rbf_feerate_2); complete_rbf_handshake(&nodes[0], &nodes[1]); // Disconnect again to clean up the in-progress interactive TX negotiation. @@ -6649,18 +6639,15 @@ fn test_splice_rbf_disconnect_filters_prior_contributions() { nodes[1].node.peer_disconnected(node_id_0); let events = nodes[0].node.get_and_clear_pending_events(); - assert_eq!(events.len(), 2, "{events:?}"); + assert_eq!(events.len(), 1, "{events:?}"); match &events[0] { - Event::SpliceFailed { channel_id: cid, reason, .. } => { + Event::SpliceFailed { channel_id: cid, reason, contribution, .. } => { assert_eq!(*cid, channel_id); assert_eq!(*reason, NegotiationFailureReason::PeerDisconnected); + assert!(contribution.is_some()); }, other => panic!("Expected SpliceFailed, got {:?}", other), } - match &events[1] { - Event::DiscardFunding { .. } => {}, - other => panic!("Expected DiscardFunding, got {:?}", other), - } let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); reconnect_args.send_announcement_sigs = (true, true); @@ -7326,8 +7313,7 @@ fn test_splice_rbf_rejects_low_feerate_after_several_attempts() { let feerate = prev_feerate + 25; provide_utxo_reserves(&nodes, 2, added_value * 2); let rbf_feerate = FeeRate::from_sat_per_kwu(feerate); - let contribution = - do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + let contribution = do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, rbf_feerate); complete_rbf_handshake(&nodes[0], &nodes[1]); complete_interactive_funding_negotiation( &nodes[0], @@ -7353,8 +7339,7 @@ fn test_splice_rbf_rejects_low_feerate_after_several_attempts() { let next_feerate = prev_feerate + 25; provide_utxo_reserves(&nodes, 2, added_value * 2); let rbf_feerate = FeeRate::from_sat_per_kwu(next_feerate); - let _contribution = - do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + let _contribution = do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, rbf_feerate); let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); nodes[1].node.handle_stfu(node_id_0, &stfu_0); let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); @@ -7403,8 +7388,7 @@ fn test_splice_rbf_rejects_own_low_feerate_after_several_attempts() { let feerate = prev_feerate + 25; provide_utxo_reserves(&nodes, 2, added_value * 2); let rbf_feerate = FeeRate::from_sat_per_kwu(feerate); - let contribution = - do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + let contribution = do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, rbf_feerate); complete_rbf_handshake(&nodes[0], &nodes[1]); complete_interactive_funding_negotiation( &nodes[0], @@ -7446,9 +7430,10 @@ fn test_splice_rbf_rejects_own_low_feerate_after_several_attempts() { let events = nodes[0].node.get_and_clear_pending_events(); assert_eq!(events.len(), 1, "{events:?}"); match &events[0] { - Event::SpliceFailed { channel_id: cid, reason, .. } => { + Event::SpliceFailed { channel_id: cid, reason, contribution, .. } => { assert_eq!(*cid, channel_id); assert_eq!(*reason, NegotiationFailureReason::FeeRateTooLow); + assert!(contribution.is_some()); }, other => panic!("Expected SpliceFailed, got {:?}", other), } From 14c6819990fefbda725f548466958f1a9c611256 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 25 Mar 2026 18:44:24 -0500 Subject: [PATCH 355/627] Emit DiscardFunding before SpliceFailed Reverse the event ordering at all emission sites so that Event::DiscardFunding is emitted before Event::SpliceFailed. If the user retries the splice when handling SpliceFailed, the contributed inputs would still be locked. A subsequent DiscardFunding would then incorrectly unlock inputs that are now committed to the new attempt. Emitting DiscardFunding first avoids this by ensuring inputs are unlocked before any retry occurs. Co-Authored-By: Claude Opus 4.6 (1M context) --- lightning/src/ln/channelmanager.rs | 156 ++++++++++++---------- lightning/src/ln/functional_test_utils.rs | 18 +-- lightning/src/ln/splicing_tests.rs | 50 +++---- 3 files changed, 120 insertions(+), 104 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 2d6aaa56c5f..6a3be0c8673 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -4163,6 +4163,15 @@ impl< if let Some(splice_funding_failed) = splice_funding_failed { let (funding_info, contribution) = splice_funding_failed.into_parts(); let mut pending_events = self.pending_events.lock().unwrap(); + if let Some(funding_info) = funding_info { + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: *chan_id, + funding_info, + }, + None, + )); + } pending_events.push_back(( events::Event::SpliceFailed { channel_id: *chan_id, @@ -4173,15 +4182,6 @@ impl< }, None, )); - if let Some(funding_info) = funding_info { - pending_events.push_back(( - events::Event::DiscardFunding { - channel_id: *chan_id, - funding_info, - }, - None, - )); - } } // We can send the `shutdown` message before updating the `ChannelMonitor` @@ -4469,6 +4469,15 @@ impl< if let Some(splice_funding_failed) = shutdown_res.splice_funding_failed.take() { let (funding_info, contribution) = splice_funding_failed.into_parts(); + if let Some(funding_info) = funding_info { + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: shutdown_res.channel_id, + funding_info, + }, + None, + )); + } pending_events.push_back(( events::Event::SpliceFailed { channel_id: shutdown_res.channel_id, @@ -4479,15 +4488,6 @@ impl< }, None, )); - if let Some(funding_info) = funding_info { - pending_events.push_back(( - events::Event::DiscardFunding { - channel_id: shutdown_res.channel_id, - funding_info, - }, - None, - )); - } } if let Some(transaction) = shutdown_res.unbroadcasted_funding_tx { @@ -4975,6 +4975,15 @@ impl< if let Some(splice_funding_failed) = splice_funding_failed { let (funding_info, contribution) = splice_funding_failed.into_parts(); let pending_events = &mut self.pending_events.lock().unwrap(); + if let Some(funding_info) = funding_info { + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: *channel_id, + funding_info, + }, + None, + )); + } pending_events.push_back(( events::Event::SpliceFailed { channel_id: *channel_id, @@ -4985,15 +4994,6 @@ impl< }, None, )); - if let Some(funding_info) = funding_info { - pending_events.push_back(( - events::Event::DiscardFunding { - channel_id: *channel_id, - funding_info, - }, - None, - )); - } } Ok(()) @@ -6676,6 +6676,12 @@ impl< QuiescentError::FailSplice(splice_funding_failed, reason) => { let (funding_info, contribution) = splice_funding_failed.into_parts(); let pending_events = &mut self.pending_events.lock().unwrap(); + if let Some(funding_info) = funding_info { + pending_events.push_back(( + events::Event::DiscardFunding { channel_id, funding_info }, + None, + )); + } pending_events.push_back(( events::Event::SpliceFailed { channel_id, @@ -6686,12 +6692,6 @@ impl< }, None, )); - if let Some(funding_info) = funding_info { - pending_events.push_back(( - events::Event::DiscardFunding { channel_id, funding_info }, - None, - )); - } }, } } @@ -11967,6 +11967,10 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ if let Some(splice_funding_failed) = err.splice_funding_failed { let (funding_info, contribution) = splice_funding_failed.into_parts(); let pending_events = &mut self.pending_events.lock().unwrap(); + if let Some(funding_info) = funding_info { + pending_events + .push_back((events::Event::DiscardFunding { channel_id, funding_info }, None)); + } pending_events.push_back(( events::Event::SpliceFailed { channel_id, @@ -11979,10 +11983,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }, None, )); - if let Some(funding_info) = funding_info { - pending_events - .push_back((events::Event::DiscardFunding { channel_id, funding_info }, None)); - } } MsgHandleErrInternal::from_chan_no_close(err.err, channel_id) } @@ -12300,6 +12300,15 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ if let Some(splice_funding_failed) = splice_failed { let (funding_info, contribution) = splice_funding_failed.into_parts(); let pending_events = &mut self.pending_events.lock().unwrap(); + if let Some(funding_info) = funding_info { + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: msg.channel_id, + funding_info, + }, + None, + )); + } pending_events.push_back(( events::Event::SpliceFailed { channel_id: msg.channel_id, @@ -12314,15 +12323,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }, None, )); - if let Some(funding_info) = funding_info { - pending_events.push_back(( - events::Event::DiscardFunding { - channel_id: msg.channel_id, - funding_info, - }, - None, - )); - } } let holding_cell_res = if needs_holding_cell_release { @@ -12452,6 +12452,15 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ if let Some(splice_funding_failed) = splice_funding_failed { let (funding_info, contribution) = splice_funding_failed.into_parts(); let mut pending_events = self.pending_events.lock().unwrap(); + if let Some(funding_info) = funding_info { + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: msg.channel_id, + funding_info, + }, + None, + )); + } pending_events.push_back(( events::Event::SpliceFailed { channel_id: msg.channel_id, @@ -12462,15 +12471,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }, None, )); - if let Some(funding_info) = funding_info { - pending_events.push_back(( - events::Event::DiscardFunding { - channel_id: msg.channel_id, - funding_info, - }, - None, - )); - } } if let Some(msg) = shutdown { @@ -15253,6 +15253,22 @@ impl< self.process_pending_events(&event_handler); let collected_events = events.into_inner(); + // When both DiscardFunding and SpliceFailed are emitted for the same channel, + // DiscardFunding must come first so that inputs are unlocked before any retry. + // Each pair is emitted adjacently under a single lock, so checking adjacent + // events is sufficient. + for window in collected_events.windows(2) { + if let events::Event::SpliceFailed { channel_id, .. } = &window[0] { + if let events::Event::DiscardFunding { channel_id: cid, .. } = &window[1] { + assert!( + channel_id != cid, + "DiscardFunding must precede SpliceFailed for channel {}", + channel_id, + ); + } + } + } + // To expand the coverage and make sure all events are properly serialised and deserialised, // we test all generated events round-trip: for event in &collected_events { @@ -15529,6 +15545,12 @@ impl< if let Some(splice_funding_failed) = splice_funding_failed { let (funding_info, contribution) = splice_funding_failed.into_parts(); + if let Some(funding_info) = funding_info { + splice_failed_events.push(events::Event::DiscardFunding { + channel_id: chan.context().channel_id(), + funding_info, + }); + } splice_failed_events.push(events::Event::SpliceFailed { channel_id: chan.context().channel_id(), counterparty_node_id, @@ -15536,12 +15558,6 @@ impl< contribution, reason: events::NegotiationFailureReason::PeerDisconnected, }); - if let Some(funding_info) = funding_info { - splice_failed_events.push(events::Event::DiscardFunding { - channel_id: chan.context().channel_id(), - funding_info, - }); - } } if is_resumable { @@ -18156,6 +18172,15 @@ impl< for chan in peer_state.channel_by_id.values().filter_map(Channel::as_funded) { if let Some(splice_funding_failed) = chan.maybe_splice_funding_failed() { let (funding_info, contribution) = splice_funding_failed.into_parts(); + if let Some(funding_info) = funding_info { + events.push_back(( + events::Event::DiscardFunding { + channel_id: chan.context().channel_id(), + funding_info, + }, + None, + )); + } events.push_back(( events::Event::SpliceFailed { channel_id: chan.context.channel_id(), @@ -18166,15 +18191,6 @@ impl< }, None, )); - if let Some(funding_info) = funding_info { - events.push_back(( - events::Event::DiscardFunding { - channel_id: chan.context().channel_id(), - funding_info, - }, - None, - )); - } } } } diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index df161715152..c5b1104cc64 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -3237,18 +3237,10 @@ pub fn expect_splice_failed_events<'a, 'b, 'c, 'd>( let events = node.node.get_and_clear_pending_events(); assert_eq!(events.len(), 2); match &events[0] { - Event::SpliceFailed { channel_id, reason, contribution, .. } => { - assert_eq!(*expected_channel_id, *channel_id); - assert_eq!(expected_reason, *reason); - assert_eq!(contribution.as_ref(), Some(&funding_contribution)); - }, - _ => panic!("Unexpected event"), - } - match &events[1] { Event::DiscardFunding { funding_info, .. } => { if let FundingInfo::Contribution { inputs, outputs } = &funding_info { let (expected_inputs, expected_outputs) = - funding_contribution.into_contributed_inputs_and_outputs(); + funding_contribution.clone().into_contributed_inputs_and_outputs(); assert_eq!(*inputs, expected_inputs); assert_eq!(*outputs, expected_outputs); } else { @@ -3257,6 +3249,14 @@ pub fn expect_splice_failed_events<'a, 'b, 'c, 'd>( }, _ => panic!("Unexpected event"), } + match &events[1] { + Event::SpliceFailed { channel_id, reason, contribution, .. } => { + assert_eq!(*expected_channel_id, *channel_id); + assert_eq!(expected_reason, *reason); + assert_eq!(contribution.as_ref(), Some(&funding_contribution)); + }, + _ => panic!("Unexpected event"), + } } #[cfg(any(test, ldk_bench, feature = "_test_utils"))] diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 63d0b32f1fd..1c6ad835ce0 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -3219,14 +3219,6 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool, pending_ let events = nodes[0].node.get_and_clear_pending_events(); assert_eq!(events.len(), 2, "{events:?}"); match &events[0] { - Event::SpliceFailed { channel_id: cid, reason, contribution, .. } => { - assert_eq!(*cid, channel_id); - assert_eq!(*reason, NegotiationFailureReason::ChannelClosing); - assert!(contribution.is_some()); - }, - other => panic!("Expected SpliceFailed, got {:?}", other), - } - match &events[1] { Event::DiscardFunding { funding_info: FundingInfo::Contribution { inputs, outputs }, .. @@ -3240,6 +3232,14 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool, pending_ }, other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), } + match &events[1] { + Event::SpliceFailed { channel_id: cid, reason, contribution, .. } => { + assert_eq!(*cid, channel_id); + assert_eq!(*reason, NegotiationFailureReason::ChannelClosing); + assert!(contribution.is_some()); + }, + other => panic!("Expected SpliceFailed, got {:?}", other), + } } else { expect_splice_failed_events( &nodes[0], @@ -4614,14 +4614,6 @@ fn test_splice_acceptor_disconnect_emits_events() { let events = nodes[1].node.get_and_clear_pending_events(); assert_eq!(events.len(), 2, "{events:?}"); match &events[0] { - Event::SpliceFailed { channel_id: cid, reason, contribution, .. } => { - assert_eq!(*cid, channel_id); - assert_eq!(*reason, NegotiationFailureReason::PeerDisconnected); - assert!(contribution.is_some()); - }, - other => panic!("Expected SpliceFailed, got {:?}", other), - } - match &events[1] { Event::DiscardFunding { funding_info: FundingInfo::Contribution { inputs, outputs }, .. @@ -4631,6 +4623,14 @@ fn test_splice_acceptor_disconnect_emits_events() { }, other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), } + match &events[1] { + Event::SpliceFailed { channel_id: cid, reason, contribution, .. } => { + assert_eq!(*cid, channel_id); + assert_eq!(*reason, NegotiationFailureReason::PeerDisconnected); + assert!(contribution.is_some()); + }, + other => panic!("Expected SpliceFailed, got {:?}", other), + } // Reconnect and verify the channel is still operational. let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); @@ -6594,18 +6594,10 @@ fn test_splice_rbf_disconnect_filters_prior_contributions() { nodes[0].node.peer_disconnected(node_id_1); nodes[1].node.peer_disconnected(node_id_0); - // The initiator should get SpliceFailed + DiscardFunding with filtered contributions. + // The initiator should get DiscardFunding + SpliceFailed with filtered contributions. let events = nodes[0].node.get_and_clear_pending_events(); assert_eq!(events.len(), 2, "{events:?}"); match &events[0] { - Event::SpliceFailed { channel_id: cid, reason, contribution, .. } => { - assert_eq!(*cid, channel_id); - assert_eq!(*reason, NegotiationFailureReason::PeerDisconnected); - assert!(contribution.is_some()); - }, - other => panic!("Expected SpliceFailed, got {:?}", other), - } - match &events[1] { Event::DiscardFunding { funding_info: FundingInfo::Contribution { inputs, outputs }, .. @@ -6618,6 +6610,14 @@ fn test_splice_rbf_disconnect_filters_prior_contributions() { }, other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), } + match &events[1] { + Event::SpliceFailed { channel_id: cid, reason, contribution, .. } => { + assert_eq!(*cid, channel_id); + assert_eq!(*reason, NegotiationFailureReason::PeerDisconnected); + assert!(contribution.is_some()); + }, + other => panic!("Expected SpliceFailed, got {:?}", other), + } // Reconnect. After a completed splice, channel_ready is not re-sent. let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); From cc7fb0f5104d88240605a7f8b25fc58090bd1469 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 25 Mar 2026 18:58:50 -0500 Subject: [PATCH 356/627] Rename SplicePending and SpliceFailed events Rename Event::SplicePending to Event::SpliceNegotiated and Event::SpliceFailed to Event::SpliceNegotiationFailed. These names better reflect the per-round semantics: each negotiation attempt resolves to one of these two outcomes, independent of the overall splice lifecycle. Co-Authored-By: Claude Opus 4.6 (1M context) --- fuzz/src/chanmon_consistency.rs | 4 +- fuzz/src/full_stack.rs | 4 +- lightning/src/events/mod.rs | 16 +++--- lightning/src/ln/async_signer_tests.rs | 8 +-- lightning/src/ln/channel.rs | 10 ++-- lightning/src/ln/channelmanager.rs | 53 +++++++++--------- lightning/src/ln/functional_test_utils.rs | 6 +-- lightning/src/ln/funding.rs | 4 +- lightning/src/ln/splicing_tests.rs | 54 +++++++++---------- .../4388-splice-failed-discard-funding.txt | 8 +-- 10 files changed, 84 insertions(+), 83 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index d678d97918f..678e6a6fc61 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -2091,7 +2091,7 @@ pub fn do_test(data: &[u8], out: Out) { ) .unwrap(); }, - events::Event::SplicePending { new_funding_txo, .. } => { + events::Event::SpliceNegotiated { new_funding_txo, .. } => { let broadcaster = match $node { 0 => &broadcast_a, 1 => &broadcast_b, @@ -2103,7 +2103,7 @@ pub fn do_test(data: &[u8], out: Out) { assert_eq!(new_funding_txo.txid, splice_tx.compute_txid()); chain_state.add_pending_tx(splice_tx); }, - events::Event::SpliceFailed { .. } => {}, + events::Event::SpliceNegotiationFailed { .. } => {}, events::Event::DiscardFunding { funding_info: events::FundingInfo::Contribution { .. } diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index 405d615e6f0..e79bef7c5ec 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -1137,10 +1137,10 @@ pub fn do_test(mut data: &[u8], logger: &Arc signed_tx, ); }, - Event::SplicePending { .. } => { + Event::SpliceNegotiated { .. } => { // Splice negotiation completed, waiting for confirmation }, - Event::SpliceFailed { .. } => { + Event::SpliceNegotiationFailed { .. } => { // Splice failed, inputs can be re-spent }, Event::OpenChannelRequest { diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 5a52be026fb..9d00273cb46 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -1646,8 +1646,8 @@ pub enum Event { /// # Failure Behavior and Persistence /// This event will eventually be replayed after failures-to-handle (i.e., the event handler /// returning `Err(ReplayEvent ())`) and will be persisted across restarts. - SplicePending { - /// The `channel_id` of the channel that has a pending splice funding transaction. + SpliceNegotiated { + /// The `channel_id` of the channel with the negotiated splice funding transaction. channel_id: ChannelId, /// The `user_channel_id` value passed in to [`ChannelManager::create_channel`] for outbound /// channels, or to [`ChannelManager::accept_inbound_channel`] for inbound channels. @@ -1667,7 +1667,7 @@ pub enum Event { }, /// Used to indicate that a splice negotiation round for the given `channel_id` has failed. /// - /// Each splice attempt (initial or RBF) resolves to either [`Event::SplicePending`] on + /// Each splice attempt (initial or RBF) resolves to either [`Event::SpliceNegotiated`] on /// success or this event on failure. Prior successfully negotiated splice transactions are /// unaffected. /// @@ -1677,7 +1677,7 @@ pub enum Event { /// # Failure Behavior and Persistence /// This event will eventually be replayed after failures-to-handle (i.e., the event handler /// returning `Err(ReplayEvent ())`) and will be persisted across restarts. - SpliceFailed { + SpliceNegotiationFailed { /// The `channel_id` of the channel for which the splice negotiation round failed. channel_id: ChannelId, /// The `user_channel_id` value passed in to [`ChannelManager::create_channel`] for outbound @@ -2468,7 +2468,7 @@ impl Writeable for Event { // We never write out FundingTransactionReadyForSigning events as they will be regenerated when // necessary. }, - &Event::SplicePending { + &Event::SpliceNegotiated { ref channel_id, ref user_channel_id, ref counterparty_node_id, @@ -2486,7 +2486,7 @@ impl Writeable for Event { (11, new_funding_redeem_script, required), }); }, - &Event::SpliceFailed { + &Event::SpliceNegotiationFailed { ref channel_id, ref user_channel_id, ref counterparty_node_id, @@ -3125,7 +3125,7 @@ impl MaybeReadable for Event { (11, new_funding_redeem_script, required), }); - Ok(Some(Event::SplicePending { + Ok(Some(Event::SpliceNegotiated { channel_id: channel_id.0.unwrap(), user_channel_id: user_channel_id.0.unwrap(), counterparty_node_id: counterparty_node_id.0.unwrap(), @@ -3146,7 +3146,7 @@ impl MaybeReadable for Event { (13, contribution, option), }); - Ok(Some(Event::SpliceFailed { + Ok(Some(Event::SpliceNegotiationFailed { channel_id: channel_id.0.unwrap(), user_channel_id: user_channel_id.0.unwrap(), counterparty_node_id: counterparty_node_id.0.unwrap(), diff --git a/lightning/src/ln/async_signer_tests.rs b/lightning/src/ln/async_signer_tests.rs index f238c1db060..ae73dd830ac 100644 --- a/lightning/src/ln/async_signer_tests.rs +++ b/lightning/src/ln/async_signer_tests.rs @@ -1647,8 +1647,8 @@ fn test_async_splice_initial_commit_sig() { get_event_msg!(initiator, MessageSendEvent::SendTxSignatures, acceptor_node_id); acceptor.node.handle_tx_signatures(initiator_node_id, &tx_signatures); - let _ = get_event!(initiator, Event::SplicePending); - let _ = get_event!(acceptor, Event::SplicePending); + let _ = get_event!(initiator, Event::SpliceNegotiated); + let _ = get_event!(acceptor, Event::SpliceNegotiated); } #[test] @@ -1739,6 +1739,6 @@ fn test_async_splice_initial_commit_sig_waits_for_monitor_before_tx_signatures() get_event_msg!(initiator, MessageSendEvent::SendTxSignatures, acceptor_node_id); acceptor.node.handle_tx_signatures(initiator_node_id, &tx_signatures); - let _ = get_event!(initiator, Event::SplicePending); - let _ = get_event!(acceptor, Event::SplicePending); + let _ = get_event!(initiator, Event::SpliceNegotiated); + let _ = get_event!(acceptor, Event::SpliceNegotiated); } diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 473fc6b46f3..b26ec70c5b8 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -1185,7 +1185,7 @@ pub(super) struct InteractiveTxMsgError { /// The underlying error. pub(super) err: ChannelError, /// If a splice was in progress when processing the message, this contains the splice funding - /// information for emitting a `SpliceFailed` event. + /// information for emitting a `SpliceNegotiationFailed` event. pub(super) splice_funding_failed: Option, } @@ -1270,7 +1270,7 @@ pub(crate) struct ShutdownResult { pub(crate) channel_funding_txo: Option, pub(crate) last_local_balance_msat: u64, /// If a splice was in progress when the channel was shut down, this contains - /// the splice funding information for emitting a SpliceFailed event. + /// the splice funding information for emitting a SpliceNegotiationFailed event. pub(crate) splice_funding_failed: Option, } @@ -1278,7 +1278,7 @@ pub(crate) struct ShutdownResult { pub(crate) struct DisconnectResult { pub(crate) is_resumable: bool, /// If a splice was in progress when the channel was shut down, this contains - /// the splice funding information for emitting a SpliceFailed event. + /// the splice funding information for emitting a SpliceNegotiationFailed event. pub(crate) splice_funding_failed: Option, } @@ -7065,7 +7065,7 @@ pub struct SpliceFundingFailed { impl SpliceFundingFailed { /// Splits into the funding info for `DiscardFunding` (if there are inputs or outputs to - /// discard) and the contribution for `SpliceFailed`. + /// discard) and the contribution for `SpliceNegotiationFailed`. pub(super) fn into_parts(self) -> (Option, Option) { let funding_info = if !self.contributed_inputs.is_empty() || !self.contributed_outputs.is_empty() { @@ -12436,7 +12436,7 @@ where // // If the in-progress negotiation later fails (e.g., tx_abort), the derived // min_rbf_feerate becomes stale, causing a slightly higher feerate than - // necessary. Call splice_channel again after receiving SpliceFailed to get a + // necessary. Call splice_channel again after receiving SpliceNegotiationFailed to get a // fresh template without the stale RBF constraint. let prev_feerate = pending_splice.last_funding_feerate_sat_per_1000_weight.or_else(|| { diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 6a3be0c8673..7a3a5bd1ee4 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -4173,7 +4173,7 @@ impl< )); } pending_events.push_back(( - events::Event::SpliceFailed { + events::Event::SpliceNegotiationFailed { channel_id: *chan_id, counterparty_node_id: *counterparty_node_id, user_channel_id: chan.context().get_user_id(), @@ -4479,7 +4479,7 @@ impl< )); } pending_events.push_back(( - events::Event::SpliceFailed { + events::Event::SpliceNegotiationFailed { channel_id: shutdown_res.channel_id, counterparty_node_id: shutdown_res.counterparty_node_id, user_channel_id: shutdown_res.user_channel_id, @@ -4985,7 +4985,7 @@ impl< )); } pending_events.push_back(( - events::Event::SpliceFailed { + events::Event::SpliceNegotiationFailed { channel_id: *channel_id, counterparty_node_id: *counterparty_node_id, user_channel_id: chan.context.get_user_id(), @@ -6683,7 +6683,7 @@ impl< )); } pending_events.push_back(( - events::Event::SpliceFailed { + events::Event::SpliceNegotiationFailed { channel_id, counterparty_node_id, user_channel_id, @@ -6741,14 +6741,14 @@ impl< /// # Events /// /// Calling this method will commence the process of creating a new funding transaction for the - /// channel. Once the funding transaction has been constructed, an [`Event::SplicePending`] + /// channel. Once the funding transaction has been constructed, an [`Event::SpliceNegotiated`] /// will be emitted. At this point, any inputs contributed to the splice can only be re-spent /// if an [`Event::DiscardFunding`] is seen. /// - /// If any failures occur while negotiating the funding transaction, an [`Event::SpliceFailed`] - /// will be emitted. Any contributed inputs no longer used will be included in an - /// [`Event::DiscardFunding`] and thus can be re-spent. If a [`FundingTemplate`] was obtained - /// while a previous splice was still being negotiated, its + /// If any failures occur while negotiating the funding transaction, an + /// [`Event::SpliceNegotiationFailed`] will be emitted. Any contributed inputs no longer used + /// will be included in an [`Event::DiscardFunding`] and thus can be re-spent. If a + /// [`FundingTemplate`] was obtained while a previous splice was still being negotiated, its /// [`min_rbf_feerate`][FundingTemplate::min_rbf_feerate] may be stale after the failure. /// Call [`ChannelManager::splice_channel`] again to get a fresh template. /// @@ -6967,7 +6967,7 @@ impl< } if let Some(splice_negotiated) = splice_negotiated { self.pending_events.lock().unwrap().push_back(( - events::Event::SplicePending { + events::Event::SpliceNegotiated { channel_id: *channel_id, counterparty_node_id: *counterparty_node_id, user_channel_id: chan.context().get_user_id(), @@ -11131,7 +11131,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ .and_then(|v| v.splice_negotiated.take()) { pending_events.push_back(( - events::Event::SplicePending { + events::Event::SpliceNegotiated { channel_id: channel.context.channel_id(), counterparty_node_id, user_channel_id: channel.context.get_user_id(), @@ -11972,7 +11972,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ .push_back((events::Event::DiscardFunding { channel_id, funding_info }, None)); } pending_events.push_back(( - events::Event::SpliceFailed { + events::Event::SpliceNegotiationFailed { channel_id, counterparty_node_id: *counterparty_node_id, user_channel_id, @@ -12224,7 +12224,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let needs_holding_cell_release = splice_negotiated.is_some(); if let Some(splice_negotiated) = splice_negotiated { self.pending_events.lock().unwrap().push_back(( - events::Event::SplicePending { + events::Event::SpliceNegotiated { channel_id: msg.channel_id, counterparty_node_id: *counterparty_node_id, user_channel_id: chan.context.get_user_id(), @@ -12310,7 +12310,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ )); } pending_events.push_back(( - events::Event::SpliceFailed { + events::Event::SpliceNegotiationFailed { channel_id: msg.channel_id, counterparty_node_id: *counterparty_node_id, user_channel_id: chan_entry.get().context().get_user_id(), @@ -12462,7 +12462,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ )); } pending_events.push_back(( - events::Event::SpliceFailed { + events::Event::SpliceNegotiationFailed { channel_id: msg.channel_id, counterparty_node_id: *counterparty_node_id, user_channel_id: chan.context().get_user_id(), @@ -15253,16 +15253,16 @@ impl< self.process_pending_events(&event_handler); let collected_events = events.into_inner(); - // When both DiscardFunding and SpliceFailed are emitted for the same channel, - // DiscardFunding must come first so that inputs are unlocked before any retry. - // Each pair is emitted adjacently under a single lock, so checking adjacent - // events is sufficient. + // When both DiscardFunding and SpliceNegotiationFailed are emitted for the same + // channel, DiscardFunding must come first so that inputs are unlocked before any + // retry. Each pair is emitted adjacently under a single lock, so checking + // adjacent events is sufficient. for window in collected_events.windows(2) { - if let events::Event::SpliceFailed { channel_id, .. } = &window[0] { + if let events::Event::SpliceNegotiationFailed { channel_id, .. } = &window[0] { if let events::Event::DiscardFunding { channel_id: cid, .. } = &window[1] { assert!( channel_id != cid, - "DiscardFunding must precede SpliceFailed for channel {}", + "DiscardFunding must precede SpliceNegotiationFailed for channel {}", channel_id, ); } @@ -15551,7 +15551,7 @@ impl< funding_info, }); } - splice_failed_events.push(events::Event::SpliceFailed { + splice_failed_events.push(events::Event::SpliceNegotiationFailed { channel_id: chan.context().channel_id(), counterparty_node_id, user_channel_id: chan.context().get_user_id(), @@ -18163,8 +18163,9 @@ impl< let our_pending_intercepts = self.pending_intercepted_htlcs.lock().unwrap(); // Since some FundingNegotiation variants are not persisted, any splice in such state must - // be failed upon reload. However, as the necessary information for the SpliceFailed and - // DiscardFunding events is not persisted, the events need to be persisted even though they + // be failed upon reload. However, as the necessary information for the + // SpliceNegotiationFailed and DiscardFunding events is not persisted, the events need to + // be persisted even though they // haven't been emitted yet. These are removed after the events are written. let mut events = self.pending_events.lock().unwrap(); let event_count = events.len(); @@ -18182,7 +18183,7 @@ impl< )); } events.push_back(( - events::Event::SpliceFailed { + events::Event::SpliceNegotiationFailed { channel_id: chan.context.channel_id(), counterparty_node_id: chan.context.get_counterparty_node_id(), user_channel_id: chan.context.get_user_id(), @@ -18311,7 +18312,7 @@ impl< (23, self.best_block.read().unwrap().previous_blocks, required), }); - // Remove the SpliceFailed and DiscardFunding events added earlier. + // Remove the SpliceNegotiationFailed and DiscardFunding events added earlier. events.truncate(event_count); Ok(()) diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index c5b1104cc64..f89fdd0572b 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -2378,7 +2378,7 @@ pub fn check_closed_events(node: &Node, expected_close_events: &[ExpectedCloseEv discard_events_count ); assert_eq!( - events.iter().filter(|e| matches!(e, Event::SpliceFailed { .. },)).count(), + events.iter().filter(|e| matches!(e, Event::SpliceNegotiationFailed { .. },)).count(), splice_events_count ); } @@ -3221,7 +3221,7 @@ pub fn expect_splice_pending_event<'a, 'b, 'c, 'd>( let events = node.node.get_and_clear_pending_events(); assert_eq!(events.len(), 1); match &events[0] { - crate::events::Event::SplicePending { channel_id, counterparty_node_id, .. } => { + crate::events::Event::SpliceNegotiated { channel_id, counterparty_node_id, .. } => { assert_eq!(*expected_counterparty_node_id, *counterparty_node_id); *channel_id }, @@ -3250,7 +3250,7 @@ pub fn expect_splice_failed_events<'a, 'b, 'c, 'd>( _ => panic!("Unexpected event"), } match &events[1] { - Event::SpliceFailed { channel_id, reason, contribution, .. } => { + Event::SpliceNegotiationFailed { channel_id, reason, contribution, .. } => { assert_eq!(*expected_channel_id, *channel_id); assert_eq!(expected_reason, *reason); assert_eq!(contribution.as_ref(), Some(&funding_contribution)); diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 2867a03add7..e954149fd6c 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -121,10 +121,10 @@ pub enum FundingContributionError { /// /// Note: [`FundingTemplate::min_rbf_feerate`] may be derived from an in-progress /// negotiation that later aborts, leaving a stale (higher than necessary) minimum. If - /// this error occurs after receiving [`Event::SpliceFailed`], call + /// this error occurs after receiving [`Event::SpliceNegotiationFailed`], call /// [`ChannelManager::splice_channel`] again to get a fresh template. /// - /// [`Event::SpliceFailed`]: crate::events::Event::SpliceFailed + /// [`Event::SpliceNegotiationFailed`]: crate::events::Event::SpliceNegotiationFailed /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel FeeRateBelowRbfMinimum { /// The requested feerate. diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 1c6ad835ce0..4135f2b5604 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -3203,7 +3203,7 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool, pending_ }; assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); - // Close the channel. We should see a `SpliceFailed` event for the pending splice + // Close the channel. We should see a `SpliceNegotiationFailed` event for the pending splice // `QuiescentAction`. let (closer_node, closee_node) = if local_shutdown { (&nodes[0], &nodes[1]) } else { (&nodes[1], &nodes[0]) }; @@ -3233,12 +3233,12 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool, pending_ other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), } match &events[1] { - Event::SpliceFailed { channel_id: cid, reason, contribution, .. } => { + Event::SpliceNegotiationFailed { channel_id: cid, reason, contribution, .. } => { assert_eq!(*cid, channel_id); assert_eq!(*reason, NegotiationFailureReason::ChannelClosing); assert!(contribution.is_some()); }, - other => panic!("Expected SpliceFailed, got {:?}", other), + other => panic!("Expected SpliceNegotiationFailed, got {:?}", other), } } else { expect_splice_failed_events( @@ -4041,7 +4041,7 @@ fn test_funding_contributed_active_funding_negotiation() { fn do_test_funding_contributed_active_funding_negotiation(state: u8) { // Tests that calling funding_contributed when a splice is already being actively negotiated // (pending_splice.funding_negotiation exists and is_initiator()) returns Err(APIMisuseError) - // and emits SpliceFailed + DiscardFunding events for non-duplicate contributions, or + // and emits SpliceNegotiationFailed + DiscardFunding events for non-duplicate contributions, or // returns Err(APIMisuseError) with no events for duplicate contributions. // // State 0: AwaitingAck (splice_init sent, splice_ack not yet received) @@ -4177,7 +4177,7 @@ fn do_test_funding_contributed_active_funding_negotiation(state: u8) { #[test] fn test_funding_contributed_channel_shutdown() { // Tests that calling funding_contributed after initiating channel shutdown returns Err(APIMisuseError) - // and emits both SpliceFailed and DiscardFunding events. The channel is no longer usable + // and emits both SpliceNegotiationFailed and DiscardFunding events. The channel is no longer usable // after shutdown is initiated, so quiescence cannot be proposed. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); @@ -4206,7 +4206,7 @@ fn test_funding_contributed_channel_shutdown() { // Now call funding_contributed - this should trigger FailSplice because // propose_quiescence() will fail when is_usable() returns false. - // Returns Err(APIMisuseError) and emits both SpliceFailed and DiscardFunding. + // Returns Err(APIMisuseError) and emits both SpliceNegotiationFailed and DiscardFunding. assert_eq!( nodes[0].node.funding_contributed( &channel_id, @@ -4561,7 +4561,7 @@ pub fn reenter_quiescence<'a, 'b, 'c>( #[test] fn test_splice_acceptor_disconnect_emits_events() { // When both nodes contribute to a splice and the negotiation fails due to disconnect, - // both the initiator and acceptor should receive SpliceFailed + DiscardFunding events + // both the initiator and acceptor should receive SpliceNegotiationFailed + DiscardFunding events // so each can reclaim their UTXOs. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); @@ -4600,7 +4600,7 @@ fn test_splice_acceptor_disconnect_emits_events() { nodes[0].node.peer_disconnected(node_id_1); nodes[1].node.peer_disconnected(node_id_0); - // The initiator should get SpliceFailed + DiscardFunding. + // The initiator should get SpliceNegotiationFailed + DiscardFunding. expect_splice_failed_events( &nodes[0], &channel_id, @@ -4608,7 +4608,7 @@ fn test_splice_acceptor_disconnect_emits_events() { NegotiationFailureReason::PeerDisconnected, ); - // The acceptor should also get SpliceFailed + DiscardFunding with its contributions + // The acceptor should also get SpliceNegotiationFailed + DiscardFunding with its contributions // so it can reclaim its UTXOs. The contribution is feerate-adjusted by handle_splice_init, // so we check for non-empty inputs/outputs rather than exact values. let events = nodes[1].node.get_and_clear_pending_events(); @@ -4624,12 +4624,12 @@ fn test_splice_acceptor_disconnect_emits_events() { other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), } match &events[1] { - Event::SpliceFailed { channel_id: cid, reason, contribution, .. } => { + Event::SpliceNegotiationFailed { channel_id: cid, reason, contribution, .. } => { assert_eq!(*cid, channel_id); assert_eq!(*reason, NegotiationFailureReason::PeerDisconnected); assert!(contribution.is_some()); }, - other => panic!("Expected SpliceFailed, got {:?}", other), + other => panic!("Expected SpliceNegotiationFailed, got {:?}", other), } // Reconnect and verify the channel is still operational. @@ -4856,11 +4856,11 @@ fn test_splice_rbf_insufficient_feerate() { // The RBF round contributed the same inputs and outputs as the prior round, so after // filtering against the prior round's committed UTXOs nothing remains to discard and - // `DiscardFunding` is suppressed; only `SpliceFailed` is emitted. + // `DiscardFunding` is suppressed; only `SpliceNegotiationFailed` is emitted. let events = nodes[0].node.get_and_clear_pending_events(); assert_eq!(events.len(), 1, "{events:?}"); assert!( - matches!(&events[0], Event::SpliceFailed { channel_id: cid, .. } if *cid == channel_id) + matches!(&events[0], Event::SpliceNegotiationFailed { channel_id: cid, .. } if *cid == channel_id) ); let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); @@ -4916,7 +4916,7 @@ fn test_splice_rbf_insufficient_feerate() { let events = nodes[0].node.get_and_clear_pending_events(); assert_eq!(events.len(), 1); assert!( - matches!(&events[0], Event::SpliceFailed { channel_id: cid, .. } if *cid == channel_id) + matches!(&events[0], Event::SpliceNegotiationFailed { channel_id: cid, .. } if *cid == channel_id) ); nodes[1].node.handle_tx_abort(node_id_0, &tx_abort_echo); @@ -5007,7 +5007,7 @@ fn test_splice_rbf_insufficient_feerate_high() { let events = nodes[0].node.get_and_clear_pending_events(); assert_eq!(events.len(), 1); assert!( - matches!(&events[0], Event::SpliceFailed { channel_id: cid, .. } if *cid == channel_id) + matches!(&events[0], Event::SpliceNegotiationFailed { channel_id: cid, .. } if *cid == channel_id) ); nodes[1].node.handle_tx_abort(node_id_0, &tx_abort_echo); @@ -6439,7 +6439,7 @@ fn test_splice_rbf_amends_prior_net_negative_contribution_request() { fn test_splice_rbf_acceptor_contributes_then_disconnects() { // When both nodes contribute to a splice and the initiator RBFs (with the acceptor // re-contributing via prior contribution), disconnecting mid-interactive-TX should emit - // SpliceFailed + DiscardFunding for both nodes so each can reclaim their UTXOs. + // SpliceNegotiationFailed + DiscardFunding for both nodes so each can reclaim their UTXOs. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); @@ -6520,12 +6520,12 @@ fn test_splice_rbf_acceptor_contributes_then_disconnects() { let events = nodes[0].node.get_and_clear_pending_events(); assert_eq!(events.len(), 1, "{events:?}"); match &events[0] { - Event::SpliceFailed { channel_id: cid, reason, contribution, .. } => { + Event::SpliceNegotiationFailed { channel_id: cid, reason, contribution, .. } => { assert_eq!(*cid, channel_id); assert_eq!(*reason, NegotiationFailureReason::PeerDisconnected); assert!(contribution.is_some()); }, - other => panic!("Expected SpliceFailed, got {:?}", other), + other => panic!("Expected SpliceNegotiationFailed, got {:?}", other), } // The acceptor re-contributed the same UTXOs as round 0 (via prior contribution @@ -6594,7 +6594,7 @@ fn test_splice_rbf_disconnect_filters_prior_contributions() { nodes[0].node.peer_disconnected(node_id_1); nodes[1].node.peer_disconnected(node_id_0); - // The initiator should get DiscardFunding + SpliceFailed with filtered contributions. + // The initiator should get DiscardFunding + SpliceNegotiationFailed with filtered contributions. let events = nodes[0].node.get_and_clear_pending_events(); assert_eq!(events.len(), 2, "{events:?}"); match &events[0] { @@ -6611,12 +6611,12 @@ fn test_splice_rbf_disconnect_filters_prior_contributions() { other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), } match &events[1] { - Event::SpliceFailed { channel_id: cid, reason, contribution, .. } => { + Event::SpliceNegotiationFailed { channel_id: cid, reason, contribution, .. } => { assert_eq!(*cid, channel_id); assert_eq!(*reason, NegotiationFailureReason::PeerDisconnected); assert!(contribution.is_some()); }, - other => panic!("Expected SpliceFailed, got {:?}", other), + other => panic!("Expected SpliceNegotiationFailed, got {:?}", other), } // Reconnect. After a completed splice, channel_ready is not re-sent. @@ -6641,12 +6641,12 @@ fn test_splice_rbf_disconnect_filters_prior_contributions() { let events = nodes[0].node.get_and_clear_pending_events(); assert_eq!(events.len(), 1, "{events:?}"); match &events[0] { - Event::SpliceFailed { channel_id: cid, reason, contribution, .. } => { + Event::SpliceNegotiationFailed { channel_id: cid, reason, contribution, .. } => { assert_eq!(*cid, channel_id); assert_eq!(*reason, NegotiationFailureReason::PeerDisconnected); assert!(contribution.is_some()); }, - other => panic!("Expected SpliceFailed, got {:?}", other), + other => panic!("Expected SpliceNegotiationFailed, got {:?}", other), } let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); @@ -7050,7 +7050,7 @@ fn test_rbf_sync_returns_err_when_max_feerate_below_min_rbf() { fn test_splice_revalidation_at_quiescence() { // When an outbound HTLC is committed between funding_contributed and quiescence, the // holder's balance decreases. If the splice-out was marginal at funding_contributed time, - // the re-validation at quiescence should fail and emit SpliceFailed + DiscardFunding. + // the re-validation at quiescence should fail and emit SpliceNegotiationFailed + DiscardFunding. // // Flow: // 1. Send payment #1 (update_add + CS) → node 0 awaits RAA @@ -7425,17 +7425,17 @@ fn test_splice_rbf_rejects_own_low_feerate_after_several_attempts() { let result = nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution, None); assert!(result.is_err(), "Expected rejection for low feerate: {:?}", result); - // SpliceFailed is emitted. DiscardFunding is not emitted because all inputs/outputs + // SpliceNegotiationFailed is emitted. DiscardFunding is not emitted because all inputs/outputs // are filtered out (same UTXOs reused for RBF, still committed to the prior splice tx). let events = nodes[0].node.get_and_clear_pending_events(); assert_eq!(events.len(), 1, "{events:?}"); match &events[0] { - Event::SpliceFailed { channel_id: cid, reason, contribution, .. } => { + Event::SpliceNegotiationFailed { channel_id: cid, reason, contribution, .. } => { assert_eq!(*cid, channel_id); assert_eq!(*reason, NegotiationFailureReason::FeeRateTooLow); assert!(contribution.is_some()); }, - other => panic!("Expected SpliceFailed, got {:?}", other), + other => panic!("Expected SpliceNegotiationFailed, got {:?}", other), } } diff --git a/pending_changelog/4388-splice-failed-discard-funding.txt b/pending_changelog/4388-splice-failed-discard-funding.txt index 64fc4ab4e26..67680f49cb1 100644 --- a/pending_changelog/4388-splice-failed-discard-funding.txt +++ b/pending_changelog/4388-splice-failed-discard-funding.txt @@ -1,21 +1,21 @@ # API Updates - * `Event::SpliceFailed` no longer carries `contributed_inputs` or `contributed_outputs` fields. + * `Event::SpliceNegotiationFailed` no longer carries `contributed_inputs` or `contributed_outputs` fields. Instead, a separate `Event::DiscardFunding` event with `FundingInfo::Contribution` is emitted for UTXO cleanup. * `Event::DiscardFunding` with `FundingInfo::Contribution` is also emitted without a - corresponding `Event::SpliceFailed` when `ChannelManager::funding_contributed` returns an + corresponding `Event::SpliceNegotiationFailed` when `ChannelManager::funding_contributed` returns an error (e.g., channel or peer not found, wrong channel state, duplicate contribution). # Backwards Compatibility * Older serializations that included `contributed_inputs` and `contributed_outputs` in - `SpliceFailed` will have those fields silently ignored on deserialization (they were odd TLV + `SpliceNegotiationFailed` will have those fields silently ignored on deserialization (they were odd TLV fields). A `DiscardFunding` event will not be produced when reading these older serializations. # Forward Compatibility * Downgrading will not set the removed `contributed_inputs`/`contributed_outputs` fields on - `SpliceFailed`, so older code expecting those fields will see empty vectors for splice + `SpliceNegotiationFailed`, so older code expecting those fields will see empty vectors for splice failures. From 2eb939b10646532754a471f2585b6e92e4e12cf6 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 27 Mar 2026 16:05:31 -0500 Subject: [PATCH 357/627] Simplify contribution pop in reset_pending_splice_state The was_negotiated check is unnecessary because reset_pending_splice_state only runs when funding_negotiation is present, meaning on_tx_signatures_exchange hasn't been called yet. Since the feerate is only recorded in last_funding_feerate_sat_per_1000_weight during on_tx_signatures_exchange, the current round's feerate can never match it. So the contribution can always be unconditionally popped. Co-Authored-By: Claude Opus 4.6 (1M context) --- lightning/src/ln/channel.rs | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index b26ec70c5b8..455a5ae80d2 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -7317,20 +7317,20 @@ where into_contributed_inputs_and_outputs ); - // Pop the current round's contribution if it wasn't from a negotiated round. Each round - // pushes a new entry to `contributions`; if the round aborts, we undo the push so that - // `contributions.last()` reflects the most recent negotiated round's contribution. This - // must happen after `maybe_create_splice_funding_failed` so that - // `prior_contributed_inputs` still includes the prior rounds' entries for filtering. - if let Some(pending_splice) = self.pending_splice.as_mut() { - if let Some(last) = pending_splice.contributions.last() { - let was_negotiated = pending_splice + // Pop the current round's contribution, if any (acceptors may not have one). This + // must happen after `maybe_create_splice_funding_failed` for correct filtering. + let pending_splice = self + .pending_splice + .as_mut() + .expect("reset_pending_splice_state requires pending_splice"); + if let Some(contribution) = pending_splice.contributions.pop() { + debug_assert!( + pending_splice .last_funding_feerate_sat_per_1000_weight - .is_some_and(|f| last.feerate() == FeeRate::from_sat_per_kwu(f as u64)); - if !was_negotiated { - pending_splice.contributions.pop(); - } - } + .map(|f| contribution.feerate() > FeeRate::from_sat_per_kwu(f as u64)) + .unwrap_or(true), + "current round's feerate should be greater than the last negotiated feerate", + ); } if self.pending_funding().is_empty() { From 9c6cca6b8ef69ba9ea55e6257cb4ba8eec28cc4a Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 27 Mar 2026 18:52:27 -0500 Subject: [PATCH 358/627] Fix output filtering in into_unique_contributions Filter outputs by script_pubkey rather than full TxOut equality. Outputs reusing the same address as a prior round are still considered committed even if the value differs (e.g., different change amounts across RBF rounds with different feerates). Co-Authored-By: Claude Opus 4.6 (1M context) --- lightning/src/ln/funding.rs | 2 +- lightning/src/ln/splicing_tests.rs | 50 ++++++++++++++++++------------ 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index e954149fd6c..2f4e89db926 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -766,7 +766,7 @@ impl FundingContribution { inputs.retain(|input| *input != existing); } for existing in existing_outputs { - outputs.retain(|output| *output != *existing); + outputs.retain(|output| output.script_pubkey != existing.script_pubkey); } if inputs.is_empty() && outputs.is_empty() { None diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 4135f2b5604..623a15180ea 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -3915,11 +3915,11 @@ fn test_funding_contributed_splice_already_pending() { .build() .unwrap(); - // Initiate a second splice with a DIFFERENT output to test that different outputs - // are included in DiscardFunding (not filtered out) + // Initiate a second splice with a DIFFERENT output (different script_pubkey) to test that + // non-overlapping outputs are included in DiscardFunding (not filtered out). let second_splice_out = TxOut { - value: Amount::from_sat(6_000), // Different amount - script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_raw_hash(Hash::all_zeros())), + value: Amount::from_sat(6_000), + script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), }; // Clear UTXOs and add a LARGER one for the second contribution to ensure @@ -3950,8 +3950,7 @@ fn test_funding_contributed_splice_already_pending() { // Second funding_contributed with a different contribution - this should trigger // DiscardFunding because there's already a pending quiescent action (splice contribution). // Only inputs/outputs NOT in the existing contribution should be discarded. - let (expected_inputs, expected_outputs) = - second_contribution.clone().into_contributed_inputs_and_outputs(); + let expected_inputs: Vec<_> = second_contribution.contributed_inputs().collect(); // Returns Err(APIMisuseError) and emits DiscardFunding for the non-duplicate parts of the second contribution assert_eq!( @@ -3971,11 +3970,10 @@ fn test_funding_contributed_splice_already_pending() { if let FundingInfo::Contribution { inputs, outputs } = funding_info { // The input is different, so it should be in the discard event assert_eq!(*inputs, expected_inputs); - // The splice-out output is different (6000 vs 5000), so it should be in discard event - assert!(expected_outputs.contains(&second_splice_out)); - assert!(!expected_outputs.contains(&first_splice_out)); - // The different outputs should NOT be filtered out - assert_eq!(*outputs, expected_outputs); + // The splice-out output (different script_pubkey) survives filtering; + // the change output (same script_pubkey as first contribution) is filtered. + assert_eq!(outputs.len(), 1); + assert!(outputs.contains(&second_splice_out)); } else { panic!("Expected FundingInfo::Contribution"); } @@ -4068,14 +4066,24 @@ fn do_test_funding_contributed_active_funding_negotiation(state: u8) { let first_contribution = funding_template.splice_in_sync(splice_in_amount, feerate, FeeRate::MAX, &wallet).unwrap(); - // Build second contribution with different UTXOs so inputs/outputs don't overlap + // Build second contribution with different UTXOs and a splice-out output using a different + // script_pubkey (node 1's address) so it survives script_pubkey-based filtering. nodes[0].wallet_source.clear_utxos(); provide_utxo_reserves(&nodes, 1, splice_in_amount * 3); + let splice_out_output = TxOut { + value: Amount::from_sat(1_000), + script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), + }; let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let second_contribution = - funding_template.splice_in_sync(splice_in_amount, feerate, FeeRate::MAX, &wallet).unwrap(); + let second_contribution = funding_template + .without_prior_contribution(feerate, FeeRate::MAX) + .with_coin_selection_source_sync(&wallet) + .add_value(splice_in_amount) + .add_outputs(vec![splice_out_output.clone()]) + .build() + .unwrap(); // First funding_contributed - sets up the quiescent action and queues STFU nodes[0] @@ -4120,10 +4128,10 @@ fn do_test_funding_contributed_active_funding_negotiation(state: u8) { } } - // Call funding_contributed with a different contribution (non-overlapping inputs/outputs). - // This hits the funding_negotiation path and returns DiscardFunding. - let (expected_inputs, expected_outputs) = - second_contribution.clone().into_contributed_inputs_and_outputs(); + // Call funding_contributed with the second contribution. Inputs don't overlap (different + // UTXOs) so they all survive. The splice-out output (different script_pubkey) survives + // while the change output (same script_pubkey as first contribution) is filtered. + let expected_inputs: Vec<_> = second_contribution.contributed_inputs().collect(); assert_eq!( nodes[0].node.funding_contributed(&channel_id, &node_id_1, second_contribution, None), Err(APIError::APIMisuseError { @@ -4131,15 +4139,17 @@ fn do_test_funding_contributed_active_funding_negotiation(state: u8) { }) ); - // Assert DiscardFunding event with the non-duplicate inputs/outputs let events = nodes[0].node.get_and_clear_pending_events(); assert_eq!(events.len(), 1, "{events:?}"); match &events[0] { Event::DiscardFunding { channel_id: event_channel_id, funding_info } => { assert_eq!(*event_channel_id, channel_id); if let FundingInfo::Contribution { inputs, outputs } = funding_info { + // Inputs are unique (different UTXOs) so none are filtered. assert_eq!(*inputs, expected_inputs); - assert_eq!(*outputs, expected_outputs); + // Only the splice-out output survives; the change output is filtered + // (same script_pubkey as first contribution's change). + assert_eq!(*outputs, vec![splice_out_output]); } else { panic!("Expected FundingInfo::Contribution"); } From db45c8335a722f12b97dd53dfdd6f0d497507924 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 27 Mar 2026 16:42:20 -0500 Subject: [PATCH 359/627] Derive SpliceFundingFailed inputs from FundingContribution Replace the maybe_create_splice_funding_failed! macro and splice_funding_failed_for method with a unified splice_funding_failed_for! macro that derives contributed inputs and outputs from the FundingContribution rather than extracting them from the negotiation state. Callers pass ident parameters for which PendingSplice filtering methods to use: contributed_inputs/contributed_outputs when the current round's contribution has been popped or was never pushed, and prior_contributed_inputs/prior_contributed_outputs for the read-only persistence path where the contribution is cloned instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- lightning/src/ln/channel.rs | 158 +++++++++++++++++------------------- 1 file changed, 74 insertions(+), 84 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 455a5ae80d2..82c8835fee7 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -6883,13 +6883,6 @@ impl FundingNegotiationContext { } } - fn into_contributed_inputs_and_outputs(self) -> (Vec, Vec) { - let contributed_inputs = - self.our_funding_inputs.into_iter().map(|input| input.utxo.outpoint).collect(); - let contributed_outputs = self.our_funding_outputs; - (contributed_inputs, contributed_outputs) - } - fn contributed_inputs(&self) -> impl Iterator + '_ { self.our_funding_inputs.iter().map(|input| input.utxo.outpoint) } @@ -6897,10 +6890,6 @@ impl FundingNegotiationContext { fn contributed_outputs(&self) -> impl Iterator + '_ { self.our_funding_outputs.iter() } - - fn to_contributed_inputs_and_outputs(&self) -> (Vec, Vec) { - (self.contributed_inputs().collect(), self.contributed_outputs().cloned().collect()) - } } // Holder designates channel data owned for the benefit of the user client. @@ -7080,48 +7069,29 @@ impl SpliceFundingFailed { } } -macro_rules! maybe_create_splice_funding_failed { - ($funded_channel: expr, $pending_splice: expr, $pending_splice_ref: expr, $get: ident, $contributed_inputs_and_outputs: ident) => {{ - $pending_splice - .and_then(|pending_splice| pending_splice.funding_negotiation.$get()) - .and_then(|funding_negotiation| { - let is_initiator = funding_negotiation.is_initiator(); - - let (mut contributed_inputs, mut contributed_outputs) = match funding_negotiation { - FundingNegotiation::AwaitingAck { context, .. } => { - context.$contributed_inputs_and_outputs() - }, - FundingNegotiation::ConstructingTransaction { - interactive_tx_constructor, - .. - } => interactive_tx_constructor.$contributed_inputs_and_outputs(), - FundingNegotiation::AwaitingSignatures { .. } => $funded_channel - .context - .interactive_tx_signing_session - .$get() - .expect("We have a pending splice awaiting signatures") - .$contributed_inputs_and_outputs(), - }; - - if let Some(pending_splice) = $pending_splice_ref { - for input in pending_splice.prior_contributed_inputs() { - contributed_inputs.retain(|i| *i != input); - } - for output in pending_splice.prior_contributed_outputs() { - contributed_outputs.retain(|o| o.script_pubkey != output.script_pubkey); - } - } - - if !is_initiator && contributed_inputs.is_empty() && contributed_outputs.is_empty() - { - return None; - } - - let contribution = - $pending_splice_ref.and_then(|ps| ps.contributions.last().cloned()); - - Some(SpliceFundingFailed { contributed_inputs, contributed_outputs, contribution }) - }) +macro_rules! splice_funding_failed_for { + ($self: expr, $is_initiator: expr, $contribution: expr, + $contributed_inputs: ident, $contributed_outputs: ident) => {{ + let contribution = $contribution; + let existing_inputs = + $self.pending_splice.as_ref().into_iter().flat_map(|ps| ps.$contributed_inputs()); + let existing_outputs = + $self.pending_splice.as_ref().into_iter().flat_map(|ps| ps.$contributed_outputs()); + let filtered = + contribution.clone().into_unique_contributions(existing_inputs, existing_outputs); + match filtered { + None if !$is_initiator => None, + None => Some(SpliceFundingFailed { + contributed_inputs: vec![], + contributed_outputs: vec![], + contribution: Some(contribution), + }), + Some((contributed_inputs, contributed_outputs)) => Some(SpliceFundingFailed { + contributed_inputs, + contributed_outputs, + contribution: Some(contribution), + }), + } }}; } @@ -7151,21 +7121,16 @@ where /// Builds a [`SpliceFundingFailed`] from a contribution, filtering out inputs/outputs /// that are still committed to a prior splice round. fn splice_funding_failed_for(&self, contribution: FundingContribution) -> SpliceFundingFailed { - let cloned_contribution = contribution.clone(); - let (mut inputs, mut outputs) = contribution.into_contributed_inputs_and_outputs(); - if let Some(ref pending_splice) = self.pending_splice { - for input in pending_splice.contributed_inputs() { - inputs.retain(|i| *i != input); - } - for output in pending_splice.contributed_outputs() { - outputs.retain(|o| o.script_pubkey != output.script_pubkey); - } - } - SpliceFundingFailed { - contributed_inputs: inputs, - contributed_outputs: outputs, - contribution: Some(cloned_contribution), - } + // The contribution was never pushed to `contributions`, so `contributed_inputs()` and + // `contributed_outputs()` return only prior rounds' entries for filtering. + splice_funding_failed_for!( + self, + true, + contribution, + contributed_inputs, + contributed_outputs + ) + .expect("is_initiator is true so this always returns Some") } fn quiescent_action_into_error(&self, action: QuiescentAction) -> QuiescentError { @@ -7309,21 +7274,23 @@ where ); } - let splice_funding_failed = maybe_create_splice_funding_failed!( - self, - self.pending_splice.as_mut(), - self.pending_splice.as_ref(), - take, - into_contributed_inputs_and_outputs - ); - - // Pop the current round's contribution, if any (acceptors may not have one). This - // must happen after `maybe_create_splice_funding_failed` for correct filtering. + // Take the funding negotiation and pop the current round's contribution, if any + // (acceptors may not have one). let pending_splice = self .pending_splice .as_mut() .expect("reset_pending_splice_state requires pending_splice"); - if let Some(contribution) = pending_splice.contributions.pop() { + debug_assert!( + pending_splice.funding_negotiation.is_some(), + "reset_pending_splice_state requires an active funding negotiation" + ); + let is_initiator = pending_splice + .funding_negotiation + .take() + .map(|negotiation| negotiation.is_initiator()) + .unwrap_or(false); + let contribution = pending_splice.contributions.pop(); + if let Some(ref contribution) = contribution { debug_assert!( pending_splice .last_funding_feerate_sat_per_1000_weight @@ -7333,6 +7300,18 @@ where ); } + // After pop, `contributed_inputs()` / `contributed_outputs()` return only prior + // rounds for filtering. + let splice_funding_failed = contribution.and_then(|contribution| { + splice_funding_failed_for!( + self, + is_initiator, + contribution, + contributed_inputs, + contributed_outputs + ) + }); + if self.pending_funding().is_empty() { self.pending_splice.take(); } @@ -7350,12 +7329,23 @@ where return None; } - maybe_create_splice_funding_failed!( + let pending_splice = self.pending_splice.as_ref()?; + debug_assert!( + pending_splice.funding_negotiation.is_some(), + "maybe_splice_funding_failed requires an active funding negotiation" + ); + let is_initiator = pending_splice + .funding_negotiation + .as_ref() + .map(|negotiation| negotiation.is_initiator()) + .unwrap_or(false); + let contribution = pending_splice.contributions.last().cloned()?; + splice_funding_failed_for!( self, - self.pending_splice.as_ref(), - self.pending_splice.as_ref(), - as_ref, - to_contributed_inputs_and_outputs + is_initiator, + contribution, + prior_contributed_inputs, + prior_contributed_outputs ) } From 367ffaa132dfdbe88d2d768346f3f376e4e8d728 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 27 Mar 2026 17:01:30 -0500 Subject: [PATCH 360/627] Remove unused NegotiationError and contributed_inputs_and_outputs methods Now that splice_funding_failed_for! derives inputs and outputs from FundingContribution directly, remove the unused NegotiationError struct and into_negotiation_error methods from the interactive tx types, along with the into/to_contributed_inputs_and_outputs methods on ConstructedTransaction. Co-Authored-By: Claude Opus 4.6 (1M context) --- lightning/src/ln/interactivetxs.rs | 83 ------------------------------ 1 file changed, 83 deletions(-) diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index ca8c4450012..10dae95cefa 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -90,13 +90,6 @@ impl SerialIdExt for SerialId { } } -#[derive(Clone, Debug)] -pub(crate) struct NegotiationError { - pub reason: AbortReason, - pub contributed_inputs: Vec, - pub contributed_outputs: Vec, -} - #[derive(Debug, Clone, Copy, PartialEq)] pub(crate) enum AbortReason { InvalidStateTransition, @@ -370,11 +363,6 @@ impl ConstructedTransaction { Ok(tx) } - fn into_negotiation_error(self, reason: AbortReason) -> NegotiationError { - let (contributed_inputs, contributed_outputs) = self.into_contributed_inputs_and_outputs(); - NegotiationError { reason, contributed_inputs, contributed_outputs } - } - fn contributed_inputs(&self) -> impl Iterator + '_ { self.tx .input @@ -401,40 +389,6 @@ impl ConstructedTransaction { .map(|(_, (txout, _))| txout) } - fn to_contributed_inputs_and_outputs(&self) -> (Vec, Vec) { - (self.contributed_inputs().collect(), self.contributed_outputs().cloned().collect()) - } - - fn into_contributed_inputs_and_outputs(self) -> (Vec, Vec) { - let contributed_inputs = self - .tx - .input - .into_iter() - .zip(self.input_metadata.iter()) - .enumerate() - .filter(|(_, (_, input))| input.is_local(self.holder_is_initiator)) - .filter(|(index, _)| { - self.shared_input_index - .map(|shared_index| *index != shared_index as usize) - .unwrap_or(true) - }) - .map(|(_, (txin, _))| txin.previous_output) - .collect(); - - let contributed_outputs = self - .tx - .output - .into_iter() - .zip(self.output_metadata.iter()) - .enumerate() - .filter(|(_, (_, output))| output.is_local(self.holder_is_initiator)) - .filter(|(index, _)| *index != self.shared_output_index as usize) - .map(|(_, (txout, _))| txout) - .collect(); - - (contributed_inputs, contributed_outputs) - } - pub fn tx(&self) -> &Transaction { &self.tx } @@ -921,10 +875,6 @@ impl InteractiveTxSigningSession { Ok(()) } - pub(crate) fn into_negotiation_error(self, reason: AbortReason) -> NegotiationError { - self.unsigned_tx.into_negotiation_error(reason) - } - pub(super) fn contributed_inputs(&self) -> impl Iterator + '_ { self.unsigned_tx.contributed_inputs() } @@ -932,14 +882,6 @@ impl InteractiveTxSigningSession { pub(super) fn contributed_outputs(&self) -> impl Iterator + '_ { self.unsigned_tx.contributed_outputs() } - - pub(super) fn to_contributed_inputs_and_outputs(&self) -> (Vec, Vec) { - (self.contributed_inputs().collect(), self.contributed_outputs().cloned().collect()) - } - - pub(super) fn into_contributed_inputs_and_outputs(self) -> (Vec, Vec) { - self.unsigned_tx.into_contributed_inputs_and_outputs() - } } impl_writeable_tlv_based!(InteractiveTxSigningSession, { @@ -2172,27 +2114,6 @@ impl InteractiveTxConstructor { Self::new(args, false) } - fn into_negotiation_error(self, reason: AbortReason) -> NegotiationError { - let (contributed_inputs, contributed_outputs) = self.into_contributed_inputs_and_outputs(); - NegotiationError { reason, contributed_inputs, contributed_outputs } - } - - pub(super) fn into_contributed_inputs_and_outputs(self) -> (Vec, Vec) { - let contributed_inputs = self - .inputs_to_contribute - .into_iter() - .filter(|(_, input)| !input.is_shared()) - .map(|(_, input)| input.into_tx_in().previous_output) - .collect(); - let contributed_outputs = self - .outputs_to_contribute - .into_iter() - .filter(|(_, output)| !output.is_shared()) - .map(|(_, output)| output.into_tx_out()) - .collect(); - (contributed_inputs, contributed_outputs) - } - pub(super) fn contributed_inputs(&self) -> impl Iterator + '_ { self.inputs_to_contribute .iter() @@ -2207,10 +2128,6 @@ impl InteractiveTxConstructor { .map(|(_, output)| output.tx_out()) } - pub(super) fn to_contributed_inputs_and_outputs(&self) -> (Vec, Vec) { - (self.contributed_inputs().collect(), self.contributed_outputs().cloned().collect()) - } - pub fn is_initiator(&self) -> bool { self.is_initiator } From b44076e0484f3abca1aaae87b65a6d2b5debe68e Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 3 Apr 2026 10:28:45 -0500 Subject: [PATCH 361/627] Add pending changelog for splice negotiation event changes Co-Authored-By: Claude Opus 4.6 (1M context) --- pending_changelog/4514-splice-negotiation-failed.txt | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 pending_changelog/4514-splice-negotiation-failed.txt diff --git a/pending_changelog/4514-splice-negotiation-failed.txt b/pending_changelog/4514-splice-negotiation-failed.txt new file mode 100644 index 00000000000..809bf7cb86d --- /dev/null +++ b/pending_changelog/4514-splice-negotiation-failed.txt @@ -0,0 +1,11 @@ +# API Updates + + * `Event::SplicePending` has been renamed to `Event::SpliceNegotiated`. + + * `Event::SpliceFailed` has been renamed to `Event::SpliceNegotiationFailed`. + + * `Event::SpliceNegotiationFailed` now includes a `reason` field + (`NegotiationFailureReason`) indicating why the negotiation round failed, + and a `contribution` field returning the `FundingContribution` for retry. + + * `FundingContribution` now exposes `feerate()` and `inputs()` accessor methods. From ffbb8fe69b80608c329075e7746c5e51346ac50d Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 13 Apr 2026 17:48:52 -0500 Subject: [PATCH 362/627] Check can_initiate_rbf in stfu handler before sending tx_init_rbf If splice_locked is sent between our outgoing STFU and the counterparty's STFU response, the stfu() handler would proceed to send tx_init_rbf for an already-confirmed splice. Guard against this by re-checking can_initiate_rbf when entering quiescence. Disconnect because there is no way to cancel quiescence after both sides have exchanged STFU. Co-Authored-By: Claude Opus 4.6 (1M context) --- fuzz/src/chanmon_consistency.rs | 1 + lightning/src/events/mod.rs | 11 ++- lightning/src/ln/channel.rs | 10 +++ lightning/src/ln/splicing_tests.rs | 109 +++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 1 deletion(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 678e6a6fc61..55b2a681725 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -930,6 +930,7 @@ fn assert_action_timeout_awaiting_response(action: &msgs::ErrorAction) { action, msgs::ErrorAction::DisconnectPeerWithWarning { msg } if msg.data.contains("Disconnecting due to timeout awaiting response") + || msg.data.contains("already sent splice_locked, cannot RBF") ), "Expected timeout disconnect, got: {:?}", action, diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 9d00273cb46..0d5b8f757ab 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -149,6 +149,12 @@ pub enum NegotiationFailureReason { /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel /// [`FundingTemplate`]: crate::ln::funding::FundingTemplate FeeRateTooLow, + /// An RBF attempt could not be initiated (e.g., a prior splice transaction already + /// confirmed). The channel remains operational — start a new splice with + /// [`ChannelManager::splice_channel`] if further changes are needed. + /// + /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel + CannotInitiateRbf, } impl NegotiationFailureReason { @@ -166,7 +172,8 @@ impl NegotiationFailureReason { Self::CounterpartyAborted { .. } | Self::NegotiationError { .. } | Self::LocallyAbandoned - | Self::ChannelClosing => false, + | Self::ChannelClosing + | Self::CannotInitiateRbf => false, } } } @@ -185,6 +192,7 @@ impl core::fmt::Display for NegotiationFailureReason { Self::ChannelClosing => f.write_str("channel is closing"), Self::FeeRateTooLow => f.write_str("feerate too low for RBF"), + Self::CannotInitiateRbf => f.write_str("cannot initiate RBF"), } } } @@ -202,6 +210,7 @@ impl_writeable_tlv_based_enum_upgradable!(NegotiationFailureReason, (11, LocallyAbandoned) => {}, (13, ChannelClosing) => {}, (15, FeeRateTooLow) => {}, + (17, CannotInitiateRbf) => {}, ); /// Some information provided on receipt of payment depends on whether the payment received is a diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 82c8835fee7..cff3466c808 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -14328,6 +14328,16 @@ where }; if self.pending_splice.is_some() { + if let Err(e) = self.can_initiate_rbf() { + let failed = self.splice_funding_failed_for(prior_contribution); + return Err(( + ChannelError::WarnAndDisconnect(e), + QuiescentError::FailSplice( + failed, + NegotiationFailureReason::CannotInitiateRbf, + ), + )); + } let tx_init_rbf = self.send_tx_init_rbf(context); self.pending_splice.as_mut().unwrap() .contributions.push(prior_contribution); diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 623a15180ea..a3396d7e84a 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -5198,6 +5198,115 @@ fn test_splice_rbf_after_splice_locked() { } } +#[test] +fn test_splice_rbf_stfu_after_splice_locked() { + // Test that we don't send tx_init_rbf when we've already sent splice_locked. + // + // Scenario: node 0 initiates an RBF and sends STFU, but before receiving the counterparty's + // STFU response, it mines enough blocks to send splice_locked (setting sent_funding_txid). + // When node 1's STFU arrives, the stfu() handler should detect that RBF is no longer valid + // and return WarnAndDisconnect instead of sending tx_init_rbf. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Complete a splice-in from node 0. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Mine the splice tx on both nodes (not enough for splice_locked yet). + mine_transaction(&nodes[0], &splice_tx); + mine_transaction(&nodes[1], &splice_tx); + + // Provide more UTXOs for the RBF attempt. + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Initiate RBF from node 0 with fresh inputs so the RBF round has a unique input that + // survives filtering when the failure cleanup runs. + let rbf_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let funding_contribution = funding_template + .without_prior_contribution(rbf_feerate, FeeRate::MAX) + .with_coin_selection_source_sync(&wallet) + .add_value(added_value) + .build() + .unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, funding_contribution.clone(), None) + .unwrap(); + + // Node 0 sends STFU (can_initiate_rbf passes since no splice_locked yet). + let stfu_init = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + + // Deliver STFU to node 1; extract node 1's STFU response but don't deliver it yet. + nodes[1].node.handle_stfu(node_id_0, &stfu_init); + let stfu_ack = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + + // Mine enough blocks on node 0 so it sends splice_locked (sets sent_funding_txid). + connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1); + let _splice_locked = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1); + + // Now deliver node 1's STFU to node 0. The stfu() handler should detect that RBF is no + // longer valid (we already sent splice_locked) and return WarnAndDisconnect. + nodes[0].node.handle_stfu(node_id_1, &stfu_ack); + + let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + match &msg_events[0] { + MessageSendEvent::HandleError { action, .. } => { + assert_eq!( + *action, + msgs::ErrorAction::DisconnectPeerWithWarning { + msg: msgs::WarningMessage { + channel_id, + data: format!( + "Channel {} already sent splice_locked, cannot RBF", + channel_id, + ), + }, + } + ); + }, + _ => panic!("Expected HandleError, got {:?}", msg_events[0]), + } + + // Node 0 should emit DiscardFunding + SpliceNegotiationFailed for the RBF contribution. + // The change output is filtered (same script_pubkey as the first splice's change output), + // but the input survives because it's a different UTXO from the first splice. + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2, "{events:?}"); + match &events[0] { + Event::DiscardFunding { + funding_info: FundingInfo::Contribution { inputs, outputs }, + .. + } => { + assert!(!inputs.is_empty()); + assert!(outputs.is_empty()); + }, + other => panic!("Expected DiscardFunding, got {:?}", other), + } + match &events[1] { + Event::SpliceNegotiationFailed { channel_id: cid, reason, .. } => { + assert_eq!(*cid, channel_id); + assert_eq!(*reason, NegotiationFailureReason::CannotInitiateRbf); + }, + other => panic!("Expected SpliceNegotiationFailed, got {:?}", other), + } +} + #[test] fn test_splice_zeroconf_no_rbf_feerate() { // Test that splice_channel returns a FundingTemplate with min_rbf_feerate = None for a From 51e6079878ac45b7f66d15683ef797b3213a23c6 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 15 Apr 2026 21:04:46 -0500 Subject: [PATCH 363/627] Derive DiscardFunding inputs and outputs from contributions on promotion When a splice funding is promoted, produce FundingInfo::Contribution instead of FundingInfo::Tx for the discarded funding events. Each contribution is filtered against the promoted funding transaction's inputs and outputs, so only inputs and outputs unique to the discarded round are reported. Co-Authored-By: Claude Opus 4.6 (1M context) --- lightning/src/ln/channel.rs | 30 ++--- lightning/src/ln/splicing_tests.rs | 198 +++++++++++++++++++++++------ 2 files changed, 176 insertions(+), 52 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index cff3466c808..18236d761a0 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -11671,7 +11671,6 @@ where .iter_mut() .find(|funding| funding.get_funding_txid() == Some(splice_txid)) .unwrap(); - let prev_funding_txid = self.funding.get_funding_txid(); if let Some(scid) = self.funding.short_channel_id { self.context.historical_scids.push(scid); @@ -11679,22 +11678,21 @@ where core::mem::swap(&mut self.funding, funding); - // The swap above places the previous `FundingScope` into `pending_funding`. - pending_splice - .negotiated_candidates - .drain(..) - .filter(|funding| funding.get_funding_txid() != prev_funding_txid) - .map(|mut funding| { - funding - .funding_transaction - .take() - .map(|tx| FundingInfo::Tx { transaction: tx }) - .unwrap_or_else(|| FundingInfo::OutPoint { - outpoint: funding - .get_funding_txo() - .expect("Negotiated splices must have a known funding outpoint"), - }) + let promoted_tx = self + .funding + .funding_transaction + .as_ref() + .expect("Promoted splice funding should have a funding transaction"); + let contributions = core::mem::take(&mut pending_splice.contributions); + contributions + .into_iter() + .filter_map(|contribution| { + contribution.into_unique_contributions( + promoted_tx.input.iter().map(|i| i.previous_output), + promoted_tx.output.iter(), + ) }) + .map(|(inputs, outputs)| FundingInfo::Contribution { inputs, outputs }) .collect::>() }; diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index a3396d7e84a..1b6879e69f0 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -683,9 +683,15 @@ pub fn splice_channel<'a, 'b, 'c, 'd>( (splice_tx, new_funding_script) } +pub struct SpliceLockedResult { + pub stfu: Option, + pub node_a_discarded: Vec<(Vec, Vec)>, + pub node_b_discarded: Vec<(Vec, Vec)>, +} + pub fn lock_splice_after_blocks<'a, 'b, 'c, 'd>( node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>, num_blocks: u32, -) -> Option { +) -> SpliceLockedResult { connect_blocks(node_a, num_blocks); connect_blocks(node_b, num_blocks); @@ -698,7 +704,7 @@ pub fn lock_splice_after_blocks<'a, 'b, 'c, 'd>( pub fn lock_splice<'a, 'b, 'c, 'd>( node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>, splice_locked_for_node_b: &msgs::SpliceLocked, is_0conf: bool, expected_discard_txids: &[Txid], -) -> Option { +) -> SpliceLockedResult { let prev_funding_txid = node_a .chain_monitor .chain_monitor @@ -735,29 +741,23 @@ pub fn lock_splice<'a, 'b, 'c, 'd>( } } - let mut all_discard_txids = Vec::new(); - let expected_num_events = 1 + expected_discard_txids.len(); - for node in [node_a, node_b] { + let mut node_a_discarded = Vec::new(); + let mut node_b_discarded = Vec::new(); + for (idx, node) in [node_a, node_b].into_iter().enumerate() { let events = node.node.get_and_clear_pending_events(); - assert_eq!(events.len(), expected_num_events, "{events:?}"); + assert!(!events.is_empty(), "Expected at least ChannelReady, got {events:?}"); assert!(matches!(events[0], Event::ChannelReady { .. })); - let discard_txids: Vec<_> = events[1..] - .iter() - .map(|e| match e { - Event::DiscardFunding { funding_info: FundingInfo::Tx { transaction }, .. } => { - transaction.compute_txid() - }, + let discarded = if idx == 0 { &mut node_a_discarded } else { &mut node_b_discarded }; + for event in &events[1..] { + match event { Event::DiscardFunding { - funding_info: FundingInfo::OutPoint { outpoint }, .. - } => outpoint.txid, - other => panic!("Expected DiscardFunding, got {:?}", other), - }) - .collect(); - for txid in expected_discard_txids { - assert!(discard_txids.contains(txid), "Missing DiscardFunding for txid {}", txid); - } - if all_discard_txids.is_empty() { - all_discard_txids = discard_txids; + funding_info: FundingInfo::Contribution { inputs, outputs }, + .. + } => { + discarded.push((inputs.clone(), outputs.clone())); + }, + other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), + } } check_added_monitors(node, 1); } @@ -795,18 +795,18 @@ pub fn lock_splice<'a, 'b, 'c, 'd>( // old funding as it is no longer being tracked. for node in [node_a, node_b] { node.chain_source.remove_watched_by_txid(prev_funding_txid); - for txid in &all_discard_txids { + for txid in expected_discard_txids { node.chain_source.remove_watched_by_txid(*txid); } } - node_a_stfu.or(node_b_stfu) + SpliceLockedResult { stfu: node_a_stfu.or(node_b_stfu), node_a_discarded, node_b_discarded } } pub fn lock_rbf_splice_after_blocks<'a, 'b, 'c, 'd>( node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>, tx: &Transaction, num_blocks: u32, expected_discard_txids: &[Txid], -) -> Option { +) -> SpliceLockedResult { mine_transaction(node_a, tx); mine_transaction(node_b, tx); @@ -1400,7 +1400,7 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { mine_transaction(&nodes[0], &splice_tx); mine_transaction(&nodes[1], &splice_tx); - let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); + let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1).stfu; // Node 0 had called splice_channel (line above) but never funding_contributed, so no stfu // is expected from node 0 at this point. assert!(stfu.is_none()); @@ -1428,7 +1428,7 @@ fn test_initiating_splice_holds_stfu_with_pending_splice() { // Mine and lock the splice. mine_transaction(&nodes[0], &splice_tx); mine_transaction(&nodes[1], &splice_tx); - let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], 5); + let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], 5).stfu; assert!(stfu.is_none()); } @@ -1664,7 +1664,7 @@ fn do_test_splice_tiebreak( mine_transaction(&nodes[1], &tx); // After splice_locked, node 1's preserved QuiescentAction triggers STFU for retry. - let node_1_stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); + let node_1_stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1).stfu; let stfu_1 = if let Some(MessageSendEvent::SendStfu { msg, .. }) = node_1_stfu { assert!(msg.initiator); msg @@ -4712,13 +4712,124 @@ fn test_splice_rbf_acceptor_basic() { expect_splice_pending_event(&nodes[1], &node_id_0); // Step 11: Mine, lock, and verify DiscardFunding for the replaced splice candidate. - lock_rbf_splice_after_blocks( + let result = lock_rbf_splice_after_blocks( &nodes[0], &nodes[1], &rbf_tx, ANTI_REORG_DELAY - 1, &[first_splice_tx.compute_txid()], ); + + // The test wallet reuses the same UTXO across RBF rounds (the wallet doesn't track + // in-flight spends), so all contributed inputs are in the promoted tx. No unique + // contributions to discard. + assert!(result.node_a_discarded.is_empty()); + assert!(result.node_b_discarded.is_empty()); +} + +#[test] +fn test_splice_rbf_discard_unique_contribution() { + // Verify that DiscardFunding events contain the correct unique inputs and outputs when the + // RBF round uses different UTXOs than the initial splice. By clearing the wallet between + // rounds and providing fresh UTXOs, we force distinct inputs per round. Round 0 also + // includes a splice-out output with a unique script_pubkey not present in the RBF tx. + // When the RBF is promoted, round 0's inputs and splice-out output should appear in + // DiscardFunding. The change output is filtered because it shares a script_pubkey with the + // promoted tx's change output. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Round 0: Splice-in-and-out from node 0 with a splice-out output. + let splice_out_output = TxOut { + value: Amount::from_sat(5_000), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()), + }; + let funding_contribution = do_initiate_splice_in_and_out( + &nodes[0], + &nodes[1], + channel_id, + added_value, + vec![splice_out_output.clone()], + ); + let round_0_inputs: Vec<_> = funding_contribution.contributed_inputs().collect(); + assert!(!round_0_inputs.is_empty()); + + let (first_splice_tx, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Clear node 0's wallet so round 1 must use different UTXOs. + nodes[0].wallet_source.clear_utxos(); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Round 1: RBF with fresh UTXOs, splice-in only (no splice-out output). + let rbf_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let funding_contribution = funding_template + .without_prior_contribution(rbf_feerate, FeeRate::MAX) + .with_coin_selection_source_sync(&wallet) + .add_value(added_value) + .build() + .unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, funding_contribution.clone(), None) + .unwrap(); + let round_1_inputs: Vec<_> = funding_contribution.contributed_inputs().collect(); + assert_ne!(round_0_inputs, round_1_inputs, "Rounds must use different UTXOs"); + + complete_rbf_handshake(&nodes[0], &nodes[1]); + + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + funding_contribution, + new_funding_script.clone(), + ); + + let (rbf_tx, splice_locked) = sign_interactive_funding_tx( + &nodes[0], + &nodes[1], + false, + Some(first_splice_tx.compute_txid()), + ); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + let result = lock_rbf_splice_after_blocks( + &nodes[0], + &nodes[1], + &rbf_tx, + ANTI_REORG_DELAY - 1, + &[first_splice_tx.compute_txid()], + ); + + // Node 0's round 0 inputs are NOT in the promoted tx (which uses round 1's fresh UTXOs), + // so they appear as unique contributions to discard. The splice-out output also survives + // because its script_pubkey is not in the promoted tx. The change output is filtered + // because it shares a script_pubkey with the promoted tx's change output. + assert_eq!(result.node_a_discarded.len(), 1); + let (ref inputs, ref outputs) = result.node_a_discarded[0]; + assert_eq!(*inputs, round_0_inputs); + assert_eq!(*outputs, vec![splice_out_output]); + + // Node 1 (non-contributing acceptor) has no contributions to discard. + assert!(result.node_b_discarded.is_empty()); } #[test] @@ -5663,13 +5774,18 @@ pub fn do_test_splice_rbf_tiebreak( expect_splice_pending_event(&nodes[1], &node_id_0); // Mine, lock, and verify DiscardFunding for the replaced splice candidate. - lock_rbf_splice_after_blocks( + let result = lock_rbf_splice_after_blocks( &nodes[0], &nodes[1], &rbf_tx, ANTI_REORG_DELAY - 1, &[first_splice_tx.compute_txid()], ); + + // The test wallet reuses the same UTXOs across RBF rounds, so all contributed inputs + // are in the promoted tx and nothing is unique to discard. + assert!(result.node_a_discarded.is_empty()); + assert!(result.node_b_discarded.is_empty()); } else { // Acceptor does not contribute — complete with only node 0's inputs/outputs. complete_interactive_funding_negotiation_for_both( @@ -5698,14 +5814,14 @@ pub fn do_test_splice_rbf_tiebreak( // Mine, lock, and verify DiscardFunding for the replaced splice candidate. // Node 1's QuiescentAction was preserved, so after splice_locked it re-initiates // quiescence to retry its contribution in a future splice. - let node_b_stfu = lock_rbf_splice_after_blocks( + let result = lock_rbf_splice_after_blocks( &nodes[0], &nodes[1], &rbf_tx, ANTI_REORG_DELAY - 1, &[first_splice_tx.compute_txid()], ); - let stfu_1 = if let Some(MessageSendEvent::SendStfu { msg, .. }) = node_b_stfu { + let stfu_1 = if let Some(MessageSendEvent::SendStfu { msg, .. }) = result.stfu { msg } else { panic!("Expected SendStfu from node 1"); @@ -5985,13 +6101,18 @@ fn test_splice_rbf_acceptor_recontributes() { expect_splice_pending_event(&nodes[1], &node_id_0); // Step 12: Mine, lock, and verify DiscardFunding for the replaced splice candidate. - lock_rbf_splice_after_blocks( + let result = lock_rbf_splice_after_blocks( &nodes[0], &nodes[1], &rbf_tx, ANTI_REORG_DELAY - 1, &[first_splice_tx.compute_txid()], ); + + // The test wallet reuses the same UTXOs across RBF rounds, so all contributed inputs + // are in the promoted tx and nothing is unique to discard. + assert!(result.node_a_discarded.is_empty()); + assert!(result.node_b_discarded.is_empty()); } #[test] @@ -6314,13 +6435,18 @@ fn test_splice_rbf_sequential() { // --- Mine and lock the final RBF, verifying DiscardFunding for both replaced candidates. --- let splice_tx_0_txid = splice_tx_0.compute_txid(); let splice_tx_1_txid = splice_tx_1.compute_txid(); - lock_rbf_splice_after_blocks( + let result = lock_rbf_splice_after_blocks( &nodes[0], &nodes[1], &rbf_tx_final, ANTI_REORG_DELAY - 1, &[splice_tx_0_txid, splice_tx_1_txid], ); + + // The test wallet reuses the same UTXOs across RBF rounds, so all contributed inputs + // are in the promoted tx and nothing is unique to discard. + assert!(result.node_a_discarded.is_empty()); + assert!(result.node_b_discarded.is_empty()); } #[test] @@ -6913,7 +7039,7 @@ fn test_funding_contributed_rbf_adjustment_exceeds_max_feerate() { // Mine and lock the pending splice → pending_splice is cleared. mine_transaction(&nodes[0], &_splice_tx); mine_transaction(&nodes[1], &_splice_tx); - let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); + let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1).stfu; // STFU is sent during lock — the splice proceeds as a fresh splice (not RBF). let stfu = match stfu { @@ -6990,7 +7116,7 @@ fn test_funding_contributed_rbf_adjustment_insufficient_budget() { // Mine and lock the pending splice → pending_splice is cleared. mine_transaction(&nodes[0], &_splice_tx); mine_transaction(&nodes[1], &_splice_tx); - let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); + let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1).stfu; // STFU is sent during lock — the splice proceeds as a fresh splice (not RBF). let stfu = match stfu { From 419908da9a4c4ab947444fac3e750668978e9a72 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 7 May 2026 15:12:58 +0200 Subject: [PATCH 364/627] Expose probe status in recent payments Previously, `ChannelManager::list_recent_payments` didn't give us the means to discern 'real' payments from inflight probes. In https://github.com/lightningdevkit/ldk-node/pull/815 we found that we need a way to re-derive which probes are still pending so our accounting of inflight probing amounts is still correct after restart. To this end, we here let callers distinguish liquidity probes while they are pending or abandoned. Co-Authored-By: HAL 9000 Co-Authored-By: HAL 9000 Signed-off-by: Elias Rohrer --- lightning/src/ln/channelmanager.rs | 14 +++++++++-- lightning/src/ln/payment_tests.rs | 38 +++++++++++++++++++++++------- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 64486598005..f5a2bb02f83 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -84,7 +84,6 @@ use crate::ln::onion_utils::{ }; use crate::ln::onion_utils::{process_fulfill_attribution_data, AttributionData}; use crate::ln::our_peer_storage::{EncryptedOurPeerStorage, PeerStorageMonitorHolder}; -#[cfg(test)] use crate::ln::outbound_payment; #[cfg(any(test, feature = "_externalize_tests"))] use crate::ln::outbound_payment::PaymentSendFailure; @@ -3289,6 +3288,8 @@ pub enum RecentPaymentDetails { /// Total amount (in msat, excluding fees) across all paths for this payment, /// not just the amount currently inflight. total_msat: u64, + /// Whether this payment is a liquidity probe. + is_probe: bool, }, /// When a pending payment is fulfilled, we continue tracking it until all pending HTLCs have /// been resolved. Upon receiving [`Event::PaymentSent`], we delay for a few minutes before the @@ -3316,6 +3317,8 @@ pub enum RecentPaymentDetails { payment_id: PaymentId, /// Hash of the payment that we have given up trying to send. payment_hash: PaymentHash, + /// Whether this payment is a liquidity probe. + is_probe: bool, }, } @@ -4102,14 +4105,21 @@ impl< Some(RecentPaymentDetails::AwaitingInvoice { payment_id: *payment_id }) }, PendingOutboundPayment::Retryable { payment_hash, total_msat, .. } => { + let is_probe = outbound_payment::payment_is_probe(payment_hash, payment_id, self.probing_cookie_secret); Some(RecentPaymentDetails::Pending { payment_id: *payment_id, payment_hash: *payment_hash, total_msat: *total_msat, + is_probe, }) }, PendingOutboundPayment::Abandoned { payment_hash, .. } => { - Some(RecentPaymentDetails::Abandoned { payment_id: *payment_id, payment_hash: *payment_hash }) + let is_probe = outbound_payment::payment_is_probe(payment_hash, payment_id, self.probing_cookie_secret); + Some(RecentPaymentDetails::Abandoned { + payment_id: *payment_id, + payment_hash: *payment_hash, + is_probe, + }) }, PendingOutboundPayment::Fulfilled { payment_hash, .. } => { Some(RecentPaymentDetails::Fulfilled { payment_id: *payment_id, payment_hash: *payment_hash }) diff --git a/lightning/src/ln/payment_tests.rs b/lightning/src/ln/payment_tests.rs index 5b4f5f93d71..f2ab44f8fe3 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -1591,17 +1591,32 @@ fn sent_probe_is_probe_of_sending_node() { // Then build an actual two-hop probing path let (route, _, _, _) = get_route_and_payment_hash!(&nodes[0], nodes[2], 100_000); - match nodes[0].node.send_probe(route.paths[0].clone()) { - Ok((payment_hash, payment_id)) => { - assert!(nodes[0].node.payment_is_probe(&payment_hash, &payment_id)); - assert!(!nodes[1].node.payment_is_probe(&payment_hash, &payment_id)); - assert!(!nodes[2].node.payment_is_probe(&payment_hash, &payment_id)); - }, - _ => panic!(), - } + let (payment_hash, payment_id) = nodes[0].node.send_probe(route.paths[0].clone()).unwrap(); + assert!(nodes[0].node.payment_is_probe(&payment_hash, &payment_id)); + assert!(!nodes[1].node.payment_is_probe(&payment_hash, &payment_id)); + assert!(!nodes[2].node.payment_is_probe(&payment_hash, &payment_id)); + assert!(matches!( + nodes[0].node.list_recent_payments().as_slice(), + [RecentPaymentDetails::Pending { + payment_id: listed_payment_id, + payment_hash: listed_payment_hash, + is_probe: true, + .. + }] if *listed_payment_id == payment_id && *listed_payment_hash == payment_hash + )); get_htlc_update_msgs(&nodes[0], &node_b_id); check_added_monitors(&nodes[0], 1); + + nodes[0].node.abandon_payment(payment_id); + assert!(matches!( + nodes[0].node.list_recent_payments().as_slice(), + [RecentPaymentDetails::Abandoned { + payment_id: listed_payment_id, + payment_hash: listed_payment_hash, + is_probe: true, + }] if *listed_payment_id == payment_id && *listed_payment_hash == payment_hash + )); } #[test] @@ -2142,7 +2157,12 @@ fn test_trivial_inflight_htlc_tracking() { } let pending_payments = nodes[0].node.list_recent_payments(); assert_eq!(pending_payments.len(), 1); - let details = RecentPaymentDetails::Pending { payment_id, payment_hash, total_msat: 500000 }; + let details = RecentPaymentDetails::Pending { + payment_id, + payment_hash, + total_msat: 500000, + is_probe: false, + }; assert_eq!(pending_payments[0], details); // Now, let's claim the payment. This should result in the used liquidity to return `None`. From 1a01b5ae4fb74bfff763b968719e362e546bd594 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 5 May 2026 20:10:01 +0200 Subject: [PATCH 365/627] Strip Unicode `Cf` characters in `PrintableString` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PrintableString` is the sanitiser LDK uses to render untrusted strings (node aliases, BOLT-12 invoice / offer text, `UntrustedString`, LSPS messages, `lightning-invoice` descriptions) to logs and UI. It only replaced `char::is_control` matches (Unicode general category `Cc`) with U+FFFD, leaving the entire `Cf` (Format) category untouched. That is the exact category covering the bidirectional override / isolate codepoints (U+202A..U+202E, U+2066..U+2069) and zero-width characters (U+200B..U+200D, U+FEFF) behind the "Trojan Source" attack family (CVE-2021-42574): a peer can set its alias / invoice description / offer fields to e.g. `safe\u{202E}cipsxe.exe`, which previously passed through verbatim while a human reader sees `safeexe.cips` — defeating the threat model `PrintableString` exists to defend against. Replace `Cf` codepoints alongside `Cc` ones. The `Cf` ranges are inlined as a `matches!` table sourced from Unicode 16.0 to keep the change `no_std`-friendly with no new dependencies. Co-Authored-By: HAL 9000 Signed-off-by: Elias Rohrer --- lightning-types/src/string.rs | 59 ++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/lightning-types/src/string.rs b/lightning-types/src/string.rs index ae5395a5289..e45c17d8586 100644 --- a/lightning-types/src/string.rs +++ b/lightning-types/src/string.rs @@ -31,7 +31,11 @@ impl<'a> fmt::Display for PrintableString<'a> { fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { use core::fmt::Write; for c in self.0.chars() { - let c = if c.is_control() { core::char::REPLACEMENT_CHARACTER } else { c }; + let c = if c.is_control() || is_format_char(c) { + core::char::REPLACEMENT_CHARACTER + } else { + c + }; f.write_char(c)?; } @@ -39,6 +43,39 @@ impl<'a> fmt::Display for PrintableString<'a> { } } +// Codepoints in Unicode general category `Cf` (Format), per Unicode standard. These are not +// matched by `char::is_control` (which only covers `Cc`), but include the bidirectional override / +// isolate controls (e.g. U+202E RLO) and zero-width characters behind the "Trojan Source" attack +// family (CVE-2021-42574), where an attacker-supplied string renders to a human reader as +// something other than its byte content. Strip them alongside `Cc` characters when sanitising +// untrusted input. +fn is_format_char(c: char) -> bool { + matches!( + c as u32, + 0x00AD + | 0x0600..=0x0605 + | 0x061C + | 0x06DD + | 0x070F + | 0x0890..=0x0891 + | 0x08E2 + | 0x180E + | 0x200B..=0x200F + | 0x202A..=0x202E + | 0x2060..=0x2064 + | 0x2066..=0x206F + | 0xFEFF + | 0xFFF9..=0xFFFB + | 0x110BD + | 0x110CD + | 0x13430..=0x1343F + | 0x1BCA0..=0x1BCA3 + | 0x1D173..=0x1D17A + | 0xE0001 + | 0xE0020..=0xE007F + ) +} + #[cfg(test)] mod tests { use super::PrintableString; @@ -50,4 +87,24 @@ mod tests { "I \u{1F496} LDK!\u{FFFD}\u{26A1}", ); } + + #[test] + fn sanitizes_unicode_bidi_override_characters() { + // U+202E RIGHT-TO-LEFT OVERRIDE and friends are Unicode general category + // `Cf` (Format), not `Cc` (Control). They enable "Trojan Source" / + // bidi-spoofing attacks where an attacker-supplied string (e.g. a node + // alias gossiped from a peer) renders to a human reader as something + // other than its byte content. `PrintableString` is the sanitiser used + // for exactly these untrusted strings, so it must replace them. + let rendered = format!("{}", PrintableString("safe\u{202E}cipsxe.exe")); + assert!( + !rendered.contains('\u{202E}'), + "PrintableString left a U+202E RLO override in its output: {:?}", + rendered + ); + + // U+13440 is in the Egyptian Hieroglyph Format Controls block, but its + // general category is `Mn`, not `Cf`, so the `Cf` range ends at U+1343F. + assert_eq!(format!("{}", PrintableString("x\u{1343F}y\u{13440}z")), "x\u{FFFD}y\u{13440}z"); + } } From b9d0eb747cf1c2f7f845e94bb89a1a6b0f129a11 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Tue, 17 Mar 2026 14:44:45 -0700 Subject: [PATCH 366/627] Change FundingInfo::Contribution to expose contributed output scripts Exposing the amounts for each output isn't very helpful because it's possible that they vary across over multiple splice candidates due to RBF. This commit changes `FundingInfo::Contribution` and several of the helpers used to derive it to be based on output scripts instead. --- lightning/src/events/mod.rs | 6 ++-- lightning/src/ln/channel.rs | 14 ++++----- lightning/src/ln/funding.rs | 42 ++++++++++++++++++-------- lightning/src/ln/interactivetxs.rs | 10 +++---- lightning/src/ln/splicing_tests.rs | 48 +++++++++++++++++++----------- 5 files changed, 75 insertions(+), 45 deletions(-) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 0d5b8f757ab..5f4f3cc7ffd 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -52,7 +52,7 @@ use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::Hash; use bitcoin::script::ScriptBuf; use bitcoin::secp256k1::PublicKey; -use bitcoin::{OutPoint, Transaction, TxOut}; +use bitcoin::{OutPoint, Transaction}; use core::ops::Deref; #[allow(unused_imports)] @@ -82,8 +82,8 @@ pub enum FundingInfo { Contribution { /// UTXOs spent as inputs contributed to the funding transaction. inputs: Vec, - /// Outputs contributed to the funding transaction. - outputs: Vec, + /// Output scripts contributed to the funding transaction. + outputs: Vec, }, } diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 18236d761a0..6967f230416 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -3133,7 +3133,7 @@ impl PendingFunding { self.contributions.iter().flat_map(|c| c.contributed_inputs()) } - fn contributed_outputs(&self) -> impl Iterator + '_ { + fn contributed_outputs(&self) -> impl Iterator + '_ { self.contributions.iter().flat_map(|c| c.contributed_outputs()) } @@ -3142,7 +3142,7 @@ impl PendingFunding { self.contributions[..len.saturating_sub(1)].iter().flat_map(|c| c.contributed_inputs()) } - fn prior_contributed_outputs(&self) -> impl Iterator + '_ { + fn prior_contributed_outputs(&self) -> impl Iterator + '_ { let len = self.contributions.len(); self.contributions[..len.saturating_sub(1)].iter().flat_map(|c| c.contributed_outputs()) } @@ -3191,7 +3191,7 @@ pub(crate) enum QuiescentAction { pub(super) enum QuiescentError { DoNothing, - DiscardFunding { inputs: Vec, outputs: Vec }, + DiscardFunding { inputs: Vec, outputs: Vec }, FailSplice(SpliceFundingFailed, NegotiationFailureReason), } @@ -6887,8 +6887,8 @@ impl FundingNegotiationContext { self.our_funding_inputs.iter().map(|input| input.utxo.outpoint) } - fn contributed_outputs(&self) -> impl Iterator + '_ { - self.our_funding_outputs.iter() + fn contributed_outputs(&self) -> impl Iterator + '_ { + self.our_funding_outputs.iter().map(|output| output.script_pubkey.as_script()) } } @@ -7046,7 +7046,7 @@ pub struct SpliceFundingFailed { /// Outputs contributed to the splice transaction. Excludes outputs already contributed /// in prior rounds, which may be included in `contribution`. - contributed_outputs: Vec, + contributed_outputs: Vec, /// The funding contribution from the failed round, if available. contribution: Option, @@ -11689,7 +11689,7 @@ where .filter_map(|contribution| { contribution.into_unique_contributions( promoted_tx.input.iter().map(|i| i.previous_output), - promoted_tx.output.iter(), + promoted_tx.output.iter().map(|o| o.script_pubkey.as_script()), ) }) .map(|(inputs, outputs)| FundingInfo::Contribution { inputs, outputs }) diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 2f4e89db926..93685cc3426 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -586,8 +586,11 @@ impl FundingContribution { self.inputs.iter().map(|input| input.utxo.outpoint) } - pub(super) fn contributed_outputs(&self) -> impl Iterator + '_ { - self.outputs.iter().chain(self.change_output.iter()) + pub(super) fn contributed_outputs(&self) -> impl Iterator + '_ { + self.outputs + .iter() + .chain(self.change_output.iter()) + .map(|output| output.script_pubkey.as_script()) } /// The value that will be added to the channel after fees. See [`Self::net_value`] for the net @@ -751,26 +754,41 @@ impl FundingContribution { (inputs, outputs) } - pub(super) fn into_contributed_inputs_and_outputs(self) -> (Vec, Vec) { - let (inputs, outputs) = self.into_tx_parts(); - - (inputs.into_iter().map(|input| input.utxo.outpoint).collect(), outputs) + pub(super) fn into_contributed_inputs_and_outputs(self) -> (Vec, Vec) { + let FundingContribution { inputs, outputs, change_output, .. } = self; + let contributed_inputs = inputs.into_iter().map(|input| input.utxo.outpoint).collect(); + let contributed_outputs = outputs.into_iter().chain(change_output.into_iter()); + (contributed_inputs, contributed_outputs.map(|output| output.script_pubkey).collect()) } pub(super) fn into_unique_contributions<'a>( self, existing_inputs: impl Iterator, - existing_outputs: impl Iterator, - ) -> Option<(Vec, Vec)> { - let (mut inputs, mut outputs) = self.into_contributed_inputs_and_outputs(); + existing_outputs: impl Iterator, + ) -> Option<(Vec, Vec)> { + let FundingContribution { mut inputs, mut outputs, mut change_output, .. } = self; for existing in existing_inputs { - inputs.retain(|input| *input != existing); + inputs.retain(|input| input.outpoint() != existing); } for existing in existing_outputs { - outputs.retain(|output| output.script_pubkey != existing.script_pubkey); + outputs.retain(|output| output.script_pubkey.as_script() != existing); + // TODO: Replace with `take_if` once our MSRV is >= 1.80. + if change_output + .as_ref() + .filter(|output| output.script_pubkey.as_script() == existing) + .is_some() + { + change_output.take(); + } } - if inputs.is_empty() && outputs.is_empty() { + if inputs.is_empty() && outputs.is_empty() && change_output.as_ref().is_none() { None } else { + let inputs = inputs.into_iter().map(|input| input.outpoint()).collect(); + let outputs = outputs + .into_iter() + .chain(change_output.into_iter()) + .map(|output| output.script_pubkey) + .collect(); Some((inputs, outputs)) } } diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index 10dae95cefa..16b2806fd5c 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -378,7 +378,7 @@ impl ConstructedTransaction { .map(|(_, (txin, _))| txin.previous_output) } - fn contributed_outputs(&self) -> impl Iterator + '_ { + fn contributed_outputs(&self) -> impl Iterator + '_ { self.tx .output .iter() @@ -386,7 +386,7 @@ impl ConstructedTransaction { .enumerate() .filter(|(_, (_, output))| output.is_local(self.holder_is_initiator)) .filter(|(index, _)| *index != self.shared_output_index as usize) - .map(|(_, (txout, _))| txout) + .map(|(_, (txout, _))| txout.script_pubkey.as_script()) } pub fn tx(&self) -> &Transaction { @@ -879,7 +879,7 @@ impl InteractiveTxSigningSession { self.unsigned_tx.contributed_inputs() } - pub(super) fn contributed_outputs(&self) -> impl Iterator + '_ { + pub(super) fn contributed_outputs(&self) -> impl Iterator + '_ { self.unsigned_tx.contributed_outputs() } } @@ -2121,11 +2121,11 @@ impl InteractiveTxConstructor { .map(|(_, input)| input.tx_in().previous_output) } - pub(super) fn contributed_outputs(&self) -> impl Iterator + '_ { + pub(super) fn contributed_outputs(&self) -> impl Iterator + '_ { self.outputs_to_contribute .iter() .filter(|(_, output)| !output.is_shared()) - .map(|(_, output)| output.tx_out()) + .map(|(_, output)| output.tx_out().script_pubkey.as_script()) } pub fn is_initiator(&self) -> bool { diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 1b6879e69f0..2887a5f8ca1 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -685,8 +685,8 @@ pub fn splice_channel<'a, 'b, 'c, 'd>( pub struct SpliceLockedResult { pub stfu: Option, - pub node_a_discarded: Vec<(Vec, Vec)>, - pub node_b_discarded: Vec<(Vec, Vec)>, + pub node_a_discarded: Vec<(Vec, Vec)>, + pub node_b_discarded: Vec<(Vec, Vec)>, } pub fn lock_splice_after_blocks<'a, 'b, 'c, 'd>( @@ -3227,7 +3227,8 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool, pending_ assert!(inputs.is_empty(), "Expected empty inputs (filtered), got {:?}", inputs); // The change output was filtered (same script_pubkey as the prior splice's // change output), but the splice-out output survives (different script_pubkey). - let expected_outputs: Vec<_> = splice_out_output.into_iter().collect(); + let expected_outputs: Vec<_> = + splice_out_output.into_iter().map(|output| output.script_pubkey).collect(); assert_eq!(*outputs, expected_outputs); }, other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), @@ -3924,10 +3925,6 @@ fn test_funding_contributed_splice_already_pending() { // Clear UTXOs and add a LARGER one for the second contribution to ensure // the change output will be different from the first contribution's change - // - // FIXME: Should we actually not consider the change value given DiscardFunding is meant to - // reclaim the change script pubkey? But that means for other cases we'd need to track which - // output is for change later in the pipeline. nodes[0].wallet_source.clear_utxos(); provide_utxo_reserves(&nodes, 1, splice_in_amount * 3); @@ -3941,6 +3938,13 @@ fn test_funding_contributed_splice_already_pending() { .build() .unwrap(); + // The change script should remain the same. + assert_eq!( + first_contribution.change_output().map(|output| &output.script_pubkey), + second_contribution.change_output().map(|output| &output.script_pubkey), + ); + let change_script = first_contribution.change_output().unwrap().script_pubkey.clone(); + // First funding_contributed - this sets up the quiescent action nodes[0].node.funding_contributed(&channel_id, &node_id_1, first_contribution, None).unwrap(); @@ -3950,7 +3954,9 @@ fn test_funding_contributed_splice_already_pending() { // Second funding_contributed with a different contribution - this should trigger // DiscardFunding because there's already a pending quiescent action (splice contribution). // Only inputs/outputs NOT in the existing contribution should be discarded. - let expected_inputs: Vec<_> = second_contribution.contributed_inputs().collect(); + let (expected_inputs, mut expected_outputs) = + second_contribution.clone().into_contributed_inputs_and_outputs(); + expected_outputs.retain(|output| *output != change_script); // Returns Err(APIMisuseError) and emits DiscardFunding for the non-duplicate parts of the second contribution assert_eq!( @@ -3960,8 +3966,6 @@ fn test_funding_contributed_splice_already_pending() { }) ); - // The second contribution has different outputs (second_splice_out differs from first_splice_out), - // so those outputs should NOT be filtered out - they should appear in DiscardFunding. let events = nodes[0].node.get_and_clear_pending_events(); assert_eq!(events.len(), 1); match &events[0] { @@ -3970,10 +3974,9 @@ fn test_funding_contributed_splice_already_pending() { if let FundingInfo::Contribution { inputs, outputs } = funding_info { // The input is different, so it should be in the discard event assert_eq!(*inputs, expected_inputs); - // The splice-out output (different script_pubkey) survives filtering; - // the change output (same script_pubkey as first contribution) is filtered. - assert_eq!(outputs.len(), 1); - assert!(outputs.contains(&second_splice_out)); + // The different output should NOT be filtered out, but the change script should as + // it is the same in both contributions. + assert_eq!(*outputs, expected_outputs); } else { panic!("Expected FundingInfo::Contribution"); } @@ -4085,6 +4088,13 @@ fn do_test_funding_contributed_active_funding_negotiation(state: u8) { .build() .unwrap(); + // The change script should remain the same. + assert_eq!( + first_contribution.change_output().map(|output| &output.script_pubkey), + second_contribution.change_output().map(|output| &output.script_pubkey), + ); + let change_script = first_contribution.change_output().unwrap().script_pubkey.clone(); + // First funding_contributed - sets up the quiescent action and queues STFU nodes[0] .node @@ -4131,7 +4141,9 @@ fn do_test_funding_contributed_active_funding_negotiation(state: u8) { // Call funding_contributed with the second contribution. Inputs don't overlap (different // UTXOs) so they all survive. The splice-out output (different script_pubkey) survives // while the change output (same script_pubkey as first contribution) is filtered. - let expected_inputs: Vec<_> = second_contribution.contributed_inputs().collect(); + let (expected_inputs, mut expected_outputs) = + second_contribution.clone().into_contributed_inputs_and_outputs(); + expected_outputs.retain(|output| *output != change_script); assert_eq!( nodes[0].node.funding_contributed(&channel_id, &node_id_1, second_contribution, None), Err(APIError::APIMisuseError { @@ -4149,7 +4161,7 @@ fn do_test_funding_contributed_active_funding_negotiation(state: u8) { assert_eq!(*inputs, expected_inputs); // Only the splice-out output survives; the change output is filtered // (same script_pubkey as first contribution's change). - assert_eq!(*outputs, vec![splice_out_output]); + assert_eq!(*outputs, vec![splice_out_output.script_pubkey]); } else { panic!("Expected FundingInfo::Contribution"); } @@ -4826,7 +4838,7 @@ fn test_splice_rbf_discard_unique_contribution() { assert_eq!(result.node_a_discarded.len(), 1); let (ref inputs, ref outputs) = result.node_a_discarded[0]; assert_eq!(*inputs, round_0_inputs); - assert_eq!(*outputs, vec![splice_out_output]); + assert_eq!(*outputs, vec![splice_out_output.script_pubkey]); // Node 1 (non-contributing acceptor) has no contributions to discard. assert!(result.node_b_discarded.is_empty()); @@ -6851,7 +6863,7 @@ fn test_splice_rbf_disconnect_filters_prior_contributions() { assert!(inputs.is_empty(), "Expected empty inputs (filtered), got {:?}", inputs); // The change output was filtered (same script_pubkey as round 0's change output), // but the splice-out output survives (different script_pubkey). - assert_eq!(*outputs, vec![splice_out_output.clone()]); + assert_eq!(*outputs, vec![splice_out_output.script_pubkey.clone()]); }, other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), } From 7e806f97c5f2a2c3bcbb739a6d10ad591aeeba04 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Tue, 3 Mar 2026 11:43:56 -0800 Subject: [PATCH 367/627] Produce FundingInfo::Contribution variants in ChannelMonitor Similar to the `ChannelManager`, we expose the contributed inputs and outputs of a splice via `FundingInfo::Contribution` at the `ChannelMonitor` level such that we don't lose the context when the channel closes while a splice is still pending. This relies on tracking the `FundingContribution` that was provided to the `ChannelManager` prior to negotiating the new funding transaction. If no `FundingContribution` exists, then we continue to emit the `FundingInfo::OutPoint` variant. --- lightning/src/chain/channelmonitor.rs | 87 +++++++++++++++++++++------ lightning/src/ln/channel.rs | 7 +++ lightning/src/ln/funding.rs | 6 +- lightning/src/ln/splicing_tests.rs | 20 +++++- lightning/src/util/ser.rs | 2 + 5 files changed, 98 insertions(+), 24 deletions(-) diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs index c3e20ef5e6f..42d04e0f8ce 100644 --- a/lightning/src/chain/channelmonitor.rs +++ b/lightning/src/chain/channelmonitor.rs @@ -44,7 +44,7 @@ use crate::chain::package::{ use crate::chain::transaction::{OutPoint, TransactionData}; use crate::chain::{BlockLocator, WatchedOutput}; use crate::events::bump_transaction::{AnchorDescriptor, BumpTransactionEvent}; -use crate::events::{ClosureReason, Event, EventHandler, ReplayEvent}; +use crate::events::{ClosureReason, Event, EventHandler, FundingInfo, ReplayEvent}; use crate::ln::chan_utils::{ self, ChannelTransactionParameters, CommitmentTransaction, CounterpartyCommitmentSecrets, HTLCClaim, HTLCOutputInCommitment, HolderCommitmentTransaction, @@ -55,6 +55,7 @@ use crate::ln::channel_keys::{ RevocationKey, }; use crate::ln::channelmanager::{HTLCSource, PaymentClaimDetails, SentHTLCId}; +use crate::ln::funding::FundingContribution; use crate::ln::msgs::DecodeError; use crate::ln::types::ChannelId; use crate::sign::{ @@ -688,6 +689,7 @@ pub(crate) enum ChannelMonitorUpdateStep { channel_parameters: ChannelTransactionParameters, holder_commitment_tx: HolderCommitmentTransaction, counterparty_commitment_tx: CommitmentTransaction, + funding_contribution: Option, }, RenegotiatedFundingLocked { funding_txid: Txid, @@ -773,6 +775,7 @@ impl_writeable_tlv_based_enum_upgradable!(ChannelMonitorUpdateStep, (1, channel_parameters, (required: ReadableArgs, None)), (3, holder_commitment_tx, required), (5, counterparty_commitment_tx, required), + (7, funding_contribution, option), }, (12, RenegotiatedFundingLocked) => { (1, funding_txid, required), @@ -1166,6 +1169,9 @@ struct FundingScope { // transaction for which we have deleted claim information on some watchtowers. current_holder_commitment_tx: HolderCommitmentTransaction, prev_holder_commitment_tx: Option, + + /// Our funding contribution when we negotiated the corresponding funding transaction. + contribution: Option, } impl FundingScope { @@ -1185,6 +1191,14 @@ impl FundingScope { fn channel_type_features(&self) -> &ChannelTypeFeatures { &self.channel_parameters.channel_type_features } + + fn contributed_inputs(&self) -> impl Iterator + '_ { + self.contribution.iter().flat_map(|contribution| contribution.contributed_inputs()) + } + + fn contributed_outputs(&self) -> impl Iterator + '_ { + self.contribution.iter().flat_map(|contribution| contribution.contributed_outputs()) + } } impl_writeable_tlv_based!(FundingScope, { @@ -1194,6 +1208,7 @@ impl_writeable_tlv_based!(FundingScope, { (7, current_holder_commitment_tx, required), (9, prev_holder_commitment_tx, option), (11, counterparty_claimable_outpoints, required), + (13, contribution, option), }); #[derive(Clone, PartialEq)] @@ -1756,6 +1771,7 @@ pub(crate) fn write_chanmon_internal( (35, channel_monitor.is_manual_broadcast, required), (37, channel_monitor.funding_seen_onchain, required), (39, channel_monitor.best_block.previous_blocks, required), + (41, channel_monitor.funding.contribution, option), }); Ok(()) @@ -1905,6 +1921,8 @@ impl ChannelMonitor { current_holder_commitment_tx: initial_holder_commitment_tx, prev_holder_commitment_tx: None, + + contribution: None, }, pending_funding: vec![], @@ -3959,6 +3977,7 @@ impl ChannelMonitorImpl { &mut self, logger: &WithContext, channel_parameters: &ChannelTransactionParameters, alternative_holder_commitment_tx: &HolderCommitmentTransaction, alternative_counterparty_commitment_tx: &CommitmentTransaction, + funding_contribution: &Option, ) -> Result<(), ()> { let alternative_counterparty_commitment_txid = alternative_counterparty_commitment_tx.trust().txid(); @@ -4025,6 +4044,7 @@ impl ChannelMonitorImpl { counterparty_claimable_outpoints, current_holder_commitment_tx: alternative_holder_commitment_tx.clone(), prev_holder_commitment_tx: None, + contribution: funding_contribution.clone(), }; let alternative_funding_outpoint = alternative_funding.funding_outpoint(); @@ -4081,6 +4101,29 @@ impl ChannelMonitorImpl { Ok(()) } + fn queue_discard_funding_event( + &mut self, discarded_funding: impl Iterator, + ) { + for funding in discarded_funding { + if let Some(contribution) = funding.contribution { + if let Some((inputs, outputs)) = contribution.into_unique_contributions( + self.funding.contributed_inputs(), + self.funding.contributed_outputs(), + ) { + self.pending_events.push(Event::DiscardFunding { + channel_id: self.channel_id, + funding_info: FundingInfo::Contribution { inputs, outputs }, + }); + } + } else { + self.pending_events.push(Event::DiscardFunding { + channel_id: self.channel_id, + funding_info: FundingInfo::OutPoint { outpoint: funding.funding_outpoint() }, + }); + } + } + } + fn promote_funding(&mut self, new_funding_txid: Txid) -> Result<(), ()> { let prev_funding_txid = self.funding.funding_txid(); @@ -4111,18 +4154,20 @@ impl ChannelMonitorImpl { let no_further_updates_allowed = self.no_further_updates_allowed(); // The swap above places the previous `FundingScope` into `pending_funding`. - for funding in self.pending_funding.drain(..) { - let funding_txid = funding.funding_txid(); - self.outputs_to_watch.remove(&funding_txid); - if no_further_updates_allowed && funding_txid != prev_funding_txid { - self.pending_events.push(Event::DiscardFunding { - channel_id: self.channel_id, - funding_info: crate::events::FundingInfo::OutPoint { - outpoint: funding.funding_outpoint(), - }, - }); - } + for funding in &self.pending_funding { + self.outputs_to_watch.remove(&funding.funding_txid()); } + let mut discarded_funding = Vec::new(); + mem::swap(&mut self.pending_funding, &mut discarded_funding); + let discarded_funding = discarded_funding + .into_iter() + // The previous funding is filtered out since it was already locked, so nothing needs to + // be discarded. + .filter(|funding| { + no_further_updates_allowed && funding.funding_txid() != prev_funding_txid + }); + self.queue_discard_funding_event(discarded_funding); + if let Some((alternative_funding_txid, _)) = self.alternative_funding_confirmed.take() { // In exceedingly rare cases, it's possible there was a reorg that caused a potential funding to // be locked in that this `ChannelMonitor` has not yet seen. Thus, we avoid a runtime assertion @@ -4239,11 +4284,13 @@ impl ChannelMonitorImpl { }, ChannelMonitorUpdateStep::RenegotiatedFunding { channel_parameters, holder_commitment_tx, counterparty_commitment_tx, + funding_contribution, } => { log_trace!(logger, "Updating ChannelMonitor with alternative holder and counterparty commitment transactions for funding txid {}", channel_parameters.funding_outpoint.unwrap().txid); if let Err(_) = self.renegotiated_funding( logger, channel_parameters, holder_commitment_tx, counterparty_commitment_tx, + funding_contribution, ) { ret = Err(()); } @@ -5810,15 +5857,14 @@ impl ChannelMonitorImpl { self.funding_spend_confirmed = Some(entry.txid); self.confirmed_commitment_tx_counterparty_output = commitment_tx_to_counterparty_output; if self.alternative_funding_confirmed.is_none() { - for funding in self.pending_funding.drain(..) { + // We saw a confirmed commitment for our currently locked funding, so + // discard all pending ones. + for funding in &self.pending_funding { self.outputs_to_watch.remove(&funding.funding_txid()); - self.pending_events.push(Event::DiscardFunding { - channel_id: self.channel_id, - funding_info: crate::events::FundingInfo::OutPoint { - outpoint: funding.funding_outpoint(), - }, - }); } + let mut discarded_funding = Vec::new(); + mem::swap(&mut self.pending_funding, &mut discarded_funding); + self.queue_discard_funding_event(discarded_funding.into_iter()); } }, OnchainEvent::AlternativeFundingConfirmation {} => { @@ -6696,6 +6742,7 @@ impl<'a, 'b, ES: EntropySource, SP: SignerProvider> ReadableArgs<(&'a ES, &'b SP let mut is_manual_broadcast = RequiredWrapper(None); let mut funding_seen_onchain = RequiredWrapper(None); let mut best_block_previous_blocks = None; + let mut current_funding_contribution = None; read_tlv_fields!(reader, { (1, funding_spend_confirmed, option), (3, htlcs_resolved_on_chain, optional_vec), @@ -6719,6 +6766,7 @@ impl<'a, 'b, ES: EntropySource, SP: SignerProvider> ReadableArgs<(&'a ES, &'b SP (35, is_manual_broadcast, (default_value, false)), (37, funding_seen_onchain, (default_value, true)), (39, best_block_previous_blocks, option), // Added and always set in 0.3 + (41, current_funding_contribution, option), }); if let Some(previous_blocks) = best_block_previous_blocks { best_block.previous_blocks = previous_blocks; @@ -6837,6 +6885,7 @@ impl<'a, 'b, ES: EntropySource, SP: SignerProvider> ReadableArgs<(&'a ES, &'b SP current_holder_commitment_tx, prev_holder_commitment_tx, + contribution: current_funding_contribution, }, pending_funding: pending_funding.unwrap_or(vec![]), is_manual_broadcast: is_manual_broadcast.0.unwrap(), diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 6967f230416..e9fde821ccc 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -8400,6 +8400,12 @@ where ); } + let funding_contribution = self + .pending_splice + .as_ref() + .and_then(|pending_splice| pending_splice.contributions.last()) + .cloned(); + log_info!( logger, "Received splice initial commitment_signed from peer with funding txid {}", @@ -8413,6 +8419,7 @@ where channel_parameters: pending_splice_funding.channel_transaction_parameters.clone(), holder_commitment_tx, counterparty_commitment_tx, + funding_contribution, }], channel_id: Some(self.context.channel_id()), }; diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 93685cc3426..3a0b4fb0630 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -582,11 +582,11 @@ impl FundingContribution { self.is_splice } - pub(super) fn contributed_inputs(&self) -> impl Iterator + '_ { + pub(crate) fn contributed_inputs(&self) -> impl Iterator + '_ { self.inputs.iter().map(|input| input.utxo.outpoint) } - pub(super) fn contributed_outputs(&self) -> impl Iterator + '_ { + pub(crate) fn contributed_outputs(&self) -> impl Iterator + '_ { self.outputs .iter() .chain(self.change_output.iter()) @@ -761,7 +761,7 @@ impl FundingContribution { (contributed_inputs, contributed_outputs.map(|output| output.script_pubkey).collect()) } - pub(super) fn into_unique_contributions<'a>( + pub(crate) fn into_unique_contributions<'a>( self, existing_inputs: impl Iterator, existing_outputs: impl Iterator, ) -> Option<(Vec, Vec)> { diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 2887a5f8ca1..9d1342acd24 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -1842,6 +1842,8 @@ fn do_test_splice_commitment_broadcast(splice_status: SpliceStatus, claim_htlcs: let splice_in_amount = initial_channel_capacity / 2; let initiator_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, Amount::from_sat(splice_in_amount)); + let (expected_discarded_inputs, expected_discarded_outputs) = + initiator_contribution.clone().into_contributed_inputs_and_outputs(); let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution.clone()); let (preimage2, payment_hash2, ..) = route_payment(&nodes[0], &[&nodes[1]], payment_amount); @@ -1985,14 +1987,25 @@ fn do_test_splice_commitment_broadcast(splice_status: SpliceStatus, claim_htlcs: .chain_source .remove_watched_txn_and_outputs(funding_outpoint, txout.script_pubkey.clone()); - // `SpendableOutputs` events are also included here, but we don't care for them. let events = nodes[0].chain_monitor.chain_monitor.get_and_clear_pending_events(); assert_eq!(events.len(), if claim_htlcs { 2 } else { 4 }, "{events:?}"); if let Event::DiscardFunding { funding_info, .. } = &events[0] { - assert_eq!(*funding_info, FundingInfo::OutPoint { outpoint: funding_outpoint }); + assert_eq!( + *funding_info, + FundingInfo::Contribution { + inputs: expected_discarded_inputs, + outputs: expected_discarded_outputs, + } + ); } else { panic!(); } + assert!(matches!(&events[1], Event::SpendableOutputs { .. })); + if !claim_htlcs { + assert!(matches!(&events[2], Event::SpendableOutputs { .. })); + assert!(matches!(&events[3], Event::SpendableOutputs { .. })); + } + let events = nodes[1].chain_monitor.chain_monitor.get_and_clear_pending_events(); assert_eq!(events.len(), if claim_htlcs { 2 } else { 1 }, "{events:?}"); if let Event::DiscardFunding { funding_info, .. } = &events[0] { @@ -2000,6 +2013,9 @@ fn do_test_splice_commitment_broadcast(splice_status: SpliceStatus, claim_htlcs: } else { panic!(); } + if claim_htlcs { + assert!(matches!(&events[1], Event::SpendableOutputs { .. })); + } } } diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs index bd2488bd8d1..88c03638c82 100644 --- a/lightning/src/util/ser.rs +++ b/lightning/src/util/ser.rs @@ -1099,6 +1099,8 @@ impl_for_vec!(crate::ln::channelmanager::MonitorUpdateCompletionAction); impl_for_vec!(crate::ln::channelmanager::PaymentClaimDetails); impl_for_vec!(crate::ln::msgs::SocketAddress); impl_for_vec!((A, B), A, B); +impl_for_vec!(OutPoint); +impl_for_vec!(ScriptBuf); impl_for_vec!(SerialId); impl_for_vec!(TxInMetadata); impl_for_vec!(TxOutMetadata); From 554d833c2709f9da3c1c6852ee471385194a085c Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Fri, 13 Mar 2026 10:50:26 -0700 Subject: [PATCH 368/627] Allow cancellation of pending splice funding negotiations A user may wish to cancel an in-flight funding negotiation for whatever reason (e.g., mempool feerates have gone down, inability to sign, etc.), so we should make it possible for them to do so. Note that this can only be done for splice funding negotiations for which the user has made a contribution to. --- lightning/src/events/mod.rs | 20 +- lightning/src/ln/channel.rs | 101 ++++++-- lightning/src/ln/channelmanager.rs | 164 ++++++------ lightning/src/ln/interactivetxs.rs | 6 + lightning/src/ln/splicing_tests.rs | 403 ++++++++++++++++++++++++++++- 5 files changed, 575 insertions(+), 119 deletions(-) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 0d5b8f757ab..2be6ef13965 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -137,8 +137,10 @@ pub enum NegotiationFailureReason { /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel /// [`FundingTemplate`]: crate::ln::funding::FundingTemplate ContributionInvalid, - /// The negotiation was locally abandoned via `ChannelManager::abandon_splice`. - LocallyAbandoned, + /// The negotiation was locally canceled via [`ChannelManager::cancel_funding_contributed`]. + /// + /// [`ChannelManager::cancel_funding_contributed`]: crate::ln::channelmanager::ChannelManager::cancel_funding_contributed + LocallyCanceled, /// The channel is closing, so the negotiation cannot continue. See [`Event::ChannelClosed`] /// for the closure reason. ChannelClosing, @@ -171,7 +173,7 @@ impl NegotiationFailureReason { | Self::FeeRateTooLow => true, Self::CounterpartyAborted { .. } | Self::NegotiationError { .. } - | Self::LocallyAbandoned + | Self::LocallyCanceled | Self::ChannelClosing | Self::CannotInitiateRbf => false, } @@ -188,7 +190,7 @@ impl core::fmt::Display for NegotiationFailureReason { }, Self::NegotiationError { msg } => write!(f, "negotiation error: {}", msg), Self::ContributionInvalid => f.write_str("funding contribution was invalid"), - Self::LocallyAbandoned => f.write_str("splice locally abandoned"), + Self::LocallyCanceled => f.write_str("splice locally canceled"), Self::ChannelClosing => f.write_str("channel is closing"), Self::FeeRateTooLow => f.write_str("feerate too low for RBF"), @@ -207,7 +209,7 @@ impl_writeable_tlv_based_enum_upgradable!(NegotiationFailureReason, (1, msg, required), }, (9, ContributionInvalid) => {}, - (11, LocallyAbandoned) => {}, + (11, LocallyCanceled) => {}, (13, ChannelClosing) => {}, (15, FeeRateTooLow) => {}, (17, CannotInitiateRbf) => {}, @@ -1955,7 +1957,7 @@ pub enum Event { invoice_request: InvoiceRequest, }, /// Indicates that a channel funding transaction constructed interactively is ready to be - /// signed. This event will only be triggered if at least one input was contributed. + /// signed. This event will only be triggered if a contribution was made to the transaction. /// /// The transaction contains all inputs and outputs provided by both parties including the /// channel's funding output and a change output if applicable. @@ -1966,8 +1968,9 @@ pub enum Event { /// Each signature MUST use the `SIGHASH_ALL` flag to avoid invalidation of the initial commitment and /// hence possible loss of funds. /// - /// After signing, call [`ChannelManager::funding_transaction_signed`] with the (partially) signed - /// funding transaction. + /// After signing, call [`ChannelManager::funding_transaction_signed`] with the (partially) + /// signed funding transaction. For splices where you contributed inputs or outputs, call + /// [`ChannelManager::cancel_funding_contributed`] instead if you no longer wish to proceed. /// /// Generated in [`ChannelManager`] message handling. /// @@ -1976,6 +1979,7 @@ pub enum Event { /// returning `Err(ReplayEvent ())`), but will only be regenerated as needed after restarts. /// /// [`ChannelManager`]: crate::ln::channelmanager::ChannelManager + /// [`ChannelManager::cancel_funding_contributed`]: crate::ln::channelmanager::ChannelManager::cancel_funding_contributed /// [`ChannelManager::funding_transaction_signed`]: crate::ln::channelmanager::ChannelManager::funding_transaction_signed FundingTransactionReadyForSigning { /// The `channel_id` of the channel which you'll need to pass back into diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 18236d761a0..d37ab2be400 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12779,30 +12779,93 @@ where } } - #[cfg(test)] - pub fn abandon_splice( - &mut self, - ) -> Result<(msgs::TxAbort, Option), APIError> { - if self.should_reset_pending_splice_state(false) { - let tx_abort = - msgs::TxAbort { channel_id: self.context.channel_id(), data: Vec::new() }; - let splice_funding_failed = self.reset_pending_splice_state(); - Ok((tx_abort, splice_funding_failed)) - } else if self.has_pending_splice_awaiting_signatures() { - Err(APIError::APIMisuseError { + pub fn cancel_funding_contributed(&mut self) -> Result { + if matches!(self.quiescent_action, Some(QuiescentAction::Splice { .. })) { + let splice_funding_failed = self.abandon_quiescent_action(); + debug_assert!(splice_funding_failed.is_some()); + let str = "Manually canceled funding contribution"; + let err = if self.context.channel_state.is_local_stfu_sent() + && !self.context.channel_state.is_remote_stfu_sent() + { + // If we've already sent `stfu` and haven't received the counterparty's yet, we know + // it corresponds to our action. + ChannelError::WarnAndDisconnect(str.into()) + } else { + // We don't need to send `tx_abort` because our action still pending means we're not + // quiescent for it. + ChannelError::Ignore(str.into()) + }; + return Ok(InteractiveTxMsgError { err, splice_funding_failed }); + } + + let funding_negotiation = self + .pending_splice + .as_ref() + .and_then(|pending_splice| pending_splice.funding_negotiation.as_ref()); + let Some(funding_negotiation) = funding_negotiation else { + return Err(APIError::APIMisuseError { err: format!( - "Channel {} splice cannot be abandoned; already awaiting signatures", - self.context.channel_id(), + "Channel {} does not have a pending splice negotiation", + self.context.channel_id() ), - }) - } else { - Err(APIError::APIMisuseError { + }); + }; + + let made_contribution = match funding_negotiation { + FundingNegotiation::AwaitingAck { context, .. } => { + context.contributed_inputs().next().is_some() + || context.contributed_outputs().next().is_some() + }, + FundingNegotiation::ConstructingTransaction { interactive_tx_constructor, .. } => { + interactive_tx_constructor.contributed_inputs().next().is_some() + || interactive_tx_constructor.contributed_outputs().next().is_some() + }, + FundingNegotiation::AwaitingSignatures { .. } => self + .context + .interactive_tx_signing_session + .as_ref() + .expect("We have a pending splice awaiting signatures") + .has_local_contribution(), + }; + if !made_contribution { + return Err(APIError::APIMisuseError { err: format!( - "Channel {} splice cannot be abandoned; no pending splice", - self.context.channel_id(), + "Channel {} has a pending splice negotiation with no contribution made", + self.context.channel_id() ), - }) + }); } + + // We typically don't reset the pending funding negotiation when we're in + // [`FundingNegotiation::AwaitingSignatures`] since we're able to resume it on + // re-establishment, so we still need to handle this case separately if the user wishes to + // cancel. If they've yet to call [`Channel::funding_transaction_signed`], then we can + // guarantee to never have sent any signatures to the counterparty, or have processed any + // signatures from them. + if matches!(funding_negotiation, FundingNegotiation::AwaitingSignatures { .. }) { + let already_signed = self + .context + .interactive_tx_signing_session + .as_ref() + .expect("We have a pending splice awaiting signatures") + .has_holder_tx_signatures(); + if already_signed { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} has pending splice negotiation that was already signed", + self.context.channel_id(), + ), + }); + } + } + + debug_assert!(self.context.channel_state.is_quiescent()); + let splice_funding_failed = self.reset_pending_splice_state(); + debug_assert!(splice_funding_failed.is_some()); + Ok(InteractiveTxMsgError { + err: ChannelError::Abort(AbortReason::ManualIntervention), + splice_funding_failed, + }) } /// Checks during handling splice_init diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 7a3a5bd1ee4..9bcc5414eae 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -4920,96 +4920,80 @@ impl< } } - #[cfg(test)] - pub(crate) fn abandon_splice( - &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, - ) -> Result<(), APIError> { - let mut res = Ok(()); - PersistenceNotifierGuard::optionally_notify(self, || { - let result = self.internal_abandon_splice(channel_id, counterparty_node_id); - res = result; - match res { - Ok(_) => NotifyOption::SkipPersistHandleEvents, - Err(_) => NotifyOption::SkipPersistNoEvents, - } - }); - res - } - - #[cfg(test)] - fn internal_abandon_splice( + /// Cancels an in-flight [`FundingContribution`]. + /// + /// This is primarily useful after receiving an [`Event::FundingTransactionReadyForSigning`] for + /// a [`FundingContribution`] you no longer wish to proceed with. This may be called for any + /// pending [`FundingContribution`] after its corresponding + /// [`ChannelManager::funding_contributed`] call up until + /// [`ChannelManager::funding_transaction_signed`]. + /// + /// Returns [`ChannelUnavailable`] when a channel is not found or an incorrect + /// `counterparty_node_id` is provided, or [`APIMisuseError`] otherwise with the error details. + /// + /// [`Event::FundingTransactionReadyForSigning`]: events::Event::FundingTransactionReadyForSigning + /// [`ChannelUnavailable`]: APIError::ChannelUnavailable + /// [`APIMisuseError`]: APIError::APIMisuseError + pub fn cancel_funding_contributed( &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, ) -> Result<(), APIError> { - let per_peer_state = self.per_peer_state.read().unwrap(); - - let peer_state_mutex = match per_peer_state - .get(counterparty_node_id) - .ok_or_else(|| APIError::no_such_peer(counterparty_node_id)) - { - Ok(p) => p, - Err(e) => return Err(e), - }; - - let mut peer_state_lock = peer_state_mutex.lock().unwrap(); - let peer_state = &mut *peer_state_lock; - - // Look for the channel - match peer_state.channel_by_id.entry(*channel_id) { - hash_map::Entry::Occupied(mut chan_phase_entry) => { - if !chan_phase_entry.get().context().is_connected() { - // TODO: We should probably support this, but right now `splice_channel` refuses when - // the peer is disconnected, so we just check it here. - return Err(APIError::ChannelUnavailable { - err: "Cannot abandon splice while peer is disconnected".to_owned(), - }); - } - - if let Some(chan) = chan_phase_entry.get_mut().as_funded_mut() { - let (tx_abort, splice_funding_failed) = chan.abandon_splice()?; - - peer_state.pending_msg_events.push(MessageSendEvent::SendTxAbort { - node_id: *counterparty_node_id, - msg: tx_abort, - }); + let mut result = Ok(()); + PersistenceNotifierGuard::manually_notify(self, || { + let per_peer_state = self.per_peer_state.read().unwrap(); + let peer_state_mutex = match per_peer_state + .get(counterparty_node_id) + .ok_or_else(|| APIError::no_such_peer(counterparty_node_id)) + { + Ok(p) => p, + Err(e) => { + result = Err(e); + return; + }, + }; + let mut peer_state_lock = peer_state_mutex.lock().unwrap(); + let peer_state = &mut *peer_state_lock; - if let Some(splice_funding_failed) = splice_funding_failed { - let (funding_info, contribution) = splice_funding_failed.into_parts(); - let pending_events = &mut self.pending_events.lock().unwrap(); - if let Some(funding_info) = funding_info { - pending_events.push_back(( - events::Event::DiscardFunding { - channel_id: *channel_id, - funding_info, - }, - None, - )); - } - pending_events.push_back(( - events::Event::SpliceNegotiationFailed { - channel_id: *channel_id, - counterparty_node_id: *counterparty_node_id, - user_channel_id: chan.context.get_user_id(), - contribution, - reason: events::NegotiationFailureReason::LocallyAbandoned, + match peer_state.channel_by_id.entry(*channel_id) { + hash_map::Entry::Occupied(mut chan_entry) => { + if let Some(channel) = chan_entry.get_mut().as_funded_mut() { + let err = match channel.cancel_funding_contributed() { + Ok(v) => v, + Err(e) => { + result = Err(e); + return; }, - None, - )); - } + }; + let user_channel_id = channel.context().get_user_id(); + mem::drop(peer_state_lock); + mem::drop(per_peer_state); - Ok(()) - } else { - Err(APIError::ChannelUnavailable { - err: format!( - "Channel with id {} is not funded, cannot abandon splice", - channel_id - ), - }) - } - }, - hash_map::Entry::Vacant(_) => { - Err(APIError::no_such_channel_for_peer(channel_id, counterparty_node_id)) - }, - } + let err = self.handle_interactive_tx_msg_err( + err, + *channel_id, + counterparty_node_id, + user_channel_id, + Some(events::NegotiationFailureReason::LocallyCanceled), + ); + let _ = self.handle_error(Err::<(), _>(err), *counterparty_node_id); + self.event_persist_notifier.notify(); + } else { + result = Err(APIError::ChannelUnavailable { + err: format!( + "Channel with id {} is not funded, cannot cancel splice", + channel_id + ), + }); + return; + } + }, + hash_map::Entry::Vacant(_) => { + result = + Err(APIError::no_such_channel_for_peer(channel_id, counterparty_node_id)); + return; + }, + } + }); + result } fn forward_needs_intercept_to_known_chan( @@ -11962,7 +11946,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ fn handle_interactive_tx_msg_err( &self, err: InteractiveTxMsgError, channel_id: ChannelId, counterparty_node_id: &PublicKey, - user_channel_id: u128, + user_channel_id: u128, reason: Option, ) -> MsgHandleErrInternal { if let Some(splice_funding_failed) = err.splice_funding_failed { let (funding_info, contribution) = splice_funding_failed.into_parts(); @@ -11977,9 +11961,9 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ counterparty_node_id: *counterparty_node_id, user_channel_id, contribution, - reason: events::NegotiationFailureReason::NegotiationError { + reason: reason.unwrap_or(events::NegotiationFailureReason::NegotiationError { msg: format!("{:?}", err.err), - }, + }), }, None, )); @@ -12015,6 +11999,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ channel_id, counterparty_node_id, user_channel_id, + None, )) }, } @@ -12150,6 +12135,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ msg.channel_id, &counterparty_node_id, user_channel_id, + None, )) }, } @@ -13395,6 +13381,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ msg.channel_id, counterparty_node_id, user_channel_id, + None, )) }, } @@ -13452,6 +13439,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ msg.channel_id, counterparty_node_id, user_channel_id, + None, )) }, } diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index 10dae95cefa..43589137c0b 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -136,6 +136,11 @@ pub(crate) enum AbortReason { NegotiationInProgress, /// The initiator's feerate exceeds our maximum. FeeRateTooHigh, + /// The user manually intervened to abort the funding negotiation via + /// [`ChannelManager::cancel_funding_contributed`]. + /// + /// [`ChannelManager::cancel_funding_contributed`]: crate::ln::channelmanager::ChannelManager::cancel_funding_contributed + ManualIntervention, /// Internal error InternalError(&'static str), } @@ -202,6 +207,7 @@ impl Display for AbortReason { AbortReason::FeeRateTooHigh => { f.write_str("The initiator's feerate exceeds our maximum") }, + AbortReason::ManualIntervention => f.write_str("Manually aborted funding negotiation"), AbortReason::InternalError(text) => { f.write_fmt(format_args!("Internal error: {}", text)) }, diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 1b6879e69f0..b0ca6e494c5 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -2815,8 +2815,8 @@ fn fail_splice_on_tx_abort() { let _tx_complete = get_event_msg!(acceptor, MessageSendEvent::SendTxComplete, node_id_initiator); - acceptor.node.abandon_splice(&channel_id, &node_id_initiator).unwrap(); - let tx_abort = get_event_msg!(acceptor, MessageSendEvent::SendTxAbort, node_id_initiator); + // Inject a fake `tx_abort` to the initiator to trigger the splice to be aborted. + let tx_abort = msgs::TxAbort { channel_id, data: Vec::new() }; initiator.node.handle_tx_abort(node_id_acceptor, &tx_abort); expect_splice_failed_events( @@ -2833,6 +2833,9 @@ fn fail_splice_on_tx_abort() { check_added_monitors(initiator, 1); if let MessageSendEvent::SendTxAbort { msg, .. } = &msg_events[0] { acceptor.node.handle_tx_abort(node_id_initiator, msg); + // The acceptor still tries to ack the abort by sending its own back to the initiator since + // a fake one was originally sent to it. + let _ = get_event_msg!(acceptor, MessageSendEvent::SendTxAbort, node_id_initiator); } else { panic!("Unexpected event {:?}", msg_events[0]); }; @@ -2844,6 +2847,398 @@ fn fail_splice_on_tx_abort() { }; } +#[test] +fn acceptor_with_local_contribution_can_cancel_funding_contributed_before_funding_transaction_signed( +) { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let initiator = &nodes[0]; + let acceptor = &nodes[1]; + + let node_id_initiator = initiator.node.get_our_node_id(); + let node_id_acceptor = acceptor.node.get_our_node_id(); + + let initial_channel_capacity = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0); + + provide_utxo_reserves(&nodes, 2, Amount::ONE_BTC); + + let outputs = vec![TxOut { + value: Amount::from_sat(1_000), + script_pubkey: initiator.wallet_source.get_change_script().unwrap(), + }]; + let initiator_contribution = + initiate_splice_out(initiator, acceptor, channel_id, outputs).unwrap(); + let acceptor_contribution = initiate_splice_in( + acceptor, + initiator, + channel_id, + Amount::from_sat(initial_channel_capacity / 2), + ); + + let stfu_initiator = get_event_msg!(initiator, MessageSendEvent::SendStfu, node_id_acceptor); + let stfu_acceptor = get_event_msg!(acceptor, MessageSendEvent::SendStfu, node_id_initiator); + + acceptor.node.handle_stfu(node_id_initiator, &stfu_initiator); + assert!(acceptor.node.get_and_clear_pending_msg_events().is_empty()); + + initiator.node.handle_stfu(node_id_acceptor, &stfu_acceptor); + + let splice_init = get_event_msg!(initiator, MessageSendEvent::SendSpliceInit, node_id_acceptor); + acceptor.node.handle_splice_init(node_id_initiator, &splice_init); + let splice_ack = get_event_msg!(acceptor, MessageSendEvent::SendSpliceAck, node_id_initiator); + assert_ne!(splice_ack.funding_contribution_satoshis, 0); + initiator.node.handle_splice_ack(node_id_acceptor, &splice_ack); + + let new_funding_script = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + complete_interactive_funding_negotiation_for_both( + initiator, + acceptor, + channel_id, + initiator_contribution.clone(), + Some(acceptor_contribution.clone()), + splice_ack.funding_contribution_satoshis, + new_funding_script, + ); + + let event = get_event!(initiator, Event::FundingTransactionReadyForSigning); + if let Event::FundingTransactionReadyForSigning { + channel_id, + counterparty_node_id, + unsigned_transaction, + .. + } = event + { + let partially_signed_tx = initiator.wallet_source.sign_tx(unsigned_transaction).unwrap(); + initiator + .node + .funding_transaction_signed(&channel_id, &counterparty_node_id, partially_signed_tx) + .unwrap(); + } else { + unreachable!(); + } + + let msg_events = initiator.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + let initial_commit_sig = if let MessageSendEvent::UpdateHTLCs { updates, .. } = &msg_events[0] { + updates.commitment_signed[0].clone() + } else { + panic!("Unexpected event {:?}", msg_events[0]); + }; + acceptor.node.handle_commitment_signed(node_id_initiator, &initial_commit_sig); + assert!(acceptor.node.get_and_clear_pending_msg_events().is_empty()); + + let _signing_event = get_event!(acceptor, Event::FundingTransactionReadyForSigning); + + acceptor.node.cancel_funding_contributed(&channel_id, &node_id_initiator).unwrap(); + let events = acceptor.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2); + assert!(matches!(events[0], Event::DiscardFunding { .. })); + assert!(matches!(events[1], Event::SpliceNegotiationFailed { .. })); + let tx_abort = get_event_msg!(acceptor, MessageSendEvent::SendTxAbort, node_id_initiator); + + initiator.node.handle_tx_abort(node_id_acceptor, &tx_abort); + let reason = NegotiationFailureReason::CounterpartyAborted { + msg: UntrustedString("Manually aborted funding negotiation".into()), + }; + expect_splice_failed_events(initiator, &channel_id, initiator_contribution, reason); + let tx_abort = get_event_msg!(initiator, MessageSendEvent::SendTxAbort, node_id_acceptor); + acceptor.node.handle_tx_abort(node_id_initiator, &tx_abort); +} + +#[test] +fn acceptor_can_cancel_queued_funding_contributed_during_counterparty_splice() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let acceptor = &nodes[0]; + let initiator = &nodes[1]; + + let node_id_acceptor = acceptor.node.get_our_node_id(); + let node_id_initiator = initiator.node.get_our_node_id(); + + let initial_channel_capacity = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + let initiator_contribution = + do_initiate_splice_in(initiator, acceptor, channel_id, added_value); + + let stfu_initiator = get_event_msg!(initiator, MessageSendEvent::SendStfu, node_id_acceptor); + acceptor.node.handle_stfu(node_id_initiator, &stfu_initiator); + let stfu_acceptor = get_event_msg!(acceptor, MessageSendEvent::SendStfu, node_id_initiator); + initiator.node.handle_stfu(node_id_acceptor, &stfu_acceptor); + + let splice_init = get_event_msg!(initiator, MessageSendEvent::SendSpliceInit, node_id_acceptor); + acceptor.node.handle_splice_init(node_id_initiator, &splice_init); + let splice_ack = get_event_msg!(acceptor, MessageSendEvent::SendSpliceAck, node_id_initiator); + assert_eq!(splice_ack.funding_contribution_satoshis, 0); + + let funding_template = acceptor.node.splice_channel(&channel_id, &node_id_initiator).unwrap(); + let feerate = funding_template.min_rbf_feerate().unwrap(); + let wallet = WalletSync::new(Arc::clone(&acceptor.wallet_source), acceptor.logger); + let queued_contribution = funding_template + .splice_in_sync(Amount::from_sat(25_000), feerate, FeeRate::MAX, &wallet) + .unwrap(); + acceptor + .node + .funding_contributed(&channel_id, &node_id_initiator, queued_contribution.clone(), None) + .unwrap(); + assert!(acceptor.node.get_and_clear_pending_msg_events().is_empty()); + + acceptor.node.cancel_funding_contributed(&channel_id, &node_id_initiator).unwrap(); + let reason = NegotiationFailureReason::LocallyCanceled; + expect_splice_failed_events(acceptor, &channel_id, queued_contribution, reason); + assert!(acceptor.node.get_and_clear_pending_msg_events().is_empty()); + + initiator.node.handle_splice_ack(node_id_acceptor, &splice_ack); + let new_funding_script = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + complete_interactive_funding_negotiation( + initiator, + acceptor, + channel_id, + initiator_contribution, + new_funding_script, + ); + + let (splice_tx, splice_locked) = sign_interactive_funding_tx(initiator, acceptor, false, None); + assert!(splice_locked.is_none()); + expect_splice_pending_event(initiator, &node_id_acceptor); + expect_splice_pending_event(acceptor, &node_id_initiator); + + mine_transaction(initiator, &splice_tx); + mine_transaction(acceptor, &splice_tx); + assert!(lock_splice_after_blocks(initiator, acceptor, ANTI_REORG_DELAY - 1).stfu.is_none()); +} + +#[test] +fn cancel_funding_contributed_before_funding_transaction_signed() { + do_cancel_funding_contributed_before_funding_transaction_signed(0); // AwaitingQuiescence + do_cancel_funding_contributed_before_funding_transaction_signed(1); // AwaitingAck + do_cancel_funding_contributed_before_funding_transaction_signed(2); // ConstructingTransaction + do_cancel_funding_contributed_before_funding_transaction_signed(3); // AwaitingSignatures +} + +#[cfg(test)] +fn do_cancel_funding_contributed_before_funding_transaction_signed(state: u8) { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let initiator = &nodes[0]; + let acceptor = &nodes[1]; + + let node_id_initiator = initiator.node.get_our_node_id(); + let node_id_acceptor = acceptor.node.get_our_node_id(); + + let initial_channel_capacity = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0); + + let outputs = vec![TxOut { + value: Amount::from_sat(1_000), + script_pubkey: initiator.wallet_source.get_change_script().unwrap(), + }]; + let funding_contribution = + initiate_splice_out(initiator, acceptor, channel_id, outputs).unwrap(); + + match state { + 0 => { + // Cancel after funding_contributed queues `stfu`, but before the quiescence attempt is + // delivered to the peer. + }, + 1 => { + // Deliver splice_init, but keep splice_ack queued so the initiator remains in + // FundingNegotiation::AwaitingAck while the acceptor tracks the pending splice. + let stfu_init = get_event_msg!(initiator, MessageSendEvent::SendStfu, node_id_acceptor); + acceptor.node.handle_stfu(node_id_initiator, &stfu_init); + let stfu_ack = get_event_msg!(acceptor, MessageSendEvent::SendStfu, node_id_initiator); + initiator.node.handle_stfu(node_id_acceptor, &stfu_ack); + + let splice_init = + get_event_msg!(initiator, MessageSendEvent::SendSpliceInit, node_id_acceptor); + acceptor.node.handle_splice_init(node_id_initiator, &splice_init); + assert!(initiator.node.get_and_clear_pending_msg_events().is_empty()); + + let msg_events = acceptor.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + assert!(matches!(msg_events[0], MessageSendEvent::SendSpliceAck { .. })); + }, + 2 => { + // Complete the splice handshake so the initiator is constructing the interactive tx. + let _new_funding_script = complete_splice_handshake(initiator, acceptor); + + let msg_events = initiator.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + assert!(matches!(msg_events[0], MessageSendEvent::SendTxAddInput { .. })); + assert!(acceptor.node.get_and_clear_pending_msg_events().is_empty()); + }, + 3 => { + // Complete interactive tx negotiation so the initiator is awaiting funding signatures. + let new_funding_script = complete_splice_handshake(initiator, acceptor); + complete_interactive_funding_negotiation( + initiator, + acceptor, + channel_id, + funding_contribution.clone(), + new_funding_script, + ); + + // The initiator should have a signing event to handle, while the acceptor immediately + // sends their initial commitment_signed. Deliver it before canceling to ensure it gets + // discarded with the splice. + let _signing_event = get_event!(initiator, Event::FundingTransactionReadyForSigning); + assert!(acceptor.node.get_and_clear_pending_events().is_empty()); + let acceptor_commit_sig = get_htlc_update_msgs(acceptor, &node_id_initiator); + initiator.node.handle_commitment_signed( + node_id_acceptor, + &acceptor_commit_sig.commitment_signed[0], + ); + check_added_monitors(initiator, 0); + assert!(initiator.node.get_and_clear_pending_msg_events().is_empty()); + }, + _ => panic!("unexpected state {state}"), + } + assert!(initiator.node.get_and_clear_pending_events().is_empty()); + assert!(acceptor.node.get_and_clear_pending_events().is_empty()); + + // Queue an outgoing HTLC to the holding cell. It should be freed once we cancel the splice and + // exit quiescence. + if state != 0 { + let (route, payment_hash, _payment_preimage, payment_secret) = + get_route_and_payment_hash!(initiator, acceptor, 1_000_000); + let onion = RecipientOnionFields::secret_only(payment_secret, 1_000_000); + let payment_id = PaymentId(payment_hash.0); + initiator.node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); + assert!(initiator.node.get_and_clear_pending_msg_events().is_empty()); + } + + initiator.node.cancel_funding_contributed(&channel_id, &node_id_acceptor).unwrap(); + let reason = NegotiationFailureReason::LocallyCanceled; + expect_splice_failed_events(initiator, &channel_id, funding_contribution, reason); + + let msg_events = initiator.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + if state == 0 { + // We didn't reach quiescence prior to canceling, so we should see our `stfu` followed by a + // disconnect. + if let MessageSendEvent::SendStfu { .. } = &msg_events[0] { + } else { + panic!("Unexpected event {:?}", msg_events[0]); + } + if let MessageSendEvent::HandleError { action, .. } = &msg_events[1] { + assert!(matches!(action, msgs::ErrorAction::DisconnectPeerWithWarning { .. })); + } else { + panic!("Unexpected event {:?}", msg_events[1]); + } + return; + } + + // We exit or terminate the quiescence attempt upon canceling the splice, so we should see a + // tx_abort followed by the holding cell HTLC being released immediately. + let tx_abort = if let MessageSendEvent::SendTxAbort { msg, .. } = &msg_events[0] { + msg + } else { + panic!("Unexpected event {:?}", msg_events[0]); + }; + let update = if let MessageSendEvent::UpdateHTLCs { updates, .. } = &msg_events[1] { + updates + } else { + panic!("Unexpected event {:?}", msg_events[1]); + }; + check_added_monitors(initiator, 1); + + acceptor.node.handle_tx_abort(node_id_initiator, tx_abort); + let tx_abort = get_event_msg!(acceptor, MessageSendEvent::SendTxAbort, node_id_initiator); + initiator.node.handle_tx_abort(node_id_acceptor, &tx_abort); + + acceptor.node.handle_update_add_htlc(node_id_initiator, &update.update_add_htlcs[0]); + do_commitment_signed_dance(acceptor, initiator, &update.commitment_signed, false, false); +} + +#[test] +fn cannot_cancel_funding_contributed_after_funding_transaction_signed() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let initiator = &nodes[0]; + let acceptor = &nodes[1]; + + let node_id_initiator = initiator.node.get_our_node_id(); + let node_id_acceptor = acceptor.node.get_our_node_id(); + + let initial_channel_capacity = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0); + + let outputs = vec![TxOut { + value: Amount::from_sat(1_000), + script_pubkey: initiator.wallet_source.get_change_script().unwrap(), + }]; + let funding_contribution = + initiate_splice_out(initiator, acceptor, channel_id, outputs).unwrap(); + let new_funding_script = complete_splice_handshake(initiator, acceptor); + complete_interactive_funding_negotiation( + initiator, + acceptor, + channel_id, + funding_contribution, + new_funding_script, + ); + assert!(acceptor.node.get_and_clear_pending_events().is_empty()); + let _acceptor_commit_sig = get_htlc_update_msgs(acceptor, &node_id_initiator); + + let event = get_event!(initiator, Event::FundingTransactionReadyForSigning); + if let Event::FundingTransactionReadyForSigning { + channel_id, + counterparty_node_id, + unsigned_transaction, + .. + } = event + { + let partially_signed_tx = initiator.wallet_source.sign_tx(unsigned_transaction).unwrap(); + initiator + .node + .funding_transaction_signed(&channel_id, &counterparty_node_id, partially_signed_tx) + .unwrap(); + } else { + unreachable!(); + } + + let res = initiator.node.cancel_funding_contributed(&channel_id, &node_id_acceptor); + match res { + Err(APIError::APIMisuseError { err }) => assert!(err.contains("already signed")), + _ => panic!("Unexpected result {res:?}"), + } + + assert!(initiator.node.get_and_clear_pending_events().is_empty()); + let msg_events = initiator.node.get_and_clear_pending_msg_events(); + assert!( + msg_events.iter().all(|event| !matches!(event, MessageSendEvent::SendTxAbort { .. })), + "{msg_events:?}" + ); +} + #[test] fn fail_splice_on_tx_complete_error() { let chanmon_cfgs = create_chanmon_cfgs(2); @@ -7773,13 +8168,13 @@ fn test_no_disconnect_after_splice_aborted() { nodes[1].node.timer_tick_occurred(); // Abort the splice, which should clear the timer when exiting quiescence. - nodes[0].node.abandon_splice(&channel_id, &node_id_1).unwrap(); + nodes[0].node.cancel_funding_contributed(&channel_id, &node_id_1).unwrap(); expect_splice_failed_events( &nodes[0], &channel_id, funding_contribution, - NegotiationFailureReason::LocallyAbandoned, + NegotiationFailureReason::LocallyCanceled, ); let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); From 637cc413aad41e37c5d5975b493d452c46da0279 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Thu, 19 Mar 2026 11:57:20 -0700 Subject: [PATCH 369/627] Rename should_reset_pending_splice_state argument There's a case in `should_reset_pending_splice_state` where we are awaiting signatures, but still want to preserve the pending negotiation upon a disconnection. We previously used `counterparty_aborted` as a way to toggle this behavior. Now that we support the user manually canceling an ongoing negotiation, we interpret the argument a bit more generically in terms of whether we wish to resume the negotiation or not when we are found in such a state. --- lightning/src/ln/channel.rs | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index d37ab2be400..3f3a6feb414 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -1724,7 +1724,7 @@ where if matches!(chan.context.channel_state, ChannelState::ChannelReady(_)) { chan.context.channel_state.clear_local_stfu_sent(); chan.context.channel_state.clear_remote_stfu_sent(); - if chan.should_reset_pending_splice_state(false) { + if chan.should_reset_pending_splice_state(true) { // If there was a pending splice negotiation that failed due to disconnecting, we // also take the opportunity to clean up our state. let splice_funding_failed = chan.reset_pending_splice_state(); @@ -1841,7 +1841,7 @@ where None }, ChannelPhase::Funded(funded_channel) => { - if funded_channel.should_reset_pending_splice_state(false) { + if funded_channel.should_reset_pending_splice_state(true) { funded_channel.reset_pending_splice_state() } else { debug_assert!(false, "We should never fail an interactive funding negotiation once we're exchanging tx_signatures"); @@ -2024,7 +2024,7 @@ where "Received tx_abort while awaiting tx_signatures exchange".to_owned(), )); } - if funded_channel.should_reset_pending_splice_state(true) { + if funded_channel.should_reset_pending_splice_state(false) { let has_funding_negotiation = funded_channel .pending_splice .as_ref() @@ -7159,7 +7159,7 @@ where fn maybe_fail_splice_negotiation(&mut self) -> Option { if matches!(self.context.channel_state, ChannelState::ChannelReady(_)) { - if self.should_reset_pending_splice_state(false) { + if self.should_reset_pending_splice_state(true) { self.reset_pending_splice_state() } else { self.abandon_quiescent_action() @@ -7216,7 +7216,7 @@ where /// Returns a boolean indicating whether we should reset the splice's /// [`PendingFunding::funding_negotiation`]. - fn should_reset_pending_splice_state(&self, counterparty_aborted: bool) -> bool { + fn should_reset_pending_splice_state(&self, allow_resumption: bool) -> bool { self.pending_splice .as_ref() .map(|pending_splice| { @@ -7228,7 +7228,11 @@ where funding_negotiation, FundingNegotiation::AwaitingSignatures { .. } ); - if counterparty_aborted { + if allow_resumption { + // If we want to resume the negotiation after reconnecting, we must be + // in [`FundingNegotiation::AwaitingSignatures`] to not reset our state. + !is_awaiting_signatures + } else { !is_awaiting_signatures || !self .context() @@ -7236,8 +7240,6 @@ where .as_ref() .expect("We have a pending splice awaiting signatures") .has_received_commitment_signed() - } else { - !is_awaiting_signatures } }) .unwrap_or_else(|| { @@ -7251,7 +7253,7 @@ where } fn reset_pending_splice_state(&mut self) -> Option { - debug_assert!(self.should_reset_pending_splice_state(true)); + debug_assert!(self.should_reset_pending_splice_state(false)); // Only clear the signing session if the current round is mid-signing. When an earlier // round completed signing and a later RBF round is in AwaitingAck or @@ -7325,7 +7327,7 @@ where } pub(super) fn maybe_splice_funding_failed(&self) -> Option { - if !self.should_reset_pending_splice_state(false) { + if !self.should_reset_pending_splice_state(true) { return None; } @@ -15647,7 +15649,7 @@ impl Writeable for FundedChannel { ChannelState::ChannelReady(_) => { channel_state.clear_local_stfu_sent(); channel_state.clear_remote_stfu_sent(); - if self.should_reset_pending_splice_state(false) + if self.should_reset_pending_splice_state(true) || !self.has_pending_splice_awaiting_signatures() { // We shouldn't be quiescent anymore upon reconnecting if: @@ -16037,7 +16039,7 @@ impl Writeable for FundedChannel { // We don't have to worry about resetting the pending `FundingNegotiation` because we // can only read `FundingNegotiation::AwaitingSignatures` variants anyway. let pending_splice = - self.pending_splice.as_ref().filter(|_| !self.should_reset_pending_splice_state(false)); + self.pending_splice.as_ref().filter(|_| !self.should_reset_pending_splice_state(true)); let monitor_pending_tx_signatures = self.context.monitor_pending_tx_signatures.then_some(()); From 3835f842009cf1c317bb5cb166b0ae464e9088f4 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 8 Apr 2026 17:24:48 +0000 Subject: [PATCH 370/627] Error if the calculated v2 reserve is greater than the channel value In 0FC channels, capping the reserve to the total value of the channel allowed a splice initiator to withdraw past their reserve in case the acceptor had no balance in the channel. This is because the post-splice value of the channel was equal to the initiator's post splice balance. Hence, this post splice balance always matched the reserve, even though the reserve was below the dust limit. The only thing that prevented the initiator from withdrawing all their balance was the script dust limit check in `interactivetxs::NegotiationContext::receive_tx_add_output`. In case the splice acceptor had any balance in the channel, or there were HTLCs in the channel, or the channel was not 0FC, the splice initiator's post-splice balance was always below the full channel value. Hence when the reserve was capped at the channel value, the post-splice balance was always below the reserve, and the splice was rejected. Also, in `validate_splice_contributions`, to determine the `counterparty_selected_channel_reserve`, we now read the holder's dust limit from the context, instead of the current global constant. --- lightning/src/ln/channel.rs | 65 ++++++-- lightning/src/ln/splicing_tests.rs | 247 ++++++++++++++++++++++++++++- lightning/src/sign/tx_builder.rs | 20 ++- 3 files changed, 312 insertions(+), 20 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index e07ee7fceab..f2c5b3b8fb8 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2752,16 +2752,14 @@ impl FundingScope { ) -> Result { if our_funding_contribution.unsigned_abs() > Amount::MAX_MONEY { return Err(format!( - "Channel {} cannot be spliced; our {} contribution exceeds the total bitcoin supply", - context.channel_id(), + "Our {} contribution exceeds the total bitcoin supply", our_funding_contribution, )); } if their_funding_contribution.unsigned_abs() > Amount::MAX_MONEY { return Err(format!( - "Channel {} cannot be spliced; their {} contribution exceeds the total bitcoin supply", - context.channel_id(), + "Their {} contribution exceeds the total bitcoin supply", their_funding_contribution, )); } @@ -2821,17 +2819,31 @@ impl FundingScope { // New reserve values are based on the new channel value and are v2-specific let counterparty_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis( post_channel_value_sat, - MIN_CHAN_DUST_LIMIT_SATOSHIS, + context.holder_dust_limit_satoshis, prev_funding .counterparty_selected_channel_reserve_satoshis .expect("counterparty reserve is set") == 0, - ); + ) + .map_err(|()| { + format!( + "The post-splice channel value {post_channel_value_sat} is smaller \ + than our dust limit {}", + context.holder_dust_limit_satoshis + ) + })?; let holder_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis( post_channel_value_sat, context.counterparty_dust_limit_satoshis, prev_funding.holder_selected_channel_reserve_satoshis == 0, - ); + ) + .map_err(|()| { + format!( + "The post-splice channel value {post_channel_value_sat} is smaller \ + than their dust limit {}", + context.counterparty_dust_limit_satoshis, + ) + })?; Ok(Self { channel_transaction_parameters: post_channel_transaction_parameters, @@ -3384,6 +3396,9 @@ pub(super) struct ChannelContext { /// We use this to close if funding is never broadcasted. pub(super) channel_creation_height: u32, + #[cfg(any(test, feature = "_test_utils"))] + pub(crate) counterparty_dust_limit_satoshis: u64, + #[cfg(not(any(test, feature = "_test_utils")))] counterparty_dust_limit_satoshis: u64, #[cfg(any(test, feature = "_test_utils"))] @@ -6776,19 +6791,24 @@ pub(crate) fn get_legacy_default_holder_selected_channel_reserve_satoshis( /// Returns a minimum channel reserve value each party needs to maintain, fixed in the spec to a /// default of 1% of the total channel value. /// -/// Guaranteed to return a value no larger than channel_value_satoshis +/// Guaranteed to return a value no larger than `channel_value_satoshis` /// /// This is used both for outbound and inbound channels and has lower bound /// of `dust_limit_satoshis`. +/// +/// Returns `Err` if `channel_value_satoshis` is smaller than `dust_limit_satoshis`. pub(crate) fn get_v2_channel_reserve_satoshis( channel_value_satoshis: u64, dust_limit_satoshis: u64, is_0reserve: bool, -) -> u64 { +) -> Result { + if channel_value_satoshis < dust_limit_satoshis { + return Err(()); + } if is_0reserve { - return 0; + return Ok(0); } // Fixed at 1% of channel value by spec. let (q, _) = channel_value_satoshis.overflowing_div(100); - cmp::min(channel_value_satoshis, cmp::max(q, dust_limit_satoshis)) + Ok(cmp::max(q, dust_limit_satoshis)) } /// Returns the minimum feerate for RBF attempts given a previous feerate. @@ -12824,7 +12844,8 @@ where their_funding_contribution, counterparty_funding_pubkey, our_new_holder_keys, - )?; + ) + .map_err(|e| format!("Channel {} cannot be spliced; {}", self.context.channel_id(), e))?; let (post_splice_holder_balance, post_splice_counterparty_balance) = self.get_holder_counterparty_balances_floor_incl_fee(&candidate_scope).map_err( @@ -15117,8 +15138,13 @@ impl PendingV2Channel { }); let holder_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis( - funding_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS, trusted_channel_features.is_some_and(|f| f.is_0reserve())); - + funding_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS, trusted_channel_features.is_some_and(|f| f.is_0reserve()) + ).map_err(|()| APIError::APIMisuseError { + err: format!( + "The channel value {funding_satoshis} is smaller than their dust \ + limit {MIN_CHAN_DUST_LIMIT_SATOSHIS}" + ) + })?; let funding_feerate_sat_per_1000_weight = fee_estimator.bounded_sat_per_1000_weight(funding_confirmation_target); let funding_tx_locktime = LockTime::from_height(current_chain_height) .map_err(|_| APIError::APIMisuseError { @@ -15257,9 +15283,16 @@ impl PendingV2Channel { let channel_value_satoshis = our_funding_contribution_sats.saturating_add(msg.common_fields.funding_satoshis); let counterparty_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis( - channel_value_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS, msg.disable_channel_reserve.is_some()); + channel_value_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS, msg.disable_channel_reserve.is_some() + ).map_err(|()| ChannelError::close(format!( + "The channel value {channel_value_satoshis} is smaller than our dust limit {MIN_CHAN_DUST_LIMIT_SATOSHIS}" + )))?; + let their_dust_limit_satoshis = msg.common_fields.dust_limit_satoshis; let holder_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis( - channel_value_satoshis, msg.common_fields.dust_limit_satoshis, trusted_channel_features.is_some_and(|f| f.is_0reserve())); + channel_value_satoshis, their_dust_limit_satoshis, trusted_channel_features.is_some_and(|f| f.is_0reserve()) + ).map_err(|()| ChannelError::close(format!( + "The channel value {channel_value_satoshis} is smaller than their dust limit {their_dust_limit_satoshis}" + )))?; let channel_type = channel_type_from_open_channel(&msg.common_fields, our_supported_features)?; diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index a5361358653..0de7574d416 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -7865,13 +7865,15 @@ fn do_test_0reserve_splice_counterparty_validation( // They obviously can't afford their contribution, so we fail before even // querying `TxBuilder` format!( - "Got non-closing error: Their contribution candidate {funding_contribution_sat}sat \ + "Got non-closing error: Channel {channel_id} cannot be spliced; \ + Their contribution candidate {funding_contribution_sat}sat \ is greater than their total balance in the channel {initiator_value_to_self_sat}sat" ) } else if post_channel_value_sat < MIN_CHANNEL_VALUE_SATOSHIS { // We require all spliced channels to have a value of at least 1000 satoshis after the splice format!( - "Got non-closing error: Spliced channel value must be at least {MIN_CHANNEL_VALUE_SATOSHIS} satoshis. \ + "Got non-closing error: Channel {channel_id} cannot be spliced; \ + Spliced channel value must be at least {MIN_CHANNEL_VALUE_SATOSHIS} satoshis. \ It would be {post_channel_value_sat}" ) } else { @@ -7888,3 +7890,244 @@ fn do_test_0reserve_splice_counterparty_validation( channel_type } + +/// We previously allowed a splice initiator to splice out funds past their channel reserve if the +/// the acceptor had no balance in the channel, and there were no HTLCs in the channel +#[cfg(test)] +enum AcceptorBalance { + NoBalance, + BalanceInHTLC, + SettledBalance, +} + +#[cfg(test)] +enum ValidationCase { + Passes, + FailsAtHolder, + FailsAtCounterparty, +} + +#[test] +fn test_splice_out_initiator_reserve_breach_zero_fee_commitments() { + do_test_splice_out_initiator_reserve_breach_zero_fee_commitments( + AcceptorBalance::NoBalance, + ValidationCase::Passes, + ); + do_test_splice_out_initiator_reserve_breach_zero_fee_commitments( + AcceptorBalance::BalanceInHTLC, + ValidationCase::Passes, + ); + do_test_splice_out_initiator_reserve_breach_zero_fee_commitments( + AcceptorBalance::SettledBalance, + ValidationCase::Passes, + ); + + // We used to fail this case here + do_test_splice_out_initiator_reserve_breach_zero_fee_commitments( + AcceptorBalance::NoBalance, + ValidationCase::FailsAtHolder, + ); + + do_test_splice_out_initiator_reserve_breach_zero_fee_commitments( + AcceptorBalance::BalanceInHTLC, + ValidationCase::FailsAtHolder, + ); + do_test_splice_out_initiator_reserve_breach_zero_fee_commitments( + AcceptorBalance::SettledBalance, + ValidationCase::FailsAtHolder, + ); + + // We used to fail this case here + do_test_splice_out_initiator_reserve_breach_zero_fee_commitments( + AcceptorBalance::NoBalance, + ValidationCase::FailsAtCounterparty, + ); + + do_test_splice_out_initiator_reserve_breach_zero_fee_commitments( + AcceptorBalance::BalanceInHTLC, + ValidationCase::FailsAtCounterparty, + ); + do_test_splice_out_initiator_reserve_breach_zero_fee_commitments( + AcceptorBalance::SettledBalance, + ValidationCase::FailsAtCounterparty, + ); +} + +#[cfg(test)] +fn do_test_splice_out_initiator_reserve_breach_zero_fee_commitments( + acceptor_balance: AcceptorBalance, validation_case: ValidationCase, +) { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut config = test_default_channel_config(); + // This reserve breach was only possible in 0FC channels + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = true; + config.channel_handshake_config.our_htlc_minimum_msat = 1; + let node_chanmgrs = + create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config.clone())]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + // Node 0 is initiator, node 1 is acceptor + let _node_id_0 = nodes[0].node.get_our_node_id(); + let _node_id_1 = nodes[1].node.get_our_node_id(); + + let channel_value_sat = 100_000; + let node_1_settled_balance_msat = + if matches!(acceptor_balance, AcceptorBalance::SettledBalance) { 1 } else { 0 }; + let node_1_htlc_balance_msat = + if matches!(acceptor_balance, AcceptorBalance::BalanceInHTLC) { 1 } else { 0 }; + let node_0_balance_msat = + channel_value_sat * 1000 - node_1_settled_balance_msat - node_1_htlc_balance_msat; + + // Bump initiator's dust limit to the highest value we allow in anchor channels + let high_dust_limit_satoshis = 10_000; + + let (_, _, channel_id, _tx) = create_announced_chan_between_nodes_with_value( + &nodes, + 0, + 1, + channel_value_sat, + node_1_settled_balance_msat, + ); + + if matches!(acceptor_balance, AcceptorBalance::BalanceInHTLC) { + let _ = route_payment(&nodes[0], &[&nodes[1]], node_1_htlc_balance_msat); + } + + { + let per_peer_lock; + let mut peer_state_lock; + let channel = + get_channel_ref!(nodes[0], nodes[1], per_peer_lock, peer_state_lock, channel_id); + if let Some(chan) = channel.as_funded_mut() { + chan.context.holder_dust_limit_satoshis = high_dust_limit_satoshis; + } else { + panic!("Unexpected Channel phase"); + } + } + + { + let per_peer_lock; + let mut peer_state_lock; + let channel = + get_channel_ref!(nodes[1], nodes[0], per_peer_lock, peer_state_lock, channel_id); + if let Some(chan) = channel.as_funded_mut() { + chan.context.counterparty_dust_limit_satoshis = high_dust_limit_satoshis; + } else { + panic!("Unexpected Channel phase"); + } + } + + if matches!(validation_case, ValidationCase::Passes) { + let node_0_balance_leftover_amount = Amount::from_sat(high_dust_limit_satoshis); + // Estimated fees of a splice_out at 253sat/kw + let estimated_fees = 183; + // Note in 0FC we've got no fee spike buffer, no commit tx fee, no anchors + let splice_out_output_sat = + node_0_balance_msat / 1000 - node_0_balance_leftover_amount.to_sat() - estimated_fees; + let splice_out_output_amount = Amount::from_sat(splice_out_output_sat); + let outputs = vec![TxOut { + value: splice_out_output_amount, + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]; + let contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); + + let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, contribution); + mine_transaction(&nodes[0], &splice_tx); + mine_transaction(&nodes[1], &splice_tx); + lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); + } else { + let node_0_balance_leftover_amount = Amount::from_sat(high_dust_limit_satoshis - 1); + // Note in 0FC we've got no fee spike buffer, no commit tx fee, no anchors + let funding_contribution_sat = + -((node_0_balance_msat / 1000 - node_0_balance_leftover_amount.to_sat()) as i64); + let value = if matches!(validation_case, ValidationCase::FailsAtHolder) { + Amount::from_sat(funding_contribution_sat.unsigned_abs() - 183) + } else if matches!(validation_case, ValidationCase::FailsAtCounterparty) { + // Splice out some dummy amount to get past the initiator's validation, + // we'll modify the message in-flight. + Amount::from_sat(1000) + } else { + panic!("Unexpected test case"); + }; + let outputs = vec![TxOut { + value, + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]; + let contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs); + + if matches!(validation_case, ValidationCase::FailsAtHolder) { + assert_eq!( + contribution.unwrap_err(), + APIError::APIMisuseError { + err: format!("Channel {channel_id} cannot accept funding contribution"), + } + ); + let splice_out_value = value + Amount::from_sat(183); + let splice_out_max = splice_out_value - Amount::ONE_SAT; + let cannot_splice_out = format!( + "Channel {channel_id} cannot be funded: \ + Our splice-out value of {splice_out_value} is greater than the \ + maximum {splice_out_max}" + ); + nodes[0].logger.assert_log("lightning::ln::channel", cannot_splice_out, 1); + return; + } + + // The dummy contribution should have passed the holder's validation + assert!(contribution.is_ok()); + + // When acceptor has no balance, the reserve the initiator should keep should remain + // clamped at its dust limit. We previously allowed the initiator to withdraw past + // this point. + let v2_channel_reserve = Amount::from_sat(high_dust_limit_satoshis); + + let initiator = &nodes[0]; + let acceptor = &nodes[1]; + let node_id_initiator = initiator.node.get_our_node_id(); + let node_id_acceptor = acceptor.node.get_our_node_id(); + + let stfu_init = get_event_msg!(initiator, MessageSendEvent::SendStfu, node_id_acceptor); + acceptor.node.handle_stfu(node_id_initiator, &stfu_init); + let stfu_ack = get_event_msg!(acceptor, MessageSendEvent::SendStfu, node_id_initiator); + initiator.node.handle_stfu(node_id_acceptor, &stfu_ack); + + let mut splice_init = + get_event_msg!(initiator, MessageSendEvent::SendSpliceInit, node_id_acceptor); + // Make the modification here, acceptor should now complain. If the acceptor has no + // balance, we previously would not complain. + splice_init.funding_contribution_satoshis = funding_contribution_sat; + acceptor.node.handle_splice_init(node_id_initiator, &splice_init); + let msg_events = acceptor.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + if let MessageSendEvent::HandleError { action, .. } = &msg_events[0] { + assert!(matches!(action, msgs::ErrorAction::DisconnectPeerWithWarning { .. })); + } else { + panic!("Expected MessageSendEvent::HandleError"); + } + let post_splice_channel_value_sat = node_0_balance_leftover_amount.to_sat(); + let cannot_splice_out = if matches!(acceptor_balance, AcceptorBalance::NoBalance) { + format!( + "Got non-closing error: Channel {channel_id} cannot \ + be spliced; The post-splice channel value {post_splice_channel_value_sat} \ + is smaller than their dust limit {high_dust_limit_satoshis}" + ) + } else { + // As soon as we've pushed any sats out of our balance, the channel value + // is now at the dust limit, so we don't complain when determining the new + // dust limits, but later when we check the balances against those new + // dust limits + assert_eq!( + channel_value_sat.checked_add_signed(funding_contribution_sat).unwrap(), + high_dust_limit_satoshis + ); + format!( + "Got non-closing error: Channel {channel_id} cannot \ + be spliced out; their post-splice channel balance \ + {node_0_balance_leftover_amount} is smaller than our selected v2 reserve \ + {v2_channel_reserve}" + ) + }; + acceptor.logger.assert_log("lightning::ln::channelmanager", cannot_splice_out, 1); + } +} diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index ffb01c571b7..98a64e828ec 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -365,7 +365,8 @@ fn get_next_splice_out_maximum_sat( channel_value_satoshis, channel_constraints.holder_dust_limit_satoshis, false, - ); + ) + .unwrap(); // If the holder cannot splice out anything, they must be at or // below the v2 reserve debug_assert!(current_balance_sat <= v2_reserve_sat); @@ -374,7 +375,8 @@ fn get_next_splice_out_maximum_sat( channel_value_satoshis.saturating_sub(max_splice_out_sat), channel_constraints.holder_dust_limit_satoshis, false, - ); + ) + .unwrap(); // If the holder can splice out some maximum, splicing out that // maximum lands them at exactly the new v2 reserve + the // `post_splice_delta_above_reserve_sat` @@ -382,6 +384,20 @@ fn get_next_splice_out_maximum_sat( local_balance_before_fee_sat.saturating_sub(max_splice_out_sat), post_splice_reserve_sat.saturating_add(post_splice_delta_above_reserve_sat) ); + // Splice out an additional satoshi, and check that we are offside + let offside_splice_out_sat = max_splice_out_sat + 1; + let post_splice_reserve_sat_result = get_v2_channel_reserve_satoshis( + channel_value_satoshis.saturating_sub(offside_splice_out_sat), + channel_constraints.holder_dust_limit_satoshis, + false, + ); + match post_splice_reserve_sat_result { + Ok(reserve) => debug_assert!( + local_balance_before_fee_sat.saturating_sub(offside_splice_out_sat) + < reserve.saturating_add(post_splice_delta_above_reserve_sat) + ), + Err(()) => (), + } } max_splice_out_sat } else { From 53e156a7613cdfc63df23a45df2e67380072b9b2 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 30 Apr 2026 00:08:49 +0000 Subject: [PATCH 371/627] Error if the calculated v1 reserve is greater than the channel value We made the same change to the calculation of the v2 reserve in the previous commit. --- lightning/src/ln/channel.rs | 81 ++++++++++++++++----- lightning/src/ln/channel_open_tests.rs | 14 +++- lightning/src/ln/functional_tests.rs | 3 +- lightning/src/ln/htlc_reserve_unit_tests.rs | 15 ++-- lightning/src/ln/payment_tests.rs | 2 +- lightning/src/ln/update_fee_tests.rs | 7 +- 6 files changed, 89 insertions(+), 33 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index f2c5b3b8fb8..137bdd28f9c 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -6761,20 +6761,32 @@ fn get_legacy_default_holder_max_htlc_value_in_flight_msat(channel_value_satoshi /// This is used both for outbound and inbound channels and has lower bound /// of `MIN_THEIR_CHAN_RESERVE_SATOSHIS`, and the `dust_limit_satoshis` of /// the counterparty. +/// +/// Returns `Err` if `channel_value_satoshis` is smaller than +/// `MIN_THEIR_CHAN_RESERVE_SATOSHIS` or the `dust_limit_satoshis` of the +/// counterparty. pub(crate) fn get_holder_selected_channel_reserve_satoshis( channel_value_satoshis: u64, their_dust_limit_satoshis: u64, config: &UserConfig, is_0reserve: bool, -) -> u64 { +) -> Result { + if channel_value_satoshis < MIN_THEIR_CHAN_RESERVE_SATOSHIS + || channel_value_satoshis < their_dust_limit_satoshis + { + return Err(()); + } if is_0reserve { - return 0; + return Ok(0); } - let counterparty_chan_reserve_prop_mil = - config.channel_handshake_config.their_channel_reserve_proportional_millionths as u64; + // As described in the `ChannelHandshakeConfig` docs, we cap this value at 1_000_000. + let counterparty_chan_reserve_prop_mil = cmp::min( + config.channel_handshake_config.their_channel_reserve_proportional_millionths as u64, + 1_000_000, + ); let calculated_reserve = channel_value_satoshis.saturating_mul(counterparty_chan_reserve_prop_mil) / 1_000_000; let channel_reserve_satoshis = cmp::max(calculated_reserve, MIN_THEIR_CHAN_RESERVE_SATOSHIS); let channel_reserve_satoshis = cmp::max(channel_reserve_satoshis, their_dust_limit_satoshis); - cmp::min(channel_value_satoshis, channel_reserve_satoshis) + Ok(channel_reserve_satoshis) } /// This is for legacy reasons, present for forward-compatibility. @@ -14479,12 +14491,19 @@ impl OutboundV1Channel { // a dust limit higher than our selected reserve. let their_dust_limit_satoshis = 0; let is_0reserve = trusted_channel_features.is_some_and(|f| f.is_0reserve()); - let holder_selected_channel_reserve_satoshis = get_holder_selected_channel_reserve_satoshis( - channel_value_satoshis, - their_dust_limit_satoshis, - config, - is_0reserve, - ); + let holder_selected_channel_reserve_satoshis = + get_holder_selected_channel_reserve_satoshis( + channel_value_satoshis, + their_dust_limit_satoshis, + config, + is_0reserve, + ) + .map_err(|()| APIError::APIMisuseError { + err: format!( + "The channel value {channel_value_satoshis} is smaller than \ + {MIN_THEIR_CHAN_RESERVE_SATOSHIS}" + ), + })?; if holder_selected_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS && !is_0reserve { // Protocol level safety check in place, although it should never happen because // of `MIN_THEIR_CHAN_RESERVE_SATOSHIS` and `MIN_CHANNEL_VALUE_SATOSHIS` @@ -14876,12 +14895,20 @@ impl InboundV1Channel { let channel_type = channel_type_from_open_channel(&msg.common_fields, our_supported_features)?; - let holder_selected_channel_reserve_satoshis = get_holder_selected_channel_reserve_satoshis( - msg.common_fields.funding_satoshis, - msg.common_fields.dust_limit_satoshis, - config, - trusted_channel_features.is_some_and(|f| f.is_0reserve()), - ); + let holder_selected_channel_reserve_satoshis = + get_holder_selected_channel_reserve_satoshis( + msg.common_fields.funding_satoshis, + msg.common_fields.dust_limit_satoshis, + config, + trusted_channel_features.is_some_and(|f| f.is_0reserve()), + ) + .map_err(|()| { + ChannelError::close(format!( + "The channel value {} is smaller than either their dust \ + limit {}, or {MIN_THEIR_CHAN_RESERVE_SATOSHIS}", + msg.common_fields.funding_satoshis, msg.common_fields.dust_limit_satoshis, + )) + })?; let counterparty_pubkeys = ChannelPublicKeys { funding_pubkey: msg.common_fields.funding_pubkey, revocation_basepoint: RevocationBasepoint::from(msg.common_fields.revocation_basepoint), @@ -17483,6 +17510,10 @@ mod tests { // to channel value test_self_and_counterparty_channel_reserve(10_000_000, 0.50, 0.50); test_self_and_counterparty_channel_reserve(10_000_000, 0.60, 0.50); + + // Make sure we correctly handle reserves greater than the channel value + test_self_and_counterparty_channel_reserve(100_000, 1.1, 0.30); + test_self_and_counterparty_channel_reserve(100_000, 0.30, 1.1); } #[rustfmt::skip] @@ -17502,7 +17533,19 @@ mod tests { outbound_node_config.channel_handshake_config.their_channel_reserve_proportional_millionths = (outbound_selected_channel_reserve_perc * 1_000_000.0) as u32; let mut chan = OutboundV1Channel::<&TestKeysInterface>::new(&&fee_est, &&keys_provider, &&keys_provider, outbound_node_id, &channelmanager::provided_init_features(&outbound_node_config), channel_value_satoshis, 100_000, 42, &outbound_node_config, 0, 42, None, &logger, None).unwrap(); - let expected_outbound_selected_chan_reserve = cmp::max(MIN_THEIR_CHAN_RESERVE_SATOSHIS, (chan.funding.get_value_satoshis() as f64 * outbound_selected_channel_reserve_perc) as u64); + let outbound_capped_reserve_perc = if outbound_selected_channel_reserve_perc.lt(&1.0) { + outbound_selected_channel_reserve_perc + } else { + 1.0 + }; + + let inbound_capped_reserve_perc = if inbound_selected_channel_reserve_perc.lt(&1.0) { + inbound_selected_channel_reserve_perc + } else { + 1.0 + }; + + let expected_outbound_selected_chan_reserve = cmp::max(MIN_THEIR_CHAN_RESERVE_SATOSHIS, (chan.funding.get_value_satoshis() as f64 * outbound_capped_reserve_perc) as u64); assert_eq!(chan.funding.holder_selected_channel_reserve_satoshis, expected_outbound_selected_chan_reserve); let chan_open_channel_msg = chan.get_open_channel(ChainHash::using_genesis_block(network), &&logger).unwrap(); @@ -17512,7 +17555,7 @@ mod tests { if outbound_selected_channel_reserve_perc + inbound_selected_channel_reserve_perc < 1.0 { let chan_inbound_node = InboundV1Channel::<&TestKeysInterface>::new(&&fee_est, &&keys_provider, &&keys_provider, inbound_node_id, &channelmanager::provided_channel_type_features(&inbound_node_config), &channelmanager::provided_init_features(&outbound_node_config), &chan_open_channel_msg, 7, &inbound_node_config, 0, &&logger, None).unwrap(); - let expected_inbound_selected_chan_reserve = cmp::max(MIN_THEIR_CHAN_RESERVE_SATOSHIS, (chan.funding.get_value_satoshis() as f64 * inbound_selected_channel_reserve_perc) as u64); + let expected_inbound_selected_chan_reserve = cmp::max(MIN_THEIR_CHAN_RESERVE_SATOSHIS, (chan.funding.get_value_satoshis() as f64 * inbound_capped_reserve_perc) as u64); assert_eq!(chan_inbound_node.funding.holder_selected_channel_reserve_satoshis, expected_inbound_selected_chan_reserve); assert_eq!(chan_inbound_node.funding.counterparty_selected_channel_reserve_satoshis.unwrap(), expected_outbound_selected_chan_reserve); diff --git a/lightning/src/ln/channel_open_tests.rs b/lightning/src/ln/channel_open_tests.rs index ac4a1b67994..50ef0721e07 100644 --- a/lightning/src/ln/channel_open_tests.rs +++ b/lightning/src/ln/channel_open_tests.rs @@ -16,7 +16,8 @@ use crate::chain::{self, ChannelMonitorUpdateStatus}; use crate::events::{ClosureReason, Event, FundingInfo}; use crate::ln::channel::{ get_holder_selected_channel_reserve_satoshis, ChannelError, InboundV1Channel, - OutboundV1Channel, COINBASE_MATURITY, UNFUNDED_CHANNEL_AGE_LIMIT_TICKS, + OutboundV1Channel, COINBASE_MATURITY, MIN_THEIR_CHAN_RESERVE_SATOSHIS, + UNFUNDED_CHANNEL_AGE_LIMIT_TICKS, }; use crate::ln::channelmanager::{ self, TrustedChannelFeatures, BREAKDOWN_TIMEOUT, MAX_UNFUNDED_CHANNEL_PEERS, @@ -473,7 +474,8 @@ pub fn test_insane_channel_opens() { // funding satoshis let channel_value_sat = 31337; // same as funding satoshis let channel_reserve_satoshis = - get_holder_selected_channel_reserve_satoshis(channel_value_sat, 0, &legacy_cfg, false); + get_holder_selected_channel_reserve_satoshis(channel_value_sat, 0, &legacy_cfg, false) + .unwrap(); let push_msat = (channel_value_sat - channel_reserve_satoshis) * 1000; // Have node0 initiate a channel to node1 with aforementioned parameters @@ -552,7 +554,13 @@ pub fn test_insane_channel_opens() { }, ); - insane_open_helper("Peer never wants payout outputs?", |mut msg| { + let crazy_dust_limit = channel_value_sat + 1; + let expected_error_str = format!( + "Got non-closing error: The channel value \ + {channel_value_sat} is smaller than either their dust limit {crazy_dust_limit}, or \ + {MIN_THEIR_CHAN_RESERVE_SATOSHIS}" + ); + insane_open_helper(&expected_error_str, |mut msg| { msg.common_fields.dust_limit_satoshis = msg.common_fields.funding_satoshis + 1; msg }); diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index c8ecb40fa6d..8bbb9b99479 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -415,7 +415,8 @@ pub fn test_inbound_outbound_capacity_is_not_zero() { assert_eq!(channels0.len(), 1); assert_eq!(channels1.len(), 1); - let reserve = get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config, false); + let reserve = + get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config, false).unwrap(); assert_eq!(channels0[0].inbound_capacity_msat, 95000000 - reserve * 1000); assert_eq!(channels1[0].outbound_capacity_msat, 95000000 - reserve * 1000); diff --git a/lightning/src/ln/htlc_reserve_unit_tests.rs b/lightning/src/ln/htlc_reserve_unit_tests.rs index 45d3cf5950f..a4d92b7a045 100644 --- a/lightning/src/ln/htlc_reserve_unit_tests.rs +++ b/lightning/src/ln/htlc_reserve_unit_tests.rs @@ -55,8 +55,9 @@ fn do_test_counterparty_no_reserve(send_from_initiator: bool) { push_amt -= feerate_per_kw as u64 * (commitment_tx_base_weight(&channel_type_features) + 4 * COMMITMENT_TX_WEIGHT_PER_HTLC) / 1000 * 1000; - push_amt -= - get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config, false) * 1000; + push_amt -= get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config, false) + .unwrap() + * 1000; let push = if send_from_initiator { 0 } else { push_amt }; let temp_channel_id = @@ -1002,8 +1003,9 @@ pub fn test_chan_reserve_violation_outbound_htlc_inbound_chan() { &channel_type_features, ); - push_amt -= - get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config, false) * 1000; + push_amt -= get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config, false) + .unwrap() + * 1000; let _ = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, push_amt); @@ -1048,8 +1050,9 @@ pub fn test_chan_reserve_dust_inbound_htlcs_outbound_chan() { MIN_AFFORDABLE_HTLC_COUNT as u64, &channel_type_features, ); - push_amt -= - get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config, false) * 1000; + push_amt -= get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config, false) + .unwrap() + * 1000; create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100000, push_amt); let (htlc_success_tx_fee_sat, _) = diff --git a/lightning/src/ln/payment_tests.rs b/lightning/src/ln/payment_tests.rs index 5b4f5f93d71..ccb933a95d7 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -5043,7 +5043,7 @@ fn test_htlc_forward_considers_anchor_outputs_value() { create_announced_chan_between_nodes_with_value(&nodes, 1, 2, CHAN_AMT, PUSH_MSAT); let channel_reserve_msat = - get_holder_selected_channel_reserve_satoshis(CHAN_AMT, 0, &config, false) * 1000; + get_holder_selected_channel_reserve_satoshis(CHAN_AMT, 0, &config, false).unwrap() * 1000; let commitment_fee_msat = chan_utils::commit_tx_fee_sat( *nodes[1].fee_estimator.sat_per_kw.lock().unwrap(), 2, diff --git a/lightning/src/ln/update_fee_tests.rs b/lightning/src/ln/update_fee_tests.rs index b1f8257088e..1cb04f13a33 100644 --- a/lightning/src/ln/update_fee_tests.rs +++ b/lightning/src/ln/update_fee_tests.rs @@ -410,7 +410,7 @@ pub fn do_test_update_fee_that_funder_cannot_afford(channel_type_features: Chann let channel_id = chan.2; let secp_ctx = Secp256k1::new(); let bs_channel_reserve_sats = - get_holder_selected_channel_reserve_satoshis(channel_value, 0, &cfg, false); + get_holder_selected_channel_reserve_satoshis(channel_value, 0, &cfg, false).unwrap(); let (anchor_outputs_value_sats, outputs_num_no_htlcs) = if channel_type_features.supports_anchors_zero_fee_htlc_tx() { (ANCHOR_OUTPUT_VALUE_SATOSHI * 2, 4) @@ -886,8 +886,9 @@ pub fn test_chan_init_feerate_unaffordability() { // During open, we don't have a "counterparty channel reserve" to check against, so that // requirement only comes into play on the open_channel handling side. - push_amt -= - get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config, false) * 1000; + push_amt -= get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config, false) + .unwrap() + * 1000; nodes[0].node.create_channel(node_b_id, 100_000, push_amt, 42, None, None).unwrap(); let mut open_channel_msg = get_event_msg!(nodes[0], MessageSendEvent::SendOpenChannel, node_b_id); From 06459fbd1615773da198f112ecd67f77e9cc243c Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Tue, 21 Apr 2026 16:45:06 +0200 Subject: [PATCH 372/627] Extract chanmon bootstrap helpers Extract the repeated peer-connection and channel-funding setup into small helpers. This leaves the fuzz scenario setup behavior unchanged while making later harness refactors easier to review. --- fuzz/src/chanmon_consistency.rs | 565 +++++++++++++++++--------------- 1 file changed, 299 insertions(+), 266 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 55b2a681725..6270ce148d9 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -937,12 +937,240 @@ fn assert_action_timeout_awaiting_response(action: &msgs::ErrorAction) { ); } +#[derive(Copy, Clone)] enum ChanType { Legacy, KeyedAnchors, ZeroFeeCommitments, } +fn build_node_config(chan_type: ChanType) -> UserConfig { + let mut config = UserConfig::default(); + config.channel_config.forwarding_fee_proportional_millionths = 0; + config.channel_handshake_config.announce_for_forwarding = true; + config.reject_inbound_splices = false; + match chan_type { + ChanType::Legacy => { + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + }, + ChanType::KeyedAnchors => { + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + }, + ChanType::ZeroFeeCommitments => { + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = true; + }, + } + config +} + +fn complete_all_pending_monitor_updates(monitor: &Arc) { + for (channel_id, state) in monitor.latest_monitors.lock().unwrap().iter_mut() { + for (id, data) in state.pending_monitors.drain(..) { + monitor.chain_monitor.channel_monitor_updated(*channel_id, id).unwrap(); + if id >= state.persisted_monitor_id { + state.persisted_monitor_id = id; + state.persisted_monitor = data; + } + } + } +} + +fn connect_peers(source: &ChanMan<'_>, dest: &ChanMan<'_>) { + let init_dest = + Init { features: dest.init_features(), networks: None, remote_network_address: None }; + source.peer_connected(dest.get_our_node_id(), &init_dest, true).unwrap(); + let init_src = + Init { features: source.init_features(), networks: None, remote_network_address: None }; + dest.peer_connected(source.get_our_node_id(), &init_src, false).unwrap(); +} + +fn make_channel( + source: &ChanMan<'_>, dest: &ChanMan<'_>, source_monitor: &Arc, + dest_monitor: &Arc, dest_keys_manager: &Arc, chan_id: i32, + trusted_open: bool, trusted_accept: bool, chain_state: &mut ChainState, +) { + if trusted_open { + source + .create_channel_to_trusted_peer_0reserve( + dest.get_our_node_id(), + 100_000, + 42, + 0, + None, + None, + ) + .unwrap(); + } else { + source.create_channel(dest.get_our_node_id(), 100_000, 42, 0, None, None).unwrap(); + } + let open_channel = { + let events = source.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + if let MessageSendEvent::SendOpenChannel { ref msg, .. } = events[0] { + msg.clone() + } else { + panic!("Wrong event type"); + } + }; + + dest.handle_open_channel(source.get_our_node_id(), &open_channel); + let accept_channel = { + let events = dest.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + if let events::Event::OpenChannelRequest { + ref temporary_channel_id, + ref counterparty_node_id, + .. + } = events[0] + { + let mut random_bytes = [0u8; 16]; + random_bytes.copy_from_slice(&dest_keys_manager.get_secure_random_bytes()[..16]); + let user_channel_id = u128::from_be_bytes(random_bytes); + if trusted_accept { + dest.accept_inbound_channel_from_trusted_peer( + temporary_channel_id, + counterparty_node_id, + user_channel_id, + TrustedChannelFeatures::ZeroReserve, + None, + ) + .unwrap(); + } else { + dest.accept_inbound_channel( + temporary_channel_id, + counterparty_node_id, + user_channel_id, + None, + ) + .unwrap(); + } + } else { + panic!("Wrong event type"); + } + let events = dest.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + if let MessageSendEvent::SendAcceptChannel { ref msg, .. } = events[0] { + msg.clone() + } else { + panic!("Wrong event type"); + } + }; + + source.handle_accept_channel(dest.get_our_node_id(), &accept_channel); + { + let mut events = source.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + if let events::Event::FundingGenerationReady { + temporary_channel_id, + channel_value_satoshis, + output_script, + .. + } = events.pop().unwrap() + { + let tx = Transaction { + version: Version(chan_id), + lock_time: LockTime::ZERO, + input: Vec::new(), + output: vec![TxOut { + value: Amount::from_sat(channel_value_satoshis), + script_pubkey: output_script, + }], + }; + source + .funding_transaction_generated( + temporary_channel_id, + dest.get_our_node_id(), + tx.clone(), + ) + .unwrap(); + chain_state.confirm_tx(tx); + } else { + panic!("Wrong event type"); + } + } + + let funding_created = { + let events = source.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + if let MessageSendEvent::SendFundingCreated { ref msg, .. } = events[0] { + msg.clone() + } else { + panic!("Wrong event type"); + } + }; + dest.handle_funding_created(source.get_our_node_id(), &funding_created); + // Complete any pending monitor updates for dest after watch_channel. + complete_all_pending_monitor_updates(dest_monitor); + + let (funding_signed, channel_id) = { + let events = dest.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + if let MessageSendEvent::SendFundingSigned { ref msg, .. } = events[0] { + (msg.clone(), msg.channel_id) + } else { + panic!("Wrong event type"); + } + }; + let events = dest.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + if let events::Event::ChannelPending { ref counterparty_node_id, .. } = events[0] { + assert_eq!(counterparty_node_id, &source.get_our_node_id()); + } else { + panic!("Wrong event type"); + } + + source.handle_funding_signed(dest.get_our_node_id(), &funding_signed); + // Complete any pending monitor updates for source after watch_channel. + complete_all_pending_monitor_updates(source_monitor); + + let events = source.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + if let events::Event::ChannelPending { + ref counterparty_node_id, + channel_id: ref event_channel_id, + .. + } = events[0] + { + assert_eq!(counterparty_node_id, &dest.get_our_node_id()); + assert_eq!(*event_channel_id, channel_id); + } else { + panic!("Wrong event type"); + } +} + +fn lock_fundings(nodes: &[ChanMan<'_>; 3]) { + let mut node_events = Vec::new(); + for node in nodes.iter() { + node_events.push(node.get_and_clear_pending_msg_events()); + } + for (idx, node_event) in node_events.iter().enumerate() { + for event in node_event { + if let MessageSendEvent::SendChannelReady { ref node_id, ref msg } = event { + for node in nodes.iter() { + if node.get_our_node_id() == *node_id { + node.handle_channel_ready(nodes[idx].get_our_node_id(), msg); + } + } + } else { + panic!("Wrong event type"); + } + } + } + + for node in nodes.iter() { + let events = node.get_and_clear_pending_msg_events(); + for event in events { + if let MessageSendEvent::SendAnnouncementSignatures { .. } = event { + } else { + panic!("Wrong event type"); + } + } + } +} + #[inline] pub fn do_test(data: &[u8], out: Out) { let broadcast_a = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); @@ -1006,24 +1234,6 @@ pub fn do_test(data: &[u8], out: Out) { Arc::clone(&keys_manager), )); - let mut config = UserConfig::default(); - config.channel_config.forwarding_fee_proportional_millionths = 0; - config.channel_handshake_config.announce_for_forwarding = true; - config.reject_inbound_splices = false; - match chan_type { - ChanType::Legacy => { - config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; - config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; - }, - ChanType::KeyedAnchors => { - config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; - config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; - }, - ChanType::ZeroFeeCommitments => { - config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; - config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = true; - }, - } let network = Network::Bitcoin; let best_block_timestamp = genesis_block(network).header.time; let params = @@ -1039,7 +1249,7 @@ pub fn do_test(data: &[u8], out: Out) { keys_manager.clone(), keys_manager.clone(), keys_manager.clone(), - config, + build_node_config(chan_type), params, best_block_timestamp, ), @@ -1070,25 +1280,6 @@ pub fn do_test(data: &[u8], out: Out) { Arc::clone(keys), )); - let mut config = UserConfig::default(); - config.channel_config.forwarding_fee_proportional_millionths = 0; - config.channel_handshake_config.announce_for_forwarding = true; - config.reject_inbound_splices = false; - match chan_type { - ChanType::Legacy => { - config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; - config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; - }, - ChanType::KeyedAnchors => { - config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; - config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; - }, - ChanType::ZeroFeeCommitments => { - config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; - config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = true; - }, - } - let mut monitors = new_hash_map(); let mut old_monitors = old_monitors.latest_monitors.lock().unwrap(); for (channel_id, mut prev_state) in old_monitors.drain() { @@ -1138,7 +1329,7 @@ pub fn do_test(data: &[u8], out: Out) { router: &router, message_router: &router, logger, - config, + config: build_node_config(chan_type), channel_monitors: monitor_refs, }; @@ -1155,224 +1346,6 @@ pub fn do_test(data: &[u8], out: Out) { res }; - macro_rules! complete_all_pending_monitor_updates { - ($monitor: expr) => {{ - for (channel_id, state) in $monitor.latest_monitors.lock().unwrap().iter_mut() { - for (id, data) in state.pending_monitors.drain(..) { - $monitor.chain_monitor.channel_monitor_updated(*channel_id, id).unwrap(); - if id >= state.persisted_monitor_id { - state.persisted_monitor_id = id; - state.persisted_monitor = data; - } - } - } - }}; - } - macro_rules! connect_peers { - ($source: expr, $dest: expr) => {{ - let init_dest = Init { - features: $dest.init_features(), - networks: None, - remote_network_address: None, - }; - $source.peer_connected($dest.get_our_node_id(), &init_dest, true).unwrap(); - let init_src = Init { - features: $source.init_features(), - networks: None, - remote_network_address: None, - }; - $dest.peer_connected($source.get_our_node_id(), &init_src, false).unwrap(); - }}; - } - macro_rules! make_channel { - ($source: expr, $dest: expr, $source_monitor: expr, $dest_monitor: expr, $dest_keys_manager: expr, $chan_id: expr, $trusted_open: expr, $trusted_accept: expr) => {{ - if $trusted_open { - $source - .create_channel_to_trusted_peer_0reserve( - $dest.get_our_node_id(), - 100_000, - 42, - 0, - None, - None, - ) - .unwrap(); - } else { - $source - .create_channel($dest.get_our_node_id(), 100_000, 42, 0, None, None) - .unwrap(); - } - let open_channel = { - let events = $source.get_and_clear_pending_msg_events(); - assert_eq!(events.len(), 1); - if let MessageSendEvent::SendOpenChannel { ref msg, .. } = events[0] { - msg.clone() - } else { - panic!("Wrong event type"); - } - }; - - $dest.handle_open_channel($source.get_our_node_id(), &open_channel); - let accept_channel = { - let events = $dest.get_and_clear_pending_events(); - assert_eq!(events.len(), 1); - if let events::Event::OpenChannelRequest { - ref temporary_channel_id, - ref counterparty_node_id, - .. - } = events[0] - { - let mut random_bytes = [0u8; 16]; - random_bytes - .copy_from_slice(&$dest_keys_manager.get_secure_random_bytes()[..16]); - let user_channel_id = u128::from_be_bytes(random_bytes); - if $trusted_accept { - $dest - .accept_inbound_channel_from_trusted_peer( - temporary_channel_id, - counterparty_node_id, - user_channel_id, - TrustedChannelFeatures::ZeroReserve, - None, - ) - .unwrap(); - } else { - $dest - .accept_inbound_channel( - temporary_channel_id, - counterparty_node_id, - user_channel_id, - None, - ) - .unwrap(); - } - } else { - panic!("Wrong event type"); - } - let events = $dest.get_and_clear_pending_msg_events(); - assert_eq!(events.len(), 1); - if let MessageSendEvent::SendAcceptChannel { ref msg, .. } = events[0] { - msg.clone() - } else { - panic!("Wrong event type"); - } - }; - - $source.handle_accept_channel($dest.get_our_node_id(), &accept_channel); - { - let mut events = $source.get_and_clear_pending_events(); - assert_eq!(events.len(), 1); - if let events::Event::FundingGenerationReady { - temporary_channel_id, - channel_value_satoshis, - output_script, - .. - } = events.pop().unwrap() - { - let tx = Transaction { - version: Version($chan_id), - lock_time: LockTime::ZERO, - input: Vec::new(), - output: vec![TxOut { - value: Amount::from_sat(channel_value_satoshis), - script_pubkey: output_script, - }], - }; - $source - .funding_transaction_generated( - temporary_channel_id, - $dest.get_our_node_id(), - tx.clone(), - ) - .unwrap(); - chain_state.confirm_tx(tx); - } else { - panic!("Wrong event type"); - } - } - - let funding_created = { - let events = $source.get_and_clear_pending_msg_events(); - assert_eq!(events.len(), 1); - if let MessageSendEvent::SendFundingCreated { ref msg, .. } = events[0] { - msg.clone() - } else { - panic!("Wrong event type"); - } - }; - $dest.handle_funding_created($source.get_our_node_id(), &funding_created); - // Complete any pending monitor updates for dest after watch_channel - complete_all_pending_monitor_updates!($dest_monitor); - - let (funding_signed, channel_id) = { - let events = $dest.get_and_clear_pending_msg_events(); - assert_eq!(events.len(), 1); - if let MessageSendEvent::SendFundingSigned { ref msg, .. } = events[0] { - (msg.clone(), msg.channel_id.clone()) - } else { - panic!("Wrong event type"); - } - }; - let events = $dest.get_and_clear_pending_events(); - assert_eq!(events.len(), 1); - if let events::Event::ChannelPending { ref counterparty_node_id, .. } = events[0] { - assert_eq!(counterparty_node_id, &$source.get_our_node_id()); - } else { - panic!("Wrong event type"); - } - - $source.handle_funding_signed($dest.get_our_node_id(), &funding_signed); - // Complete any pending monitor updates for source after watch_channel - complete_all_pending_monitor_updates!($source_monitor); - - let events = $source.get_and_clear_pending_events(); - assert_eq!(events.len(), 1); - if let events::Event::ChannelPending { - ref counterparty_node_id, - channel_id: ref event_channel_id, - .. - } = events[0] - { - assert_eq!(counterparty_node_id, &$dest.get_our_node_id()); - assert_eq!(*event_channel_id, channel_id); - } else { - panic!("Wrong event type"); - } - }}; - } - - macro_rules! lock_fundings { - ($nodes: expr) => {{ - let mut node_events = Vec::new(); - for node in $nodes.iter() { - node_events.push(node.get_and_clear_pending_msg_events()); - } - for (idx, node_event) in node_events.iter().enumerate() { - for event in node_event { - if let MessageSendEvent::SendChannelReady { ref node_id, ref msg } = event { - for node in $nodes.iter() { - if node.get_our_node_id() == *node_id { - node.handle_channel_ready($nodes[idx].get_our_node_id(), msg); - } - } - } else { - panic!("Wrong event type"); - } - } - } - - for node in $nodes.iter() { - let events = node.get_and_clear_pending_msg_events(); - for event in events { - if let MessageSendEvent::SendAnnouncementSignatures { .. } = event { - } else { - panic!("Wrong event type"); - } - } - } - }}; - } - let wallet_a = TestWalletSource::new(SecretKey::from_slice(&[1; 32]).unwrap()); let wallet_b = TestWalletSource::new(SecretKey::from_slice(&[2; 32]).unwrap()); let wallet_c = TestWalletSource::new(SecretKey::from_slice(&[3; 32]).unwrap()); @@ -1414,8 +1387,8 @@ pub fn do_test(data: &[u8], out: Out) { let fee_estimators = [Arc::clone(&fee_est_a), Arc::clone(&fee_est_b), Arc::clone(&fee_est_c)]; // Connect peers first, then create channels - connect_peers!(nodes[0], nodes[1]); - connect_peers!(nodes[1], nodes[2]); + connect_peers(&nodes[0], &nodes[1]); + connect_peers(&nodes[1], &nodes[2]); // Create 3 channels between A-B and 3 channels between B-C (6 total). // @@ -1423,14 +1396,74 @@ pub fn do_test(data: &[u8], out: Out) { // txid and funding outpoint. // A-B: channel 2 A and B have 0-reserve (trusted open + trusted accept), // channel 3 A has 0-reserve (trusted accept) - make_channel!(nodes[0], nodes[1], monitor_a, monitor_b, keys_manager_b, 1, false, false); - make_channel!(nodes[0], nodes[1], monitor_a, monitor_b, keys_manager_b, 2, true, true); - make_channel!(nodes[0], nodes[1], monitor_a, monitor_b, keys_manager_b, 3, false, true); + make_channel( + &nodes[0], + &nodes[1], + &monitor_a, + &monitor_b, + &keys_manager_b, + 1, + false, + false, + &mut chain_state, + ); + make_channel( + &nodes[0], + &nodes[1], + &monitor_a, + &monitor_b, + &keys_manager_b, + 2, + true, + true, + &mut chain_state, + ); + make_channel( + &nodes[0], + &nodes[1], + &monitor_a, + &monitor_b, + &keys_manager_b, + 3, + false, + true, + &mut chain_state, + ); // B-C: channel 4 B has 0-reserve (via trusted accept), // channel 5 C has 0-reserve (via trusted open) - make_channel!(nodes[1], nodes[2], monitor_b, monitor_c, keys_manager_c, 4, false, true); - make_channel!(nodes[1], nodes[2], monitor_b, monitor_c, keys_manager_c, 5, true, false); - make_channel!(nodes[1], nodes[2], monitor_b, monitor_c, keys_manager_c, 6, false, false); + make_channel( + &nodes[1], + &nodes[2], + &monitor_b, + &monitor_c, + &keys_manager_c, + 4, + false, + true, + &mut chain_state, + ); + make_channel( + &nodes[1], + &nodes[2], + &monitor_b, + &monitor_c, + &keys_manager_c, + 5, + true, + false, + &mut chain_state, + ); + make_channel( + &nodes[1], + &nodes[2], + &monitor_b, + &monitor_c, + &keys_manager_c, + 6, + false, + false, + &mut chain_state, + ); // Wipe the transactions-broadcasted set to make sure we don't broadcast any transactions // during normal operation in `test_return`. @@ -1464,7 +1497,7 @@ pub fn do_test(data: &[u8], out: Out) { sync_with_chain_state(&mut chain_state, &nodes[1], &mut node_height_b, None); sync_with_chain_state(&mut chain_state, &nodes[2], &mut node_height_c, None); - lock_fundings!(nodes); + lock_fundings(&nodes); // Get channel IDs for all A-B channels (from node A's perspective) let chan_ab_ids = { From 7eccad39b401426ec7afa92cb6813c433b65a434 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Tue, 21 Apr 2026 17:31:38 +0200 Subject: [PATCH 373/627] Wrap chanmon nodes in HarnessNode Introduce a small wrapper around each channel manager and its test resources. This keeps node-local state together before moving more operations onto the harness. --- fuzz/src/chanmon_consistency.rs | 158 +++++++++++++------------------- 1 file changed, 66 insertions(+), 92 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 6270ce148d9..742135f1468 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -944,6 +944,34 @@ enum ChanType { ZeroFeeCommitments, } +struct HarnessNode<'a> { + node: ChanMan<'a>, + monitor: Arc, + keys_manager: Arc, +} + +impl<'a> std::ops::Deref for HarnessNode<'a> { + type Target = ChanMan<'a>; + + fn deref(&self) -> &Self::Target { + &self.node + } +} + +impl<'a> HarnessNode<'a> { + fn complete_all_pending_monitor_updates(&self) { + for (channel_id, state) in self.monitor.latest_monitors.lock().unwrap().iter_mut() { + for (id, data) in state.pending_monitors.drain(..) { + self.monitor.chain_monitor.channel_monitor_updated(*channel_id, id).unwrap(); + if id >= state.persisted_monitor_id { + state.persisted_monitor_id = id; + state.persisted_monitor = data; + } + } + } + } +} + fn build_node_config(chan_type: ChanType) -> UserConfig { let mut config = UserConfig::default(); config.channel_config.forwarding_fee_proportional_millionths = 0; @@ -966,18 +994,6 @@ fn build_node_config(chan_type: ChanType) -> UserConfig { config } -fn complete_all_pending_monitor_updates(monitor: &Arc) { - for (channel_id, state) in monitor.latest_monitors.lock().unwrap().iter_mut() { - for (id, data) in state.pending_monitors.drain(..) { - monitor.chain_monitor.channel_monitor_updated(*channel_id, id).unwrap(); - if id >= state.persisted_monitor_id { - state.persisted_monitor_id = id; - state.persisted_monitor = data; - } - } - } -} - fn connect_peers(source: &ChanMan<'_>, dest: &ChanMan<'_>) { let init_dest = Init { features: dest.init_features(), networks: None, remote_network_address: None }; @@ -988,9 +1004,8 @@ fn connect_peers(source: &ChanMan<'_>, dest: &ChanMan<'_>) { } fn make_channel( - source: &ChanMan<'_>, dest: &ChanMan<'_>, source_monitor: &Arc, - dest_monitor: &Arc, dest_keys_manager: &Arc, chan_id: i32, - trusted_open: bool, trusted_accept: bool, chain_state: &mut ChainState, + source: &HarnessNode<'_>, dest: &HarnessNode<'_>, chan_id: i32, trusted_open: bool, + trusted_accept: bool, chain_state: &mut ChainState, ) { if trusted_open { source @@ -1027,7 +1042,7 @@ fn make_channel( } = events[0] { let mut random_bytes = [0u8; 16]; - random_bytes.copy_from_slice(&dest_keys_manager.get_secure_random_bytes()[..16]); + random_bytes.copy_from_slice(&dest.keys_manager.get_secure_random_bytes()[..16]); let user_channel_id = u128::from_be_bytes(random_bytes); if trusted_accept { dest.accept_inbound_channel_from_trusted_peer( @@ -1103,7 +1118,7 @@ fn make_channel( }; dest.handle_funding_created(source.get_our_node_id(), &funding_created); // Complete any pending monitor updates for dest after watch_channel. - complete_all_pending_monitor_updates(dest_monitor); + dest.complete_all_pending_monitor_updates(); let (funding_signed, channel_id) = { let events = dest.get_and_clear_pending_msg_events(); @@ -1124,7 +1139,7 @@ fn make_channel( source.handle_funding_signed(dest.get_our_node_id(), &funding_signed); // Complete any pending monitor updates for source after watch_channel. - complete_all_pending_monitor_updates(source_monitor); + source.complete_all_pending_monitor_updates(); let events = source.get_and_clear_pending_events(); assert_eq!(events.len(), 1); @@ -1141,7 +1156,7 @@ fn make_channel( } } -fn lock_fundings(nodes: &[ChanMan<'_>; 3]) { +fn lock_fundings(nodes: &[HarnessNode<'_>; 3]) { let mut node_events = Vec::new(); for node in nodes.iter() { node_events.push(node.get_and_clear_pending_msg_events()); @@ -1380,7 +1395,23 @@ pub fn do_test(data: &[u8], out: Out) { let (node_b, mut monitor_b, keys_manager_b, logger_b) = make_node!(1, fee_est_b, broadcast_b); let (node_c, mut monitor_c, keys_manager_c, logger_c) = make_node!(2, fee_est_c, broadcast_c); - let mut nodes = [node_a, node_b, node_c]; + let mut nodes = [ + HarnessNode { + node: node_a, + monitor: Arc::clone(&monitor_a), + keys_manager: Arc::clone(&keys_manager_a), + }, + HarnessNode { + node: node_b, + monitor: Arc::clone(&monitor_b), + keys_manager: Arc::clone(&keys_manager_b), + }, + HarnessNode { + node: node_c, + monitor: Arc::clone(&monitor_c), + keys_manager: Arc::clone(&keys_manager_c), + }, + ]; #[allow(unused_variables)] let loggers = [logger_a, logger_b, logger_c]; #[allow(unused_variables)] @@ -1396,74 +1427,14 @@ pub fn do_test(data: &[u8], out: Out) { // txid and funding outpoint. // A-B: channel 2 A and B have 0-reserve (trusted open + trusted accept), // channel 3 A has 0-reserve (trusted accept) - make_channel( - &nodes[0], - &nodes[1], - &monitor_a, - &monitor_b, - &keys_manager_b, - 1, - false, - false, - &mut chain_state, - ); - make_channel( - &nodes[0], - &nodes[1], - &monitor_a, - &monitor_b, - &keys_manager_b, - 2, - true, - true, - &mut chain_state, - ); - make_channel( - &nodes[0], - &nodes[1], - &monitor_a, - &monitor_b, - &keys_manager_b, - 3, - false, - true, - &mut chain_state, - ); + make_channel(&nodes[0], &nodes[1], 1, false, false, &mut chain_state); + make_channel(&nodes[0], &nodes[1], 2, true, true, &mut chain_state); + make_channel(&nodes[0], &nodes[1], 3, false, true, &mut chain_state); // B-C: channel 4 B has 0-reserve (via trusted accept), // channel 5 C has 0-reserve (via trusted open) - make_channel( - &nodes[1], - &nodes[2], - &monitor_b, - &monitor_c, - &keys_manager_c, - 4, - false, - true, - &mut chain_state, - ); - make_channel( - &nodes[1], - &nodes[2], - &monitor_b, - &monitor_c, - &keys_manager_c, - 5, - true, - false, - &mut chain_state, - ); - make_channel( - &nodes[1], - &nodes[2], - &monitor_b, - &monitor_c, - &keys_manager_c, - 6, - false, - false, - &mut chain_state, - ); + make_channel(&nodes[1], &nodes[2], 4, false, true, &mut chain_state); + make_channel(&nodes[1], &nodes[2], 5, true, false, &mut chain_state); + make_channel(&nodes[1], &nodes[2], 6, false, false, &mut chain_state); // Wipe the transactions-broadcasted set to make sure we don't broadcast any transactions // during normal operation in `test_return`. @@ -2657,8 +2628,9 @@ pub fn do_test(data: &[u8], out: Out) { &fee_est_a, broadcast_a.clone(), ); - nodes[0] = new_node_a; - monitor_a = new_monitor_a; + nodes[0].node = new_node_a; + monitor_a = Arc::clone(&new_monitor_a); + nodes[0].monitor = new_monitor_a; }, 0xb3..=0xbb => { // Restart node B, picking among the in-flight `ChannelMonitor`s to use based on @@ -2686,8 +2658,9 @@ pub fn do_test(data: &[u8], out: Out) { &fee_est_b, broadcast_b.clone(), ); - nodes[1] = new_node_b; - monitor_b = new_monitor_b; + nodes[1].node = new_node_b; + monitor_b = Arc::clone(&new_monitor_b); + nodes[1].monitor = new_monitor_b; }, 0xbc | 0xbd | 0xbe => { // Restart node C, picking among the in-flight `ChannelMonitor`s to use based on @@ -2711,8 +2684,9 @@ pub fn do_test(data: &[u8], out: Out) { &fee_est_c, broadcast_c.clone(), ); - nodes[2] = new_node_c; - monitor_c = new_monitor_c; + nodes[2].node = new_node_c; + monitor_c = Arc::clone(&new_monitor_c); + nodes[2].monitor = new_monitor_c; }, 0xc0 => keys_manager_a.disable_supported_ops_for_all_signers(), From 1ad022b6f458da83499335f9173610f6d6a3dc04 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Tue, 21 Apr 2026 18:00:00 +0200 Subject: [PATCH 374/627] Build chanmon node resources Move construction of loggers, keys, monitors, broadcasters, wallets, and fee estimators into node resource setup. This removes ad hoc local closures while preserving the deterministic test inputs used by the fuzzer. --- fuzz/src/chanmon_consistency.rs | 669 ++++++++++++++++---------------- 1 file changed, 345 insertions(+), 324 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 742135f1468..c149c861506 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -948,6 +948,10 @@ struct HarnessNode<'a> { node: ChanMan<'a>, monitor: Arc, keys_manager: Arc, + logger: Arc, + broadcaster: Arc, + fee_estimator: Arc, + wallet: TestWalletSource, } impl<'a> std::ops::Deref for HarnessNode<'a> { @@ -959,6 +963,72 @@ impl<'a> std::ops::Deref for HarnessNode<'a> { } impl<'a> HarnessNode<'a> { + fn build_loggers( + node_id: u8, out: &Out, + ) -> (Arc, Arc) { + let raw_logger = Arc::new(test_logger::TestLogger::new(node_id.to_string(), out.clone())); + let logger_for_monitor: Arc = raw_logger.clone(); + let logger: Arc = raw_logger; + (logger_for_monitor, logger) + } + + fn build_chain_monitor( + broadcaster: &Arc, fee_estimator: &Arc, + keys_manager: &Arc, logger_for_monitor: Arc, + persistence_style: ChannelMonitorUpdateStatus, + ) -> Arc { + Arc::new(TestChainMonitor::new( + Arc::clone(broadcaster), + logger_for_monitor, + Arc::clone(fee_estimator), + Arc::new(TestPersister { update_ret: Mutex::new(persistence_style) }), + Arc::clone(keys_manager), + )) + } + + fn new( + node_id: u8, wallet: TestWalletSource, fee_estimator: Arc, + broadcaster: Arc, persistence_style: ChannelMonitorUpdateStatus, + out: &Out, router: &'a FuzzRouter, chan_type: ChanType, + ) -> Self { + let (logger_for_monitor, logger) = Self::build_loggers(node_id, out); + let node_secret = SecretKey::from_slice(&[ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 1, node_id, + ]) + .unwrap(); + let keys_manager = Arc::new(KeyProvider { + node_secret, + rand_bytes_id: atomic::AtomicU32::new(0), + enforcement_states: Mutex::new(new_hash_map()), + }); + let monitor = Self::build_chain_monitor( + &broadcaster, + &fee_estimator, + &keys_manager, + logger_for_monitor, + persistence_style, + ); + let network = Network::Bitcoin; + let best_block_timestamp = genesis_block(network).header.time; + let params = ChainParameters { network, best_block: BlockLocator::from_network(network) }; + let node = ChannelManager::new( + Arc::clone(&fee_estimator), + Arc::clone(&monitor), + Arc::clone(&broadcaster), + router, + router, + Arc::clone(&logger), + Arc::clone(&keys_manager), + Arc::clone(&keys_manager), + Arc::clone(&keys_manager), + build_node_config(chan_type), + params, + best_block_timestamp, + ); + Self { node, monitor, keys_manager, logger, broadcaster, fee_estimator, wallet } + } + fn complete_all_pending_monitor_updates(&self) { for (channel_id, state) in self.monitor.latest_monitors.lock().unwrap().iter_mut() { for (id, data) in state.pending_monitors.drain(..) { @@ -994,6 +1064,17 @@ fn build_node_config(chan_type: ChanType) -> UserConfig { config } +fn assert_test_invariants(nodes: &[HarnessNode<'_>; 3]) { + assert_eq!(nodes[0].list_channels().len(), 3); + assert_eq!(nodes[1].list_channels().len(), 6); + assert_eq!(nodes[2].list_channels().len(), 3); + + // All broadcasters should be empty. Broadcast transactions are handled explicitly. + assert!(nodes[0].broadcaster.txn_broadcasted.borrow().is_empty()); + assert!(nodes[1].broadcaster.txn_broadcasted.borrow().is_empty()); + assert!(nodes[2].broadcaster.txn_broadcasted.borrow().is_empty()); +} + fn connect_peers(source: &ChanMan<'_>, dest: &ChanMan<'_>) { let init_dest = Init { features: dest.init_features(), networks: None, remote_network_address: None }; @@ -1188,9 +1269,6 @@ fn lock_fundings(nodes: &[HarnessNode<'_>; 3]) { #[inline] pub fn do_test(data: &[u8], out: Out) { - let broadcast_a = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); - let broadcast_b = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); - let broadcast_c = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); let router = FuzzRouter {}; // Read initial monitor styles and channel type from fuzz input byte 0: @@ -1224,163 +1302,26 @@ pub fn do_test(data: &[u8], out: Out) { let mut node_height_a: u32 = 0; let mut node_height_b: u32 = 0; let mut node_height_c: u32 = 0; - - macro_rules! make_node { - ($node_id: expr, $fee_estimator: expr, $broadcaster: expr) => {{ - let logger: Arc = - Arc::new(test_logger::TestLogger::new($node_id.to_string(), out.clone())); - let node_secret = SecretKey::from_slice(&[ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 1, $node_id, - ]) - .unwrap(); - let keys_manager = Arc::new(KeyProvider { - node_secret, - rand_bytes_id: atomic::AtomicU32::new(0), - enforcement_states: Mutex::new(new_hash_map()), - }); - let monitor = Arc::new(TestChainMonitor::new( - $broadcaster.clone(), - logger.clone(), - $fee_estimator.clone(), - Arc::new(TestPersister { - update_ret: Mutex::new(mon_style[$node_id as usize].borrow().clone()), - }), - Arc::clone(&keys_manager), - )); - - let network = Network::Bitcoin; - let best_block_timestamp = genesis_block(network).header.time; - let params = - ChainParameters { network, best_block: BlockLocator::from_network(network) }; - ( - ChannelManager::new( - $fee_estimator.clone(), - monitor.clone(), - $broadcaster.clone(), - &router, - &router, - Arc::clone(&logger), - keys_manager.clone(), - keys_manager.clone(), - keys_manager.clone(), - build_node_config(chan_type), - params, - best_block_timestamp, - ), - monitor, - keys_manager, - logger, - ) - }}; - } - - let reload_node = |ser: &Vec, - node_id: u8, - old_monitors: &TestChainMonitor, - mut use_old_mons, - keys, - fee_estimator, - broadcaster: Arc| { - let keys_manager = Arc::clone(keys); - let logger: Arc = - Arc::new(test_logger::TestLogger::new(node_id.to_string(), out.clone())); - let chain_monitor = Arc::new(TestChainMonitor::new( - broadcaster.clone(), - logger.clone(), - Arc::clone(fee_estimator), - Arc::new(TestPersister { - update_ret: Mutex::new(ChannelMonitorUpdateStatus::Completed), - }), - Arc::clone(keys), - )); - - let mut monitors = new_hash_map(); - let mut old_monitors = old_monitors.latest_monitors.lock().unwrap(); - for (channel_id, mut prev_state) in old_monitors.drain() { - let (mon_id, serialized_mon) = if use_old_mons % 3 == 0 { - // Reload with the oldest `ChannelMonitor` (the one that we already told - // `ChannelManager` we finished persisting). - (prev_state.persisted_monitor_id, prev_state.persisted_monitor) - } else if use_old_mons % 3 == 1 { - // Reload with the second-oldest `ChannelMonitor` - let old_mon = (prev_state.persisted_monitor_id, prev_state.persisted_monitor); - prev_state.pending_monitors.drain(..).next().unwrap_or(old_mon) - } else { - // Reload with the newest `ChannelMonitor` - let old_mon = (prev_state.persisted_monitor_id, prev_state.persisted_monitor); - prev_state.pending_monitors.pop().unwrap_or(old_mon) - }; - // Use a different value of `use_old_mons` if we have another monitor (only for node B) - // by shifting `use_old_mons` one in base-3. - use_old_mons /= 3; - let mon = <(BlockLocator, ChannelMonitor)>::read( - &mut &serialized_mon[..], - (&**keys, &**keys), - ) - .expect("Failed to read monitor"); - monitors.insert(channel_id, mon.1); - // Update the latest `ChannelMonitor` state to match what we just told LDK. - prev_state.persisted_monitor = serialized_mon; - prev_state.persisted_monitor_id = mon_id; - // Wipe any `ChannelMonitor`s which we never told LDK we finished persisting, - // considering them discarded. LDK should replay these for us as they're stored in - // the `ChannelManager`. - prev_state.pending_monitors.clear(); - chain_monitor.latest_monitors.lock().unwrap().insert(channel_id, prev_state); - } - let mut monitor_refs = new_hash_map(); - for (channel_id, monitor) in monitors.iter() { - monitor_refs.insert(*channel_id, monitor); - } - - let read_args = ChannelManagerReadArgs { - entropy_source: Arc::clone(&keys_manager), - node_signer: Arc::clone(&keys_manager), - signer_provider: keys_manager, - fee_estimator: Arc::clone(fee_estimator), - chain_monitor: chain_monitor.clone(), - tx_broadcaster: broadcaster, - router: &router, - message_router: &router, - logger, - config: build_node_config(chan_type), - channel_monitors: monitor_refs, - }; - - let manager = <(BlockLocator, ChanMan)>::read(&mut &ser[..], read_args) - .expect("Failed to read manager"); - let res = (manager.1, chain_monitor.clone()); - for (channel_id, mon) in monitors.drain() { - assert_eq!( - chain_monitor.chain_monitor.watch_channel(channel_id, mon), - Ok(ChannelMonitorUpdateStatus::Completed) - ); - } - *chain_monitor.persister.update_ret.lock().unwrap() = *mon_style[node_id as usize].borrow(); - res - }; - let wallet_a = TestWalletSource::new(SecretKey::from_slice(&[1; 32]).unwrap()); let wallet_b = TestWalletSource::new(SecretKey::from_slice(&[2; 32]).unwrap()); let wallet_c = TestWalletSource::new(SecretKey::from_slice(&[3; 32]).unwrap()); - let wallets = vec![wallet_a, wallet_b, wallet_c]; + let wallets = [&wallet_a, &wallet_b, &wallet_c]; let coinbase_tx = bitcoin::Transaction { version: bitcoin::transaction::Version::TWO, lock_time: bitcoin::absolute::LockTime::ZERO, input: vec![bitcoin::TxIn { ..Default::default() }], output: wallets .iter() - .map(|w| TxOut { + .map(|wallet| TxOut { value: Amount::from_sat(100_000), - script_pubkey: w.get_change_script().unwrap(), + script_pubkey: wallet.get_change_script().unwrap(), }) .collect(), }; - wallets.iter().enumerate().for_each(|(i, w)| { - w.add_utxo(coinbase_tx.clone(), i as u32); - }); + for (idx, wallet) in wallets.iter().enumerate() { + wallet.add_utxo(coinbase_tx.clone(), idx as u32); + } let fee_est_a = Arc::new(FuzzEstimator { ret_val: atomic::AtomicU32::new(253) }); let mut last_htlc_clear_fee_a = 253; @@ -1388,34 +1329,50 @@ pub fn do_test(data: &[u8], out: Out) { let mut last_htlc_clear_fee_b = 253; let fee_est_c = Arc::new(FuzzEstimator { ret_val: atomic::AtomicU32::new(253) }); let mut last_htlc_clear_fee_c = 253; + let broadcast_a = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); + let broadcast_b = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); + let broadcast_c = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); // 3 nodes is enough to hit all the possible cases, notably unknown-source-unknown-dest // forwarding. - let (node_a, mut monitor_a, keys_manager_a, logger_a) = make_node!(0, fee_est_a, broadcast_a); - let (node_b, mut monitor_b, keys_manager_b, logger_b) = make_node!(1, fee_est_b, broadcast_b); - let (node_c, mut monitor_c, keys_manager_c, logger_c) = make_node!(2, fee_est_c, broadcast_c); - let mut nodes = [ - HarnessNode { - node: node_a, - monitor: Arc::clone(&monitor_a), - keys_manager: Arc::clone(&keys_manager_a), - }, - HarnessNode { - node: node_b, - monitor: Arc::clone(&monitor_b), - keys_manager: Arc::clone(&keys_manager_b), - }, - HarnessNode { - node: node_c, - monitor: Arc::clone(&monitor_c), - keys_manager: Arc::clone(&keys_manager_c), - }, + HarnessNode::new( + 0, + wallet_a, + Arc::clone(&fee_est_a), + Arc::clone(&broadcast_a), + mon_style[0].borrow().clone(), + &out, + &router, + chan_type, + ), + HarnessNode::new( + 1, + wallet_b, + Arc::clone(&fee_est_b), + Arc::clone(&broadcast_b), + mon_style[1].borrow().clone(), + &out, + &router, + chan_type, + ), + HarnessNode::new( + 2, + wallet_c, + Arc::clone(&fee_est_c), + Arc::clone(&broadcast_c), + mon_style[2].borrow().clone(), + &out, + &router, + chan_type, + ), ]; - #[allow(unused_variables)] - let loggers = [logger_a, logger_b, logger_c]; - #[allow(unused_variables)] - let fee_estimators = [Arc::clone(&fee_est_a), Arc::clone(&fee_est_b), Arc::clone(&fee_est_c)]; + let mut monitor_a = Arc::clone(&nodes[0].monitor); + let mut monitor_b = Arc::clone(&nodes[1].monitor); + let mut monitor_c = Arc::clone(&nodes[2].monitor); + let keys_manager_a = Arc::clone(&nodes[0].keys_manager); + let keys_manager_b = Arc::clone(&nodes[1].keys_manager); + let keys_manager_c = Arc::clone(&nodes[2].keys_manager); // Connect peers first, then create channels connect_peers(&nodes[0], &nodes[1]); @@ -1438,12 +1395,12 @@ pub fn do_test(data: &[u8], out: Out) { // Wipe the transactions-broadcasted set to make sure we don't broadcast any transactions // during normal operation in `test_return`. - broadcast_a.txn_broadcasted.borrow_mut().clear(); - broadcast_b.txn_broadcasted.borrow_mut().clear(); - broadcast_c.txn_broadcasted.borrow_mut().clear(); + nodes[0].broadcaster.txn_broadcasted.borrow_mut().clear(); + nodes[1].broadcaster.txn_broadcasted.borrow_mut().clear(); + nodes[2].broadcaster.txn_broadcasted.borrow_mut().clear(); let sync_with_chain_state = |chain_state: &ChainState, - node: &ChannelManager<_, _, _, _, _, _, _, _, _>, + node: &HarnessNode<'_>, node_height: &mut u32, num_blocks: Option| { let target_height = if let Some(num_blocks) = num_blocks { @@ -1451,7 +1408,6 @@ pub fn do_test(data: &[u8], out: Out) { } else { chain_state.tip_height() }; - while *node_height < target_height { *node_height += 1; let (header, txn) = chain_state.block_at(*node_height); @@ -1464,9 +1420,9 @@ pub fn do_test(data: &[u8], out: Out) { }; // Sync all nodes to tip to lock the funding. - sync_with_chain_state(&mut chain_state, &nodes[0], &mut node_height_a, None); - sync_with_chain_state(&mut chain_state, &nodes[1], &mut node_height_b, None); - sync_with_chain_state(&mut chain_state, &nodes[2], &mut node_height_c, None); + sync_with_chain_state(&chain_state, &nodes[0], &mut node_height_a, None); + sync_with_chain_state(&chain_state, &nodes[1], &mut node_height_b, None); + sync_with_chain_state(&chain_state, &nodes[2], &mut node_height_c, None); lock_fundings(&nodes); @@ -1506,20 +1462,93 @@ pub fn do_test(data: &[u8], out: Out) { macro_rules! test_return { () => {{ - assert_eq!(nodes[0].list_channels().len(), 3); - assert_eq!(nodes[1].list_channels().len(), 6); - assert_eq!(nodes[2].list_channels().len(), 3); - - // All broadcasters should be empty (all broadcast transactions should be handled - // explicitly). - assert!(broadcast_a.txn_broadcasted.borrow().is_empty()); - assert!(broadcast_b.txn_broadcasted.borrow().is_empty()); - assert!(broadcast_c.txn_broadcasted.borrow().is_empty()); - + assert_test_invariants(&nodes); return; }}; } + let reload_node = |ser: &Vec, + node_id: u8, + old_monitors: &TestChainMonitor, + mut use_old_mons, + keys: &Arc, + fee_estimator: &Arc, + broadcaster: Arc| { + let keys_manager = Arc::clone(keys); + let (logger_for_monitor, logger) = HarnessNode::build_loggers(node_id, &out); + let chain_monitor = HarnessNode::build_chain_monitor( + &broadcaster, + fee_estimator, + &keys_manager, + logger_for_monitor, + ChannelMonitorUpdateStatus::Completed, + ); + + let mut monitors = new_hash_map(); + let mut old_monitors = old_monitors.latest_monitors.lock().unwrap(); + for (channel_id, mut prev_state) in old_monitors.drain() { + let (mon_id, serialized_mon) = if use_old_mons % 3 == 0 { + // Reload with the oldest `ChannelMonitor` (the one that we already told + // `ChannelManager` we finished persisting). + (prev_state.persisted_monitor_id, prev_state.persisted_monitor) + } else if use_old_mons % 3 == 1 { + // Reload with the second-oldest `ChannelMonitor` + let old_mon = (prev_state.persisted_monitor_id, prev_state.persisted_monitor); + prev_state.pending_monitors.drain(..).next().unwrap_or(old_mon) + } else { + // Reload with the newest `ChannelMonitor` + let old_mon = (prev_state.persisted_monitor_id, prev_state.persisted_monitor); + prev_state.pending_monitors.pop().unwrap_or(old_mon) + }; + // Use a different value of `use_old_mons` if we have another monitor (only for node B) + // by shifting `use_old_mons` one in base-3. + use_old_mons /= 3; + let mon = <(BlockLocator, ChannelMonitor)>::read( + &mut &serialized_mon[..], + (&*keys_manager, &*keys_manager), + ) + .expect("Failed to read monitor"); + monitors.insert(channel_id, mon.1); + // Update the latest `ChannelMonitor` state to match what we just told LDK. + prev_state.persisted_monitor = serialized_mon; + prev_state.persisted_monitor_id = mon_id; + // Wipe any `ChannelMonitor`s which we never told LDK we finished persisting, + // considering them discarded. LDK should replay these for us as they're stored in + // the `ChannelManager`. + prev_state.pending_monitors.clear(); + chain_monitor.latest_monitors.lock().unwrap().insert(channel_id, prev_state); + } + let mut monitor_refs = new_hash_map(); + for (channel_id, monitor) in monitors.iter() { + monitor_refs.insert(*channel_id, monitor); + } + + let read_args = ChannelManagerReadArgs { + entropy_source: Arc::clone(&keys_manager), + node_signer: Arc::clone(&keys_manager), + signer_provider: Arc::clone(&keys_manager), + fee_estimator: Arc::clone(fee_estimator), + chain_monitor: chain_monitor.clone(), + tx_broadcaster: broadcaster, + router: &router, + message_router: &router, + logger: Arc::clone(&logger), + config: build_node_config(chan_type), + channel_monitors: monitor_refs, + }; + + let manager = <(BlockLocator, ChanMan)>::read(&mut &ser[..], read_args) + .expect("Failed to read manager"); + for (channel_id, mon) in monitors.drain() { + assert_eq!( + chain_monitor.chain_monitor.watch_channel(channel_id, mon), + Ok(ChannelMonitorUpdateStatus::Completed) + ); + } + *chain_monitor.persister.update_ret.lock().unwrap() = *mon_style[node_id as usize].borrow(); + (manager.1, chain_monitor, logger) + }; + let mut read_pos = 1; // First byte was consumed for initial config (mon_style + chan_type) macro_rules! get_slice { ($len: expr) => {{ @@ -1532,82 +1561,6 @@ pub fn do_test(data: &[u8], out: Out) { }}; } - let splice_channel = - |node: &ChanMan, - counterparty_node_id: &PublicKey, - channel_id: &ChannelId, - f: &dyn Fn(FundingTemplate) -> Result| { - match node.splice_channel(channel_id, counterparty_node_id) { - Ok(funding_template) => { - if let Ok(contribution) = f(funding_template) { - let _ = node.funding_contributed( - channel_id, - counterparty_node_id, - contribution, - None, - ); - } - }, - Err(e) => { - assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), - "{:?}", - e - ); - }, - } - }; - - let splice_in = - |node: &ChanMan, - counterparty_node_id: &PublicKey, - channel_id: &ChannelId, - wallet: &WalletSync<&TestWalletSource, Arc>, - funding_feerate_sat_per_kw: FeeRate| { - splice_channel( - node, - counterparty_node_id, - channel_id, - &move |funding_template: FundingTemplate| { - let feerate = - funding_template.min_rbf_feerate().unwrap_or(funding_feerate_sat_per_kw); - funding_template.splice_in_sync( - Amount::from_sat(10_000), - feerate, - FeeRate::MAX, - wallet, - ) - }, - ); - }; - - let splice_out = |node: &ChanMan, - counterparty_node_id: &PublicKey, - channel_id: &ChannelId, - wallet: &TestWalletSource, - funding_feerate_sat_per_kw: FeeRate| { - // We conditionally splice out `MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS` only when the node - // has double the balance required to send a payment upon a `0xff` byte. We do this to - // ensure there's always liquidity available for a payment to succeed then. - let outbound_capacity_msat = node - .list_channels() - .iter() - .find(|chan| chan.channel_id == *channel_id) - .map(|chan| chan.outbound_capacity_msat) - .unwrap(); - if outbound_capacity_msat < 20_000_000 { - return; - } - splice_channel(node, counterparty_node_id, channel_id, &move |funding_template| { - let feerate = funding_template.min_rbf_feerate().unwrap_or(funding_feerate_sat_per_kw); - let outputs = vec![TxOut { - value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), - script_pubkey: wallet.get_change_script().unwrap(), - }]; - funding_template.splice_out(outputs, feerate, FeeRate::MAX) - }); - }; - loop { // Push any events from Node B onto ba_events and bc_events macro_rules! push_excess_b_events { @@ -2087,7 +2040,8 @@ pub fn do_test(data: &[u8], out: Out) { unsigned_transaction, .. } => { - let signed_tx = wallets[$node].sign_tx(unsigned_transaction).unwrap(); + let signed_tx = + nodes[$node].wallet.sign_tx(unsigned_transaction).unwrap(); nodes[$node] .funding_transaction_signed( &channel_id, @@ -2097,12 +2051,7 @@ pub fn do_test(data: &[u8], out: Out) { .unwrap(); }, events::Event::SpliceNegotiated { new_funding_txo, .. } => { - let broadcaster = match $node { - 0 => &broadcast_a, - 1 => &broadcast_b, - _ => &broadcast_c, - }; - let mut txs = broadcaster.txn_broadcasted.borrow_mut(); + let mut txs = nodes[$node].broadcaster.txn_broadcasted.borrow_mut(); assert!(txs.len() >= 1); let splice_tx = txs.remove(0); assert_eq!(new_funding_txo.txid, splice_tx.compute_txid()); @@ -2152,7 +2101,6 @@ pub fn do_test(data: &[u8], out: Out) { } } }; - let complete_all_monitor_updates = |monitor: &Arc, chan_id| { if let Some(state) = monitor.latest_monitors.lock().unwrap().get_mut(chan_id) { assert!( @@ -2169,6 +2117,85 @@ pub fn do_test(data: &[u8], out: Out) { } }; + let splice_channel = + |node: &HarnessNode<'_>, + counterparty_node_id: &PublicKey, + channel_id: &ChannelId, + f: &dyn Fn( + FundingTemplate, + ) -> Result| { + match node.splice_channel(channel_id, counterparty_node_id) { + Ok(funding_template) => { + if let Ok(contribution) = f(funding_template) { + let _ = node.funding_contributed( + channel_id, + counterparty_node_id, + contribution, + None, + ); + } + }, + Err(e) => { + assert!( + matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), + "{:?}", + e + ); + }, + } + }; + + let splice_in = |node: &HarnessNode<'_>, + counterparty_node_id: &PublicKey, + channel_id: &ChannelId| { + let wallet = WalletSync::new(&node.wallet, Arc::clone(&node.logger)); + let funding_feerate_sat_per_kw = node.fee_estimator.feerate_sat_per_kw(); + splice_channel( + node, + counterparty_node_id, + channel_id, + &move |funding_template: FundingTemplate| { + let feerate = + funding_template.min_rbf_feerate().unwrap_or(funding_feerate_sat_per_kw); + funding_template.splice_in_sync( + Amount::from_sat(10_000), + feerate, + FeeRate::MAX, + &wallet, + ) + }, + ); + }; + + let splice_out = |node: &HarnessNode<'_>, + counterparty_node_id: &PublicKey, + channel_id: &ChannelId| { + let outbound_capacity_msat = node + .list_channels() + .iter() + .find(|chan| chan.channel_id == *channel_id) + .map(|chan| chan.outbound_capacity_msat) + .unwrap(); + if outbound_capacity_msat < 20_000_000 { + return; + } + let funding_feerate_sat_per_kw = node.fee_estimator.feerate_sat_per_kw(); + splice_channel( + node, + counterparty_node_id, + channel_id, + &move |funding_template: FundingTemplate| { + let feerate = + funding_template.min_rbf_feerate().unwrap_or(funding_feerate_sat_per_kw); + let outputs = vec![TxOut { + value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), + script_pubkey: node.wallet.get_change_script().unwrap(), + }]; + funding_template.splice_out(outputs, feerate, FeeRate::MAX) + }, + ); + }; + let send = |source_idx: usize, dest_idx: usize, dest_chan_id, amt, payment_ctr: &mut u64| { let source = &nodes[source_idx]; @@ -2465,43 +2492,47 @@ pub fn do_test(data: &[u8], out: Out) { if matches!(chan_type, ChanType::Legacy) { max_feerate *= FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32; } - if fee_est_a.ret_val.fetch_add(250, atomic::Ordering::AcqRel) + 250 > max_feerate { - fee_est_a.ret_val.store(max_feerate, atomic::Ordering::Release); + if nodes[0].fee_estimator.ret_val.fetch_add(250, atomic::Ordering::AcqRel) + 250 + > max_feerate + { + nodes[0].fee_estimator.ret_val.store(max_feerate, atomic::Ordering::Release); } nodes[0].timer_tick_occurred(); }, 0x81 => { - fee_est_a.ret_val.store(253, atomic::Ordering::Release); + nodes[0].fee_estimator.ret_val.store(253, atomic::Ordering::Release); nodes[0].timer_tick_occurred(); }, - 0x84 => { let mut max_feerate = last_htlc_clear_fee_b; if matches!(chan_type, ChanType::Legacy) { max_feerate *= FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32; } - if fee_est_b.ret_val.fetch_add(250, atomic::Ordering::AcqRel) + 250 > max_feerate { - fee_est_b.ret_val.store(max_feerate, atomic::Ordering::Release); + if nodes[1].fee_estimator.ret_val.fetch_add(250, atomic::Ordering::AcqRel) + 250 + > max_feerate + { + nodes[1].fee_estimator.ret_val.store(max_feerate, atomic::Ordering::Release); } nodes[1].timer_tick_occurred(); }, 0x85 => { - fee_est_b.ret_val.store(253, atomic::Ordering::Release); + nodes[1].fee_estimator.ret_val.store(253, atomic::Ordering::Release); nodes[1].timer_tick_occurred(); }, - 0x88 => { let mut max_feerate = last_htlc_clear_fee_c; if matches!(chan_type, ChanType::Legacy) { max_feerate *= FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32; } - if fee_est_c.ret_val.fetch_add(250, atomic::Ordering::AcqRel) + 250 > max_feerate { - fee_est_c.ret_val.store(max_feerate, atomic::Ordering::Release); + if nodes[2].fee_estimator.ret_val.fetch_add(250, atomic::Ordering::AcqRel) + 250 + > max_feerate + { + nodes[2].fee_estimator.ret_val.store(max_feerate, atomic::Ordering::Release); } nodes[2].timer_tick_occurred(); }, 0x89 => { - fee_est_c.ret_val.store(253, atomic::Ordering::Release); + nodes[2].fee_estimator.ret_val.store(253, atomic::Ordering::Release); nodes[2].timer_tick_occurred(); }, @@ -2510,36 +2541,28 @@ pub fn do_test(data: &[u8], out: Out) { test_return!(); } let cp_node_id = nodes[1].get_our_node_id(); - let wallet = WalletSync::new(&wallets[0], Arc::clone(&loggers[0])); - let feerate_sat_per_kw = fee_estimators[0].feerate_sat_per_kw(); - splice_in(&nodes[0], &cp_node_id, &chan_a_id, &wallet, feerate_sat_per_kw); + splice_in(&nodes[0], &cp_node_id, &chan_a_id); }, 0xa1 => { if !cfg!(splicing) { test_return!(); } let cp_node_id = nodes[0].get_our_node_id(); - let wallet = WalletSync::new(&wallets[1], Arc::clone(&loggers[1])); - let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw(); - splice_in(&nodes[1], &cp_node_id, &chan_a_id, &wallet, feerate_sat_per_kw); + splice_in(&nodes[1], &cp_node_id, &chan_a_id); }, 0xa2 => { if !cfg!(splicing) { test_return!(); } let cp_node_id = nodes[2].get_our_node_id(); - let wallet = WalletSync::new(&wallets[1], Arc::clone(&loggers[1])); - let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw(); - splice_in(&nodes[1], &cp_node_id, &chan_b_id, &wallet, feerate_sat_per_kw); + splice_in(&nodes[1], &cp_node_id, &chan_b_id); }, 0xa3 => { if !cfg!(splicing) { test_return!(); } let cp_node_id = nodes[1].get_our_node_id(); - let wallet = WalletSync::new(&wallets[2], Arc::clone(&loggers[2])); - let feerate_sat_per_kw = fee_estimators[2].feerate_sat_per_kw(); - splice_in(&nodes[2], &cp_node_id, &chan_b_id, &wallet, feerate_sat_per_kw); + splice_in(&nodes[2], &cp_node_id, &chan_b_id); }, 0xa4 => { @@ -2547,63 +2570,55 @@ pub fn do_test(data: &[u8], out: Out) { test_return!(); } let cp_node_id = nodes[1].get_our_node_id(); - let wallet = &wallets[0]; - let feerate_sat_per_kw = fee_estimators[0].feerate_sat_per_kw(); - splice_out(&nodes[0], &cp_node_id, &chan_a_id, wallet, feerate_sat_per_kw); + splice_out(&nodes[0], &cp_node_id, &chan_a_id); }, 0xa5 => { if !cfg!(splicing) { test_return!(); } let cp_node_id = nodes[0].get_our_node_id(); - let wallet = &wallets[1]; - let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw(); - splice_out(&nodes[1], &cp_node_id, &chan_a_id, wallet, feerate_sat_per_kw); + splice_out(&nodes[1], &cp_node_id, &chan_a_id); }, 0xa6 => { if !cfg!(splicing) { test_return!(); } let cp_node_id = nodes[2].get_our_node_id(); - let wallet = &wallets[1]; - let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw(); - splice_out(&nodes[1], &cp_node_id, &chan_b_id, wallet, feerate_sat_per_kw); + splice_out(&nodes[1], &cp_node_id, &chan_b_id); }, 0xa7 => { if !cfg!(splicing) { test_return!(); } let cp_node_id = nodes[1].get_our_node_id(); - let wallet = &wallets[2]; - let feerate_sat_per_kw = fee_estimators[2].feerate_sat_per_kw(); - splice_out(&nodes[2], &cp_node_id, &chan_b_id, wallet, feerate_sat_per_kw); + splice_out(&nodes[2], &cp_node_id, &chan_b_id); }, // Sync node by 1 block to cover confirmation of a transaction. 0xa8 => { chain_state.confirm_pending_txs(); - sync_with_chain_state(&mut chain_state, &nodes[0], &mut node_height_a, Some(1)); + sync_with_chain_state(&chain_state, &nodes[0], &mut node_height_a, Some(1)); }, 0xa9 => { chain_state.confirm_pending_txs(); - sync_with_chain_state(&mut chain_state, &nodes[1], &mut node_height_b, Some(1)); + sync_with_chain_state(&chain_state, &nodes[1], &mut node_height_b, Some(1)); }, 0xaa => { chain_state.confirm_pending_txs(); - sync_with_chain_state(&mut chain_state, &nodes[2], &mut node_height_c, Some(1)); + sync_with_chain_state(&chain_state, &nodes[2], &mut node_height_c, Some(1)); }, // Sync node to chain tip to cover confirmation of a transaction post-reorg-risk. 0xab => { chain_state.confirm_pending_txs(); - sync_with_chain_state(&mut chain_state, &nodes[0], &mut node_height_a, None); + sync_with_chain_state(&chain_state, &nodes[0], &mut node_height_a, None); }, 0xac => { chain_state.confirm_pending_txs(); - sync_with_chain_state(&mut chain_state, &nodes[1], &mut node_height_b, None); + sync_with_chain_state(&chain_state, &nodes[1], &mut node_height_b, None); }, 0xad => { chain_state.confirm_pending_txs(); - sync_with_chain_state(&mut chain_state, &nodes[2], &mut node_height_c, None); + sync_with_chain_state(&chain_state, &nodes[2], &mut node_height_c, None); }, 0xb0 | 0xb1 | 0xb2 => { @@ -2619,18 +2634,19 @@ pub fn do_test(data: &[u8], out: Out) { ab_events.clear(); ba_events.clear(); } - let (new_node_a, new_monitor_a) = reload_node( + let (new_node_a, new_monitor_a, new_logger_a) = reload_node( &node_a_ser, 0, &monitor_a, v, &keys_manager_a, &fee_est_a, - broadcast_a.clone(), + Arc::clone(&broadcast_a), ); nodes[0].node = new_node_a; monitor_a = Arc::clone(&new_monitor_a); nodes[0].monitor = new_monitor_a; + nodes[0].logger = new_logger_a; }, 0xb3..=0xbb => { // Restart node B, picking among the in-flight `ChannelMonitor`s to use based on @@ -2649,18 +2665,19 @@ pub fn do_test(data: &[u8], out: Out) { bc_events.clear(); cb_events.clear(); } - let (new_node_b, new_monitor_b) = reload_node( + let (new_node_b, new_monitor_b, new_logger_b) = reload_node( &node_b_ser, 1, &monitor_b, v, &keys_manager_b, &fee_est_b, - broadcast_b.clone(), + Arc::clone(&broadcast_b), ); nodes[1].node = new_node_b; monitor_b = Arc::clone(&new_monitor_b); nodes[1].monitor = new_monitor_b; + nodes[1].logger = new_logger_b; }, 0xbc | 0xbd | 0xbe => { // Restart node C, picking among the in-flight `ChannelMonitor`s to use based on @@ -2675,18 +2692,19 @@ pub fn do_test(data: &[u8], out: Out) { bc_events.clear(); cb_events.clear(); } - let (new_node_c, new_monitor_c) = reload_node( + let (new_node_c, new_monitor_c, new_logger_c) = reload_node( &node_c_ser, 2, &monitor_c, v, &keys_manager_c, &fee_est_c, - broadcast_c.clone(), + Arc::clone(&broadcast_c), ); nodes[2].node = new_node_c; monitor_c = Arc::clone(&new_monitor_c); nodes[2].monitor = new_monitor_c; + nodes[2].logger = new_logger_c; }, 0xc0 => keys_manager_a.disable_supported_ops_for_all_signers(), @@ -2961,20 +2979,23 @@ pub fn do_test(data: &[u8], out: Out) { ); } - last_htlc_clear_fee_a = fee_est_a.ret_val.load(atomic::Ordering::Acquire); - last_htlc_clear_fee_b = fee_est_b.ret_val.load(atomic::Ordering::Acquire); - last_htlc_clear_fee_c = fee_est_c.ret_val.load(atomic::Ordering::Acquire); + last_htlc_clear_fee_a = + nodes[0].fee_estimator.ret_val.load(atomic::Ordering::Acquire); + last_htlc_clear_fee_b = + nodes[1].fee_estimator.ret_val.load(atomic::Ordering::Acquire); + last_htlc_clear_fee_c = + nodes[2].fee_estimator.ret_val.load(atomic::Ordering::Acquire); }, _ => test_return!(), } - if nodes[0].get_and_clear_needs_persistence() == true { + if nodes[0].get_and_clear_needs_persistence() { node_a_ser = nodes[0].encode(); } - if nodes[1].get_and_clear_needs_persistence() == true { + if nodes[1].get_and_clear_needs_persistence() { node_b_ser = nodes[1].encode(); } - if nodes[2].get_and_clear_needs_persistence() == true { + if nodes[2].get_and_clear_needs_persistence() { node_c_ser = nodes[2].encode(); } } From 69cda6ba0aa4ea6a9abfb97b4918f505b34bc7f3 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Tue, 21 Apr 2026 18:00:24 +0200 Subject: [PATCH 375/627] Extract chanmon harness nodes Centralize creation of the three chanmon harness nodes. The fuzzer now initializes the node array through one path, which reduces duplicated setup before the event and payment helpers are split out. --- fuzz/src/chanmon_consistency.rs | 155 +++++++++++++------------------- 1 file changed, 63 insertions(+), 92 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index c149c861506..b083aacb1e2 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1367,12 +1367,6 @@ pub fn do_test(data: &[u8], out: Out) { chan_type, ), ]; - let mut monitor_a = Arc::clone(&nodes[0].monitor); - let mut monitor_b = Arc::clone(&nodes[1].monitor); - let mut monitor_c = Arc::clone(&nodes[2].monitor); - let keys_manager_a = Arc::clone(&nodes[0].keys_manager); - let keys_manager_b = Arc::clone(&nodes[1].keys_manager); - let keys_manager_c = Arc::clone(&nodes[2].keys_manager); // Connect peers first, then create channels connect_peers(&nodes[0], &nodes[1]); @@ -1467,25 +1461,18 @@ pub fn do_test(data: &[u8], out: Out) { }}; } - let reload_node = |ser: &Vec, - node_id: u8, - old_monitors: &TestChainMonitor, - mut use_old_mons, - keys: &Arc, - fee_estimator: &Arc, - broadcaster: Arc| { - let keys_manager = Arc::clone(keys); + let reload_node = |ser: &Vec, node_id: u8, old_node: &HarnessNode<'_>, mut use_old_mons| { let (logger_for_monitor, logger) = HarnessNode::build_loggers(node_id, &out); let chain_monitor = HarnessNode::build_chain_monitor( - &broadcaster, - fee_estimator, - &keys_manager, + &old_node.broadcaster, + &old_node.fee_estimator, + &old_node.keys_manager, logger_for_monitor, ChannelMonitorUpdateStatus::Completed, ); let mut monitors = new_hash_map(); - let mut old_monitors = old_monitors.latest_monitors.lock().unwrap(); + let mut old_monitors = old_node.monitor.latest_monitors.lock().unwrap(); for (channel_id, mut prev_state) in old_monitors.drain() { let (mon_id, serialized_mon) = if use_old_mons % 3 == 0 { // Reload with the oldest `ChannelMonitor` (the one that we already told @@ -1505,7 +1492,7 @@ pub fn do_test(data: &[u8], out: Out) { use_old_mons /= 3; let mon = <(BlockLocator, ChannelMonitor)>::read( &mut &serialized_mon[..], - (&*keys_manager, &*keys_manager), + (&*old_node.keys_manager, &*old_node.keys_manager), ) .expect("Failed to read monitor"); monitors.insert(channel_id, mon.1); @@ -1524,12 +1511,12 @@ pub fn do_test(data: &[u8], out: Out) { } let read_args = ChannelManagerReadArgs { - entropy_source: Arc::clone(&keys_manager), - node_signer: Arc::clone(&keys_manager), - signer_provider: Arc::clone(&keys_manager), - fee_estimator: Arc::clone(fee_estimator), + entropy_source: Arc::clone(&old_node.keys_manager), + node_signer: Arc::clone(&old_node.keys_manager), + signer_provider: Arc::clone(&old_node.keys_manager), + fee_estimator: Arc::clone(&old_node.fee_estimator), chain_monitor: chain_monitor.clone(), - tx_broadcaster: broadcaster, + tx_broadcaster: Arc::clone(&old_node.broadcaster), router: &router, message_router: &router, logger: Arc::clone(&logger), @@ -2316,22 +2303,22 @@ pub fn do_test(data: &[u8], out: Out) { 0x08 => { for id in &chan_ab_ids { - complete_all_monitor_updates(&monitor_a, id); + complete_all_monitor_updates(&nodes[0].monitor, id); } }, 0x09 => { for id in &chan_ab_ids { - complete_all_monitor_updates(&monitor_b, id); + complete_all_monitor_updates(&nodes[1].monitor, id); } }, 0x0a => { for id in &chan_bc_ids { - complete_all_monitor_updates(&monitor_b, id); + complete_all_monitor_updates(&nodes[1].monitor, id); } }, 0x0b => { for id in &chan_bc_ids { - complete_all_monitor_updates(&monitor_c, id); + complete_all_monitor_updates(&nodes[2].monitor, id); } }, @@ -2634,17 +2621,9 @@ pub fn do_test(data: &[u8], out: Out) { ab_events.clear(); ba_events.clear(); } - let (new_node_a, new_monitor_a, new_logger_a) = reload_node( - &node_a_ser, - 0, - &monitor_a, - v, - &keys_manager_a, - &fee_est_a, - Arc::clone(&broadcast_a), - ); + let (new_node_a, new_monitor_a, new_logger_a) = + reload_node(&node_a_ser, 0, &nodes[0], v); nodes[0].node = new_node_a; - monitor_a = Arc::clone(&new_monitor_a); nodes[0].monitor = new_monitor_a; nodes[0].logger = new_logger_a; }, @@ -2665,17 +2644,9 @@ pub fn do_test(data: &[u8], out: Out) { bc_events.clear(); cb_events.clear(); } - let (new_node_b, new_monitor_b, new_logger_b) = reload_node( - &node_b_ser, - 1, - &monitor_b, - v, - &keys_manager_b, - &fee_est_b, - Arc::clone(&broadcast_b), - ); + let (new_node_b, new_monitor_b, new_logger_b) = + reload_node(&node_b_ser, 1, &nodes[1], v); nodes[1].node = new_node_b; - monitor_b = Arc::clone(&new_monitor_b); nodes[1].monitor = new_monitor_b; nodes[1].logger = new_logger_b; }, @@ -2692,140 +2663,140 @@ pub fn do_test(data: &[u8], out: Out) { bc_events.clear(); cb_events.clear(); } - let (new_node_c, new_monitor_c, new_logger_c) = reload_node( - &node_c_ser, - 2, - &monitor_c, - v, - &keys_manager_c, - &fee_est_c, - Arc::clone(&broadcast_c), - ); + let (new_node_c, new_monitor_c, new_logger_c) = + reload_node(&node_c_ser, 2, &nodes[2], v); nodes[2].node = new_node_c; - monitor_c = Arc::clone(&new_monitor_c); nodes[2].monitor = new_monitor_c; nodes[2].logger = new_logger_c; }, - 0xc0 => keys_manager_a.disable_supported_ops_for_all_signers(), - 0xc1 => keys_manager_b.disable_supported_ops_for_all_signers(), - 0xc2 => keys_manager_c.disable_supported_ops_for_all_signers(), + 0xc0 => nodes[0].keys_manager.disable_supported_ops_for_all_signers(), + 0xc1 => nodes[1].keys_manager.disable_supported_ops_for_all_signers(), + 0xc2 => nodes[2].keys_manager.disable_supported_ops_for_all_signers(), 0xc3 => { - keys_manager_a.enable_op_for_all_signers(SignerOp::SignCounterpartyCommitment); + nodes[0] + .keys_manager + .enable_op_for_all_signers(SignerOp::SignCounterpartyCommitment); nodes[0].signer_unblocked(None); }, 0xc4 => { - keys_manager_b.enable_op_for_all_signers(SignerOp::SignCounterpartyCommitment); + nodes[1] + .keys_manager + .enable_op_for_all_signers(SignerOp::SignCounterpartyCommitment); let filter = Some((nodes[0].get_our_node_id(), chan_a_id)); nodes[1].signer_unblocked(filter); }, 0xc5 => { - keys_manager_b.enable_op_for_all_signers(SignerOp::SignCounterpartyCommitment); + nodes[1] + .keys_manager + .enable_op_for_all_signers(SignerOp::SignCounterpartyCommitment); let filter = Some((nodes[2].get_our_node_id(), chan_b_id)); nodes[1].signer_unblocked(filter); }, 0xc6 => { - keys_manager_c.enable_op_for_all_signers(SignerOp::SignCounterpartyCommitment); + nodes[2] + .keys_manager + .enable_op_for_all_signers(SignerOp::SignCounterpartyCommitment); nodes[2].signer_unblocked(None); }, 0xc7 => { - keys_manager_a.enable_op_for_all_signers(SignerOp::GetPerCommitmentPoint); + nodes[0].keys_manager.enable_op_for_all_signers(SignerOp::GetPerCommitmentPoint); nodes[0].signer_unblocked(None); }, 0xc8 => { - keys_manager_b.enable_op_for_all_signers(SignerOp::GetPerCommitmentPoint); + nodes[1].keys_manager.enable_op_for_all_signers(SignerOp::GetPerCommitmentPoint); let filter = Some((nodes[0].get_our_node_id(), chan_a_id)); nodes[1].signer_unblocked(filter); }, 0xc9 => { - keys_manager_b.enable_op_for_all_signers(SignerOp::GetPerCommitmentPoint); + nodes[1].keys_manager.enable_op_for_all_signers(SignerOp::GetPerCommitmentPoint); let filter = Some((nodes[2].get_our_node_id(), chan_b_id)); nodes[1].signer_unblocked(filter); }, 0xca => { - keys_manager_c.enable_op_for_all_signers(SignerOp::GetPerCommitmentPoint); + nodes[2].keys_manager.enable_op_for_all_signers(SignerOp::GetPerCommitmentPoint); nodes[2].signer_unblocked(None); }, 0xcb => { - keys_manager_a.enable_op_for_all_signers(SignerOp::ReleaseCommitmentSecret); + nodes[0].keys_manager.enable_op_for_all_signers(SignerOp::ReleaseCommitmentSecret); nodes[0].signer_unblocked(None); }, 0xcc => { - keys_manager_b.enable_op_for_all_signers(SignerOp::ReleaseCommitmentSecret); + nodes[1].keys_manager.enable_op_for_all_signers(SignerOp::ReleaseCommitmentSecret); let filter = Some((nodes[0].get_our_node_id(), chan_a_id)); nodes[1].signer_unblocked(filter); }, 0xcd => { - keys_manager_b.enable_op_for_all_signers(SignerOp::ReleaseCommitmentSecret); + nodes[1].keys_manager.enable_op_for_all_signers(SignerOp::ReleaseCommitmentSecret); let filter = Some((nodes[2].get_our_node_id(), chan_b_id)); nodes[1].signer_unblocked(filter); }, 0xce => { - keys_manager_c.enable_op_for_all_signers(SignerOp::ReleaseCommitmentSecret); + nodes[2].keys_manager.enable_op_for_all_signers(SignerOp::ReleaseCommitmentSecret); nodes[2].signer_unblocked(None); }, 0xf0 => { for id in &chan_ab_ids { - complete_monitor_update(&monitor_a, id, &complete_first); + complete_monitor_update(&nodes[0].monitor, id, &complete_first); } }, 0xf1 => { for id in &chan_ab_ids { - complete_monitor_update(&monitor_a, id, &complete_second); + complete_monitor_update(&nodes[0].monitor, id, &complete_second); } }, 0xf2 => { for id in &chan_ab_ids { - complete_monitor_update(&monitor_a, id, &Vec::pop); + complete_monitor_update(&nodes[0].monitor, id, &Vec::pop); } }, 0xf4 => { for id in &chan_ab_ids { - complete_monitor_update(&monitor_b, id, &complete_first); + complete_monitor_update(&nodes[1].monitor, id, &complete_first); } }, 0xf5 => { for id in &chan_ab_ids { - complete_monitor_update(&monitor_b, id, &complete_second); + complete_monitor_update(&nodes[1].monitor, id, &complete_second); } }, 0xf6 => { for id in &chan_ab_ids { - complete_monitor_update(&monitor_b, id, &Vec::pop); + complete_monitor_update(&nodes[1].monitor, id, &Vec::pop); } }, 0xf8 => { for id in &chan_bc_ids { - complete_monitor_update(&monitor_b, id, &complete_first); + complete_monitor_update(&nodes[1].monitor, id, &complete_first); } }, 0xf9 => { for id in &chan_bc_ids { - complete_monitor_update(&monitor_b, id, &complete_second); + complete_monitor_update(&nodes[1].monitor, id, &complete_second); } }, 0xfa => { for id in &chan_bc_ids { - complete_monitor_update(&monitor_b, id, &Vec::pop); + complete_monitor_update(&nodes[1].monitor, id, &Vec::pop); } }, 0xfc => { for id in &chan_bc_ids { - complete_monitor_update(&monitor_c, id, &complete_first); + complete_monitor_update(&nodes[2].monitor, id, &complete_first); } }, 0xfd => { for id in &chan_bc_ids { - complete_monitor_update(&monitor_c, id, &complete_second); + complete_monitor_update(&nodes[2].monitor, id, &complete_second); } }, 0xfe => { for id in &chan_bc_ids { - complete_monitor_update(&monitor_c, id, &Vec::pop); + complete_monitor_update(&nodes[2].monitor, id, &Vec::pop); } }, @@ -2866,9 +2837,9 @@ pub fn do_test(data: &[u8], out: Out) { } for op in SUPPORTED_SIGNER_OPS { - keys_manager_a.enable_op_for_all_signers(op); - keys_manager_b.enable_op_for_all_signers(op); - keys_manager_c.enable_op_for_all_signers(op); + nodes[0].keys_manager.enable_op_for_all_signers(op); + nodes[1].keys_manager.enable_op_for_all_signers(op); + nodes[2].keys_manager.enable_op_for_all_signers(op); } nodes[0].signer_unblocked(None); nodes[1].signer_unblocked(None); @@ -2883,12 +2854,12 @@ pub fn do_test(data: &[u8], out: Out) { } // Next, make sure no monitor updates are pending for id in &chan_ab_ids { - complete_all_monitor_updates(&monitor_a, id); - complete_all_monitor_updates(&monitor_b, id); + complete_all_monitor_updates(&nodes[0].monitor, id); + complete_all_monitor_updates(&nodes[1].monitor, id); } for id in &chan_bc_ids { - complete_all_monitor_updates(&monitor_b, id); - complete_all_monitor_updates(&monitor_c, id); + complete_all_monitor_updates(&nodes[1].monitor, id); + complete_all_monitor_updates(&nodes[2].monitor, id); } // Then, make sure any current forwards make their way to their destination if process_msg_events!(0, false, ProcessMessages::AllMessages) { From 424494190a5b326920d069b00fbdc24e7fbafcea Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Tue, 21 Apr 2026 17:24:01 +0200 Subject: [PATCH 376/627] Extract chanmon harness node lifecycle Move persistence, reload, and chain sync state onto each harness node. Keeping serialized managers and heights with the node makes restarts and block updates easier to reason about. --- fuzz/src/chanmon_consistency.rs | 337 ++++++++++++++++---------------- 1 file changed, 167 insertions(+), 170 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index b083aacb1e2..a0b30af66c2 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -945,6 +945,7 @@ enum ChanType { } struct HarnessNode<'a> { + node_id: u8, node: ChanMan<'a>, monitor: Arc, keys_manager: Arc, @@ -952,6 +953,10 @@ struct HarnessNode<'a> { broadcaster: Arc, fee_estimator: Arc, wallet: TestWalletSource, + persistence_style: ChannelMonitorUpdateStatus, + serialized_manager: Vec, + height: u32, + last_htlc_clear_fee: u32, } impl<'a> std::ops::Deref for HarnessNode<'a> { @@ -1026,7 +1031,24 @@ impl<'a> HarnessNode<'a> { params, best_block_timestamp, ); - Self { node, monitor, keys_manager, logger, broadcaster, fee_estimator, wallet } + Self { + node_id, + node, + monitor, + keys_manager, + logger, + broadcaster, + fee_estimator, + wallet, + persistence_style, + serialized_manager: Vec::new(), + height: 0, + last_htlc_clear_fee: 253, + } + } + + fn set_persistence_style(&mut self, style: ChannelMonitorUpdateStatus) { + self.persistence_style = style; } fn complete_all_pending_monitor_updates(&self) { @@ -1040,6 +1062,94 @@ impl<'a> HarnessNode<'a> { } } } + + fn refresh_serialized_manager(&mut self) { + if self.node.get_and_clear_needs_persistence() { + self.serialized_manager = self.node.encode(); + } + } + + fn reload( + &mut self, use_old_mons: u8, out: &Out, router: &'a FuzzRouter, chan_type: ChanType, + ) { + let (logger_for_monitor, logger) = Self::build_loggers(self.node_id, out); + let chain_monitor = Self::build_chain_monitor( + &self.broadcaster, + &self.fee_estimator, + &self.keys_manager, + logger_for_monitor, + ChannelMonitorUpdateStatus::Completed, + ); + + let mut monitors = new_hash_map(); + let mut use_old_mons = use_old_mons; + { + let mut old_monitors = self.monitor.latest_monitors.lock().unwrap(); + for (channel_id, mut prev_state) in old_monitors.drain() { + let (mon_id, serialized_mon) = if use_old_mons % 3 == 0 { + // Reload with the oldest `ChannelMonitor` (the one that we already told + // `ChannelManager` we finished persisting). + (prev_state.persisted_monitor_id, prev_state.persisted_monitor) + } else if use_old_mons % 3 == 1 { + // Reload with the second-oldest `ChannelMonitor`. + let old_mon = (prev_state.persisted_monitor_id, prev_state.persisted_monitor); + prev_state.pending_monitors.drain(..).next().unwrap_or(old_mon) + } else { + // Reload with the newest `ChannelMonitor`. + let old_mon = (prev_state.persisted_monitor_id, prev_state.persisted_monitor); + prev_state.pending_monitors.pop().unwrap_or(old_mon) + }; + // Use a different value of `use_old_mons` if we have another monitor + // (only for node B) by shifting `use_old_mons` one in base-3. + use_old_mons /= 3; + let mon = <(BlockLocator, ChannelMonitor)>::read( + &mut &serialized_mon[..], + (&*self.keys_manager, &*self.keys_manager), + ) + .expect("Failed to read monitor"); + monitors.insert(channel_id, mon.1); + // Update the latest `ChannelMonitor` state to match what we just told LDK. + prev_state.persisted_monitor = serialized_mon; + prev_state.persisted_monitor_id = mon_id; + // Wipe any `ChannelMonitor`s which we never told LDK we finished persisting, + // considering them discarded. LDK should replay these for us as they're stored in + // the `ChannelManager`. + prev_state.pending_monitors.clear(); + chain_monitor.latest_monitors.lock().unwrap().insert(channel_id, prev_state); + } + } + let mut monitor_refs = new_hash_map(); + for (channel_id, monitor) in monitors.iter() { + monitor_refs.insert(*channel_id, monitor); + } + + let read_args = ChannelManagerReadArgs { + entropy_source: Arc::clone(&self.keys_manager), + node_signer: Arc::clone(&self.keys_manager), + signer_provider: Arc::clone(&self.keys_manager), + fee_estimator: Arc::clone(&self.fee_estimator), + chain_monitor: Arc::clone(&chain_monitor), + tx_broadcaster: Arc::clone(&self.broadcaster), + router, + message_router: router, + logger: Arc::clone(&logger), + config: build_node_config(chan_type), + channel_monitors: monitor_refs, + }; + + let manager = <(BlockLocator, ChanMan)>::read(&mut &self.serialized_manager[..], read_args) + .expect("Failed to read manager"); + for (channel_id, mon) in monitors.drain() { + assert_eq!( + chain_monitor.chain_monitor.watch_channel(channel_id, mon), + Ok(ChannelMonitorUpdateStatus::Completed) + ); + } + *chain_monitor.persister.update_ret.lock().unwrap() = self.persistence_style; + self.node = manager.1; + self.monitor = chain_monitor; + self.logger = logger; + } } fn build_node_config(chan_type: ChanType) -> UserConfig { @@ -1280,28 +1390,25 @@ pub fn do_test(data: &[u8], out: Out) { 1 => ChanType::KeyedAnchors, _ => ChanType::ZeroFeeCommitments, }; - let mon_style = [ - RefCell::new(if config_byte & 0b01 != 0 { + let persistence_styles = [ + if config_byte & 0b01 != 0 { ChannelMonitorUpdateStatus::InProgress } else { ChannelMonitorUpdateStatus::Completed - }), - RefCell::new(if config_byte & 0b10 != 0 { + }, + if config_byte & 0b10 != 0 { ChannelMonitorUpdateStatus::InProgress } else { ChannelMonitorUpdateStatus::Completed - }), - RefCell::new(if config_byte & 0b100 != 0 { + }, + if config_byte & 0b100 != 0 { ChannelMonitorUpdateStatus::InProgress } else { ChannelMonitorUpdateStatus::Completed - }), + }, ]; let mut chain_state = ChainState::new(); - let mut node_height_a: u32 = 0; - let mut node_height_b: u32 = 0; - let mut node_height_c: u32 = 0; let wallet_a = TestWalletSource::new(SecretKey::from_slice(&[1; 32]).unwrap()); let wallet_b = TestWalletSource::new(SecretKey::from_slice(&[2; 32]).unwrap()); let wallet_c = TestWalletSource::new(SecretKey::from_slice(&[3; 32]).unwrap()); @@ -1324,11 +1431,8 @@ pub fn do_test(data: &[u8], out: Out) { } let fee_est_a = Arc::new(FuzzEstimator { ret_val: atomic::AtomicU32::new(253) }); - let mut last_htlc_clear_fee_a = 253; let fee_est_b = Arc::new(FuzzEstimator { ret_val: atomic::AtomicU32::new(253) }); - let mut last_htlc_clear_fee_b = 253; let fee_est_c = Arc::new(FuzzEstimator { ret_val: atomic::AtomicU32::new(253) }); - let mut last_htlc_clear_fee_c = 253; let broadcast_a = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); let broadcast_b = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); let broadcast_c = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); @@ -1341,7 +1445,7 @@ pub fn do_test(data: &[u8], out: Out) { wallet_a, Arc::clone(&fee_est_a), Arc::clone(&broadcast_a), - mon_style[0].borrow().clone(), + persistence_styles[0], &out, &router, chan_type, @@ -1351,7 +1455,7 @@ pub fn do_test(data: &[u8], out: Out) { wallet_b, Arc::clone(&fee_est_b), Arc::clone(&broadcast_b), - mon_style[1].borrow().clone(), + persistence_styles[1], &out, &router, chan_type, @@ -1361,7 +1465,7 @@ pub fn do_test(data: &[u8], out: Out) { wallet_c, Arc::clone(&fee_est_c), Arc::clone(&broadcast_c), - mon_style[2].borrow().clone(), + persistence_styles[2], &out, &router, chan_type, @@ -1393,30 +1497,28 @@ pub fn do_test(data: &[u8], out: Out) { nodes[1].broadcaster.txn_broadcasted.borrow_mut().clear(); nodes[2].broadcaster.txn_broadcasted.borrow_mut().clear(); - let sync_with_chain_state = |chain_state: &ChainState, - node: &HarnessNode<'_>, - node_height: &mut u32, - num_blocks: Option| { - let target_height = if let Some(num_blocks) = num_blocks { - std::cmp::min(*node_height + num_blocks, chain_state.tip_height()) - } else { - chain_state.tip_height() - }; - while *node_height < target_height { - *node_height += 1; - let (header, txn) = chain_state.block_at(*node_height); - let txdata: Vec<_> = txn.iter().enumerate().map(|(i, tx)| (i + 1, tx)).collect(); - if !txdata.is_empty() { - node.transactions_confirmed(header, &txdata, *node_height); + let sync_with_chain_state = + |node: &mut HarnessNode<'_>, chain_state: &ChainState, num_blocks: Option| { + let target_height = if let Some(num_blocks) = num_blocks { + std::cmp::min(node.height + num_blocks, chain_state.tip_height()) + } else { + chain_state.tip_height() + }; + while node.height < target_height { + node.height += 1; + let (header, txn) = chain_state.block_at(node.height); + let txdata: Vec<_> = txn.iter().enumerate().map(|(i, tx)| (i + 1, tx)).collect(); + if !txdata.is_empty() { + node.transactions_confirmed(header, &txdata, node.height); + } + node.best_block_updated(header, node.height); } - node.best_block_updated(header, *node_height); - } - }; + }; // Sync all nodes to tip to lock the funding. - sync_with_chain_state(&chain_state, &nodes[0], &mut node_height_a, None); - sync_with_chain_state(&chain_state, &nodes[1], &mut node_height_b, None); - sync_with_chain_state(&chain_state, &nodes[2], &mut node_height_c, None); + sync_with_chain_state(&mut nodes[0], &chain_state, None); + sync_with_chain_state(&mut nodes[1], &chain_state, None); + sync_with_chain_state(&mut nodes[2], &chain_state, None); lock_fundings(&nodes); @@ -1443,9 +1545,9 @@ pub fn do_test(data: &[u8], out: Out) { let mut bc_events = Vec::new(); let mut cb_events = Vec::new(); - let mut node_a_ser = nodes[0].encode(); - let mut node_b_ser = nodes[1].encode(); - let mut node_c_ser = nodes[2].encode(); + for node in &mut nodes { + node.serialized_manager = node.encode(); + } let pending_payments = RefCell::new([Vec::new(), Vec::new(), Vec::new()]); let resolved_payments: RefCell<[HashMap>; 3]> = @@ -1461,82 +1563,7 @@ pub fn do_test(data: &[u8], out: Out) { }}; } - let reload_node = |ser: &Vec, node_id: u8, old_node: &HarnessNode<'_>, mut use_old_mons| { - let (logger_for_monitor, logger) = HarnessNode::build_loggers(node_id, &out); - let chain_monitor = HarnessNode::build_chain_monitor( - &old_node.broadcaster, - &old_node.fee_estimator, - &old_node.keys_manager, - logger_for_monitor, - ChannelMonitorUpdateStatus::Completed, - ); - - let mut monitors = new_hash_map(); - let mut old_monitors = old_node.monitor.latest_monitors.lock().unwrap(); - for (channel_id, mut prev_state) in old_monitors.drain() { - let (mon_id, serialized_mon) = if use_old_mons % 3 == 0 { - // Reload with the oldest `ChannelMonitor` (the one that we already told - // `ChannelManager` we finished persisting). - (prev_state.persisted_monitor_id, prev_state.persisted_monitor) - } else if use_old_mons % 3 == 1 { - // Reload with the second-oldest `ChannelMonitor` - let old_mon = (prev_state.persisted_monitor_id, prev_state.persisted_monitor); - prev_state.pending_monitors.drain(..).next().unwrap_or(old_mon) - } else { - // Reload with the newest `ChannelMonitor` - let old_mon = (prev_state.persisted_monitor_id, prev_state.persisted_monitor); - prev_state.pending_monitors.pop().unwrap_or(old_mon) - }; - // Use a different value of `use_old_mons` if we have another monitor (only for node B) - // by shifting `use_old_mons` one in base-3. - use_old_mons /= 3; - let mon = <(BlockLocator, ChannelMonitor)>::read( - &mut &serialized_mon[..], - (&*old_node.keys_manager, &*old_node.keys_manager), - ) - .expect("Failed to read monitor"); - monitors.insert(channel_id, mon.1); - // Update the latest `ChannelMonitor` state to match what we just told LDK. - prev_state.persisted_monitor = serialized_mon; - prev_state.persisted_monitor_id = mon_id; - // Wipe any `ChannelMonitor`s which we never told LDK we finished persisting, - // considering them discarded. LDK should replay these for us as they're stored in - // the `ChannelManager`. - prev_state.pending_monitors.clear(); - chain_monitor.latest_monitors.lock().unwrap().insert(channel_id, prev_state); - } - let mut monitor_refs = new_hash_map(); - for (channel_id, monitor) in monitors.iter() { - monitor_refs.insert(*channel_id, monitor); - } - - let read_args = ChannelManagerReadArgs { - entropy_source: Arc::clone(&old_node.keys_manager), - node_signer: Arc::clone(&old_node.keys_manager), - signer_provider: Arc::clone(&old_node.keys_manager), - fee_estimator: Arc::clone(&old_node.fee_estimator), - chain_monitor: chain_monitor.clone(), - tx_broadcaster: Arc::clone(&old_node.broadcaster), - router: &router, - message_router: &router, - logger: Arc::clone(&logger), - config: build_node_config(chan_type), - channel_monitors: monitor_refs, - }; - - let manager = <(BlockLocator, ChanMan)>::read(&mut &ser[..], read_args) - .expect("Failed to read manager"); - for (channel_id, mon) in monitors.drain() { - assert_eq!( - chain_monitor.chain_monitor.watch_channel(channel_id, mon), - Ok(ChannelMonitorUpdateStatus::Completed) - ); - } - *chain_monitor.persister.update_ret.lock().unwrap() = *mon_style[node_id as usize].borrow(); - (manager.1, chain_monitor, logger) - }; - - let mut read_pos = 1; // First byte was consumed for initial config (mon_style + chan_type) + let mut read_pos = 1; // First byte was consumed for initial config (persistence styles + chan_type) macro_rules! get_slice { ($len: expr) => {{ let slice_len = $len as usize; @@ -2282,24 +2309,12 @@ pub fn do_test(data: &[u8], out: Out) { // In general, we keep related message groups close together in binary form, allowing // bit-twiddling mutations to have similar effects. This is probably overkill, but no // harm in doing so. - 0x00 => { - *mon_style[0].borrow_mut() = ChannelMonitorUpdateStatus::InProgress; - }, - 0x01 => { - *mon_style[1].borrow_mut() = ChannelMonitorUpdateStatus::InProgress; - }, - 0x02 => { - *mon_style[2].borrow_mut() = ChannelMonitorUpdateStatus::InProgress; - }, - 0x04 => { - *mon_style[0].borrow_mut() = ChannelMonitorUpdateStatus::Completed; - }, - 0x05 => { - *mon_style[1].borrow_mut() = ChannelMonitorUpdateStatus::Completed; - }, - 0x06 => { - *mon_style[2].borrow_mut() = ChannelMonitorUpdateStatus::Completed; - }, + 0x00 => nodes[0].set_persistence_style(ChannelMonitorUpdateStatus::InProgress), + 0x01 => nodes[1].set_persistence_style(ChannelMonitorUpdateStatus::InProgress), + 0x02 => nodes[2].set_persistence_style(ChannelMonitorUpdateStatus::InProgress), + 0x04 => nodes[0].set_persistence_style(ChannelMonitorUpdateStatus::Completed), + 0x05 => nodes[1].set_persistence_style(ChannelMonitorUpdateStatus::Completed), + 0x06 => nodes[2].set_persistence_style(ChannelMonitorUpdateStatus::Completed), 0x08 => { for id in &chan_ab_ids { @@ -2475,7 +2490,7 @@ pub fn do_test(data: &[u8], out: Out) { }, 0x80 => { - let mut max_feerate = last_htlc_clear_fee_a; + let mut max_feerate = nodes[0].last_htlc_clear_fee; if matches!(chan_type, ChanType::Legacy) { max_feerate *= FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32; } @@ -2491,7 +2506,7 @@ pub fn do_test(data: &[u8], out: Out) { nodes[0].timer_tick_occurred(); }, 0x84 => { - let mut max_feerate = last_htlc_clear_fee_b; + let mut max_feerate = nodes[1].last_htlc_clear_fee; if matches!(chan_type, ChanType::Legacy) { max_feerate *= FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32; } @@ -2507,7 +2522,7 @@ pub fn do_test(data: &[u8], out: Out) { nodes[1].timer_tick_occurred(); }, 0x88 => { - let mut max_feerate = last_htlc_clear_fee_c; + let mut max_feerate = nodes[2].last_htlc_clear_fee; if matches!(chan_type, ChanType::Legacy) { max_feerate *= FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32; } @@ -2584,28 +2599,28 @@ pub fn do_test(data: &[u8], out: Out) { // Sync node by 1 block to cover confirmation of a transaction. 0xa8 => { chain_state.confirm_pending_txs(); - sync_with_chain_state(&chain_state, &nodes[0], &mut node_height_a, Some(1)); + sync_with_chain_state(&mut nodes[0], &chain_state, Some(1)); }, 0xa9 => { chain_state.confirm_pending_txs(); - sync_with_chain_state(&chain_state, &nodes[1], &mut node_height_b, Some(1)); + sync_with_chain_state(&mut nodes[1], &chain_state, Some(1)); }, 0xaa => { chain_state.confirm_pending_txs(); - sync_with_chain_state(&chain_state, &nodes[2], &mut node_height_c, Some(1)); + sync_with_chain_state(&mut nodes[2], &chain_state, Some(1)); }, // Sync node to chain tip to cover confirmation of a transaction post-reorg-risk. 0xab => { chain_state.confirm_pending_txs(); - sync_with_chain_state(&chain_state, &nodes[0], &mut node_height_a, None); + sync_with_chain_state(&mut nodes[0], &chain_state, None); }, 0xac => { chain_state.confirm_pending_txs(); - sync_with_chain_state(&chain_state, &nodes[1], &mut node_height_b, None); + sync_with_chain_state(&mut nodes[1], &chain_state, None); }, 0xad => { chain_state.confirm_pending_txs(); - sync_with_chain_state(&chain_state, &nodes[2], &mut node_height_c, None); + sync_with_chain_state(&mut nodes[2], &chain_state, None); }, 0xb0 | 0xb1 | 0xb2 => { @@ -2621,11 +2636,7 @@ pub fn do_test(data: &[u8], out: Out) { ab_events.clear(); ba_events.clear(); } - let (new_node_a, new_monitor_a, new_logger_a) = - reload_node(&node_a_ser, 0, &nodes[0], v); - nodes[0].node = new_node_a; - nodes[0].monitor = new_monitor_a; - nodes[0].logger = new_logger_a; + nodes[0].reload(v, &out, &router, chan_type); }, 0xb3..=0xbb => { // Restart node B, picking among the in-flight `ChannelMonitor`s to use based on @@ -2644,11 +2655,7 @@ pub fn do_test(data: &[u8], out: Out) { bc_events.clear(); cb_events.clear(); } - let (new_node_b, new_monitor_b, new_logger_b) = - reload_node(&node_b_ser, 1, &nodes[1], v); - nodes[1].node = new_node_b; - nodes[1].monitor = new_monitor_b; - nodes[1].logger = new_logger_b; + nodes[1].reload(v, &out, &router, chan_type); }, 0xbc | 0xbd | 0xbe => { // Restart node C, picking among the in-flight `ChannelMonitor`s to use based on @@ -2663,11 +2670,7 @@ pub fn do_test(data: &[u8], out: Out) { bc_events.clear(); cb_events.clear(); } - let (new_node_c, new_monitor_c, new_logger_c) = - reload_node(&node_c_ser, 2, &nodes[2], v); - nodes[2].node = new_node_c; - nodes[2].monitor = new_monitor_c; - nodes[2].logger = new_logger_c; + nodes[2].reload(v, &out, &router, chan_type); }, 0xc0 => nodes[0].keys_manager.disable_supported_ops_for_all_signers(), @@ -2950,24 +2953,18 @@ pub fn do_test(data: &[u8], out: Out) { ); } - last_htlc_clear_fee_a = + nodes[0].last_htlc_clear_fee = nodes[0].fee_estimator.ret_val.load(atomic::Ordering::Acquire); - last_htlc_clear_fee_b = + nodes[1].last_htlc_clear_fee = nodes[1].fee_estimator.ret_val.load(atomic::Ordering::Acquire); - last_htlc_clear_fee_c = + nodes[2].last_htlc_clear_fee = nodes[2].fee_estimator.ret_val.load(atomic::Ordering::Acquire); }, _ => test_return!(), } - if nodes[0].get_and_clear_needs_persistence() { - node_a_ser = nodes[0].encode(); - } - if nodes[1].get_and_clear_needs_persistence() { - node_b_ser = nodes[1].encode(); - } - if nodes[2].get_and_clear_needs_persistence() { - node_c_ser = nodes[2].encode(); + for node in &mut nodes { + node.refresh_serialized_manager(); } } } From 84c40774251440a8e7c60f8c79ded8f9a5c10ce5 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Tue, 21 Apr 2026 16:32:31 +0200 Subject: [PATCH 377/627] Extract chanmon harness node operations Move the action helpers onto `HarnessNode` methods. Node-local operations now live with the state they mutate, which reduces argument threading through the fuzz loop. --- fuzz/src/chanmon_consistency.rs | 442 ++++++++++++++++---------------- 1 file changed, 217 insertions(+), 225 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index a0b30af66c2..755fb18ff10 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -56,7 +56,6 @@ use lightning::ln::channelmanager::{ TrustedChannelFeatures, }; use lightning::ln::functional_test_utils::*; -use lightning::ln::funding::{FundingContribution, FundingContributionError, FundingTemplate}; use lightning::ln::inbound_payment::ExpandedKey; use lightning::ln::msgs::{ self, BaseMessageHandler, ChannelMessageHandler, CommitmentUpdate, Init, MessageSendEvent, @@ -1051,6 +1050,22 @@ impl<'a> HarnessNode<'a> { self.persistence_style = style; } + fn complete_all_monitor_updates(&self, chan_id: &ChannelId) { + if let Some(state) = self.monitor.latest_monitors.lock().unwrap().get_mut(chan_id) { + assert!( + state.pending_monitors.windows(2).all(|pair| pair[0].0 < pair[1].0), + "updates should be sorted by id" + ); + for (id, data) in state.pending_monitors.drain(..) { + self.monitor.chain_monitor.channel_monitor_updated(*chan_id, id).unwrap(); + if id > state.persisted_monitor_id { + state.persisted_monitor_id = id; + state.persisted_monitor = data; + } + } + } + } + fn complete_all_pending_monitor_updates(&self) { for (channel_id, state) in self.monitor.latest_monitors.lock().unwrap().iter_mut() { for (id, data) in state.pending_monitors.drain(..) { @@ -1063,12 +1078,160 @@ impl<'a> HarnessNode<'a> { } } + fn complete_monitor_update(&self, chan_id: &ChannelId, selector: MonitorUpdateSelector) { + if let Some(state) = self.monitor.latest_monitors.lock().unwrap().get_mut(chan_id) { + assert!( + state.pending_monitors.windows(2).all(|pair| pair[0].0 < pair[1].0), + "updates should be sorted by id" + ); + let update = match selector { + MonitorUpdateSelector::First => { + if state.pending_monitors.is_empty() { + None + } else { + Some(state.pending_monitors.remove(0)) + } + }, + MonitorUpdateSelector::Second => { + if state.pending_monitors.len() > 1 { + Some(state.pending_monitors.remove(1)) + } else { + None + } + }, + MonitorUpdateSelector::Last => state.pending_monitors.pop(), + }; + if let Some((id, data)) = update { + self.monitor.chain_monitor.channel_monitor_updated(*chan_id, id).unwrap(); + if id > state.persisted_monitor_id { + state.persisted_monitor_id = id; + state.persisted_monitor = data; + } + } + } + } + + fn sync_with_chain_state(&mut self, chain_state: &ChainState, num_blocks: Option) { + let target_height = if let Some(num_blocks) = num_blocks { + std::cmp::min(self.height + num_blocks, chain_state.tip_height()) + } else { + chain_state.tip_height() + }; + + while self.height < target_height { + self.height += 1; + let (header, txn) = chain_state.block_at(self.height); + let txdata: Vec<_> = txn.iter().enumerate().map(|(i, tx)| (i + 1, tx)).collect(); + if !txdata.is_empty() { + self.node.transactions_confirmed(header, &txdata, self.height); + } + self.node.best_block_updated(header, self.height); + } + } + fn refresh_serialized_manager(&mut self) { if self.node.get_and_clear_needs_persistence() { self.serialized_manager = self.node.encode(); } } + fn bump_fee_estimate(&mut self, chan_type: ChanType) { + let mut max_feerate = self.last_htlc_clear_fee; + if matches!(chan_type, ChanType::Legacy) { + max_feerate *= FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32; + } + if self.fee_estimator.ret_val.fetch_add(250, atomic::Ordering::AcqRel) + 250 > max_feerate { + self.fee_estimator.ret_val.store(max_feerate, atomic::Ordering::Release); + } + self.node.timer_tick_occurred(); + } + + fn reset_fee_estimate(&self) { + self.fee_estimator.ret_val.store(253, atomic::Ordering::Release); + self.node.timer_tick_occurred(); + } + + fn current_feerate_sat_per_kw(&self) -> FeeRate { + self.fee_estimator.feerate_sat_per_kw() + } + + fn record_last_htlc_clear_fee(&mut self) { + self.last_htlc_clear_fee = self.fee_estimator.ret_val.load(atomic::Ordering::Acquire); + } + + fn splice_in(&self, counterparty_node_id: &PublicKey, channel_id: &ChannelId) { + let wallet = WalletSync::new(&self.wallet, Arc::clone(&self.logger)); + match self.node.splice_channel(channel_id, counterparty_node_id) { + Ok(funding_template) => { + let feerate = + funding_template.min_rbf_feerate().unwrap_or(self.current_feerate_sat_per_kw()); + if let Ok(contribution) = funding_template.splice_in_sync( + Amount::from_sat(10_000), + feerate, + FeeRate::MAX, + &wallet, + ) { + let _ = self.node.funding_contributed( + channel_id, + counterparty_node_id, + contribution, + None, + ); + } + }, + Err(e) => { + assert!( + matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), + "{:?}", + e + ); + }, + } + } + + fn splice_out(&self, counterparty_node_id: &PublicKey, channel_id: &ChannelId) { + // We conditionally splice out `MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS` only when the node + // has double the balance required to send a payment upon a `0xff` byte. We do this to + // ensure there's always liquidity available for a payment to succeed then. + let outbound_capacity_msat = self + .node + .list_channels() + .iter() + .find(|chan| chan.channel_id == *channel_id) + .map(|chan| chan.outbound_capacity_msat) + .unwrap(); + if outbound_capacity_msat < 20_000_000 { + return; + } + match self.node.splice_channel(channel_id, counterparty_node_id) { + Ok(funding_template) => { + let feerate = + funding_template.min_rbf_feerate().unwrap_or(self.current_feerate_sat_per_kw()); + let outputs = vec![TxOut { + value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), + script_pubkey: self.wallet.get_change_script().unwrap(), + }]; + if let Ok(contribution) = + funding_template.splice_out(outputs, feerate, FeeRate::MAX) + { + let _ = self.node.funding_contributed( + channel_id, + counterparty_node_id, + contribution, + None, + ); + } + }, + Err(e) => { + assert!( + matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), + "{:?}", + e + ); + }, + } + } + fn reload( &mut self, use_old_mons: u8, out: &Out, router: &'a FuzzRouter, chan_type: ChanType, ) { @@ -1152,6 +1315,13 @@ impl<'a> HarnessNode<'a> { } } +#[derive(Copy, Clone)] +enum MonitorUpdateSelector { + First, + Second, + Last, +} + fn build_node_config(chan_type: ChanType) -> UserConfig { let mut config = UserConfig::default(); config.channel_config.forwarding_fee_proportional_millionths = 0; @@ -1497,28 +1667,10 @@ pub fn do_test(data: &[u8], out: Out) { nodes[1].broadcaster.txn_broadcasted.borrow_mut().clear(); nodes[2].broadcaster.txn_broadcasted.borrow_mut().clear(); - let sync_with_chain_state = - |node: &mut HarnessNode<'_>, chain_state: &ChainState, num_blocks: Option| { - let target_height = if let Some(num_blocks) = num_blocks { - std::cmp::min(node.height + num_blocks, chain_state.tip_height()) - } else { - chain_state.tip_height() - }; - while node.height < target_height { - node.height += 1; - let (header, txn) = chain_state.block_at(node.height); - let txdata: Vec<_> = txn.iter().enumerate().map(|(i, tx)| (i + 1, tx)).collect(); - if !txdata.is_empty() { - node.transactions_confirmed(header, &txdata, node.height); - } - node.best_block_updated(header, node.height); - } - }; - // Sync all nodes to tip to lock the funding. - sync_with_chain_state(&mut nodes[0], &chain_state, None); - sync_with_chain_state(&mut nodes[1], &chain_state, None); - sync_with_chain_state(&mut nodes[2], &chain_state, None); + nodes[0].sync_with_chain_state(&chain_state, None); + nodes[1].sync_with_chain_state(&chain_state, None); + nodes[2].sync_with_chain_state(&chain_state, None); lock_fundings(&nodes); @@ -2095,121 +2247,6 @@ pub fn do_test(data: &[u8], out: Out) { }}; } - let complete_first = |v: &mut Vec<_>| if !v.is_empty() { Some(v.remove(0)) } else { None }; - let complete_second = |v: &mut Vec<_>| if v.len() > 1 { Some(v.remove(1)) } else { None }; - let complete_monitor_update = - |monitor: &Arc, - chan_funding, - compl_selector: &dyn Fn(&mut Vec<(u64, Vec)>) -> Option<(u64, Vec)>| { - if let Some(state) = monitor.latest_monitors.lock().unwrap().get_mut(chan_funding) { - assert!( - state.pending_monitors.windows(2).all(|pair| pair[0].0 < pair[1].0), - "updates should be sorted by id" - ); - if let Some((id, data)) = compl_selector(&mut state.pending_monitors) { - monitor.chain_monitor.channel_monitor_updated(*chan_funding, id).unwrap(); - if id > state.persisted_monitor_id { - state.persisted_monitor_id = id; - state.persisted_monitor = data; - } - } - } - }; - let complete_all_monitor_updates = |monitor: &Arc, chan_id| { - if let Some(state) = monitor.latest_monitors.lock().unwrap().get_mut(chan_id) { - assert!( - state.pending_monitors.windows(2).all(|pair| pair[0].0 < pair[1].0), - "updates should be sorted by id" - ); - for (id, data) in state.pending_monitors.drain(..) { - monitor.chain_monitor.channel_monitor_updated(*chan_id, id).unwrap(); - if id > state.persisted_monitor_id { - state.persisted_monitor_id = id; - state.persisted_monitor = data; - } - } - } - }; - - let splice_channel = - |node: &HarnessNode<'_>, - counterparty_node_id: &PublicKey, - channel_id: &ChannelId, - f: &dyn Fn( - FundingTemplate, - ) -> Result| { - match node.splice_channel(channel_id, counterparty_node_id) { - Ok(funding_template) => { - if let Ok(contribution) = f(funding_template) { - let _ = node.funding_contributed( - channel_id, - counterparty_node_id, - contribution, - None, - ); - } - }, - Err(e) => { - assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), - "{:?}", - e - ); - }, - } - }; - - let splice_in = |node: &HarnessNode<'_>, - counterparty_node_id: &PublicKey, - channel_id: &ChannelId| { - let wallet = WalletSync::new(&node.wallet, Arc::clone(&node.logger)); - let funding_feerate_sat_per_kw = node.fee_estimator.feerate_sat_per_kw(); - splice_channel( - node, - counterparty_node_id, - channel_id, - &move |funding_template: FundingTemplate| { - let feerate = - funding_template.min_rbf_feerate().unwrap_or(funding_feerate_sat_per_kw); - funding_template.splice_in_sync( - Amount::from_sat(10_000), - feerate, - FeeRate::MAX, - &wallet, - ) - }, - ); - }; - - let splice_out = |node: &HarnessNode<'_>, - counterparty_node_id: &PublicKey, - channel_id: &ChannelId| { - let outbound_capacity_msat = node - .list_channels() - .iter() - .find(|chan| chan.channel_id == *channel_id) - .map(|chan| chan.outbound_capacity_msat) - .unwrap(); - if outbound_capacity_msat < 20_000_000 { - return; - } - let funding_feerate_sat_per_kw = node.fee_estimator.feerate_sat_per_kw(); - splice_channel( - node, - counterparty_node_id, - channel_id, - &move |funding_template: FundingTemplate| { - let feerate = - funding_template.min_rbf_feerate().unwrap_or(funding_feerate_sat_per_kw); - let outputs = vec![TxOut { - value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), - script_pubkey: node.wallet.get_change_script().unwrap(), - }]; - funding_template.splice_out(outputs, feerate, FeeRate::MAX) - }, - ); - }; - let send = |source_idx: usize, dest_idx: usize, dest_chan_id, amt, payment_ctr: &mut u64| { let source = &nodes[source_idx]; @@ -2318,22 +2355,22 @@ pub fn do_test(data: &[u8], out: Out) { 0x08 => { for id in &chan_ab_ids { - complete_all_monitor_updates(&nodes[0].monitor, id); + nodes[0].complete_all_monitor_updates(id); } }, 0x09 => { for id in &chan_ab_ids { - complete_all_monitor_updates(&nodes[1].monitor, id); + nodes[1].complete_all_monitor_updates(id); } }, 0x0a => { for id in &chan_bc_ids { - complete_all_monitor_updates(&nodes[1].monitor, id); + nodes[1].complete_all_monitor_updates(id); } }, 0x0b => { for id in &chan_bc_ids { - complete_all_monitor_updates(&nodes[2].monitor, id); + nodes[2].complete_all_monitor_updates(id); } }, @@ -2489,82 +2526,40 @@ pub fn do_test(data: &[u8], out: Out) { send_mpp_direct(0, 1, &[chan_a_id, chan_a_id, chan_a_id], 1_000_000, &mut p_ctr) }, - 0x80 => { - let mut max_feerate = nodes[0].last_htlc_clear_fee; - if matches!(chan_type, ChanType::Legacy) { - max_feerate *= FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32; - } - if nodes[0].fee_estimator.ret_val.fetch_add(250, atomic::Ordering::AcqRel) + 250 - > max_feerate - { - nodes[0].fee_estimator.ret_val.store(max_feerate, atomic::Ordering::Release); - } - nodes[0].timer_tick_occurred(); - }, - 0x81 => { - nodes[0].fee_estimator.ret_val.store(253, atomic::Ordering::Release); - nodes[0].timer_tick_occurred(); - }, - 0x84 => { - let mut max_feerate = nodes[1].last_htlc_clear_fee; - if matches!(chan_type, ChanType::Legacy) { - max_feerate *= FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32; - } - if nodes[1].fee_estimator.ret_val.fetch_add(250, atomic::Ordering::AcqRel) + 250 - > max_feerate - { - nodes[1].fee_estimator.ret_val.store(max_feerate, atomic::Ordering::Release); - } - nodes[1].timer_tick_occurred(); - }, - 0x85 => { - nodes[1].fee_estimator.ret_val.store(253, atomic::Ordering::Release); - nodes[1].timer_tick_occurred(); - }, - 0x88 => { - let mut max_feerate = nodes[2].last_htlc_clear_fee; - if matches!(chan_type, ChanType::Legacy) { - max_feerate *= FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32; - } - if nodes[2].fee_estimator.ret_val.fetch_add(250, atomic::Ordering::AcqRel) + 250 - > max_feerate - { - nodes[2].fee_estimator.ret_val.store(max_feerate, atomic::Ordering::Release); - } - nodes[2].timer_tick_occurred(); - }, - 0x89 => { - nodes[2].fee_estimator.ret_val.store(253, atomic::Ordering::Release); - nodes[2].timer_tick_occurred(); - }, + 0x80 => nodes[0].bump_fee_estimate(chan_type), + 0x81 => nodes[0].reset_fee_estimate(), + 0x84 => nodes[1].bump_fee_estimate(chan_type), + 0x85 => nodes[1].reset_fee_estimate(), + 0x88 => nodes[2].bump_fee_estimate(chan_type), + 0x89 => nodes[2].reset_fee_estimate(), 0xa0 => { if !cfg!(splicing) { test_return!(); } let cp_node_id = nodes[1].get_our_node_id(); - splice_in(&nodes[0], &cp_node_id, &chan_a_id); + nodes[0].splice_in(&cp_node_id, &chan_a_id); }, 0xa1 => { if !cfg!(splicing) { test_return!(); } let cp_node_id = nodes[0].get_our_node_id(); - splice_in(&nodes[1], &cp_node_id, &chan_a_id); + nodes[1].splice_in(&cp_node_id, &chan_a_id); }, 0xa2 => { if !cfg!(splicing) { test_return!(); } let cp_node_id = nodes[2].get_our_node_id(); - splice_in(&nodes[1], &cp_node_id, &chan_b_id); + nodes[1].splice_in(&cp_node_id, &chan_b_id); }, 0xa3 => { if !cfg!(splicing) { test_return!(); } let cp_node_id = nodes[1].get_our_node_id(); - splice_in(&nodes[2], &cp_node_id, &chan_b_id); + nodes[2].splice_in(&cp_node_id, &chan_b_id); }, 0xa4 => { @@ -2572,55 +2567,55 @@ pub fn do_test(data: &[u8], out: Out) { test_return!(); } let cp_node_id = nodes[1].get_our_node_id(); - splice_out(&nodes[0], &cp_node_id, &chan_a_id); + nodes[0].splice_out(&cp_node_id, &chan_a_id); }, 0xa5 => { if !cfg!(splicing) { test_return!(); } let cp_node_id = nodes[0].get_our_node_id(); - splice_out(&nodes[1], &cp_node_id, &chan_a_id); + nodes[1].splice_out(&cp_node_id, &chan_a_id); }, 0xa6 => { if !cfg!(splicing) { test_return!(); } let cp_node_id = nodes[2].get_our_node_id(); - splice_out(&nodes[1], &cp_node_id, &chan_b_id); + nodes[1].splice_out(&cp_node_id, &chan_b_id); }, 0xa7 => { if !cfg!(splicing) { test_return!(); } let cp_node_id = nodes[1].get_our_node_id(); - splice_out(&nodes[2], &cp_node_id, &chan_b_id); + nodes[2].splice_out(&cp_node_id, &chan_b_id); }, // Sync node by 1 block to cover confirmation of a transaction. 0xa8 => { chain_state.confirm_pending_txs(); - sync_with_chain_state(&mut nodes[0], &chain_state, Some(1)); + nodes[0].sync_with_chain_state(&chain_state, Some(1)); }, 0xa9 => { chain_state.confirm_pending_txs(); - sync_with_chain_state(&mut nodes[1], &chain_state, Some(1)); + nodes[1].sync_with_chain_state(&chain_state, Some(1)); }, 0xaa => { chain_state.confirm_pending_txs(); - sync_with_chain_state(&mut nodes[2], &chain_state, Some(1)); + nodes[2].sync_with_chain_state(&chain_state, Some(1)); }, // Sync node to chain tip to cover confirmation of a transaction post-reorg-risk. 0xab => { chain_state.confirm_pending_txs(); - sync_with_chain_state(&mut nodes[0], &chain_state, None); + nodes[0].sync_with_chain_state(&chain_state, None); }, 0xac => { chain_state.confirm_pending_txs(); - sync_with_chain_state(&mut nodes[1], &chain_state, None); + nodes[1].sync_with_chain_state(&chain_state, None); }, 0xad => { chain_state.confirm_pending_txs(); - sync_with_chain_state(&mut nodes[2], &chain_state, None); + nodes[2].sync_with_chain_state(&chain_state, None); }, 0xb0 | 0xb1 | 0xb2 => { @@ -2741,65 +2736,65 @@ pub fn do_test(data: &[u8], out: Out) { 0xf0 => { for id in &chan_ab_ids { - complete_monitor_update(&nodes[0].monitor, id, &complete_first); + nodes[0].complete_monitor_update(id, MonitorUpdateSelector::First); } }, 0xf1 => { for id in &chan_ab_ids { - complete_monitor_update(&nodes[0].monitor, id, &complete_second); + nodes[0].complete_monitor_update(id, MonitorUpdateSelector::Second); } }, 0xf2 => { for id in &chan_ab_ids { - complete_monitor_update(&nodes[0].monitor, id, &Vec::pop); + nodes[0].complete_monitor_update(id, MonitorUpdateSelector::Last); } }, 0xf4 => { for id in &chan_ab_ids { - complete_monitor_update(&nodes[1].monitor, id, &complete_first); + nodes[1].complete_monitor_update(id, MonitorUpdateSelector::First); } }, 0xf5 => { for id in &chan_ab_ids { - complete_monitor_update(&nodes[1].monitor, id, &complete_second); + nodes[1].complete_monitor_update(id, MonitorUpdateSelector::Second); } }, 0xf6 => { for id in &chan_ab_ids { - complete_monitor_update(&nodes[1].monitor, id, &Vec::pop); + nodes[1].complete_monitor_update(id, MonitorUpdateSelector::Last); } }, 0xf8 => { for id in &chan_bc_ids { - complete_monitor_update(&nodes[1].monitor, id, &complete_first); + nodes[1].complete_monitor_update(id, MonitorUpdateSelector::First); } }, 0xf9 => { for id in &chan_bc_ids { - complete_monitor_update(&nodes[1].monitor, id, &complete_second); + nodes[1].complete_monitor_update(id, MonitorUpdateSelector::Second); } }, 0xfa => { for id in &chan_bc_ids { - complete_monitor_update(&nodes[1].monitor, id, &Vec::pop); + nodes[1].complete_monitor_update(id, MonitorUpdateSelector::Last); } }, 0xfc => { for id in &chan_bc_ids { - complete_monitor_update(&nodes[2].monitor, id, &complete_first); + nodes[2].complete_monitor_update(id, MonitorUpdateSelector::First); } }, 0xfd => { for id in &chan_bc_ids { - complete_monitor_update(&nodes[2].monitor, id, &complete_second); + nodes[2].complete_monitor_update(id, MonitorUpdateSelector::Second); } }, 0xfe => { for id in &chan_bc_ids { - complete_monitor_update(&nodes[2].monitor, id, &Vec::pop); + nodes[2].complete_monitor_update(id, MonitorUpdateSelector::Last); } }, @@ -2857,12 +2852,12 @@ pub fn do_test(data: &[u8], out: Out) { } // Next, make sure no monitor updates are pending for id in &chan_ab_ids { - complete_all_monitor_updates(&nodes[0].monitor, id); - complete_all_monitor_updates(&nodes[1].monitor, id); + nodes[0].complete_all_monitor_updates(id); + nodes[1].complete_all_monitor_updates(id); } for id in &chan_bc_ids { - complete_all_monitor_updates(&nodes[1].monitor, id); - complete_all_monitor_updates(&nodes[2].monitor, id); + nodes[1].complete_all_monitor_updates(id); + nodes[2].complete_all_monitor_updates(id); } // Then, make sure any current forwards make their way to their destination if process_msg_events!(0, false, ProcessMessages::AllMessages) { @@ -2953,12 +2948,9 @@ pub fn do_test(data: &[u8], out: Out) { ); } - nodes[0].last_htlc_clear_fee = - nodes[0].fee_estimator.ret_val.load(atomic::Ordering::Acquire); - nodes[1].last_htlc_clear_fee = - nodes[1].fee_estimator.ret_val.load(atomic::Ordering::Acquire); - nodes[2].last_htlc_clear_fee = - nodes[2].fee_estimator.ret_val.load(atomic::Ordering::Acquire); + nodes[0].record_last_htlc_clear_fee(); + nodes[1].record_last_htlc_clear_fee(); + nodes[2].record_last_htlc_clear_fee(); }, _ => test_return!(), } From 55df0b3dba43ded8d0634c4a8f168d76507d94ad Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Tue, 28 Apr 2026 11:40:02 +0200 Subject: [PATCH 378/627] Route chanmon messages through EventQueues Replace the four directional message vectors with one queue owner. Move per-node queue draining, middle-node routing, and disconnect cleanup into EventQueues so routing behavior lives with the queue state. --- fuzz/src/chanmon_consistency.rs | 365 ++++++++++++++++---------------- 1 file changed, 182 insertions(+), 183 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 755fb18ff10..3a44cfffc71 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1322,6 +1322,158 @@ enum MonitorUpdateSelector { Last, } +struct EventQueues { + ab: Vec, + ba: Vec, + bc: Vec, + cb: Vec, +} + +impl EventQueues { + fn new() -> Self { + Self { ab: Vec::new(), ba: Vec::new(), bc: Vec::new(), cb: Vec::new() } + } + + fn take_for_node(&mut self, node_idx: usize) -> Vec { + match node_idx { + 0 => { + let mut events = Vec::new(); + mem::swap(&mut events, &mut self.ab); + events + }, + 1 => { + let mut events = Vec::new(); + mem::swap(&mut events, &mut self.ba); + events.extend_from_slice(&self.bc[..]); + self.bc.clear(); + events + }, + 2 => { + let mut events = Vec::new(); + mem::swap(&mut events, &mut self.cb); + events + }, + _ => panic!("invalid node index"), + } + } + + fn push_for_node(&mut self, node_idx: usize, event: MessageSendEvent) { + match node_idx { + 0 => self.ab.push(event), + 2 => self.cb.push(event), + _ => panic!("cannot directly queue messages for node {}", node_idx), + } + } + + fn extend_for_node>( + &mut self, node_idx: usize, events: I, + ) { + match node_idx { + 0 => self.ab.extend(events), + 2 => self.cb.extend(events), + _ => panic!("cannot directly queue messages for node {}", node_idx), + } + } + + fn route_from_middle<'a, I: IntoIterator>( + &mut self, excess_events: I, expect_drop_node: Option, nodes: &[HarnessNode<'a>; 3], + ) { + // Push any events from Node B onto queues.ba and queues.bc. + let a_id = nodes[0].get_our_node_id(); + let expect_drop_id = expect_drop_node.map(|id| nodes[id].get_our_node_id()); + for event in excess_events { + let push_a = match event { + MessageSendEvent::UpdateHTLCs { ref node_id, .. } + | MessageSendEvent::SendRevokeAndACK { ref node_id, .. } + | MessageSendEvent::SendChannelReestablish { ref node_id, .. } + | MessageSendEvent::SendStfu { ref node_id, .. } + | MessageSendEvent::SendSpliceInit { ref node_id, .. } + | MessageSendEvent::SendSpliceAck { ref node_id, .. } + | MessageSendEvent::SendSpliceLocked { ref node_id, .. } + | MessageSendEvent::SendTxAddInput { ref node_id, .. } + | MessageSendEvent::SendTxAddOutput { ref node_id, .. } + | MessageSendEvent::SendTxRemoveInput { ref node_id, .. } + | MessageSendEvent::SendTxRemoveOutput { ref node_id, .. } + | MessageSendEvent::SendTxComplete { ref node_id, .. } + | MessageSendEvent::SendTxAbort { ref node_id, .. } + | MessageSendEvent::SendTxInitRbf { ref node_id, .. } + | MessageSendEvent::SendTxAckRbf { ref node_id, .. } + | MessageSendEvent::SendTxSignatures { ref node_id, .. } + | MessageSendEvent::SendChannelUpdate { ref node_id, .. } => { + if Some(*node_id) == expect_drop_id { + panic!( + "peer_disconnected should drop msgs bound for the disconnected peer" + ); + } + *node_id == a_id + }, + MessageSendEvent::HandleError { ref action, ref node_id } => { + assert_action_timeout_awaiting_response(action); + if Some(*node_id) == expect_drop_id { + panic!( + "peer_disconnected should drop msgs bound for the disconnected peer" + ); + } + *node_id == a_id + }, + MessageSendEvent::SendChannelReady { .. } + | MessageSendEvent::SendAnnouncementSignatures { .. } + | MessageSendEvent::BroadcastChannelUpdate { .. } => continue, + _ => panic!("Unhandled message event {:?}", event), + }; + if push_a { + self.ba.push(event); + } else { + self.bc.push(event); + } + } + } + + fn drain_on_disconnect(&mut self, edge_node: usize, nodes: &[HarnessNode<'_>; 3]) { + match edge_node { + 0 => { + for event in nodes[0].get_and_clear_pending_msg_events() { + match event { + MessageSendEvent::UpdateHTLCs { .. } => {}, + MessageSendEvent::SendRevokeAndACK { .. } => {}, + MessageSendEvent::SendChannelReestablish { .. } => {}, + MessageSendEvent::SendStfu { .. } => {}, + MessageSendEvent::SendChannelReady { .. } => {}, + MessageSendEvent::SendAnnouncementSignatures { .. } => {}, + MessageSendEvent::BroadcastChannelUpdate { .. } => {}, + MessageSendEvent::SendChannelUpdate { .. } => {}, + MessageSendEvent::HandleError { ref action, .. } => { + assert_action_timeout_awaiting_response(action); + }, + _ => panic!("Unhandled message event"), + } + } + self.route_from_middle(nodes[1].get_and_clear_pending_msg_events(), Some(0), nodes); + }, + 2 => { + for event in nodes[2].get_and_clear_pending_msg_events() { + match event { + MessageSendEvent::UpdateHTLCs { .. } => {}, + MessageSendEvent::SendRevokeAndACK { .. } => {}, + MessageSendEvent::SendChannelReestablish { .. } => {}, + MessageSendEvent::SendStfu { .. } => {}, + MessageSendEvent::SendChannelReady { .. } => {}, + MessageSendEvent::SendAnnouncementSignatures { .. } => {}, + MessageSendEvent::BroadcastChannelUpdate { .. } => {}, + MessageSendEvent::SendChannelUpdate { .. } => {}, + MessageSendEvent::HandleError { ref action, .. } => { + assert_action_timeout_awaiting_response(action); + }, + _ => panic!("Unhandled message event"), + } + } + self.route_from_middle(nodes[1].get_and_clear_pending_msg_events(), Some(2), nodes); + }, + _ => panic!("unsupported disconnected edge"), + } + } +} + fn build_node_config(chan_type: ChanType) -> UserConfig { let mut config = UserConfig::default(); config.channel_config.forwarding_fee_proportional_millionths = 0; @@ -1692,10 +1844,7 @@ pub fn do_test(data: &[u8], out: Out) { let mut peers_ab_disconnected = false; let mut peers_bc_disconnected = false; - let mut ab_events = Vec::new(); - let mut ba_events = Vec::new(); - let mut bc_events = Vec::new(); - let mut cb_events = Vec::new(); + let mut queues = EventQueues::new(); for node in &mut nodes { node.serialized_manager = node.encode(); @@ -1728,97 +1877,6 @@ pub fn do_test(data: &[u8], out: Out) { } loop { - // Push any events from Node B onto ba_events and bc_events - macro_rules! push_excess_b_events { - ($excess_events: expr, $expect_drop_node: expr) => { { - let a_id = nodes[0].get_our_node_id(); - let expect_drop_node: Option = $expect_drop_node; - let expect_drop_id = if let Some(id) = expect_drop_node { Some(nodes[id].get_our_node_id()) } else { None }; - for event in $excess_events { - let push_a = match event { - MessageSendEvent::UpdateHTLCs { ref node_id, .. } => { - if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } - *node_id == a_id - }, - MessageSendEvent::SendRevokeAndACK { ref node_id, .. } => { - if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } - *node_id == a_id - }, - MessageSendEvent::SendChannelReestablish { ref node_id, .. } => { - if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } - *node_id == a_id - }, - MessageSendEvent::SendStfu { ref node_id, .. } => { - if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } - *node_id == a_id - }, - MessageSendEvent::SendSpliceInit { ref node_id, .. } => { - if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } - *node_id == a_id - }, - MessageSendEvent::SendSpliceAck { ref node_id, .. } => { - if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } - *node_id == a_id - }, - MessageSendEvent::SendSpliceLocked { ref node_id, .. } => { - if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } - *node_id == a_id - }, - MessageSendEvent::SendTxAddInput { ref node_id, .. } => { - if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } - *node_id == a_id - }, - MessageSendEvent::SendTxAddOutput { ref node_id, .. } => { - if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } - *node_id == a_id - }, - MessageSendEvent::SendTxRemoveInput { ref node_id, .. } => { - if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } - *node_id == a_id - }, - MessageSendEvent::SendTxRemoveOutput { ref node_id, .. } => { - if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } - *node_id == a_id - }, - MessageSendEvent::SendTxComplete { ref node_id, .. } => { - if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } - *node_id == a_id - }, - MessageSendEvent::SendTxAbort { ref node_id, .. } => { - if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } - *node_id == a_id - }, - MessageSendEvent::SendTxInitRbf { ref node_id, .. } => { - if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } - *node_id == a_id - }, - MessageSendEvent::SendTxAckRbf { ref node_id, .. } => { - if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } - *node_id == a_id - }, - MessageSendEvent::SendTxSignatures { ref node_id, .. } => { - if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } - *node_id == a_id - }, - MessageSendEvent::SendChannelReady { .. } => continue, - MessageSendEvent::SendAnnouncementSignatures { .. } => continue, - MessageSendEvent::BroadcastChannelUpdate { .. } => continue, - MessageSendEvent::SendChannelUpdate { ref node_id, .. } => { - if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } - *node_id == a_id - }, - MessageSendEvent::HandleError { ref action, ref node_id } => { - assert_action_timeout_awaiting_response(action); - if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } - *node_id == a_id - }, - _ => panic!("Unhandled message event {:?}", event), - }; - if push_a { ba_events.push(event); } else { bc_events.push(event); } - } - } } - } - // While delivering messages, we select across three possible message selection processes // to ensure we get as much coverage as possible. See the individual enum variants for more // details. @@ -1838,21 +1896,7 @@ pub fn do_test(data: &[u8], out: Out) { macro_rules! process_msg_events { ($node: expr, $corrupt_forward: expr, $limit_events: expr) => { { - let mut events = if $node == 1 { - let mut new_events = Vec::new(); - mem::swap(&mut new_events, &mut ba_events); - new_events.extend_from_slice(&bc_events[..]); - bc_events.clear(); - new_events - } else if $node == 0 { - let mut new_events = Vec::new(); - mem::swap(&mut new_events, &mut ab_events); - new_events - } else { - let mut new_events = Vec::new(); - mem::swap(&mut new_events, &mut cb_events); - new_events - }; + let mut events = queues.take_for_node($node); let mut new_events = Vec::new(); if $limit_events != ProcessMessages::OnePendingMessage { new_events = nodes[$node].get_and_clear_pending_msg_events(); @@ -2060,13 +2104,14 @@ pub fn do_test(data: &[u8], out: Out) { } } if $node == 1 { - push_excess_b_events!(extra_ev.into_iter().chain(events_iter), None); + let remaining = extra_ev.into_iter().chain(events_iter).collect::>(); + queues.route_from_middle(remaining, None, &nodes); } else if $node == 0 { - if let Some(ev) = extra_ev { ab_events.push(ev); } - for event in events_iter { ab_events.push(event); } + if let Some(ev) = extra_ev { queues.push_for_node(0, ev); } + queues.extend_for_node(0, events_iter); } else { - if let Some(ev) = extra_ev { cb_events.push(ev); } - for event in events_iter { cb_events.push(event); } + if let Some(ev) = extra_ev { queues.push_for_node(2, ev); } + queues.extend_for_node(2, events_iter); } had_events } } @@ -2078,58 +2123,6 @@ pub fn do_test(data: &[u8], out: Out) { }}; } - macro_rules! drain_msg_events_on_disconnect { - ($counterparty_id: expr) => {{ - if $counterparty_id == 0 { - for event in nodes[0].get_and_clear_pending_msg_events() { - match event { - MessageSendEvent::UpdateHTLCs { .. } => {}, - MessageSendEvent::SendRevokeAndACK { .. } => {}, - MessageSendEvent::SendChannelReestablish { .. } => {}, - MessageSendEvent::SendStfu { .. } => {}, - MessageSendEvent::SendChannelReady { .. } => {}, - MessageSendEvent::SendAnnouncementSignatures { .. } => {}, - MessageSendEvent::BroadcastChannelUpdate { .. } => {}, - MessageSendEvent::SendChannelUpdate { .. } => {}, - MessageSendEvent::HandleError { ref action, .. } => { - assert_action_timeout_awaiting_response(action); - }, - _ => panic!("Unhandled message event"), - } - } - push_excess_b_events!( - nodes[1].get_and_clear_pending_msg_events().drain(..), - Some(0) - ); - ab_events.clear(); - ba_events.clear(); - } else { - for event in nodes[2].get_and_clear_pending_msg_events() { - match event { - MessageSendEvent::UpdateHTLCs { .. } => {}, - MessageSendEvent::SendRevokeAndACK { .. } => {}, - MessageSendEvent::SendChannelReestablish { .. } => {}, - MessageSendEvent::SendStfu { .. } => {}, - MessageSendEvent::SendChannelReady { .. } => {}, - MessageSendEvent::SendAnnouncementSignatures { .. } => {}, - MessageSendEvent::BroadcastChannelUpdate { .. } => {}, - MessageSendEvent::SendChannelUpdate { .. } => {}, - MessageSendEvent::HandleError { ref action, .. } => { - assert_action_timeout_awaiting_response(action); - }, - _ => panic!("Unhandled message event"), - } - } - push_excess_b_events!( - nodes[1].get_and_clear_pending_msg_events().drain(..), - Some(2) - ); - bc_events.clear(); - cb_events.clear(); - } - }}; - } - macro_rules! process_events { ($node: expr, $fail: expr) => {{ // Multiple HTLCs can resolve for the same payment hash, so deduplicate @@ -2379,7 +2372,9 @@ pub fn do_test(data: &[u8], out: Out) { nodes[0].peer_disconnected(nodes[1].get_our_node_id()); nodes[1].peer_disconnected(nodes[0].get_our_node_id()); peers_ab_disconnected = true; - drain_msg_events_on_disconnect!(0); + queues.drain_on_disconnect(0, &nodes); + queues.ab.clear(); + queues.ba.clear(); } }, 0x0d => { @@ -2387,7 +2382,9 @@ pub fn do_test(data: &[u8], out: Out) { nodes[1].peer_disconnected(nodes[2].get_our_node_id()); nodes[2].peer_disconnected(nodes[1].get_our_node_id()); peers_bc_disconnected = true; - drain_msg_events_on_disconnect!(2); + queues.drain_on_disconnect(2, &nodes); + queues.bc.clear(); + queues.cb.clear(); } }, 0x0e => { @@ -2624,12 +2621,13 @@ pub fn do_test(data: &[u8], out: Out) { if !peers_ab_disconnected { nodes[1].peer_disconnected(nodes[0].get_our_node_id()); peers_ab_disconnected = true; - push_excess_b_events!( - nodes[1].get_and_clear_pending_msg_events().drain(..), - Some(0) + queues.route_from_middle( + nodes[1].get_and_clear_pending_msg_events(), + Some(0), + &nodes, ); - ab_events.clear(); - ba_events.clear(); + queues.ab.clear(); + queues.ba.clear(); } nodes[0].reload(v, &out, &router, chan_type); }, @@ -2640,15 +2638,15 @@ pub fn do_test(data: &[u8], out: Out) { nodes[0].peer_disconnected(nodes[1].get_our_node_id()); peers_ab_disconnected = true; nodes[0].get_and_clear_pending_msg_events(); - ab_events.clear(); - ba_events.clear(); + queues.ab.clear(); + queues.ba.clear(); } if !peers_bc_disconnected { nodes[2].peer_disconnected(nodes[1].get_our_node_id()); peers_bc_disconnected = true; nodes[2].get_and_clear_pending_msg_events(); - bc_events.clear(); - cb_events.clear(); + queues.bc.clear(); + queues.cb.clear(); } nodes[1].reload(v, &out, &router, chan_type); }, @@ -2658,12 +2656,13 @@ pub fn do_test(data: &[u8], out: Out) { if !peers_bc_disconnected { nodes[1].peer_disconnected(nodes[2].get_our_node_id()); peers_bc_disconnected = true; - push_excess_b_events!( - nodes[1].get_and_clear_pending_msg_events().drain(..), - Some(2) + queues.route_from_middle( + nodes[1].get_and_clear_pending_msg_events(), + Some(2), + &nodes, ); - bc_events.clear(); - cb_events.clear(); + queues.bc.clear(); + queues.cb.clear(); } nodes[2].reload(v, &out, &router, chan_type); }, From b75102284bda8d0aae74ba7a94f18ba0ab96fc91 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Tue, 21 Apr 2026 16:07:32 +0200 Subject: [PATCH 379/627] Extract chanmon harness peer links Represent each channel pair as a peer link with its channel ids and disconnect state. Link methods now own peer reconnect, disconnect, and monitor-update operations for that channel group. --- fuzz/src/chanmon_consistency.rs | 325 +++++++++++++++----------------- 1 file changed, 151 insertions(+), 174 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 3a44cfffc71..7030f4ee205 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1429,6 +1429,20 @@ impl EventQueues { } } + fn clear_link(&mut self, link: &PeerLink) { + match (link.node_a, link.node_b) { + (0, 1) | (1, 0) => { + self.ab.clear(); + self.ba.clear(); + }, + (1, 2) | (2, 1) => { + self.bc.clear(); + self.cb.clear(); + }, + _ => panic!("unsupported link"), + } + } + fn drain_on_disconnect(&mut self, edge_node: usize, nodes: &[HarnessNode<'_>; 3]) { match edge_node { 0 => { @@ -1474,6 +1488,109 @@ impl EventQueues { } } +struct PeerLink { + node_a: usize, + node_b: usize, + channel_ids: [ChannelId; 3], + disconnected: bool, +} + +impl PeerLink { + fn new(node_a: usize, node_b: usize, channel_ids: [ChannelId; 3]) -> Self { + Self { node_a, node_b, channel_ids, disconnected: false } + } + + fn first_channel_id(&self) -> ChannelId { + self.channel_ids[0] + } + + fn channel_ids(&self) -> &[ChannelId; 3] { + &self.channel_ids + } + + fn complete_all_monitor_updates(&self, nodes: &[HarnessNode<'_>; 3]) { + for id in &self.channel_ids { + nodes[self.node_a].complete_all_monitor_updates(id); + nodes[self.node_b].complete_all_monitor_updates(id); + } + } + + fn complete_monitor_updates_for_node( + &self, node_idx: usize, nodes: &[HarnessNode<'_>; 3], selector: MonitorUpdateSelector, + ) { + assert!(node_idx == self.node_a || node_idx == self.node_b); + for id in &self.channel_ids { + nodes[node_idx].complete_monitor_update(id, selector); + } + } + + fn disconnect(&mut self, nodes: &[HarnessNode<'_>; 3], queues: &mut EventQueues) { + if self.disconnected { + return; + } + let node_a_id = nodes[self.node_a].get_our_node_id(); + let node_b_id = nodes[self.node_b].get_our_node_id(); + nodes[self.node_a].peer_disconnected(node_b_id); + nodes[self.node_b].peer_disconnected(node_a_id); + self.disconnected = true; + let edge_node = if self.node_a == 1 { + self.node_b + } else if self.node_b == 1 { + self.node_a + } else { + panic!("unsupported link topology") + }; + queues.drain_on_disconnect(edge_node, nodes); + queues.clear_link(self); + } + + fn reconnect(&mut self, nodes: &[HarnessNode<'_>; 3]) { + if !self.disconnected { + return; + } + let node_a_id = nodes[self.node_a].get_our_node_id(); + let node_b_id = nodes[self.node_b].get_our_node_id(); + let init_b = Init { + features: nodes[self.node_b].init_features(), + networks: None, + remote_network_address: None, + }; + nodes[self.node_a].peer_connected(node_b_id, &init_b, true).unwrap(); + let init_a = Init { + features: nodes[self.node_a].init_features(), + networks: None, + remote_network_address: None, + }; + nodes[self.node_b].peer_connected(node_a_id, &init_a, false).unwrap(); + self.disconnected = false; + } + + fn disconnect_for_reload( + &mut self, restarted_node: usize, nodes: &[HarnessNode<'_>; 3], queues: &mut EventQueues, + ) { + if self.disconnected { + return; + } + assert!(restarted_node == self.node_a || restarted_node == self.node_b); + + let remaining_node = if restarted_node == self.node_a { self.node_b } else { self.node_a }; + let restarted_node_id = nodes[restarted_node].get_our_node_id(); + nodes[remaining_node].peer_disconnected(restarted_node_id); + self.disconnected = true; + + if remaining_node == 1 { + queues.route_from_middle( + nodes[1].get_and_clear_pending_msg_events(), + Some(restarted_node), + nodes, + ); + } else { + nodes[remaining_node].get_and_clear_pending_msg_events(); + } + queues.clear_link(self); + } +} + fn build_node_config(chan_type: ChanType) -> UserConfig { let mut config = UserConfig::default(); config.channel_config.forwarding_fee_proportional_millionths = 0; @@ -1836,14 +1953,14 @@ pub fn do_test(data: &[u8], out: Out) { let node_c_chans = nodes[2].list_usable_channels(); [node_c_chans[0].channel_id, node_c_chans[1].channel_id, node_c_chans[2].channel_id] }; + let mut ab_link = PeerLink::new(0, 1, chan_ab_ids); + let mut bc_link = PeerLink::new(1, 2, chan_bc_ids); // Keep old names for backward compatibility in existing code - let chan_a_id = chan_ab_ids[0]; - let chan_b_id = chan_bc_ids[0]; + let chan_a_id = ab_link.first_channel_id(); + let chan_b_id = bc_link.first_channel_id(); let mut p_ctr: u64 = 0; - let mut peers_ab_disconnected = false; - let mut peers_bc_disconnected = false; let mut queues = EventQueues::new(); for node in &mut nodes { @@ -2347,80 +2464,30 @@ pub fn do_test(data: &[u8], out: Out) { 0x06 => nodes[2].set_persistence_style(ChannelMonitorUpdateStatus::Completed), 0x08 => { - for id in &chan_ab_ids { + for id in ab_link.channel_ids() { nodes[0].complete_all_monitor_updates(id); } }, 0x09 => { - for id in &chan_ab_ids { + for id in ab_link.channel_ids() { nodes[1].complete_all_monitor_updates(id); } }, 0x0a => { - for id in &chan_bc_ids { + for id in bc_link.channel_ids() { nodes[1].complete_all_monitor_updates(id); } }, 0x0b => { - for id in &chan_bc_ids { + for id in bc_link.channel_ids() { nodes[2].complete_all_monitor_updates(id); } }, - 0x0c => { - if !peers_ab_disconnected { - nodes[0].peer_disconnected(nodes[1].get_our_node_id()); - nodes[1].peer_disconnected(nodes[0].get_our_node_id()); - peers_ab_disconnected = true; - queues.drain_on_disconnect(0, &nodes); - queues.ab.clear(); - queues.ba.clear(); - } - }, - 0x0d => { - if !peers_bc_disconnected { - nodes[1].peer_disconnected(nodes[2].get_our_node_id()); - nodes[2].peer_disconnected(nodes[1].get_our_node_id()); - peers_bc_disconnected = true; - queues.drain_on_disconnect(2, &nodes); - queues.bc.clear(); - queues.cb.clear(); - } - }, - 0x0e => { - if peers_ab_disconnected { - let init_1 = Init { - features: nodes[1].init_features(), - networks: None, - remote_network_address: None, - }; - nodes[0].peer_connected(nodes[1].get_our_node_id(), &init_1, true).unwrap(); - let init_0 = Init { - features: nodes[0].init_features(), - networks: None, - remote_network_address: None, - }; - nodes[1].peer_connected(nodes[0].get_our_node_id(), &init_0, false).unwrap(); - peers_ab_disconnected = false; - } - }, - 0x0f => { - if peers_bc_disconnected { - let init_2 = Init { - features: nodes[2].init_features(), - networks: None, - remote_network_address: None, - }; - nodes[1].peer_connected(nodes[2].get_our_node_id(), &init_2, true).unwrap(); - let init_1 = Init { - features: nodes[1].init_features(), - networks: None, - remote_network_address: None, - }; - nodes[2].peer_connected(nodes[1].get_our_node_id(), &init_1, false).unwrap(); - peers_bc_disconnected = false; - } - }, + 0x0c => ab_link.disconnect(&nodes, &mut queues), + 0x0d => bc_link.disconnect(&nodes, &mut queues), + 0x0e => ab_link.reconnect(&nodes), + 0x0f => bc_link.reconnect(&nodes), 0x10 => process_msg_noret!(0, true, ProcessMessages::AllMessages), 0x11 => process_msg_noret!(0, false, ProcessMessages::AllMessages), @@ -2618,52 +2685,20 @@ pub fn do_test(data: &[u8], out: Out) { 0xb0 | 0xb1 | 0xb2 => { // Restart node A, picking among the in-flight `ChannelMonitor`s to use based on // the value of `v` we're matching. - if !peers_ab_disconnected { - nodes[1].peer_disconnected(nodes[0].get_our_node_id()); - peers_ab_disconnected = true; - queues.route_from_middle( - nodes[1].get_and_clear_pending_msg_events(), - Some(0), - &nodes, - ); - queues.ab.clear(); - queues.ba.clear(); - } + ab_link.disconnect_for_reload(0, &nodes, &mut queues); nodes[0].reload(v, &out, &router, chan_type); }, 0xb3..=0xbb => { // Restart node B, picking among the in-flight `ChannelMonitor`s to use based on // the value of `v` we're matching. - if !peers_ab_disconnected { - nodes[0].peer_disconnected(nodes[1].get_our_node_id()); - peers_ab_disconnected = true; - nodes[0].get_and_clear_pending_msg_events(); - queues.ab.clear(); - queues.ba.clear(); - } - if !peers_bc_disconnected { - nodes[2].peer_disconnected(nodes[1].get_our_node_id()); - peers_bc_disconnected = true; - nodes[2].get_and_clear_pending_msg_events(); - queues.bc.clear(); - queues.cb.clear(); - } + ab_link.disconnect_for_reload(1, &nodes, &mut queues); + bc_link.disconnect_for_reload(1, &nodes, &mut queues); nodes[1].reload(v, &out, &router, chan_type); }, 0xbc | 0xbd | 0xbe => { // Restart node C, picking among the in-flight `ChannelMonitor`s to use based on // the value of `v` we're matching. - if !peers_bc_disconnected { - nodes[1].peer_disconnected(nodes[2].get_our_node_id()); - peers_bc_disconnected = true; - queues.route_from_middle( - nodes[1].get_and_clear_pending_msg_events(), - Some(2), - &nodes, - ); - queues.bc.clear(); - queues.cb.clear(); - } + bc_link.disconnect_for_reload(2, &nodes, &mut queues); nodes[2].reload(v, &out, &router, chan_type); }, @@ -2734,67 +2769,43 @@ pub fn do_test(data: &[u8], out: Out) { }, 0xf0 => { - for id in &chan_ab_ids { - nodes[0].complete_monitor_update(id, MonitorUpdateSelector::First); - } + ab_link.complete_monitor_updates_for_node(0, &nodes, MonitorUpdateSelector::First) }, 0xf1 => { - for id in &chan_ab_ids { - nodes[0].complete_monitor_update(id, MonitorUpdateSelector::Second); - } + ab_link.complete_monitor_updates_for_node(0, &nodes, MonitorUpdateSelector::Second) }, 0xf2 => { - for id in &chan_ab_ids { - nodes[0].complete_monitor_update(id, MonitorUpdateSelector::Last); - } + ab_link.complete_monitor_updates_for_node(0, &nodes, MonitorUpdateSelector::Last) }, 0xf4 => { - for id in &chan_ab_ids { - nodes[1].complete_monitor_update(id, MonitorUpdateSelector::First); - } + ab_link.complete_monitor_updates_for_node(1, &nodes, MonitorUpdateSelector::First) }, 0xf5 => { - for id in &chan_ab_ids { - nodes[1].complete_monitor_update(id, MonitorUpdateSelector::Second); - } + ab_link.complete_monitor_updates_for_node(1, &nodes, MonitorUpdateSelector::Second) }, 0xf6 => { - for id in &chan_ab_ids { - nodes[1].complete_monitor_update(id, MonitorUpdateSelector::Last); - } + ab_link.complete_monitor_updates_for_node(1, &nodes, MonitorUpdateSelector::Last) }, 0xf8 => { - for id in &chan_bc_ids { - nodes[1].complete_monitor_update(id, MonitorUpdateSelector::First); - } + bc_link.complete_monitor_updates_for_node(1, &nodes, MonitorUpdateSelector::First) }, 0xf9 => { - for id in &chan_bc_ids { - nodes[1].complete_monitor_update(id, MonitorUpdateSelector::Second); - } + bc_link.complete_monitor_updates_for_node(1, &nodes, MonitorUpdateSelector::Second) }, 0xfa => { - for id in &chan_bc_ids { - nodes[1].complete_monitor_update(id, MonitorUpdateSelector::Last); - } + bc_link.complete_monitor_updates_for_node(1, &nodes, MonitorUpdateSelector::Last) }, 0xfc => { - for id in &chan_bc_ids { - nodes[2].complete_monitor_update(id, MonitorUpdateSelector::First); - } + bc_link.complete_monitor_updates_for_node(2, &nodes, MonitorUpdateSelector::First) }, 0xfd => { - for id in &chan_bc_ids { - nodes[2].complete_monitor_update(id, MonitorUpdateSelector::Second); - } + bc_link.complete_monitor_updates_for_node(2, &nodes, MonitorUpdateSelector::Second) }, 0xfe => { - for id in &chan_bc_ids { - nodes[2].complete_monitor_update(id, MonitorUpdateSelector::Last); - } + bc_link.complete_monitor_updates_for_node(2, &nodes, MonitorUpdateSelector::Last) }, 0xff => { @@ -2802,36 +2813,8 @@ pub fn do_test(data: &[u8], out: Out) { // after we resolve all pending events. // First, make sure peers are all connected to each other - if peers_ab_disconnected { - let init_1 = Init { - features: nodes[1].init_features(), - networks: None, - remote_network_address: None, - }; - nodes[0].peer_connected(nodes[1].get_our_node_id(), &init_1, true).unwrap(); - let init_0 = Init { - features: nodes[0].init_features(), - networks: None, - remote_network_address: None, - }; - nodes[1].peer_connected(nodes[0].get_our_node_id(), &init_0, false).unwrap(); - peers_ab_disconnected = false; - } - if peers_bc_disconnected { - let init_2 = Init { - features: nodes[2].init_features(), - networks: None, - remote_network_address: None, - }; - nodes[1].peer_connected(nodes[2].get_our_node_id(), &init_2, true).unwrap(); - let init_1 = Init { - features: nodes[1].init_features(), - networks: None, - remote_network_address: None, - }; - nodes[2].peer_connected(nodes[1].get_our_node_id(), &init_1, false).unwrap(); - peers_bc_disconnected = false; - } + ab_link.reconnect(&nodes); + bc_link.reconnect(&nodes); for op in SUPPORTED_SIGNER_OPS { nodes[0].keys_manager.enable_op_for_all_signers(op); @@ -2850,14 +2833,8 @@ pub fn do_test(data: &[u8], out: Out) { panic!("It may take may iterations to settle the state, but it should not take forever"); } // Next, make sure no monitor updates are pending - for id in &chan_ab_ids { - nodes[0].complete_all_monitor_updates(id); - nodes[1].complete_all_monitor_updates(id); - } - for id in &chan_bc_ids { - nodes[1].complete_all_monitor_updates(id); - nodes[2].complete_all_monitor_updates(id); - } + ab_link.complete_all_monitor_updates(&nodes); + bc_link.complete_all_monitor_updates(&nodes); // Then, make sure any current forwards make their way to their destination if process_msg_events!(0, false, ProcessMessages::AllMessages) { last_pass_no_updates = false; @@ -2934,13 +2911,13 @@ pub fn do_test(data: &[u8], out: Out) { } // Finally, make sure that at least one end of each channel can make a substantial payment - for &chan_id in &chan_ab_ids { + for &chan_id in ab_link.channel_ids() { assert!( send(0, 1, chan_id, 10_000_000, &mut p_ctr) || send(1, 0, chan_id, 10_000_000, &mut p_ctr) ); } - for &chan_id in &chan_bc_ids { + for &chan_id in bc_link.channel_ids() { assert!( send(1, 2, chan_id, 10_000_000, &mut p_ctr) || send(2, 1, chan_id, 10_000_000, &mut p_ctr) From 94981ff12d70e3aa0af9d8678c4e3675f18d6de7 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Wed, 6 May 2026 17:28:00 +0200 Subject: [PATCH 380/627] Hoist chanmon process_all_events macro Move the settlement helper outside the final input arm. This lets later payment helper extraction use it from more arms. --- fuzz/src/chanmon_consistency.rs | 102 ++++++++++++++++---------------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 7030f4ee205..1f955a1c9be 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -2450,6 +2450,57 @@ pub fn do_test(data: &[u8], out: Out) { } }; + macro_rules! process_all_events { + () => { { + let mut last_pass_no_updates = false; + for i in 0..std::usize::MAX { + if i == 100 { + panic!("It may take may iterations to settle the state, but it should not take forever"); + } + // Next, make sure no monitor updates are pending + ab_link.complete_all_monitor_updates(&nodes); + bc_link.complete_all_monitor_updates(&nodes); + // Then, make sure any current forwards make their way to their destination + if process_msg_events!(0, false, ProcessMessages::AllMessages) { + last_pass_no_updates = false; + continue; + } + if process_msg_events!(1, false, ProcessMessages::AllMessages) { + last_pass_no_updates = false; + continue; + } + if process_msg_events!(2, false, ProcessMessages::AllMessages) { + last_pass_no_updates = false; + continue; + } + // ...making sure any payments are claimed. + if process_events!(0, false) { + last_pass_no_updates = false; + continue; + } + if process_events!(1, false) { + last_pass_no_updates = false; + continue; + } + if process_events!(2, false) { + last_pass_no_updates = false; + continue; + } + if last_pass_no_updates { + // In some cases, we may generate a message to send in + // `process_msg_events`, but block sending until + // `complete_all_monitor_updates` gets called on the next + // iteration. + // + // Thus, we only exit if we manage two iterations with no messages + // or events to process. + break; + } + last_pass_no_updates = true; + } + } }; + } + let v = get_slice!(1)[0]; out.locked_write(format!("READ A BYTE! HANDLING INPUT {:x}...........\n", v).as_bytes()); match v { @@ -2825,57 +2876,6 @@ pub fn do_test(data: &[u8], out: Out) { nodes[1].signer_unblocked(None); nodes[2].signer_unblocked(None); - macro_rules! process_all_events { - () => { { - let mut last_pass_no_updates = false; - for i in 0..std::usize::MAX { - if i == 100 { - panic!("It may take may iterations to settle the state, but it should not take forever"); - } - // Next, make sure no monitor updates are pending - ab_link.complete_all_monitor_updates(&nodes); - bc_link.complete_all_monitor_updates(&nodes); - // Then, make sure any current forwards make their way to their destination - if process_msg_events!(0, false, ProcessMessages::AllMessages) { - last_pass_no_updates = false; - continue; - } - if process_msg_events!(1, false, ProcessMessages::AllMessages) { - last_pass_no_updates = false; - continue; - } - if process_msg_events!(2, false, ProcessMessages::AllMessages) { - last_pass_no_updates = false; - continue; - } - // ...making sure any payments are claimed. - if process_events!(0, false) { - last_pass_no_updates = false; - continue; - } - if process_events!(1, false) { - last_pass_no_updates = false; - continue; - } - if process_events!(2, false) { - last_pass_no_updates = false; - continue; - } - if last_pass_no_updates { - // In some cases, we may generate a message to send in - // `process_msg_events`, but block sending until - // `complete_all_monitor_updates` gets called on the next - // iteration. - // - // Thus, we only exit if we manage two iterations with no messages - // or events to process. - break; - } - last_pass_no_updates = true; - } - } }; - } - process_all_events!(); // Since MPP payments are supported, we wait until we fully settle the state of all From 3d1899cbf02c3c8f0334a56522cf810c294532c3 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Wed, 6 May 2026 17:29:56 +0200 Subject: [PATCH 381/627] Extract chanmon harness payment helpers Move payment bookkeeping into a payment tracker. Payment sends, resolutions, claims, and stuck checks share one owner. This avoids borrowing several local maps. --- fuzz/src/chanmon_consistency.rs | 1088 ++++++++++++++++--------------- 1 file changed, 547 insertions(+), 541 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 1f955a1c9be..d38e4182842 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -600,23 +600,6 @@ impl KeyProvider { } } -// Returns a bool indicating whether the payment failed. -#[inline] -fn check_payment_send_events(source: &ChanMan, sent_payment_id: PaymentId) -> bool { - for payment in source.list_recent_payments() { - match payment { - RecentPaymentDetails::Pending { payment_id, .. } if payment_id == sent_payment_id => { - return true; - }, - RecentPaymentDetails::Abandoned { payment_id, .. } if payment_id == sent_payment_id => { - return false; - }, - _ => {}, - } - } - return false; -} - type ChanMan<'a> = ChannelManager< Arc, Arc, @@ -629,297 +612,6 @@ type ChanMan<'a> = ChannelManager< Arc, >; -#[inline] -fn get_payment_secret_hash( - dest: &ChanMan, payment_ctr: &mut u64, - payment_preimages: &RefCell>, -) -> (PaymentSecret, PaymentHash) { - *payment_ctr += 1; - let mut payment_preimage = PaymentPreimage([0; 32]); - payment_preimage.0[0..8].copy_from_slice(&payment_ctr.to_be_bytes()); - let payment_hash = PaymentHash(Sha256::hash(&payment_preimage.0).to_byte_array()); - let payment_secret = dest - .create_inbound_payment_for_hash(payment_hash, None, 3600, None) - .expect("create_inbound_payment_for_hash failed"); - assert!(payment_preimages.borrow_mut().insert(payment_hash, payment_preimage).is_none()); - (payment_secret, payment_hash) -} - -#[inline] -fn send_payment( - source: &ChanMan, dest: &ChanMan, dest_chan_id: ChannelId, amt: u64, - payment_secret: PaymentSecret, payment_hash: PaymentHash, payment_id: PaymentId, -) -> bool { - let (min_value_sendable, max_value_sendable, dest_scid) = source - .list_usable_channels() - .iter() - .find(|chan| chan.channel_id == dest_chan_id) - .map(|chan| { - ( - chan.next_outbound_htlc_minimum_msat, - chan.next_outbound_htlc_limit_msat, - chan.short_channel_id.unwrap_or(0), - ) - }) - .unwrap_or((0, 0, 0)); - let route_params = RouteParameters::from_payment_params_and_value( - PaymentParameters::from_node_id(source.get_our_node_id(), TEST_FINAL_CLTV), - amt, - ); - let route = Route { - paths: vec![Path { - hops: vec![RouteHop { - pubkey: dest.get_our_node_id(), - node_features: dest.node_features(), - short_channel_id: dest_scid, - channel_features: dest.channel_features(), - fee_msat: amt, - cltv_expiry_delta: 200, - maybe_announced_channel: true, - }], - blinded_tail: None, - }], - route_params: Some(route_params.clone()), - }; - let onion = RecipientOnionFields::secret_only(payment_secret, amt); - let res = source.send_payment_with_route(route, payment_hash, onion, payment_id); - match res { - Err(err) => { - panic!("Errored with {:?} on initial payment send", err); - }, - Ok(()) => { - let expect_failure = amt < min_value_sendable || amt > max_value_sendable; - let succeeded = check_payment_send_events(source, payment_id); - assert_eq!(succeeded, !expect_failure); - succeeded - }, - } -} - -#[inline] -fn send_hop_payment( - source: &ChanMan, middle: &ChanMan, middle_chan_id: ChannelId, dest: &ChanMan, - dest_chan_id: ChannelId, amt: u64, payment_secret: PaymentSecret, payment_hash: PaymentHash, - payment_id: PaymentId, -) -> bool { - let (min_value_sendable, max_value_sendable, middle_scid) = source - .list_usable_channels() - .iter() - .find(|chan| chan.channel_id == middle_chan_id) - .map(|chan| { - ( - chan.next_outbound_htlc_minimum_msat, - chan.next_outbound_htlc_limit_msat, - chan.short_channel_id.unwrap_or(0), - ) - }) - .unwrap_or((0, 0, 0)); - let dest_scid = dest - .list_channels() - .iter() - .find(|chan| chan.channel_id == dest_chan_id) - .and_then(|chan| chan.short_channel_id) - .unwrap_or(0); - let first_hop_fee = 50_000; - let route_params = RouteParameters::from_payment_params_and_value( - PaymentParameters::from_node_id(source.get_our_node_id(), TEST_FINAL_CLTV), - amt, - ); - let route = Route { - paths: vec![Path { - hops: vec![ - RouteHop { - pubkey: middle.get_our_node_id(), - node_features: middle.node_features(), - short_channel_id: middle_scid, - channel_features: middle.channel_features(), - fee_msat: first_hop_fee, - cltv_expiry_delta: 100, - maybe_announced_channel: true, - }, - RouteHop { - pubkey: dest.get_our_node_id(), - node_features: dest.node_features(), - short_channel_id: dest_scid, - channel_features: dest.channel_features(), - fee_msat: amt, - cltv_expiry_delta: 200, - maybe_announced_channel: true, - }, - ], - blinded_tail: None, - }], - route_params: Some(route_params.clone()), - }; - let onion = RecipientOnionFields::secret_only(payment_secret, amt); - let res = source.send_payment_with_route(route, payment_hash, onion, payment_id); - match res { - Err(err) => { - panic!("Errored with {:?} on initial payment send", err); - }, - Ok(()) => { - let sent_amt = amt + first_hop_fee; - let expect_failure = sent_amt < min_value_sendable || sent_amt > max_value_sendable; - let succeeded = check_payment_send_events(source, payment_id); - assert_eq!(succeeded, !expect_failure); - succeeded - }, - } -} - -/// Send an MPP payment directly from source to dest using multiple channels. -#[inline] -fn send_mpp_payment( - source: &ChanMan, dest: &ChanMan, dest_chan_ids: &[ChannelId], amt: u64, - payment_secret: PaymentSecret, payment_hash: PaymentHash, payment_id: PaymentId, -) -> bool { - let num_paths = dest_chan_ids.len(); - if num_paths == 0 { - return false; - } - - let amt_per_path = amt / num_paths as u64; - let mut paths = Vec::with_capacity(num_paths); - - let dest_chans = dest.list_channels(); - let dest_scids = dest_chan_ids.iter().map(|chan_id| { - dest_chans - .iter() - .find(|chan| chan.channel_id == *chan_id) - .and_then(|chan| chan.short_channel_id) - .unwrap() - }); - - for (i, dest_scid) in dest_scids.enumerate() { - let path_amt = if i == num_paths - 1 { - amt - amt_per_path * (num_paths as u64 - 1) - } else { - amt_per_path - }; - - paths.push(Path { - hops: vec![RouteHop { - pubkey: dest.get_our_node_id(), - node_features: dest.node_features(), - short_channel_id: dest_scid, - channel_features: dest.channel_features(), - fee_msat: path_amt, - cltv_expiry_delta: 200, - maybe_announced_channel: true, - }], - blinded_tail: None, - }); - } - - let route_params = RouteParameters::from_payment_params_and_value( - PaymentParameters::from_node_id(dest.get_our_node_id(), TEST_FINAL_CLTV), - amt, - ); - let route = Route { paths, route_params: Some(route_params) }; - let onion = RecipientOnionFields::secret_only(payment_secret, amt); - let res = source.send_payment_with_route(route, payment_hash, onion, payment_id); - match res { - Err(_) => false, - Ok(()) => check_payment_send_events(source, payment_id), - } -} - -/// Send an MPP payment from source to dest via middle node. -/// Supports multiple channels on either or both hops. -#[inline] -fn send_mpp_hop_payment( - source: &ChanMan, middle: &ChanMan, middle_chan_ids: &[ChannelId], dest: &ChanMan, - dest_chan_ids: &[ChannelId], amt: u64, payment_secret: PaymentSecret, - payment_hash: PaymentHash, payment_id: PaymentId, -) -> bool { - // Create paths by pairing middle_scids with dest_scids - let num_paths = middle_chan_ids.len().max(dest_chan_ids.len()); - if num_paths == 0 { - return false; - } - - let first_hop_fee = 50_000; - let amt_per_path = amt / num_paths as u64; - let fee_per_path = first_hop_fee / num_paths as u64; - let mut paths = Vec::with_capacity(num_paths); - - let middle_chans = middle.list_channels(); - let middle_scids: Vec<_> = middle_chan_ids - .iter() - .map(|chan_id| { - middle_chans - .iter() - .find(|chan| chan.channel_id == *chan_id) - .and_then(|chan| chan.short_channel_id) - .unwrap() - }) - .collect(); - - let dest_chans = dest.list_channels(); - let dest_scids: Vec<_> = dest_chan_ids - .iter() - .map(|chan_id| { - dest_chans - .iter() - .find(|chan| chan.channel_id == *chan_id) - .and_then(|chan| chan.short_channel_id) - .unwrap() - }) - .collect(); - - for i in 0..num_paths { - let middle_scid = middle_scids[i % middle_scids.len()]; - let dest_scid = dest_scids[i % dest_scids.len()]; - - let path_amt = if i == num_paths - 1 { - amt - amt_per_path * (num_paths as u64 - 1) - } else { - amt_per_path - }; - let path_fee = if i == num_paths - 1 { - first_hop_fee - fee_per_path * (num_paths as u64 - 1) - } else { - fee_per_path - }; - - paths.push(Path { - hops: vec![ - RouteHop { - pubkey: middle.get_our_node_id(), - node_features: middle.node_features(), - short_channel_id: middle_scid, - channel_features: middle.channel_features(), - fee_msat: path_fee, - cltv_expiry_delta: 100, - maybe_announced_channel: true, - }, - RouteHop { - pubkey: dest.get_our_node_id(), - node_features: dest.node_features(), - short_channel_id: dest_scid, - channel_features: dest.channel_features(), - fee_msat: path_amt, - cltv_expiry_delta: 200, - maybe_announced_channel: true, - }, - ], - blinded_tail: None, - }); - } - - let route_params = RouteParameters::from_payment_params_and_value( - PaymentParameters::from_node_id(dest.get_our_node_id(), TEST_FINAL_CLTV), - amt, - ); - let route = Route { paths, route_params: Some(route_params) }; - let onion = RecipientOnionFields::secret_only(payment_secret, amt); - let res = source.send_payment_with_route(route, payment_hash, onion, payment_id); - match res { - Err(_) => false, - Ok(()) => check_payment_send_events(source, payment_id), - } -} - #[inline] fn assert_action_timeout_awaiting_response(action: &msgs::ErrorAction) { // Since sending/receiving messages may be delayed, `timer_tick_occurred` may cause a node to @@ -1591,6 +1283,444 @@ impl PeerLink { } } +struct NodePayments { + pending: Vec, + resolved: HashMap>, +} + +impl NodePayments { + fn new() -> Self { + Self { pending: Vec::new(), resolved: new_hash_map() } + } +} + +struct PaymentTracker { + nodes: [NodePayments; 3], + claimed_payment_hashes: HashSet, + payment_preimages: HashMap, + payment_ctr: u64, +} + +impl PaymentTracker { + fn new() -> Self { + Self { + nodes: [NodePayments::new(), NodePayments::new(), NodePayments::new()], + claimed_payment_hashes: HashSet::new(), + payment_preimages: new_hash_map(), + payment_ctr: 0, + } + } + + // Returns a bool indicating whether the payment failed. + fn check_payment_send_events(source: &ChanMan, sent_payment_id: PaymentId) -> bool { + for payment in source.list_recent_payments() { + match payment { + RecentPaymentDetails::Pending { payment_id, .. } + if payment_id == sent_payment_id => + { + return true; + }, + RecentPaymentDetails::Abandoned { payment_id, .. } + if payment_id == sent_payment_id => + { + return false; + }, + _ => {}, + } + } + return false; + } + + fn next_payment(&mut self, dest: &ChanMan) -> (PaymentSecret, PaymentHash, PaymentId) { + self.payment_ctr += 1; + let mut payment_preimage = PaymentPreimage([0; 32]); + payment_preimage.0[0..8].copy_from_slice(&self.payment_ctr.to_be_bytes()); + let hash = PaymentHash(Sha256::hash(&payment_preimage.0).to_byte_array()); + let secret = dest + .create_inbound_payment_for_hash(hash, None, 3600, None) + .expect("create_inbound_payment_for_hash failed"); + assert!(self.payment_preimages.insert(hash, payment_preimage).is_none()); + let mut id = PaymentId([0; 32]); + id.0[0..8].copy_from_slice(&self.payment_ctr.to_ne_bytes()); + (secret, hash, id) + } + + fn send( + &mut self, nodes: &[HarnessNode<'_>; 3], source_idx: usize, dest_idx: usize, + dest_chan_id: ChannelId, amt: u64, + ) -> bool { + let source = &nodes[source_idx]; + let dest = &nodes[dest_idx]; + let (secret, hash, id) = self.next_payment(dest); + let (min_value_sendable, max_value_sendable, dest_scid) = source + .list_usable_channels() + .iter() + .find(|chan| chan.channel_id == dest_chan_id) + .map(|chan| { + ( + chan.next_outbound_htlc_minimum_msat, + chan.next_outbound_htlc_limit_msat, + chan.short_channel_id.unwrap_or(0), + ) + }) + .unwrap_or((0, 0, 0)); + let route_params = RouteParameters::from_payment_params_and_value( + PaymentParameters::from_node_id(source.get_our_node_id(), TEST_FINAL_CLTV), + amt, + ); + let route = Route { + paths: vec![Path { + hops: vec![RouteHop { + pubkey: dest.get_our_node_id(), + node_features: dest.node_features(), + short_channel_id: dest_scid, + channel_features: dest.channel_features(), + fee_msat: amt, + cltv_expiry_delta: 200, + maybe_announced_channel: true, + }], + blinded_tail: None, + }], + route_params: Some(route_params.clone()), + }; + let onion = RecipientOnionFields::secret_only(secret, amt); + let res = source.send_payment_with_route(route, hash, onion, id); + let succeeded = match res { + Err(err) => { + panic!("Errored with {:?} on initial payment send", err); + }, + Ok(()) => { + let expect_failure = amt < min_value_sendable || amt > max_value_sendable; + let succeeded = Self::check_payment_send_events(source, id); + assert_eq!(succeeded, !expect_failure); + succeeded + }, + }; + if succeeded { + self.nodes[source_idx].pending.push(id); + } + succeeded + } + + fn send_hop( + &mut self, nodes: &[HarnessNode<'_>; 3], source_idx: usize, middle_idx: usize, + middle_chan_id: ChannelId, dest_idx: usize, dest_chan_id: ChannelId, amt: u64, + ) { + let source = &nodes[source_idx]; + let middle = &nodes[middle_idx]; + let dest = &nodes[dest_idx]; + let (secret, hash, id) = self.next_payment(dest); + let (min_value_sendable, max_value_sendable, middle_scid) = source + .list_usable_channels() + .iter() + .find(|chan| chan.channel_id == middle_chan_id) + .map(|chan| { + ( + chan.next_outbound_htlc_minimum_msat, + chan.next_outbound_htlc_limit_msat, + chan.short_channel_id.unwrap_or(0), + ) + }) + .unwrap_or((0, 0, 0)); + let dest_scid = dest + .list_channels() + .iter() + .find(|chan| chan.channel_id == dest_chan_id) + .and_then(|chan| chan.short_channel_id) + .unwrap_or(0); + let first_hop_fee = 50_000; + let route_params = RouteParameters::from_payment_params_and_value( + PaymentParameters::from_node_id(source.get_our_node_id(), TEST_FINAL_CLTV), + amt, + ); + let route = Route { + paths: vec![Path { + hops: vec![ + RouteHop { + pubkey: middle.get_our_node_id(), + node_features: middle.node_features(), + short_channel_id: middle_scid, + channel_features: middle.channel_features(), + fee_msat: first_hop_fee, + cltv_expiry_delta: 100, + maybe_announced_channel: true, + }, + RouteHop { + pubkey: dest.get_our_node_id(), + node_features: dest.node_features(), + short_channel_id: dest_scid, + channel_features: dest.channel_features(), + fee_msat: amt, + cltv_expiry_delta: 200, + maybe_announced_channel: true, + }, + ], + blinded_tail: None, + }], + route_params: Some(route_params.clone()), + }; + let onion = RecipientOnionFields::secret_only(secret, amt); + let res = source.send_payment_with_route(route, hash, onion, id); + let succeeded = match res { + Err(err) => { + panic!("Errored with {:?} on initial payment send", err); + }, + Ok(()) => { + let sent_amt = amt + first_hop_fee; + let expect_failure = sent_amt < min_value_sendable || sent_amt > max_value_sendable; + let succeeded = Self::check_payment_send_events(source, id); + assert_eq!(succeeded, !expect_failure); + succeeded + }, + }; + if succeeded { + self.nodes[source_idx].pending.push(id); + } + } + + fn send_noret( + &mut self, nodes: &[HarnessNode<'_>; 3], source_idx: usize, dest_idx: usize, + dest_chan_id: ChannelId, amt: u64, + ) { + self.send(nodes, source_idx, dest_idx, dest_chan_id, amt); + } + + // Direct MPP payment (no hop) + fn send_mpp_direct( + &mut self, nodes: &[HarnessNode<'_>; 3], source_idx: usize, dest_idx: usize, + dest_chan_ids: &[ChannelId], amt: u64, + ) { + let source = &nodes[source_idx]; + let dest = &nodes[dest_idx]; + let (secret, hash, id) = self.next_payment(dest); + let num_paths = dest_chan_ids.len(); + if num_paths == 0 { + return; + } + + let amt_per_path = amt / num_paths as u64; + let mut paths = Vec::with_capacity(num_paths); + + let dest_chans = dest.list_channels(); + let dest_scids = dest_chan_ids.iter().map(|chan_id| { + dest_chans + .iter() + .find(|chan| chan.channel_id == *chan_id) + .and_then(|chan| chan.short_channel_id) + .unwrap() + }); + + for (i, dest_scid) in dest_scids.enumerate() { + let path_amt = if i == num_paths - 1 { + amt - amt_per_path * (num_paths as u64 - 1) + } else { + amt_per_path + }; + + paths.push(Path { + hops: vec![RouteHop { + pubkey: dest.get_our_node_id(), + node_features: dest.node_features(), + short_channel_id: dest_scid, + channel_features: dest.channel_features(), + fee_msat: path_amt, + cltv_expiry_delta: 200, + maybe_announced_channel: true, + }], + blinded_tail: None, + }); + } + + let route_params = RouteParameters::from_payment_params_and_value( + PaymentParameters::from_node_id(dest.get_our_node_id(), TEST_FINAL_CLTV), + amt, + ); + let route = Route { paths, route_params: Some(route_params) }; + let onion = RecipientOnionFields::secret_only(secret, amt); + let res = source.send_payment_with_route(route, hash, onion, id); + let succeeded = match res { + Err(_) => false, + Ok(()) => Self::check_payment_send_events(source, id), + }; + if succeeded { + self.nodes[source_idx].pending.push(id); + } + } + + // MPP payment via hop - splits payment across multiple channels on either or both hops + fn send_mpp_hop( + &mut self, nodes: &[HarnessNode<'_>; 3], source_idx: usize, middle_idx: usize, + middle_chan_ids: &[ChannelId], dest_idx: usize, dest_chan_ids: &[ChannelId], amt: u64, + ) { + let source = &nodes[source_idx]; + let middle = &nodes[middle_idx]; + let dest = &nodes[dest_idx]; + let (secret, hash, id) = self.next_payment(dest); + // Create paths by pairing middle_scids with dest_scids. + let num_paths = middle_chan_ids.len().max(dest_chan_ids.len()); + if num_paths == 0 { + return; + } + + let first_hop_fee = 50_000; + let amt_per_path = amt / num_paths as u64; + let fee_per_path = first_hop_fee / num_paths as u64; + let mut paths = Vec::with_capacity(num_paths); + + let middle_chans = middle.list_channels(); + let middle_scids: Vec<_> = middle_chan_ids + .iter() + .map(|chan_id| { + middle_chans + .iter() + .find(|chan| chan.channel_id == *chan_id) + .and_then(|chan| chan.short_channel_id) + .unwrap() + }) + .collect(); + + let dest_chans = dest.list_channels(); + let dest_scids: Vec<_> = dest_chan_ids + .iter() + .map(|chan_id| { + dest_chans + .iter() + .find(|chan| chan.channel_id == *chan_id) + .and_then(|chan| chan.short_channel_id) + .unwrap() + }) + .collect(); + + for i in 0..num_paths { + let middle_scid = middle_scids[i % middle_scids.len()]; + let dest_scid = dest_scids[i % dest_scids.len()]; + + let path_amt = if i == num_paths - 1 { + amt - amt_per_path * (num_paths as u64 - 1) + } else { + amt_per_path + }; + let path_fee = if i == num_paths - 1 { + first_hop_fee - fee_per_path * (num_paths as u64 - 1) + } else { + fee_per_path + }; + + paths.push(Path { + hops: vec![ + RouteHop { + pubkey: middle.get_our_node_id(), + node_features: middle.node_features(), + short_channel_id: middle_scid, + channel_features: middle.channel_features(), + fee_msat: path_fee, + cltv_expiry_delta: 100, + maybe_announced_channel: true, + }, + RouteHop { + pubkey: dest.get_our_node_id(), + node_features: dest.node_features(), + short_channel_id: dest_scid, + channel_features: dest.channel_features(), + fee_msat: path_amt, + cltv_expiry_delta: 200, + maybe_announced_channel: true, + }, + ], + blinded_tail: None, + }); + } + + let route_params = RouteParameters::from_payment_params_and_value( + PaymentParameters::from_node_id(dest.get_our_node_id(), TEST_FINAL_CLTV), + amt, + ); + let route = Route { paths, route_params: Some(route_params) }; + let onion = RecipientOnionFields::secret_only(secret, amt); + let res = source.send_payment_with_route(route, hash, onion, id); + let succeeded = match res { + Err(_) => false, + Ok(()) => Self::check_payment_send_events(source, id), + }; + if succeeded { + self.nodes[source_idx].pending.push(id); + } + } + + fn claim_payment(&mut self, node: &HarnessNode<'_>, payment_hash: PaymentHash, fail: bool) { + if fail { + node.fail_htlc_backwards(&payment_hash); + } else { + let payment_preimage = *self + .payment_preimages + .get(&payment_hash) + .expect("PaymentClaimable for unknown payment hash"); + node.claim_funds(payment_preimage); + self.claimed_payment_hashes.insert(payment_hash); + } + } + + fn mark_sent(&mut self, node_idx: usize, sent_id: PaymentId, payment_hash: PaymentHash) { + let node = &mut self.nodes[node_idx]; + let idx_opt = node.pending.iter().position(|id| *id == sent_id); + if let Some(idx) = idx_opt { + node.pending.remove(idx); + node.resolved.insert(sent_id, Some(payment_hash)); + } else { + assert!(node.resolved.contains_key(&sent_id)); + } + } + + fn mark_resolved_without_hash(&mut self, node_idx: usize, payment_id: PaymentId) { + let node = &mut self.nodes[node_idx]; + let idx_opt = node.pending.iter().position(|id| *id == payment_id); + if let Some(idx) = idx_opt { + node.pending.remove(idx); + node.resolved.insert(payment_id, None); + } else if !node.resolved.contains_key(&payment_id) { + // Some resolutions can arrive immediately, before the send helper records + // the payment as pending. Track them so later duplicate events are accepted. + node.resolved.insert(payment_id, None); + } + } + + fn mark_successful_probe(&mut self, node_idx: usize, payment_id: PaymentId) { + let node = &mut self.nodes[node_idx]; + let idx_opt = node.pending.iter().position(|id| *id == payment_id); + if let Some(idx) = idx_opt { + node.pending.remove(idx); + node.resolved.insert(payment_id, None); + } else { + assert!(node.resolved.contains_key(&payment_id)); + } + } + + fn assert_all_resolved(&self) { + for (idx, node) in self.nodes.iter().enumerate() { + assert!( + node.pending.is_empty(), + "Node {} has {} stuck pending payments after settling all state", + idx, + node.pending.len() + ); + } + } + + fn assert_claims_reported(&self) { + for hash in self.claimed_payment_hashes.iter() { + let found = self + .nodes + .iter() + .any(|node| node.resolved.values().any(|h| h.as_ref() == Some(hash))); + assert!( + found, + "Payment {:?} was claimed by receiver but sender never got PaymentSent", + hash + ); + } + } +} + fn build_node_config(chan_type: ChanType) -> UserConfig { let mut config = UserConfig::default(); config.channel_config.forwarding_fee_proportional_millionths = 0; @@ -1959,21 +2089,13 @@ pub fn do_test(data: &[u8], out: Out) { let chan_a_id = ab_link.first_channel_id(); let chan_b_id = bc_link.first_channel_id(); - let mut p_ctr: u64 = 0; - let mut queues = EventQueues::new(); + let mut payments = PaymentTracker::new(); for node in &mut nodes { node.serialized_manager = node.encode(); } - let pending_payments = RefCell::new([Vec::new(), Vec::new(), Vec::new()]); - let resolved_payments: RefCell<[HashMap>; 3]> = - RefCell::new([new_hash_map(), new_hash_map(), new_hash_map()]); - let claimed_payment_hashes: RefCell> = RefCell::new(HashSet::new()); - let payment_preimages: RefCell> = - RefCell::new(new_hash_map()); - macro_rules! test_return { () => {{ assert_test_invariants(&nodes); @@ -2247,61 +2369,26 @@ pub fn do_test(data: &[u8], out: Out) { let mut claim_set = new_hash_map(); let mut events = nodes[$node].get_and_clear_pending_events(); let had_events = !events.is_empty(); - let mut pending_payments = pending_payments.borrow_mut(); - let mut resolved_payments = resolved_payments.borrow_mut(); for event in events.drain(..) { match event { events::Event::PaymentClaimable { payment_hash, .. } => { if claim_set.insert(payment_hash.0, ()).is_none() { - if $fail { - nodes[$node].fail_htlc_backwards(&payment_hash); - } else { - let payment_preimage = *payment_preimages - .borrow() - .get(&payment_hash) - .expect("PaymentClaimable for unknown payment hash"); - nodes[$node].claim_funds(payment_preimage); - claimed_payment_hashes.borrow_mut().insert(payment_hash); - } + payments.claim_payment(&nodes[$node], payment_hash, $fail); } }, events::Event::PaymentSent { payment_id, payment_hash, .. } => { - let sent_id = payment_id.unwrap(); - let idx_opt = - pending_payments[$node].iter().position(|id| *id == sent_id); - if let Some(idx) = idx_opt { - pending_payments[$node].remove(idx); - resolved_payments[$node].insert(sent_id, Some(payment_hash)); - } else { - assert!(resolved_payments[$node].contains_key(&sent_id)); - } + payments.mark_sent($node, payment_id.unwrap(), payment_hash); }, // Even though we don't explicitly send probes, because probes are - // detected based on hashing the payment hash+preimage, its rather + // detected based on hashing the payment hash+preimage, it is rather // trivial for the fuzzer to build payments that accidentally end up // looking like probes. events::Event::ProbeSuccessful { payment_id, .. } => { - let idx_opt = - pending_payments[$node].iter().position(|id| *id == payment_id); - if let Some(idx) = idx_opt { - pending_payments[$node].remove(idx); - resolved_payments[$node].insert(payment_id, None); - } else { - assert!(resolved_payments[$node].contains_key(&payment_id)); - } + payments.mark_successful_probe($node, payment_id); }, events::Event::PaymentFailed { payment_id, .. } | events::Event::ProbeFailed { payment_id, .. } => { - let idx_opt = - pending_payments[$node].iter().position(|id| *id == payment_id); - if let Some(idx) = idx_opt { - pending_payments[$node].remove(idx); - resolved_payments[$node].insert(payment_id, None); - } else if !resolved_payments[$node].contains_key(&payment_id) { - // Payment failed immediately on send, so it was never added to - // pending_payments. Add it to resolved_payments to track it. - resolved_payments[$node].insert(payment_id, None); - } + payments.mark_resolved_without_hash($node, payment_id); }, events::Event::PaymentClaimed { .. } => {}, events::Event::PaymentPathSuccessful { .. } => {}, @@ -2309,7 +2396,6 @@ pub fn do_test(data: &[u8], out: Out) { events::Event::PaymentForwarded { .. } if $node == 1 => {}, events::Event::ChannelReady { .. } => {}, events::Event::HTLCHandlingFailed { .. } => {}, - events::Event::FundingTransactionReadyForSigning { channel_id, counterparty_node_id, @@ -2340,7 +2426,6 @@ pub fn do_test(data: &[u8], out: Out) { | events::FundingInfo::Tx { .. }, .. } => {}, - _ => panic!("Unhandled event: {:?}", event), } } @@ -2357,110 +2442,19 @@ pub fn do_test(data: &[u8], out: Out) { }}; } - let send = - |source_idx: usize, dest_idx: usize, dest_chan_id, amt, payment_ctr: &mut u64| { - let source = &nodes[source_idx]; - let dest = &nodes[dest_idx]; - let (secret, hash) = get_payment_secret_hash(dest, payment_ctr, &payment_preimages); - let mut id = PaymentId([0; 32]); - id.0[0..8].copy_from_slice(&payment_ctr.to_ne_bytes()); - let succeeded = send_payment(source, dest, dest_chan_id, amt, secret, hash, id); - if succeeded { - pending_payments.borrow_mut()[source_idx].push(id); - } - succeeded - }; - let send_noret = |source_idx, dest_idx, dest_chan_id, amt, payment_ctr: &mut u64| { - send(source_idx, dest_idx, dest_chan_id, amt, payment_ctr); - }; - - let send_hop_noret = |source_idx: usize, - middle_idx: usize, - middle_chan_id: ChannelId, - dest_idx: usize, - dest_chan_id: ChannelId, - amt: u64, - payment_ctr: &mut u64| { - let source = &nodes[source_idx]; - let middle = &nodes[middle_idx]; - let dest = &nodes[dest_idx]; - let (secret, hash) = get_payment_secret_hash(dest, payment_ctr, &payment_preimages); - let mut id = PaymentId([0; 32]); - id.0[0..8].copy_from_slice(&payment_ctr.to_ne_bytes()); - let succeeded = send_hop_payment( - source, - middle, - middle_chan_id, - dest, - dest_chan_id, - amt, - secret, - hash, - id, - ); - if succeeded { - pending_payments.borrow_mut()[source_idx].push(id); - } - }; - - // Direct MPP payment (no hop) - let send_mpp_direct = |source_idx: usize, - dest_idx: usize, - dest_chan_ids: &[ChannelId], - amt: u64, - payment_ctr: &mut u64| { - let source = &nodes[source_idx]; - let dest = &nodes[dest_idx]; - let (secret, hash) = get_payment_secret_hash(dest, payment_ctr, &payment_preimages); - let mut id = PaymentId([0; 32]); - id.0[0..8].copy_from_slice(&payment_ctr.to_ne_bytes()); - let succeeded = send_mpp_payment(source, dest, dest_chan_ids, amt, secret, hash, id); - if succeeded { - pending_payments.borrow_mut()[source_idx].push(id); - } - }; - - // MPP payment via hop - splits payment across multiple channels on either or both hops - let send_mpp_hop = |source_idx: usize, - middle_idx: usize, - middle_chan_ids: &[ChannelId], - dest_idx: usize, - dest_chan_ids: &[ChannelId], - amt: u64, - payment_ctr: &mut u64| { - let source = &nodes[source_idx]; - let middle = &nodes[middle_idx]; - let dest = &nodes[dest_idx]; - let (secret, hash) = get_payment_secret_hash(dest, payment_ctr, &payment_preimages); - let mut id = PaymentId([0; 32]); - id.0[0..8].copy_from_slice(&payment_ctr.to_ne_bytes()); - let succeeded = send_mpp_hop_payment( - source, - middle, - middle_chan_ids, - dest, - dest_chan_ids, - amt, - secret, - hash, - id, - ); - if succeeded { - pending_payments.borrow_mut()[source_idx].push(id); - } - }; - macro_rules! process_all_events { - () => { { + () => {{ let mut last_pass_no_updates = false; for i in 0..std::usize::MAX { if i == 100 { - panic!("It may take may iterations to settle the state, but it should not take forever"); + panic!( + "It may take may iterations to settle the state, but it should not take forever" + ); } - // Next, make sure no monitor updates are pending + // Next, make sure no monitor updates are pending. ab_link.complete_all_monitor_updates(&nodes); bc_link.complete_all_monitor_updates(&nodes); - // Then, make sure any current forwards make their way to their destination + // Then, make sure any current forwards make their way to their destination. if process_msg_events!(0, false, ProcessMessages::AllMessages) { last_pass_no_updates = false; continue; @@ -2498,7 +2492,7 @@ pub fn do_test(data: &[u8], out: Out) { } last_pass_no_updates = true; } - } }; + }}; } let v = get_slice!(1)[0]; @@ -2571,74 +2565,104 @@ pub fn do_test(data: &[u8], out: Out) { 0x27 => process_ev_noret!(2, false), // 1/10th the channel size: - 0x30 => send_noret(0, 1, chan_a_id, 10_000_000, &mut p_ctr), - 0x31 => send_noret(1, 0, chan_a_id, 10_000_000, &mut p_ctr), - 0x32 => send_noret(1, 2, chan_b_id, 10_000_000, &mut p_ctr), - 0x33 => send_noret(2, 1, chan_b_id, 10_000_000, &mut p_ctr), - 0x34 => send_hop_noret(0, 1, chan_a_id, 2, chan_b_id, 10_000_000, &mut p_ctr), - 0x35 => send_hop_noret(2, 1, chan_b_id, 0, chan_a_id, 10_000_000, &mut p_ctr), - - 0x38 => send_noret(0, 1, chan_a_id, 1_000_000, &mut p_ctr), - 0x39 => send_noret(1, 0, chan_a_id, 1_000_000, &mut p_ctr), - 0x3a => send_noret(1, 2, chan_b_id, 1_000_000, &mut p_ctr), - 0x3b => send_noret(2, 1, chan_b_id, 1_000_000, &mut p_ctr), - 0x3c => send_hop_noret(0, 1, chan_a_id, 2, chan_b_id, 1_000_000, &mut p_ctr), - 0x3d => send_hop_noret(2, 1, chan_b_id, 0, chan_a_id, 1_000_000, &mut p_ctr), - - 0x40 => send_noret(0, 1, chan_a_id, 100_000, &mut p_ctr), - 0x41 => send_noret(1, 0, chan_a_id, 100_000, &mut p_ctr), - 0x42 => send_noret(1, 2, chan_b_id, 100_000, &mut p_ctr), - 0x43 => send_noret(2, 1, chan_b_id, 100_000, &mut p_ctr), - 0x44 => send_hop_noret(0, 1, chan_a_id, 2, chan_b_id, 100_000, &mut p_ctr), - 0x45 => send_hop_noret(2, 1, chan_b_id, 0, chan_a_id, 100_000, &mut p_ctr), - - 0x48 => send_noret(0, 1, chan_a_id, 10_000, &mut p_ctr), - 0x49 => send_noret(1, 0, chan_a_id, 10_000, &mut p_ctr), - 0x4a => send_noret(1, 2, chan_b_id, 10_000, &mut p_ctr), - 0x4b => send_noret(2, 1, chan_b_id, 10_000, &mut p_ctr), - 0x4c => send_hop_noret(0, 1, chan_a_id, 2, chan_b_id, 10_000, &mut p_ctr), - 0x4d => send_hop_noret(2, 1, chan_b_id, 0, chan_a_id, 10_000, &mut p_ctr), - - 0x50 => send_noret(0, 1, chan_a_id, 1_000, &mut p_ctr), - 0x51 => send_noret(1, 0, chan_a_id, 1_000, &mut p_ctr), - 0x52 => send_noret(1, 2, chan_b_id, 1_000, &mut p_ctr), - 0x53 => send_noret(2, 1, chan_b_id, 1_000, &mut p_ctr), - 0x54 => send_hop_noret(0, 1, chan_a_id, 2, chan_b_id, 1_000, &mut p_ctr), - 0x55 => send_hop_noret(2, 1, chan_b_id, 0, chan_a_id, 1_000, &mut p_ctr), - - 0x58 => send_noret(0, 1, chan_a_id, 100, &mut p_ctr), - 0x59 => send_noret(1, 0, chan_a_id, 100, &mut p_ctr), - 0x5a => send_noret(1, 2, chan_b_id, 100, &mut p_ctr), - 0x5b => send_noret(2, 1, chan_b_id, 100, &mut p_ctr), - 0x5c => send_hop_noret(0, 1, chan_a_id, 2, chan_b_id, 100, &mut p_ctr), - 0x5d => send_hop_noret(2, 1, chan_b_id, 0, chan_a_id, 100, &mut p_ctr), - - 0x60 => send_noret(0, 1, chan_a_id, 10, &mut p_ctr), - 0x61 => send_noret(1, 0, chan_a_id, 10, &mut p_ctr), - 0x62 => send_noret(1, 2, chan_b_id, 10, &mut p_ctr), - 0x63 => send_noret(2, 1, chan_b_id, 10, &mut p_ctr), - 0x64 => send_hop_noret(0, 1, chan_a_id, 2, chan_b_id, 10, &mut p_ctr), - 0x65 => send_hop_noret(2, 1, chan_b_id, 0, chan_a_id, 10, &mut p_ctr), - - 0x68 => send_noret(0, 1, chan_a_id, 1, &mut p_ctr), - 0x69 => send_noret(1, 0, chan_a_id, 1, &mut p_ctr), - 0x6a => send_noret(1, 2, chan_b_id, 1, &mut p_ctr), - 0x6b => send_noret(2, 1, chan_b_id, 1, &mut p_ctr), - 0x6c => send_hop_noret(0, 1, chan_a_id, 2, chan_b_id, 1, &mut p_ctr), - 0x6d => send_hop_noret(2, 1, chan_b_id, 0, chan_a_id, 1, &mut p_ctr), + 0x30 => payments.send_noret(&nodes, 0, 1, chan_a_id, 10_000_000), + 0x31 => payments.send_noret(&nodes, 1, 0, chan_a_id, 10_000_000), + 0x32 => payments.send_noret(&nodes, 1, 2, chan_b_id, 10_000_000), + 0x33 => payments.send_noret(&nodes, 2, 1, chan_b_id, 10_000_000), + 0x34 => payments.send_hop(&nodes, 0, 1, chan_a_id, 2, chan_b_id, 10_000_000), + 0x35 => payments.send_hop(&nodes, 2, 1, chan_b_id, 0, chan_a_id, 10_000_000), + + 0x38 => payments.send_noret(&nodes, 0, 1, chan_a_id, 1_000_000), + 0x39 => payments.send_noret(&nodes, 1, 0, chan_a_id, 1_000_000), + 0x3a => payments.send_noret(&nodes, 1, 2, chan_b_id, 1_000_000), + 0x3b => payments.send_noret(&nodes, 2, 1, chan_b_id, 1_000_000), + 0x3c => payments.send_hop(&nodes, 0, 1, chan_a_id, 2, chan_b_id, 1_000_000), + 0x3d => payments.send_hop(&nodes, 2, 1, chan_b_id, 0, chan_a_id, 1_000_000), + + 0x40 => payments.send_noret(&nodes, 0, 1, chan_a_id, 100_000), + 0x41 => payments.send_noret(&nodes, 1, 0, chan_a_id, 100_000), + 0x42 => payments.send_noret(&nodes, 1, 2, chan_b_id, 100_000), + 0x43 => payments.send_noret(&nodes, 2, 1, chan_b_id, 100_000), + 0x44 => payments.send_hop(&nodes, 0, 1, chan_a_id, 2, chan_b_id, 100_000), + 0x45 => payments.send_hop(&nodes, 2, 1, chan_b_id, 0, chan_a_id, 100_000), + + 0x48 => payments.send_noret(&nodes, 0, 1, chan_a_id, 10_000), + 0x49 => payments.send_noret(&nodes, 1, 0, chan_a_id, 10_000), + 0x4a => payments.send_noret(&nodes, 1, 2, chan_b_id, 10_000), + 0x4b => payments.send_noret(&nodes, 2, 1, chan_b_id, 10_000), + 0x4c => payments.send_hop(&nodes, 0, 1, chan_a_id, 2, chan_b_id, 10_000), + 0x4d => payments.send_hop(&nodes, 2, 1, chan_b_id, 0, chan_a_id, 10_000), + + 0x50 => payments.send_noret(&nodes, 0, 1, chan_a_id, 1_000), + 0x51 => payments.send_noret(&nodes, 1, 0, chan_a_id, 1_000), + 0x52 => payments.send_noret(&nodes, 1, 2, chan_b_id, 1_000), + 0x53 => payments.send_noret(&nodes, 2, 1, chan_b_id, 1_000), + 0x54 => payments.send_hop(&nodes, 0, 1, chan_a_id, 2, chan_b_id, 1_000), + 0x55 => payments.send_hop(&nodes, 2, 1, chan_b_id, 0, chan_a_id, 1_000), + + 0x58 => payments.send_noret(&nodes, 0, 1, chan_a_id, 100), + 0x59 => payments.send_noret(&nodes, 1, 0, chan_a_id, 100), + 0x5a => payments.send_noret(&nodes, 1, 2, chan_b_id, 100), + 0x5b => payments.send_noret(&nodes, 2, 1, chan_b_id, 100), + 0x5c => payments.send_hop(&nodes, 0, 1, chan_a_id, 2, chan_b_id, 100), + 0x5d => payments.send_hop(&nodes, 2, 1, chan_b_id, 0, chan_a_id, 100), + + 0x60 => payments.send_noret(&nodes, 0, 1, chan_a_id, 10), + 0x61 => payments.send_noret(&nodes, 1, 0, chan_a_id, 10), + 0x62 => payments.send_noret(&nodes, 1, 2, chan_b_id, 10), + 0x63 => payments.send_noret(&nodes, 2, 1, chan_b_id, 10), + 0x64 => payments.send_hop(&nodes, 0, 1, chan_a_id, 2, chan_b_id, 10), + 0x65 => payments.send_hop(&nodes, 2, 1, chan_b_id, 0, chan_a_id, 10), + + 0x68 => payments.send_noret(&nodes, 0, 1, chan_a_id, 1), + 0x69 => payments.send_noret(&nodes, 1, 0, chan_a_id, 1), + 0x6a => payments.send_noret(&nodes, 1, 2, chan_b_id, 1), + 0x6b => payments.send_noret(&nodes, 2, 1, chan_b_id, 1), + 0x6c => payments.send_hop(&nodes, 0, 1, chan_a_id, 2, chan_b_id, 1), + 0x6d => payments.send_hop(&nodes, 2, 1, chan_b_id, 0, chan_a_id, 1), // MPP payments // 0x70: direct MPP from 0 to 1 (multi A-B channels) - 0x70 => send_mpp_direct(0, 1, &chan_ab_ids, 1_000_000, &mut p_ctr), + 0x70 => payments.send_mpp_direct(&nodes, 0, 1, ab_link.channel_ids(), 1_000_000), // 0x71: MPP 0->1->2, multi channels on first hop (A-B) - 0x71 => send_mpp_hop(0, 1, &chan_ab_ids, 2, &[chan_b_id], 1_000_000, &mut p_ctr), + 0x71 => payments.send_mpp_hop( + &nodes, + 0, + 1, + ab_link.channel_ids(), + 2, + &[chan_b_id], + 1_000_000, + ), // 0x72: MPP 0->1->2, multi channels on both hops (A-B and B-C) - 0x72 => send_mpp_hop(0, 1, &chan_ab_ids, 2, &chan_bc_ids, 1_000_000, &mut p_ctr), + 0x72 => payments.send_mpp_hop( + &nodes, + 0, + 1, + ab_link.channel_ids(), + 2, + bc_link.channel_ids(), + 1_000_000, + ), // 0x73: MPP 0->1->2, multi channels on second hop (B-C) - 0x73 => send_mpp_hop(0, 1, &[chan_a_id], 2, &chan_bc_ids, 1_000_000, &mut p_ctr), + 0x73 => payments.send_mpp_hop( + &nodes, + 0, + 1, + &[chan_a_id], + 2, + bc_link.channel_ids(), + 1_000_000, + ), // 0x74: direct MPP from 0 to 1, multi parts over single channel 0x74 => { - send_mpp_direct(0, 1, &[chan_a_id, chan_a_id, chan_a_id], 1_000_000, &mut p_ctr) + payments.send_mpp_direct( + &nodes, + 0, + 1, + &[chan_a_id, chan_a_id, chan_a_id], + 1_000_000, + ); }, 0x80 => nodes[0].bump_fee_estimate(chan_type), @@ -2887,40 +2911,22 @@ pub fn do_test(data: &[u8], out: Out) { process_all_events!(); // Verify no payments are stuck - all should have resolved - for (idx, pending) in pending_payments.borrow().iter().enumerate() { - assert!( - pending.is_empty(), - "Node {} has {} stuck pending payments after settling all state", - idx, - pending.len() - ); - } - + payments.assert_all_resolved(); // Verify that every payment claimed by a receiver resulted in a // PaymentSent event at the sender. - let resolved = resolved_payments.borrow(); - for hash in claimed_payment_hashes.borrow().iter() { - let found = resolved.iter().any(|node_resolved| { - node_resolved.values().any(|h| h.as_ref() == Some(hash)) - }); - assert!( - found, - "Payment {:?} was claimed by receiver but sender never got PaymentSent", - hash - ); - } + payments.assert_claims_reported(); // Finally, make sure that at least one end of each channel can make a substantial payment for &chan_id in ab_link.channel_ids() { assert!( - send(0, 1, chan_id, 10_000_000, &mut p_ctr) - || send(1, 0, chan_id, 10_000_000, &mut p_ctr) + payments.send(&nodes, 0, 1, chan_id, 10_000_000) + || payments.send(&nodes, 1, 0, chan_id, 10_000_000) ); } for &chan_id in bc_link.channel_ids() { assert!( - send(1, 2, chan_id, 10_000_000, &mut p_ctr) - || send(2, 1, chan_id, 10_000_000, &mut p_ctr) + payments.send(&nodes, 1, 2, chan_id, 10_000_000) + || payments.send(&nodes, 2, 1, chan_id, 10_000_000) ); } From ad0498eb43e820b7191c4c437084d3c178931550 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Fri, 1 May 2026 13:01:06 +0200 Subject: [PATCH 382/627] Route chanmon fuzz exits through loop break Replace the local test_return macro with a labeled fuzz loop. Keep one invariant check after the loop. Leave harness setup extraction for the next commit. --- fuzz/src/chanmon_consistency.rs | 49 ++++++++++++--------------------- 1 file changed, 18 insertions(+), 31 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index d38e4182842..ebe5d469e75 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -2061,7 +2061,7 @@ pub fn do_test(data: &[u8], out: Out) { make_channel(&nodes[1], &nodes[2], 6, false, false, &mut chain_state); // Wipe the transactions-broadcasted set to make sure we don't broadcast any transactions - // during normal operation in `test_return`. + // during normal operation after setup. nodes[0].broadcaster.txn_broadcasted.borrow_mut().clear(); nodes[1].broadcaster.txn_broadcasted.borrow_mut().clear(); nodes[2].broadcaster.txn_broadcasted.borrow_mut().clear(); @@ -2096,26 +2096,8 @@ pub fn do_test(data: &[u8], out: Out) { node.serialized_manager = node.encode(); } - macro_rules! test_return { - () => {{ - assert_test_invariants(&nodes); - return; - }}; - } - - let mut read_pos = 1; // First byte was consumed for initial config (persistence styles + chan_type) - macro_rules! get_slice { - ($len: expr) => {{ - let slice_len = $len as usize; - if data.len() < read_pos + slice_len { - test_return!(); - } - read_pos += slice_len; - &data[read_pos - slice_len..read_pos] - }}; - } - - loop { + let mut read_pos = 1; // First byte was consumed for initial config. + 'fuzz_loop: loop { // While delivering messages, we select across three possible message selection processes // to ensure we get as much coverage as possible. See the individual enum variants for more // details. @@ -2495,7 +2477,11 @@ pub fn do_test(data: &[u8], out: Out) { }}; } - let v = get_slice!(1)[0]; + if data.len() < read_pos + 1 { + break 'fuzz_loop; + } + let v = data[read_pos]; + read_pos += 1; out.locked_write(format!("READ A BYTE! HANDLING INPUT {:x}...........\n", v).as_bytes()); match v { // In general, we keep related message groups close together in binary form, allowing @@ -2674,28 +2660,28 @@ pub fn do_test(data: &[u8], out: Out) { 0xa0 => { if !cfg!(splicing) { - test_return!(); + break 'fuzz_loop; } let cp_node_id = nodes[1].get_our_node_id(); nodes[0].splice_in(&cp_node_id, &chan_a_id); }, 0xa1 => { if !cfg!(splicing) { - test_return!(); + break 'fuzz_loop; } let cp_node_id = nodes[0].get_our_node_id(); nodes[1].splice_in(&cp_node_id, &chan_a_id); }, 0xa2 => { if !cfg!(splicing) { - test_return!(); + break 'fuzz_loop; } let cp_node_id = nodes[2].get_our_node_id(); nodes[1].splice_in(&cp_node_id, &chan_b_id); }, 0xa3 => { if !cfg!(splicing) { - test_return!(); + break 'fuzz_loop; } let cp_node_id = nodes[1].get_our_node_id(); nodes[2].splice_in(&cp_node_id, &chan_b_id); @@ -2703,28 +2689,28 @@ pub fn do_test(data: &[u8], out: Out) { 0xa4 => { if !cfg!(splicing) { - test_return!(); + break 'fuzz_loop; } let cp_node_id = nodes[1].get_our_node_id(); nodes[0].splice_out(&cp_node_id, &chan_a_id); }, 0xa5 => { if !cfg!(splicing) { - test_return!(); + break 'fuzz_loop; } let cp_node_id = nodes[0].get_our_node_id(); nodes[1].splice_out(&cp_node_id, &chan_a_id); }, 0xa6 => { if !cfg!(splicing) { - test_return!(); + break 'fuzz_loop; } let cp_node_id = nodes[2].get_our_node_id(); nodes[1].splice_out(&cp_node_id, &chan_b_id); }, 0xa7 => { if !cfg!(splicing) { - test_return!(); + break 'fuzz_loop; } let cp_node_id = nodes[1].get_our_node_id(); nodes[2].splice_out(&cp_node_id, &chan_b_id); @@ -2934,13 +2920,14 @@ pub fn do_test(data: &[u8], out: Out) { nodes[1].record_last_htlc_clear_fee(); nodes[2].record_last_htlc_clear_fee(); }, - _ => test_return!(), + _ => break 'fuzz_loop, } for node in &mut nodes { node.refresh_serialized_manager(); } } + assert_test_invariants(&nodes); } pub fn chanmon_consistency_test(data: &[u8], out: Out) { From 75ac90a97822ef08b140df01aa0c1d3a494ebac1 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Fri, 1 May 2026 13:35:45 +0200 Subject: [PATCH 383/627] Build chanmon consistency harness Collect the chanmon consistency setup, state, and main fuzz flow into a harness. Keep do_test focused on reading fuzz bytes and dispatching actions. --- fuzz/src/chanmon_consistency.rs | 1866 +++++++++++++++++-------------- 1 file changed, 1056 insertions(+), 810 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index ebe5d469e75..8a90dc93e97 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -635,6 +635,21 @@ enum ChanType { ZeroFeeCommitments, } +// While delivering messages, select across three possible message selection +// processes to maximize coverage. See the individual enum variants for details. +#[derive(Copy, Clone, PartialEq, Eq)] +enum ProcessMessages { + /// Deliver all available messages, including fetching any new messages from + /// `get_and_clear_pending_msg_events()` which may have side effects. + AllMessages, + /// Call `get_and_clear_pending_msg_events()` first, then deliver up to one + /// message, which may already be queued. + OneMessage, + /// Deliver up to one already-queued message. This avoids the side effects of + /// `get_and_clear_pending_msg_events()`, such as freeing the HTLC holding cell. + OnePendingMessage, +} + struct HarnessNode<'a> { node_id: u8, node: ChanMan<'a>, @@ -1014,6 +1029,19 @@ enum MonitorUpdateSelector { Last, } +#[derive(Copy, Clone)] +enum MppDirectChannels { + All, + RepeatedFirst, +} + +#[derive(Copy, Clone)] +enum MppHopChannels { + FirstHop, + BothHops, + SecondHop, +} + struct EventQueues { ab: Vec, ba: Vec, @@ -1200,6 +1228,11 @@ impl PeerLink { &self.channel_ids } + fn connects(&self, node_a: usize, node_b: usize) -> bool { + (self.node_a == node_a && self.node_b == node_b) + || (self.node_a == node_b && self.node_b == node_a) + } + fn complete_all_monitor_updates(&self, nodes: &[HarnessNode<'_>; 3]) { for id in &self.channel_ids { nodes[self.node_a].complete_all_monitor_updates(id); @@ -1721,6 +1754,17 @@ impl PaymentTracker { } } +struct Harness<'a, Out: Output + MaybeSend + MaybeSync> { + out: Out, + chan_type: ChanType, + chain_state: ChainState, + nodes: [HarnessNode<'a>; 3], + ab_link: PeerLink, + bc_link: PeerLink, + queues: EventQueues, + payments: PaymentTracker, +} + fn build_node_config(chan_type: ChanType) -> UserConfig { let mut config = UserConfig::default(); config.channel_config.forwarding_fee_proportional_millionths = 0; @@ -1946,988 +1990,1190 @@ fn lock_fundings(nodes: &[HarnessNode<'_>; 3]) { } } -#[inline] -pub fn do_test(data: &[u8], out: Out) { - let router = FuzzRouter {}; +impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { + fn new(config_byte: u8, out: Out, router: &'a FuzzRouter) -> Self { + let chan_type = match (config_byte >> 3) & 0b11 { + 0 => ChanType::Legacy, + 1 => ChanType::KeyedAnchors, + _ => ChanType::ZeroFeeCommitments, + }; + let persistence_styles = [ + if config_byte & 0b01 != 0 { + ChannelMonitorUpdateStatus::InProgress + } else { + ChannelMonitorUpdateStatus::Completed + }, + if config_byte & 0b10 != 0 { + ChannelMonitorUpdateStatus::InProgress + } else { + ChannelMonitorUpdateStatus::Completed + }, + if config_byte & 0b100 != 0 { + ChannelMonitorUpdateStatus::InProgress + } else { + ChannelMonitorUpdateStatus::Completed + }, + ]; + + let wallet_a = TestWalletSource::new(SecretKey::from_slice(&[1; 32]).unwrap()); + let wallet_b = TestWalletSource::new(SecretKey::from_slice(&[2; 32]).unwrap()); + let wallet_c = TestWalletSource::new(SecretKey::from_slice(&[3; 32]).unwrap()); + let wallets = [&wallet_a, &wallet_b, &wallet_c]; + let coinbase_tx = bitcoin::Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![bitcoin::TxIn { ..Default::default() }], + output: wallets + .iter() + .map(|wallet| TxOut { + value: Amount::from_sat(100_000), + script_pubkey: wallet.get_change_script().unwrap(), + }) + .collect(), + }; + for (idx, wallet) in wallets.iter().enumerate() { + wallet.add_utxo(coinbase_tx.clone(), idx as u32); + } - // Read initial monitor styles and channel type from fuzz input byte 0: - // bits 0-2: monitor styles (1 bit per node) - // bits 3-4: channel type (0=Legacy, 1=KeyedAnchors, 2=ZeroFeeCommitments) - let config_byte = if !data.is_empty() { data[0] } else { 0 }; - let chan_type = match (config_byte >> 3) & 0b11 { - 0 => ChanType::Legacy, - 1 => ChanType::KeyedAnchors, - _ => ChanType::ZeroFeeCommitments, - }; - let persistence_styles = [ - if config_byte & 0b01 != 0 { - ChannelMonitorUpdateStatus::InProgress - } else { - ChannelMonitorUpdateStatus::Completed - }, - if config_byte & 0b10 != 0 { - ChannelMonitorUpdateStatus::InProgress - } else { - ChannelMonitorUpdateStatus::Completed - }, - if config_byte & 0b100 != 0 { - ChannelMonitorUpdateStatus::InProgress - } else { - ChannelMonitorUpdateStatus::Completed - }, - ]; - - let mut chain_state = ChainState::new(); - let wallet_a = TestWalletSource::new(SecretKey::from_slice(&[1; 32]).unwrap()); - let wallet_b = TestWalletSource::new(SecretKey::from_slice(&[2; 32]).unwrap()); - let wallet_c = TestWalletSource::new(SecretKey::from_slice(&[3; 32]).unwrap()); - - let wallets = [&wallet_a, &wallet_b, &wallet_c]; - let coinbase_tx = bitcoin::Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: bitcoin::absolute::LockTime::ZERO, - input: vec![bitcoin::TxIn { ..Default::default() }], - output: wallets - .iter() - .map(|wallet| TxOut { - value: Amount::from_sat(100_000), - script_pubkey: wallet.get_change_script().unwrap(), - }) - .collect(), - }; - for (idx, wallet) in wallets.iter().enumerate() { - wallet.add_utxo(coinbase_tx.clone(), idx as u32); - } - - let fee_est_a = Arc::new(FuzzEstimator { ret_val: atomic::AtomicU32::new(253) }); - let fee_est_b = Arc::new(FuzzEstimator { ret_val: atomic::AtomicU32::new(253) }); - let fee_est_c = Arc::new(FuzzEstimator { ret_val: atomic::AtomicU32::new(253) }); - let broadcast_a = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); - let broadcast_b = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); - let broadcast_c = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); - - // 3 nodes is enough to hit all the possible cases, notably unknown-source-unknown-dest - // forwarding. - let mut nodes = [ - HarnessNode::new( - 0, - wallet_a, - Arc::clone(&fee_est_a), - Arc::clone(&broadcast_a), - persistence_styles[0], - &out, - &router, - chan_type, - ), - HarnessNode::new( - 1, - wallet_b, - Arc::clone(&fee_est_b), - Arc::clone(&broadcast_b), - persistence_styles[1], - &out, - &router, - chan_type, - ), - HarnessNode::new( - 2, - wallet_c, - Arc::clone(&fee_est_c), - Arc::clone(&broadcast_c), - persistence_styles[2], - &out, - &router, + let fee_est_a = Arc::new(FuzzEstimator { ret_val: atomic::AtomicU32::new(253) }); + let fee_est_b = Arc::new(FuzzEstimator { ret_val: atomic::AtomicU32::new(253) }); + let fee_est_c = Arc::new(FuzzEstimator { ret_val: atomic::AtomicU32::new(253) }); + let broadcast_a = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); + let broadcast_b = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); + let broadcast_c = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); + + // 3 nodes is enough to hit all the possible cases, notably + // unknown-source-unknown-dest forwarding. + let mut nodes = [ + HarnessNode::new( + 0, + wallet_a, + Arc::clone(&fee_est_a), + Arc::clone(&broadcast_a), + persistence_styles[0], + &out, + router, + chan_type, + ), + HarnessNode::new( + 1, + wallet_b, + Arc::clone(&fee_est_b), + Arc::clone(&broadcast_b), + persistence_styles[1], + &out, + router, + chan_type, + ), + HarnessNode::new( + 2, + wallet_c, + Arc::clone(&fee_est_c), + Arc::clone(&broadcast_c), + persistence_styles[2], + &out, + router, + chan_type, + ), + ]; + let mut chain_state = ChainState::new(); + + // Connect peers first, then create channels. + connect_peers(&nodes[0], &nodes[1]); + connect_peers(&nodes[1], &nodes[2]); + + // Create 3 channels between A-B and 3 channels between B-C (6 total). + // + // Use distinct version numbers for each funding transaction so each test + // channel gets its own txid and funding outpoint. + // A-B: channel 2 A and B have 0-reserve (trusted open + trusted accept), + // channel 3 A has 0-reserve (trusted accept). + make_channel(&nodes[0], &nodes[1], 1, false, false, &mut chain_state); + make_channel(&nodes[0], &nodes[1], 2, true, true, &mut chain_state); + make_channel(&nodes[0], &nodes[1], 3, false, true, &mut chain_state); + // B-C: channel 4 B has 0-reserve (via trusted accept), + // channel 5 C has 0-reserve (via trusted open). + make_channel(&nodes[1], &nodes[2], 4, false, true, &mut chain_state); + make_channel(&nodes[1], &nodes[2], 5, true, false, &mut chain_state); + make_channel(&nodes[1], &nodes[2], 6, false, false, &mut chain_state); + + // Wipe the transactions-broadcasted set to make sure we don't broadcast + // any transactions during normal operation after setup. + nodes[0].broadcaster.txn_broadcasted.borrow_mut().clear(); + nodes[1].broadcaster.txn_broadcasted.borrow_mut().clear(); + nodes[2].broadcaster.txn_broadcasted.borrow_mut().clear(); + + // Sync all nodes to tip to lock the funding. + nodes[0].sync_with_chain_state(&chain_state, None); + nodes[1].sync_with_chain_state(&chain_state, None); + nodes[2].sync_with_chain_state(&chain_state, None); + + lock_fundings(&nodes); + + let chan_ab_ids = { + // Get channel IDs for all A-B channels (from node A's perspective). + let node_a_chans = nodes[0].list_usable_channels(); + [node_a_chans[0].channel_id, node_a_chans[1].channel_id, node_a_chans[2].channel_id] + }; + let chan_bc_ids = { + // Get channel IDs for all B-C channels (from node C's perspective). + let node_c_chans = nodes[2].list_usable_channels(); + [node_c_chans[0].channel_id, node_c_chans[1].channel_id, node_c_chans[2].channel_id] + }; + + for node in &mut nodes { + node.serialized_manager = node.encode(); + } + + Self { + out, chan_type, - ), - ]; - - // Connect peers first, then create channels - connect_peers(&nodes[0], &nodes[1]); - connect_peers(&nodes[1], &nodes[2]); - - // Create 3 channels between A-B and 3 channels between B-C (6 total). - // - // Use distinct version numbers for each funding transaction so each test channel gets its own - // txid and funding outpoint. - // A-B: channel 2 A and B have 0-reserve (trusted open + trusted accept), - // channel 3 A has 0-reserve (trusted accept) - make_channel(&nodes[0], &nodes[1], 1, false, false, &mut chain_state); - make_channel(&nodes[0], &nodes[1], 2, true, true, &mut chain_state); - make_channel(&nodes[0], &nodes[1], 3, false, true, &mut chain_state); - // B-C: channel 4 B has 0-reserve (via trusted accept), - // channel 5 C has 0-reserve (via trusted open) - make_channel(&nodes[1], &nodes[2], 4, false, true, &mut chain_state); - make_channel(&nodes[1], &nodes[2], 5, true, false, &mut chain_state); - make_channel(&nodes[1], &nodes[2], 6, false, false, &mut chain_state); - - // Wipe the transactions-broadcasted set to make sure we don't broadcast any transactions - // during normal operation after setup. - nodes[0].broadcaster.txn_broadcasted.borrow_mut().clear(); - nodes[1].broadcaster.txn_broadcasted.borrow_mut().clear(); - nodes[2].broadcaster.txn_broadcasted.borrow_mut().clear(); - - // Sync all nodes to tip to lock the funding. - nodes[0].sync_with_chain_state(&chain_state, None); - nodes[1].sync_with_chain_state(&chain_state, None); - nodes[2].sync_with_chain_state(&chain_state, None); - - lock_fundings(&nodes); - - // Get channel IDs for all A-B channels (from node A's perspective) - let chan_ab_ids = { - let node_a_chans = nodes[0].list_usable_channels(); - [node_a_chans[0].channel_id, node_a_chans[1].channel_id, node_a_chans[2].channel_id] - }; - // Get channel IDs for all B-C channels (from node C's perspective) - let chan_bc_ids = { - let node_c_chans = nodes[2].list_usable_channels(); - [node_c_chans[0].channel_id, node_c_chans[1].channel_id, node_c_chans[2].channel_id] - }; - let mut ab_link = PeerLink::new(0, 1, chan_ab_ids); - let mut bc_link = PeerLink::new(1, 2, chan_bc_ids); - // Keep old names for backward compatibility in existing code - let chan_a_id = ab_link.first_channel_id(); - let chan_b_id = bc_link.first_channel_id(); + chain_state, + nodes, + ab_link: PeerLink::new(0, 1, chan_ab_ids), + bc_link: PeerLink::new(1, 2, chan_bc_ids), + queues: EventQueues::new(), + payments: PaymentTracker::new(), + } + } - let mut queues = EventQueues::new(); - let mut payments = PaymentTracker::new(); + fn chan_a_id(&self) -> ChannelId { + self.ab_link.first_channel_id() + } - for node in &mut nodes { - node.serialized_manager = node.encode(); + fn chan_b_id(&self) -> ChannelId { + self.bc_link.first_channel_id() } - let mut read_pos = 1; // First byte was consumed for initial config. - 'fuzz_loop: loop { - // While delivering messages, we select across three possible message selection processes - // to ensure we get as much coverage as possible. See the individual enum variants for more - // details. - #[derive(PartialEq)] - enum ProcessMessages { - /// Deliver all available messages, including fetching any new messages from - /// `get_and_clear_pending_msg_events()` (which may have side effects). - AllMessages, - /// Call `get_and_clear_pending_msg_events()` first, and then deliver up to one - /// message (which may already be queued). - OneMessage, - /// Deliver up to one already-queued message. This avoids any potential side-effects - /// of `get_and_clear_pending_msg_events()` (eg freeing the HTLC holding cell), which - /// provides potentially more coverage. - OnePendingMessage, - } - - macro_rules! process_msg_events { - ($node: expr, $corrupt_forward: expr, $limit_events: expr) => { { - let mut events = queues.take_for_node($node); - let mut new_events = Vec::new(); - if $limit_events != ProcessMessages::OnePendingMessage { - new_events = nodes[$node].get_and_clear_pending_msg_events(); - } - let mut had_events = false; - let mut events_iter = events.drain(..).chain(new_events.drain(..)); - let mut extra_ev = None; - for event in &mut events_iter { - had_events = true; - match event { - MessageSendEvent::UpdateHTLCs { node_id, channel_id, updates: CommitmentUpdate { update_add_htlcs, update_fail_htlcs, update_fulfill_htlcs, update_fail_malformed_htlcs, update_fee, commitment_signed } } => { - for (idx, dest) in nodes.iter().enumerate() { - if dest.get_our_node_id() == node_id { - for update_add in update_add_htlcs.iter() { - out.locked_write(format!("Delivering update_add_htlc from node {} to node {}.\n", $node, idx).as_bytes()); - if !$corrupt_forward { - dest.handle_update_add_htlc(nodes[$node].get_our_node_id(), update_add); - } else { - // Corrupt the update_add_htlc message so that its HMAC - // check will fail and we generate a - // update_fail_malformed_htlc instead of an - // update_fail_htlc as we do when we reject a payment. - let mut msg_ser = update_add.encode(); - msg_ser[1000] ^= 0xff; - let new_msg = UpdateAddHTLC::read_from_fixed_length_buffer(&mut &msg_ser[..]).unwrap(); - dest.handle_update_add_htlc(nodes[$node].get_our_node_id(), &new_msg); - } - } - let processed_change = !update_add_htlcs.is_empty() || !update_fulfill_htlcs.is_empty() || - !update_fail_htlcs.is_empty() || !update_fail_malformed_htlcs.is_empty(); - for update_fulfill in update_fulfill_htlcs { - out.locked_write(format!("Delivering update_fulfill_htlc from node {} to node {}.\n", $node, idx).as_bytes()); - dest.handle_update_fulfill_htlc(nodes[$node].get_our_node_id(), update_fulfill); - } - for update_fail in update_fail_htlcs.iter() { - out.locked_write(format!("Delivering update_fail_htlc from node {} to node {}.\n", $node, idx).as_bytes()); - dest.handle_update_fail_htlc(nodes[$node].get_our_node_id(), update_fail); - } - for update_fail_malformed in update_fail_malformed_htlcs.iter() { - out.locked_write(format!("Delivering update_fail_malformed_htlc from node {} to node {}.\n", $node, idx).as_bytes()); - dest.handle_update_fail_malformed_htlc(nodes[$node].get_our_node_id(), update_fail_malformed); - } - if let Some(msg) = update_fee { - out.locked_write(format!("Delivering update_fee from node {} to node {}.\n", $node, idx).as_bytes()); - dest.handle_update_fee(nodes[$node].get_our_node_id(), &msg); - } - if $limit_events != ProcessMessages::AllMessages && processed_change { - // If we only want to process some messages, don't deliver the CS until later. - extra_ev = Some(MessageSendEvent::UpdateHTLCs { node_id, channel_id, updates: CommitmentUpdate { - update_add_htlcs: Vec::new(), - update_fail_htlcs: Vec::new(), - update_fulfill_htlcs: Vec::new(), - update_fail_malformed_htlcs: Vec::new(), - update_fee: None, - commitment_signed - } }); - break; - } - out.locked_write(format!("Delivering commitment_signed from node {} to node {}.\n", $node, idx).as_bytes()); - dest.handle_commitment_signed_batch_test(nodes[$node].get_our_node_id(), &commitment_signed); - break; - } - } - }, - MessageSendEvent::SendRevokeAndACK { ref node_id, ref msg } => { - for (idx, dest) in nodes.iter().enumerate() { - if dest.get_our_node_id() == *node_id { - out.locked_write(format!("Delivering revoke_and_ack from node {} to node {}.\n", $node, idx).as_bytes()); - dest.handle_revoke_and_ack(nodes[$node].get_our_node_id(), msg); - } - } - }, - MessageSendEvent::SendChannelReestablish { ref node_id, ref msg } => { - for (idx, dest) in nodes.iter().enumerate() { - if dest.get_our_node_id() == *node_id { - out.locked_write(format!("Delivering channel_reestablish from node {} to node {}.\n", $node, idx).as_bytes()); - dest.handle_channel_reestablish(nodes[$node].get_our_node_id(), msg); - } - } - }, - MessageSendEvent::SendStfu { ref node_id, ref msg } => { - for (idx, dest) in nodes.iter().enumerate() { - if dest.get_our_node_id() == *node_id { - out.locked_write(format!("Delivering stfu from node {} to node {}.\n", $node, idx).as_bytes()); - dest.handle_stfu(nodes[$node].get_our_node_id(), msg); - } - } - }, - MessageSendEvent::SendTxAddInput { ref node_id, ref msg } => { - for (idx, dest) in nodes.iter().enumerate() { - if dest.get_our_node_id() == *node_id { - out.locked_write(format!("Delivering tx_add_input from node {} to node {}.\n", $node, idx).as_bytes()); - dest.handle_tx_add_input(nodes[$node].get_our_node_id(), msg); - } - } - }, - MessageSendEvent::SendTxAddOutput { ref node_id, ref msg } => { - for (idx, dest) in nodes.iter().enumerate() { - if dest.get_our_node_id() == *node_id { - out.locked_write(format!("Delivering tx_add_output from node {} to node {}.\n", $node, idx).as_bytes()); - dest.handle_tx_add_output(nodes[$node].get_our_node_id(), msg); - } - } - }, - MessageSendEvent::SendTxRemoveInput { ref node_id, ref msg } => { - for (idx, dest) in nodes.iter().enumerate() { - if dest.get_our_node_id() == *node_id { - out.locked_write(format!("Delivering tx_remove_input from node {} to node {}.\n", $node, idx).as_bytes()); - dest.handle_tx_remove_input(nodes[$node].get_our_node_id(), msg); - } - } - }, - MessageSendEvent::SendTxRemoveOutput { ref node_id, ref msg } => { - for (idx, dest) in nodes.iter().enumerate() { - if dest.get_our_node_id() == *node_id { - out.locked_write(format!("Delivering tx_remove_output from node {} to node {}.\n", $node, idx).as_bytes()); - dest.handle_tx_remove_output(nodes[$node].get_our_node_id(), msg); - } - } - }, - MessageSendEvent::SendTxComplete { ref node_id, ref msg } => { - for (idx, dest) in nodes.iter().enumerate() { - if dest.get_our_node_id() == *node_id { - out.locked_write(format!("Delivering tx_complete from node {} to node {}.\n", $node, idx).as_bytes()); - dest.handle_tx_complete(nodes[$node].get_our_node_id(), msg); - } - } - }, - MessageSendEvent::SendTxAbort { ref node_id, ref msg } => { - for (idx, dest) in nodes.iter().enumerate() { - if dest.get_our_node_id() == *node_id { - out.locked_write(format!("Delivering tx_abort from node {} to node {}.\n", $node, idx).as_bytes()); - dest.handle_tx_abort(nodes[$node].get_our_node_id(), msg); - } - } - }, - MessageSendEvent::SendTxInitRbf { ref node_id, ref msg } => { - for (idx, dest) in nodes.iter().enumerate() { - if dest.get_our_node_id() == *node_id { - out.locked_write(format!("Delivering tx_init_rbf from node {} to node {}.\n", $node, idx).as_bytes()); - dest.handle_tx_init_rbf(nodes[$node].get_our_node_id(), msg); - } - } - }, - MessageSendEvent::SendTxAckRbf { ref node_id, ref msg } => { - for (idx, dest) in nodes.iter().enumerate() { - if dest.get_our_node_id() == *node_id { - out.locked_write(format!("Delivering tx_ack_rbf from node {} to node {}.\n", $node, idx).as_bytes()); - dest.handle_tx_ack_rbf(nodes[$node].get_our_node_id(), msg); - } - } - }, - MessageSendEvent::SendTxSignatures { ref node_id, ref msg } => { - for (idx, dest) in nodes.iter().enumerate() { - if dest.get_our_node_id() == *node_id { - out.locked_write(format!("Delivering tx_signatures from node {} to node {}.\n", $node, idx).as_bytes()); - dest.handle_tx_signatures(nodes[$node].get_our_node_id(), msg); - } - } - }, - MessageSendEvent::SendSpliceInit { ref node_id, ref msg } => { - for (idx, dest) in nodes.iter().enumerate() { - if dest.get_our_node_id() == *node_id { - out.locked_write(format!("Delivering splice_init from node {} to node {}.\n", $node, idx).as_bytes()); - dest.handle_splice_init(nodes[$node].get_our_node_id(), msg); - } - } - }, - MessageSendEvent::SendSpliceAck { ref node_id, ref msg } => { - for (idx, dest) in nodes.iter().enumerate() { - if dest.get_our_node_id() == *node_id { - out.locked_write(format!("Delivering splice_ack from node {} to node {}.\n", $node, idx).as_bytes()); - dest.handle_splice_ack(nodes[$node].get_our_node_id(), msg); - } - } - }, - MessageSendEvent::SendSpliceLocked { ref node_id, ref msg } => { - for (idx, dest) in nodes.iter().enumerate() { - if dest.get_our_node_id() == *node_id { - out.locked_write(format!("Delivering splice_locked from node {} to node {}.\n", $node, idx).as_bytes()); - dest.handle_splice_locked(nodes[$node].get_our_node_id(), msg); - } - } - }, - MessageSendEvent::HandleError { ref action, .. } => { - assert_action_timeout_awaiting_response(action); - }, - MessageSendEvent::SendChannelReady { .. } => { - // Can be generated as a reestablish response - }, - MessageSendEvent::SendAnnouncementSignatures { .. } => { - // Can be generated as a reestablish response - }, - MessageSendEvent::SendChannelUpdate { .. } => { - // Can be generated as a reestablish response - }, - MessageSendEvent::BroadcastChannelUpdate { .. } => { - // Can be generated as a result of calling `timer_tick_occurred` enough - // times while peers are disconnected - }, - _ => panic!("Unhandled message event {:?}", event), - } - if $limit_events != ProcessMessages::AllMessages { - break; - } - } - if $node == 1 { - let remaining = extra_ev.into_iter().chain(events_iter).collect::>(); - queues.route_from_middle(remaining, None, &nodes); - } else if $node == 0 { - if let Some(ev) = extra_ev { queues.push_for_node(0, ev); } - queues.extend_for_node(0, events_iter); - } else { - if let Some(ev) = extra_ev { queues.push_for_node(2, ev); } - queues.extend_for_node(2, events_iter); - } - had_events - } } + fn finish(&self) { + assert_test_invariants(&self.nodes); + } + + fn link_between(&self, source_idx: usize, dest_idx: usize) -> &PeerLink { + if self.ab_link.connects(source_idx, dest_idx) { + &self.ab_link + } else if self.bc_link.connects(source_idx, dest_idx) { + &self.bc_link + } else { + panic!("invalid payment peers") } + } + + fn channel_ids_between(&self, source_idx: usize, dest_idx: usize) -> [ChannelId; 3] { + self.link_between(source_idx, dest_idx).channel_ids().clone() + } + + fn first_channel_id_between(&self, source_idx: usize, dest_idx: usize) -> ChannelId { + self.link_between(source_idx, dest_idx).first_channel_id() + } + + fn send_on_channel( + &mut self, source_idx: usize, dest_idx: usize, dest_chan_id: ChannelId, amt: u64, + ) -> bool { + self.payments.send(&self.nodes, source_idx, dest_idx, dest_chan_id, amt) + } + + fn send(&mut self, source_idx: usize, dest_idx: usize, amt: u64) { + let dest_chan_id = self.first_channel_id_between(source_idx, dest_idx); + self.payments.send_noret(&self.nodes, source_idx, dest_idx, dest_chan_id, amt); + } + + fn send_hop(&mut self, source_idx: usize, middle_idx: usize, dest_idx: usize, amt: u64) { + let middle_chan_id = self.first_channel_id_between(source_idx, middle_idx); + let dest_chan_id = self.first_channel_id_between(middle_idx, dest_idx); + self.payments.send_hop( + &self.nodes, + source_idx, + middle_idx, + middle_chan_id, + dest_idx, + dest_chan_id, + amt, + ); + } - macro_rules! process_msg_noret { - ($node: expr, $corrupt_forward: expr, $limit_events: expr) => {{ - process_msg_events!($node, $corrupt_forward, $limit_events); - }}; + fn send_mpp_direct( + &mut self, source_idx: usize, dest_idx: usize, channels: MppDirectChannels, amt: u64, + ) { + match channels { + MppDirectChannels::All => { + let dest_chan_ids = self.channel_ids_between(source_idx, dest_idx); + self.payments.send_mpp_direct( + &self.nodes, + source_idx, + dest_idx, + &dest_chan_ids, + amt, + ); + }, + MppDirectChannels::RepeatedFirst => { + let dest_chan_id = self.first_channel_id_between(source_idx, dest_idx); + let dest_chan_ids = [dest_chan_id, dest_chan_id, dest_chan_id]; + self.payments.send_mpp_direct( + &self.nodes, + source_idx, + dest_idx, + &dest_chan_ids, + amt, + ); + }, } + } - macro_rules! process_events { - ($node: expr, $fail: expr) => {{ - // Multiple HTLCs can resolve for the same payment hash, so deduplicate - // claim/fail handling per event batch. - let mut claim_set = new_hash_map(); - let mut events = nodes[$node].get_and_clear_pending_events(); - let had_events = !events.is_empty(); - for event in events.drain(..) { - match event { - events::Event::PaymentClaimable { payment_hash, .. } => { - if claim_set.insert(payment_hash.0, ()).is_none() { - payments.claim_payment(&nodes[$node], payment_hash, $fail); - } - }, - events::Event::PaymentSent { payment_id, payment_hash, .. } => { - payments.mark_sent($node, payment_id.unwrap(), payment_hash); - }, - // Even though we don't explicitly send probes, because probes are - // detected based on hashing the payment hash+preimage, it is rather - // trivial for the fuzzer to build payments that accidentally end up - // looking like probes. - events::Event::ProbeSuccessful { payment_id, .. } => { - payments.mark_successful_probe($node, payment_id); - }, - events::Event::PaymentFailed { payment_id, .. } - | events::Event::ProbeFailed { payment_id, .. } => { - payments.mark_resolved_without_hash($node, payment_id); - }, - events::Event::PaymentClaimed { .. } => {}, - events::Event::PaymentPathSuccessful { .. } => {}, - events::Event::PaymentPathFailed { .. } => {}, - events::Event::PaymentForwarded { .. } if $node == 1 => {}, - events::Event::ChannelReady { .. } => {}, - events::Event::HTLCHandlingFailed { .. } => {}, - events::Event::FundingTransactionReadyForSigning { - channel_id, - counterparty_node_id, - unsigned_transaction, - .. - } => { - let signed_tx = - nodes[$node].wallet.sign_tx(unsigned_transaction).unwrap(); - nodes[$node] - .funding_transaction_signed( - &channel_id, - &counterparty_node_id, - signed_tx, - ) - .unwrap(); - }, - events::Event::SpliceNegotiated { new_funding_txo, .. } => { - let mut txs = nodes[$node].broadcaster.txn_broadcasted.borrow_mut(); - assert!(txs.len() >= 1); - let splice_tx = txs.remove(0); - assert_eq!(new_funding_txo.txid, splice_tx.compute_txid()); - chain_state.add_pending_tx(splice_tx); - }, - events::Event::SpliceNegotiationFailed { .. } => {}, - events::Event::DiscardFunding { - funding_info: - events::FundingInfo::Contribution { .. } - | events::FundingInfo::Tx { .. }, - .. - } => {}, - _ => panic!("Unhandled event: {:?}", event), - } - } - while nodes[$node].needs_pending_htlc_processing() { - nodes[$node].process_pending_htlc_forwards(); - } - had_events - }}; + fn send_mpp_hop( + &mut self, source_idx: usize, middle_idx: usize, dest_idx: usize, channels: MppHopChannels, + amt: u64, + ) { + let middle_chan_ids = self.channel_ids_between(source_idx, middle_idx); + let dest_chan_ids = self.channel_ids_between(middle_idx, dest_idx); + let middle_first_chan_id = middle_chan_ids[0]; + let dest_first_chan_id = dest_chan_ids[0]; + match channels { + MppHopChannels::FirstHop => { + let dest_chan_ids = [dest_first_chan_id]; + self.payments.send_mpp_hop( + &self.nodes, + source_idx, + middle_idx, + &middle_chan_ids, + dest_idx, + &dest_chan_ids, + amt, + ); + }, + MppHopChannels::BothHops => { + self.payments.send_mpp_hop( + &self.nodes, + source_idx, + middle_idx, + &middle_chan_ids, + dest_idx, + &dest_chan_ids, + amt, + ); + }, + MppHopChannels::SecondHop => { + let middle_chan_ids = [middle_first_chan_id]; + self.payments.send_mpp_hop( + &self.nodes, + source_idx, + middle_idx, + &middle_chan_ids, + dest_idx, + &dest_chan_ids, + amt, + ); + }, } + } - macro_rules! process_ev_noret { - ($node: expr, $fail: expr) => {{ - process_events!($node, $fail); - }}; + fn process_msg_events( + &mut self, node_idx: usize, corrupt_forward: bool, limit_events: ProcessMessages, + ) -> bool { + fn find_destination_node(nodes: &[HarnessNode<'_>; 3], node_id: &PublicKey) -> usize { + nodes + .iter() + .position(|node| node.get_our_node_id() == *node_id) + .expect("message destination should be a known harness node") } - macro_rules! process_all_events { - () => {{ - let mut last_pass_no_updates = false; - for i in 0..std::usize::MAX { - if i == 100 { - panic!( - "It may take may iterations to settle the state, but it should not take forever" - ); - } - // Next, make sure no monitor updates are pending. - ab_link.complete_all_monitor_updates(&nodes); - bc_link.complete_all_monitor_updates(&nodes); - // Then, make sure any current forwards make their way to their destination. - if process_msg_events!(0, false, ProcessMessages::AllMessages) { - last_pass_no_updates = false; - continue; - } - if process_msg_events!(1, false, ProcessMessages::AllMessages) { - last_pass_no_updates = false; - continue; - } - if process_msg_events!(2, false, ProcessMessages::AllMessages) { - last_pass_no_updates = false; - continue; - } - // ...making sure any payments are claimed. - if process_events!(0, false) { - last_pass_no_updates = false; - continue; - } - if process_events!(1, false) { - last_pass_no_updates = false; - continue; - } - if process_events!(2, false) { - last_pass_no_updates = false; - continue; - } - if last_pass_no_updates { - // In some cases, we may generate a message to send in - // `process_msg_events`, but block sending until - // `complete_all_monitor_updates` gets called on the next - // iteration. - // - // Thus, we only exit if we manage two iterations with no messages - // or events to process. - break; + fn log_msg_delivery( + node_idx: usize, dest_idx: usize, msg_name: &str, out: &Out, + ) { + out.locked_write( + format!("Delivering {} from node {} to node {}.\n", msg_name, node_idx, dest_idx) + .as_bytes(), + ); + } + + fn log_peer_message( + node_idx: usize, node_id: &PublicKey, nodes: &[HarnessNode<'_>; 3], out: &Out, + msg_name: &str, + ) -> usize { + let dest_idx = find_destination_node(nodes, node_id); + log_msg_delivery(node_idx, dest_idx, msg_name, out); + dest_idx + } + + fn handle_update_add_htlc( + source_node_id: PublicKey, dest: &HarnessNode<'_>, update_add: &UpdateAddHTLC, + corrupt_forward: bool, + ) { + if !corrupt_forward { + dest.handle_update_add_htlc(source_node_id, update_add); + } else { + // Corrupt the update_add_htlc message so that its HMAC check will fail and we + // generate an update_fail_malformed_htlc instead of an update_fail_htlc as we do + // when we reject a payment. + let mut msg_ser = update_add.encode(); + msg_ser[1000] ^= 0xff; + let new_msg = + UpdateAddHTLC::read_from_fixed_length_buffer(&mut &msg_ser[..]).unwrap(); + dest.handle_update_add_htlc(source_node_id, &new_msg); + } + } + + fn handle_update_htlcs_event( + node_idx: usize, source_node_id: PublicKey, node_id: PublicKey, channel_id: ChannelId, + updates: CommitmentUpdate, corrupt_forward: bool, limit_events: ProcessMessages, + nodes: &[HarnessNode<'_>; 3], out: &Out, + ) -> Option { + let dest_idx = find_destination_node(nodes, &node_id); + let dest = &nodes[dest_idx]; + let CommitmentUpdate { + update_add_htlcs, + update_fail_htlcs, + update_fulfill_htlcs, + update_fail_malformed_htlcs, + update_fee, + commitment_signed, + } = updates; + + for update_add in update_add_htlcs.iter() { + log_msg_delivery(node_idx, dest_idx, "update_add_htlc", out); + handle_update_add_htlc(source_node_id, dest, update_add, corrupt_forward); + } + let processed_change = !update_add_htlcs.is_empty() + || !update_fulfill_htlcs.is_empty() + || !update_fail_htlcs.is_empty() + || !update_fail_malformed_htlcs.is_empty(); + for update_fulfill in update_fulfill_htlcs { + log_msg_delivery(node_idx, dest_idx, "update_fulfill_htlc", out); + dest.handle_update_fulfill_htlc(source_node_id, update_fulfill); + } + for update_fail in update_fail_htlcs.iter() { + log_msg_delivery(node_idx, dest_idx, "update_fail_htlc", out); + dest.handle_update_fail_htlc(source_node_id, update_fail); + } + for update_fail_malformed in update_fail_malformed_htlcs.iter() { + log_msg_delivery(node_idx, dest_idx, "update_fail_malformed_htlc", out); + dest.handle_update_fail_malformed_htlc(source_node_id, update_fail_malformed); + } + if let Some(msg) = update_fee { + log_msg_delivery(node_idx, dest_idx, "update_fee", out); + dest.handle_update_fee(source_node_id, &msg); + } + if limit_events != ProcessMessages::AllMessages && processed_change { + // If we only want to process some messages, don't deliver the CS until later. + return Some(MessageSendEvent::UpdateHTLCs { + node_id, + channel_id, + updates: CommitmentUpdate { + update_add_htlcs: Vec::new(), + update_fail_htlcs: Vec::new(), + update_fulfill_htlcs: Vec::new(), + update_fail_malformed_htlcs: Vec::new(), + update_fee: None, + commitment_signed, + }, + }); + } + log_msg_delivery(node_idx, dest_idx, "commitment_signed", out); + dest.handle_commitment_signed_batch_test(source_node_id, &commitment_signed); + None + } + + fn process_msg_event( + node_idx: usize, source_node_id: PublicKey, event: MessageSendEvent, + corrupt_forward: bool, limit_events: ProcessMessages, nodes: &[HarnessNode<'_>; 3], + out: &Out, + ) -> Option { + match event { + MessageSendEvent::UpdateHTLCs { node_id, channel_id, updates } => { + handle_update_htlcs_event( + node_idx, + source_node_id, + node_id, + channel_id, + updates, + corrupt_forward, + limit_events, + nodes, + out, + ) + }, + MessageSendEvent::SendRevokeAndACK { ref node_id, ref msg } => { + let dest_idx = + log_peer_message(node_idx, node_id, nodes, out, "revoke_and_ack"); + nodes[dest_idx].handle_revoke_and_ack(source_node_id, msg); + None + }, + MessageSendEvent::SendChannelReestablish { ref node_id, ref msg } => { + let dest_idx = + log_peer_message(node_idx, node_id, nodes, out, "channel_reestablish"); + nodes[dest_idx].handle_channel_reestablish(source_node_id, msg); + None + }, + MessageSendEvent::SendStfu { ref node_id, ref msg } => { + let dest_idx = log_peer_message(node_idx, node_id, nodes, out, "stfu"); + nodes[dest_idx].handle_stfu(source_node_id, msg); + None + }, + MessageSendEvent::SendTxAddInput { ref node_id, ref msg } => { + let dest_idx = log_peer_message(node_idx, node_id, nodes, out, "tx_add_input"); + nodes[dest_idx].handle_tx_add_input(source_node_id, msg); + None + }, + MessageSendEvent::SendTxAddOutput { ref node_id, ref msg } => { + let dest_idx = log_peer_message(node_idx, node_id, nodes, out, "tx_add_output"); + nodes[dest_idx].handle_tx_add_output(source_node_id, msg); + None + }, + MessageSendEvent::SendTxRemoveInput { ref node_id, ref msg } => { + let dest_idx = + log_peer_message(node_idx, node_id, nodes, out, "tx_remove_input"); + nodes[dest_idx].handle_tx_remove_input(source_node_id, msg); + None + }, + MessageSendEvent::SendTxRemoveOutput { ref node_id, ref msg } => { + let dest_idx = + log_peer_message(node_idx, node_id, nodes, out, "tx_remove_output"); + nodes[dest_idx].handle_tx_remove_output(source_node_id, msg); + None + }, + MessageSendEvent::SendTxComplete { ref node_id, ref msg } => { + let dest_idx = log_peer_message(node_idx, node_id, nodes, out, "tx_complete"); + nodes[dest_idx].handle_tx_complete(source_node_id, msg); + None + }, + MessageSendEvent::SendTxAbort { ref node_id, ref msg } => { + let dest_idx = log_peer_message(node_idx, node_id, nodes, out, "tx_abort"); + nodes[dest_idx].handle_tx_abort(source_node_id, msg); + None + }, + MessageSendEvent::SendTxInitRbf { ref node_id, ref msg } => { + let dest_idx = log_peer_message(node_idx, node_id, nodes, out, "tx_init_rbf"); + nodes[dest_idx].handle_tx_init_rbf(source_node_id, msg); + None + }, + MessageSendEvent::SendTxAckRbf { ref node_id, ref msg } => { + let dest_idx = log_peer_message(node_idx, node_id, nodes, out, "tx_ack_rbf"); + nodes[dest_idx].handle_tx_ack_rbf(source_node_id, msg); + None + }, + MessageSendEvent::SendTxSignatures { ref node_id, ref msg } => { + let dest_idx = log_peer_message(node_idx, node_id, nodes, out, "tx_signatures"); + nodes[dest_idx].handle_tx_signatures(source_node_id, msg); + None + }, + MessageSendEvent::SendSpliceInit { ref node_id, ref msg } => { + let dest_idx = log_peer_message(node_idx, node_id, nodes, out, "splice_init"); + nodes[dest_idx].handle_splice_init(source_node_id, msg); + None + }, + MessageSendEvent::SendSpliceAck { ref node_id, ref msg } => { + let dest_idx = log_peer_message(node_idx, node_id, nodes, out, "splice_ack"); + nodes[dest_idx].handle_splice_ack(source_node_id, msg); + None + }, + MessageSendEvent::SendSpliceLocked { ref node_id, ref msg } => { + let dest_idx = log_peer_message(node_idx, node_id, nodes, out, "splice_locked"); + nodes[dest_idx].handle_splice_locked(source_node_id, msg); + None + }, + MessageSendEvent::HandleError { ref action, .. } => { + assert_action_timeout_awaiting_response(action); + None + }, + MessageSendEvent::SendChannelReady { .. } + | MessageSendEvent::SendAnnouncementSignatures { .. } + | MessageSendEvent::SendChannelUpdate { .. } => { + // Can be generated as a reestablish response. + None + }, + MessageSendEvent::BroadcastChannelUpdate { .. } => { + // Can be generated as a result of calling `timer_tick_occurred` enough + // times while peers are disconnected. + None + }, + _ => panic!("Unhandled message event {:?}", event), + } + } + + let nodes = &self.nodes; + let out = &self.out; + let queues = &mut self.queues; + let mut events = queues.take_for_node(node_idx); + let mut new_events = Vec::new(); + if limit_events != ProcessMessages::OnePendingMessage { + new_events = nodes[node_idx].get_and_clear_pending_msg_events(); + } + let mut had_events = false; + let source_node_id = nodes[node_idx].get_our_node_id(); + let mut events_iter = events.drain(..).chain(new_events.drain(..)); + let mut extra_ev = None; + for event in &mut events_iter { + had_events = true; + extra_ev = process_msg_event( + node_idx, + source_node_id, + event, + corrupt_forward, + limit_events, + nodes, + out, + ); + if limit_events != ProcessMessages::AllMessages { + break; + } + } + if node_idx == 1 { + let remaining = extra_ev.into_iter().chain(events_iter).collect::>(); + queues.route_from_middle(remaining, None, nodes); + } else if node_idx == 0 { + if let Some(ev) = extra_ev { + queues.push_for_node(0, ev); + } + queues.extend_for_node(0, events_iter); + } else { + if let Some(ev) = extra_ev { + queues.push_for_node(2, ev); + } + queues.extend_for_node(2, events_iter); + } + had_events + } + + fn process_events(&mut self, node_idx: usize, fail: bool) -> bool { + let nodes = &self.nodes; + let chain_state = &mut self.chain_state; + let payments = &mut self.payments; + // Multiple HTLCs can resolve for the same payment hash, so deduplicate + // claim/fail handling per event batch. + let mut claim_set = new_hash_map(); + let mut events = nodes[node_idx].get_and_clear_pending_events(); + let had_events = !events.is_empty(); + for event in events.drain(..) { + match event { + events::Event::PaymentClaimable { payment_hash, .. } => { + if claim_set.insert(payment_hash.0, ()).is_none() { + payments.claim_payment(&nodes[node_idx], payment_hash, fail); } - last_pass_no_updates = true; - } - }}; + }, + events::Event::PaymentSent { payment_id, payment_hash, .. } => { + payments.mark_sent(node_idx, payment_id.unwrap(), payment_hash); + }, + // Even though we don't explicitly send probes, because probes are detected based on + // hashing the payment hash+preimage, it is rather trivial for the fuzzer to build + // payments that accidentally end up looking like probes. + events::Event::ProbeSuccessful { payment_id, .. } => { + payments.mark_successful_probe(node_idx, payment_id); + }, + events::Event::PaymentFailed { payment_id, .. } + | events::Event::ProbeFailed { payment_id, .. } => { + payments.mark_resolved_without_hash(node_idx, payment_id); + }, + events::Event::PaymentClaimed { .. } => {}, + events::Event::PaymentPathSuccessful { .. } => {}, + events::Event::PaymentPathFailed { .. } => {}, + events::Event::PaymentForwarded { .. } if node_idx == 1 => {}, + events::Event::ChannelReady { .. } => {}, + events::Event::HTLCHandlingFailed { .. } => {}, + events::Event::FundingTransactionReadyForSigning { + channel_id, + counterparty_node_id, + unsigned_transaction, + .. + } => { + let signed_tx = nodes[node_idx].wallet.sign_tx(unsigned_transaction).unwrap(); + nodes[node_idx] + .funding_transaction_signed(&channel_id, &counterparty_node_id, signed_tx) + .unwrap(); + }, + events::Event::SpliceNegotiated { new_funding_txo, .. } => { + let mut txs = nodes[node_idx].broadcaster.txn_broadcasted.borrow_mut(); + assert!(txs.len() >= 1); + let splice_tx = txs.remove(0); + assert_eq!(new_funding_txo.txid, splice_tx.compute_txid()); + chain_state.add_pending_tx(splice_tx); + }, + events::Event::SpliceNegotiationFailed { .. } => {}, + events::Event::DiscardFunding { + funding_info: + events::FundingInfo::Contribution { .. } | events::FundingInfo::Tx { .. }, + .. + } => {}, + _ => panic!("Unhandled event: {:?}", event), + } + } + while nodes[node_idx].needs_pending_htlc_processing() { + nodes[node_idx].process_pending_htlc_forwards(); } + had_events + } + + fn process_msg_noret( + &mut self, node_idx: usize, corrupt_forward: bool, limit_events: ProcessMessages, + ) { + self.process_msg_events(node_idx, corrupt_forward, limit_events); + } + + fn process_ev_noret(&mut self, node_idx: usize, fail: bool) { + self.process_events(node_idx, fail); + } + fn process_all_events(&mut self) { + let mut last_pass_no_updates = false; + for i in 0..std::usize::MAX { + if i == 100 { + panic!( + "It may take may iterations to settle the state, but it should not take forever" + ); + } + // Next, make sure no monitor updates are pending. + self.ab_link.complete_all_monitor_updates(&self.nodes); + self.bc_link.complete_all_monitor_updates(&self.nodes); + // Then, make sure any current forwards make their way to their destination. + if self.process_msg_events(0, false, ProcessMessages::AllMessages) { + last_pass_no_updates = false; + continue; + } + if self.process_msg_events(1, false, ProcessMessages::AllMessages) { + last_pass_no_updates = false; + continue; + } + if self.process_msg_events(2, false, ProcessMessages::AllMessages) { + last_pass_no_updates = false; + continue; + } + // ...making sure any payments are claimed. + if self.process_events(0, false) { + last_pass_no_updates = false; + continue; + } + if self.process_events(1, false) { + last_pass_no_updates = false; + continue; + } + if self.process_events(2, false) { + last_pass_no_updates = false; + continue; + } + if last_pass_no_updates { + // In some cases, we may generate a message to send in + // `process_msg_events`, but block sending until + // `complete_all_monitor_updates` gets called on the next + // iteration. + // + // Thus, we only exit if we manage two iterations with no messages + // or events to process. + break; + } + last_pass_no_updates = true; + } + } + + fn disconnect_ab(&mut self) { + self.ab_link.disconnect(&self.nodes, &mut self.queues); + } + + fn disconnect_bc(&mut self) { + self.bc_link.disconnect(&self.nodes, &mut self.queues); + } + + fn reconnect_ab(&mut self) { + self.ab_link.reconnect(&self.nodes); + } + + fn reconnect_bc(&mut self) { + self.bc_link.reconnect(&self.nodes); + } + + fn restart_node(&mut self, node_idx: usize, v: u8, router: &'a FuzzRouter) { + match node_idx { + 0 => { + self.ab_link.disconnect_for_reload(0, &self.nodes, &mut self.queues); + }, + 1 => { + self.ab_link.disconnect_for_reload(1, &self.nodes, &mut self.queues); + self.bc_link.disconnect_for_reload(1, &self.nodes, &mut self.queues); + }, + 2 => { + self.bc_link.disconnect_for_reload(2, &self.nodes, &mut self.queues); + }, + _ => panic!("invalid node index"), + } + self.nodes[node_idx].reload(v, &self.out, router, self.chan_type); + } + + fn settle_all(&mut self) { + // First, make sure peers are all connected to each other + self.reconnect_ab(); + self.reconnect_bc(); + + for op in SUPPORTED_SIGNER_OPS { + self.nodes[0].keys_manager.enable_op_for_all_signers(op); + self.nodes[1].keys_manager.enable_op_for_all_signers(op); + self.nodes[2].keys_manager.enable_op_for_all_signers(op); + } + self.nodes[0].signer_unblocked(None); + self.nodes[1].signer_unblocked(None); + self.nodes[2].signer_unblocked(None); + + self.process_all_events(); + + // Since MPP payments are supported, we wait until we fully settle the state of all + // channels to see if we have any committed HTLC parts of an MPP payment that need + // to be failed back. + for node in self.nodes.iter() { + node.timer_tick_occurred(); + } + self.process_all_events(); + + // Verify no payments are stuck - all should have resolved + self.payments.assert_all_resolved(); + // Verify that every payment claimed by a receiver resulted in a + // PaymentSent event at the sender. + self.payments.assert_claims_reported(); + + // Finally, make sure that at least one end of each channel can make a substantial payment. + let chan_ab_ids = self.ab_link.channel_ids().clone(); + let chan_bc_ids = self.bc_link.channel_ids().clone(); + for chan_id in chan_ab_ids { + assert!( + self.send_on_channel(0, 1, chan_id, 10_000_000) + || self.send_on_channel(1, 0, chan_id, 10_000_000) + ); + } + for chan_id in chan_bc_ids { + assert!( + self.send_on_channel(1, 2, chan_id, 10_000_000) + || self.send_on_channel(2, 1, chan_id, 10_000_000) + ); + } + + self.nodes[0].record_last_htlc_clear_fee(); + self.nodes[1].record_last_htlc_clear_fee(); + self.nodes[2].record_last_htlc_clear_fee(); + } + + fn refresh_serialized_managers(&mut self) { + for node in &mut self.nodes { + node.refresh_serialized_manager(); + } + } +} + +#[inline] +pub fn do_test(data: &[u8], out: Out) { + let router = FuzzRouter {}; + // Read initial monitor styles and channel type from fuzz input byte 0: + // bits 0-2: monitor styles (1 bit per node) + // bits 3-4: channel type (0=Legacy, 1=KeyedAnchors, 2=ZeroFeeCommitments) + let config_byte = if !data.is_empty() { data[0] } else { 0 }; + let mut harness = Harness::new(config_byte, out, &router); + let mut read_pos = 1; // First byte was consumed for initial config. + + 'fuzz_loop: loop { if data.len() < read_pos + 1 { break 'fuzz_loop; } let v = data[read_pos]; read_pos += 1; - out.locked_write(format!("READ A BYTE! HANDLING INPUT {:x}...........\n", v).as_bytes()); + harness + .out + .locked_write(format!("READ A BYTE! HANDLING INPUT {:x}...........\n", v).as_bytes()); match v { // In general, we keep related message groups close together in binary form, allowing // bit-twiddling mutations to have similar effects. This is probably overkill, but no // harm in doing so. - 0x00 => nodes[0].set_persistence_style(ChannelMonitorUpdateStatus::InProgress), - 0x01 => nodes[1].set_persistence_style(ChannelMonitorUpdateStatus::InProgress), - 0x02 => nodes[2].set_persistence_style(ChannelMonitorUpdateStatus::InProgress), - 0x04 => nodes[0].set_persistence_style(ChannelMonitorUpdateStatus::Completed), - 0x05 => nodes[1].set_persistence_style(ChannelMonitorUpdateStatus::Completed), - 0x06 => nodes[2].set_persistence_style(ChannelMonitorUpdateStatus::Completed), + 0x00 => harness.nodes[0].set_persistence_style(ChannelMonitorUpdateStatus::InProgress), + 0x01 => harness.nodes[1].set_persistence_style(ChannelMonitorUpdateStatus::InProgress), + 0x02 => harness.nodes[2].set_persistence_style(ChannelMonitorUpdateStatus::InProgress), + 0x04 => harness.nodes[0].set_persistence_style(ChannelMonitorUpdateStatus::Completed), + 0x05 => harness.nodes[1].set_persistence_style(ChannelMonitorUpdateStatus::Completed), + 0x06 => harness.nodes[2].set_persistence_style(ChannelMonitorUpdateStatus::Completed), 0x08 => { - for id in ab_link.channel_ids() { - nodes[0].complete_all_monitor_updates(id); + for id in harness.ab_link.channel_ids() { + harness.nodes[0].complete_all_monitor_updates(id); } }, 0x09 => { - for id in ab_link.channel_ids() { - nodes[1].complete_all_monitor_updates(id); + for id in harness.ab_link.channel_ids() { + harness.nodes[1].complete_all_monitor_updates(id); } }, 0x0a => { - for id in bc_link.channel_ids() { - nodes[1].complete_all_monitor_updates(id); + for id in harness.bc_link.channel_ids() { + harness.nodes[1].complete_all_monitor_updates(id); } }, 0x0b => { - for id in bc_link.channel_ids() { - nodes[2].complete_all_monitor_updates(id); + for id in harness.bc_link.channel_ids() { + harness.nodes[2].complete_all_monitor_updates(id); } }, - 0x0c => ab_link.disconnect(&nodes, &mut queues), - 0x0d => bc_link.disconnect(&nodes, &mut queues), - 0x0e => ab_link.reconnect(&nodes), - 0x0f => bc_link.reconnect(&nodes), + 0x0c => harness.disconnect_ab(), + 0x0d => harness.disconnect_bc(), + 0x0e => harness.reconnect_ab(), + 0x0f => harness.reconnect_bc(), - 0x10 => process_msg_noret!(0, true, ProcessMessages::AllMessages), - 0x11 => process_msg_noret!(0, false, ProcessMessages::AllMessages), - 0x12 => process_msg_noret!(0, true, ProcessMessages::OneMessage), - 0x13 => process_msg_noret!(0, false, ProcessMessages::OneMessage), - 0x14 => process_msg_noret!(0, true, ProcessMessages::OnePendingMessage), - 0x15 => process_msg_noret!(0, false, ProcessMessages::OnePendingMessage), + 0x10 => harness.process_msg_noret(0, true, ProcessMessages::AllMessages), + 0x11 => harness.process_msg_noret(0, false, ProcessMessages::AllMessages), + 0x12 => harness.process_msg_noret(0, true, ProcessMessages::OneMessage), + 0x13 => harness.process_msg_noret(0, false, ProcessMessages::OneMessage), + 0x14 => harness.process_msg_noret(0, true, ProcessMessages::OnePendingMessage), + 0x15 => harness.process_msg_noret(0, false, ProcessMessages::OnePendingMessage), - 0x16 => process_ev_noret!(0, true), - 0x17 => process_ev_noret!(0, false), + 0x16 => harness.process_ev_noret(0, true), + 0x17 => harness.process_ev_noret(0, false), - 0x18 => process_msg_noret!(1, true, ProcessMessages::AllMessages), - 0x19 => process_msg_noret!(1, false, ProcessMessages::AllMessages), - 0x1a => process_msg_noret!(1, true, ProcessMessages::OneMessage), - 0x1b => process_msg_noret!(1, false, ProcessMessages::OneMessage), - 0x1c => process_msg_noret!(1, true, ProcessMessages::OnePendingMessage), - 0x1d => process_msg_noret!(1, false, ProcessMessages::OnePendingMessage), + 0x18 => harness.process_msg_noret(1, true, ProcessMessages::AllMessages), + 0x19 => harness.process_msg_noret(1, false, ProcessMessages::AllMessages), + 0x1a => harness.process_msg_noret(1, true, ProcessMessages::OneMessage), + 0x1b => harness.process_msg_noret(1, false, ProcessMessages::OneMessage), + 0x1c => harness.process_msg_noret(1, true, ProcessMessages::OnePendingMessage), + 0x1d => harness.process_msg_noret(1, false, ProcessMessages::OnePendingMessage), - 0x1e => process_ev_noret!(1, true), - 0x1f => process_ev_noret!(1, false), + 0x1e => harness.process_ev_noret(1, true), + 0x1f => harness.process_ev_noret(1, false), - 0x20 => process_msg_noret!(2, true, ProcessMessages::AllMessages), - 0x21 => process_msg_noret!(2, false, ProcessMessages::AllMessages), - 0x22 => process_msg_noret!(2, true, ProcessMessages::OneMessage), - 0x23 => process_msg_noret!(2, false, ProcessMessages::OneMessage), - 0x24 => process_msg_noret!(2, true, ProcessMessages::OnePendingMessage), - 0x25 => process_msg_noret!(2, false, ProcessMessages::OnePendingMessage), + 0x20 => harness.process_msg_noret(2, true, ProcessMessages::AllMessages), + 0x21 => harness.process_msg_noret(2, false, ProcessMessages::AllMessages), + 0x22 => harness.process_msg_noret(2, true, ProcessMessages::OneMessage), + 0x23 => harness.process_msg_noret(2, false, ProcessMessages::OneMessage), + 0x24 => harness.process_msg_noret(2, true, ProcessMessages::OnePendingMessage), + 0x25 => harness.process_msg_noret(2, false, ProcessMessages::OnePendingMessage), - 0x26 => process_ev_noret!(2, true), - 0x27 => process_ev_noret!(2, false), + 0x26 => harness.process_ev_noret(2, true), + 0x27 => harness.process_ev_noret(2, false), // 1/10th the channel size: - 0x30 => payments.send_noret(&nodes, 0, 1, chan_a_id, 10_000_000), - 0x31 => payments.send_noret(&nodes, 1, 0, chan_a_id, 10_000_000), - 0x32 => payments.send_noret(&nodes, 1, 2, chan_b_id, 10_000_000), - 0x33 => payments.send_noret(&nodes, 2, 1, chan_b_id, 10_000_000), - 0x34 => payments.send_hop(&nodes, 0, 1, chan_a_id, 2, chan_b_id, 10_000_000), - 0x35 => payments.send_hop(&nodes, 2, 1, chan_b_id, 0, chan_a_id, 10_000_000), - - 0x38 => payments.send_noret(&nodes, 0, 1, chan_a_id, 1_000_000), - 0x39 => payments.send_noret(&nodes, 1, 0, chan_a_id, 1_000_000), - 0x3a => payments.send_noret(&nodes, 1, 2, chan_b_id, 1_000_000), - 0x3b => payments.send_noret(&nodes, 2, 1, chan_b_id, 1_000_000), - 0x3c => payments.send_hop(&nodes, 0, 1, chan_a_id, 2, chan_b_id, 1_000_000), - 0x3d => payments.send_hop(&nodes, 2, 1, chan_b_id, 0, chan_a_id, 1_000_000), - - 0x40 => payments.send_noret(&nodes, 0, 1, chan_a_id, 100_000), - 0x41 => payments.send_noret(&nodes, 1, 0, chan_a_id, 100_000), - 0x42 => payments.send_noret(&nodes, 1, 2, chan_b_id, 100_000), - 0x43 => payments.send_noret(&nodes, 2, 1, chan_b_id, 100_000), - 0x44 => payments.send_hop(&nodes, 0, 1, chan_a_id, 2, chan_b_id, 100_000), - 0x45 => payments.send_hop(&nodes, 2, 1, chan_b_id, 0, chan_a_id, 100_000), - - 0x48 => payments.send_noret(&nodes, 0, 1, chan_a_id, 10_000), - 0x49 => payments.send_noret(&nodes, 1, 0, chan_a_id, 10_000), - 0x4a => payments.send_noret(&nodes, 1, 2, chan_b_id, 10_000), - 0x4b => payments.send_noret(&nodes, 2, 1, chan_b_id, 10_000), - 0x4c => payments.send_hop(&nodes, 0, 1, chan_a_id, 2, chan_b_id, 10_000), - 0x4d => payments.send_hop(&nodes, 2, 1, chan_b_id, 0, chan_a_id, 10_000), - - 0x50 => payments.send_noret(&nodes, 0, 1, chan_a_id, 1_000), - 0x51 => payments.send_noret(&nodes, 1, 0, chan_a_id, 1_000), - 0x52 => payments.send_noret(&nodes, 1, 2, chan_b_id, 1_000), - 0x53 => payments.send_noret(&nodes, 2, 1, chan_b_id, 1_000), - 0x54 => payments.send_hop(&nodes, 0, 1, chan_a_id, 2, chan_b_id, 1_000), - 0x55 => payments.send_hop(&nodes, 2, 1, chan_b_id, 0, chan_a_id, 1_000), - - 0x58 => payments.send_noret(&nodes, 0, 1, chan_a_id, 100), - 0x59 => payments.send_noret(&nodes, 1, 0, chan_a_id, 100), - 0x5a => payments.send_noret(&nodes, 1, 2, chan_b_id, 100), - 0x5b => payments.send_noret(&nodes, 2, 1, chan_b_id, 100), - 0x5c => payments.send_hop(&nodes, 0, 1, chan_a_id, 2, chan_b_id, 100), - 0x5d => payments.send_hop(&nodes, 2, 1, chan_b_id, 0, chan_a_id, 100), - - 0x60 => payments.send_noret(&nodes, 0, 1, chan_a_id, 10), - 0x61 => payments.send_noret(&nodes, 1, 0, chan_a_id, 10), - 0x62 => payments.send_noret(&nodes, 1, 2, chan_b_id, 10), - 0x63 => payments.send_noret(&nodes, 2, 1, chan_b_id, 10), - 0x64 => payments.send_hop(&nodes, 0, 1, chan_a_id, 2, chan_b_id, 10), - 0x65 => payments.send_hop(&nodes, 2, 1, chan_b_id, 0, chan_a_id, 10), - - 0x68 => payments.send_noret(&nodes, 0, 1, chan_a_id, 1), - 0x69 => payments.send_noret(&nodes, 1, 0, chan_a_id, 1), - 0x6a => payments.send_noret(&nodes, 1, 2, chan_b_id, 1), - 0x6b => payments.send_noret(&nodes, 2, 1, chan_b_id, 1), - 0x6c => payments.send_hop(&nodes, 0, 1, chan_a_id, 2, chan_b_id, 1), - 0x6d => payments.send_hop(&nodes, 2, 1, chan_b_id, 0, chan_a_id, 1), + 0x30 => harness.send(0, 1, 10_000_000), + 0x31 => harness.send(1, 0, 10_000_000), + 0x32 => harness.send(1, 2, 10_000_000), + 0x33 => harness.send(2, 1, 10_000_000), + 0x34 => harness.send_hop(0, 1, 2, 10_000_000), + 0x35 => harness.send_hop(2, 1, 0, 10_000_000), + + 0x38 => harness.send(0, 1, 1_000_000), + 0x39 => harness.send(1, 0, 1_000_000), + 0x3a => harness.send(1, 2, 1_000_000), + 0x3b => harness.send(2, 1, 1_000_000), + 0x3c => harness.send_hop(0, 1, 2, 1_000_000), + 0x3d => harness.send_hop(2, 1, 0, 1_000_000), + + 0x40 => harness.send(0, 1, 100_000), + 0x41 => harness.send(1, 0, 100_000), + 0x42 => harness.send(1, 2, 100_000), + 0x43 => harness.send(2, 1, 100_000), + 0x44 => harness.send_hop(0, 1, 2, 100_000), + 0x45 => harness.send_hop(2, 1, 0, 100_000), + + 0x48 => harness.send(0, 1, 10_000), + 0x49 => harness.send(1, 0, 10_000), + 0x4a => harness.send(1, 2, 10_000), + 0x4b => harness.send(2, 1, 10_000), + 0x4c => harness.send_hop(0, 1, 2, 10_000), + 0x4d => harness.send_hop(2, 1, 0, 10_000), + + 0x50 => harness.send(0, 1, 1_000), + 0x51 => harness.send(1, 0, 1_000), + 0x52 => harness.send(1, 2, 1_000), + 0x53 => harness.send(2, 1, 1_000), + 0x54 => harness.send_hop(0, 1, 2, 1_000), + 0x55 => harness.send_hop(2, 1, 0, 1_000), + + 0x58 => harness.send(0, 1, 100), + 0x59 => harness.send(1, 0, 100), + 0x5a => harness.send(1, 2, 100), + 0x5b => harness.send(2, 1, 100), + 0x5c => harness.send_hop(0, 1, 2, 100), + 0x5d => harness.send_hop(2, 1, 0, 100), + + 0x60 => harness.send(0, 1, 10), + 0x61 => harness.send(1, 0, 10), + 0x62 => harness.send(1, 2, 10), + 0x63 => harness.send(2, 1, 10), + 0x64 => harness.send_hop(0, 1, 2, 10), + 0x65 => harness.send_hop(2, 1, 0, 10), + + 0x68 => harness.send(0, 1, 1), + 0x69 => harness.send(1, 0, 1), + 0x6a => harness.send(1, 2, 1), + 0x6b => harness.send(2, 1, 1), + 0x6c => harness.send_hop(0, 1, 2, 1), + 0x6d => harness.send_hop(2, 1, 0, 1), // MPP payments // 0x70: direct MPP from 0 to 1 (multi A-B channels) - 0x70 => payments.send_mpp_direct(&nodes, 0, 1, ab_link.channel_ids(), 1_000_000), + 0x70 => harness.send_mpp_direct(0, 1, MppDirectChannels::All, 1_000_000), // 0x71: MPP 0->1->2, multi channels on first hop (A-B) - 0x71 => payments.send_mpp_hop( - &nodes, - 0, - 1, - ab_link.channel_ids(), - 2, - &[chan_b_id], - 1_000_000, - ), + 0x71 => harness.send_mpp_hop(0, 1, 2, MppHopChannels::FirstHop, 1_000_000), // 0x72: MPP 0->1->2, multi channels on both hops (A-B and B-C) - 0x72 => payments.send_mpp_hop( - &nodes, - 0, - 1, - ab_link.channel_ids(), - 2, - bc_link.channel_ids(), - 1_000_000, - ), + 0x72 => harness.send_mpp_hop(0, 1, 2, MppHopChannels::BothHops, 1_000_000), // 0x73: MPP 0->1->2, multi channels on second hop (B-C) - 0x73 => payments.send_mpp_hop( - &nodes, - 0, - 1, - &[chan_a_id], - 2, - bc_link.channel_ids(), - 1_000_000, - ), + 0x73 => harness.send_mpp_hop(0, 1, 2, MppHopChannels::SecondHop, 1_000_000), // 0x74: direct MPP from 0 to 1, multi parts over single channel - 0x74 => { - payments.send_mpp_direct( - &nodes, - 0, - 1, - &[chan_a_id, chan_a_id, chan_a_id], - 1_000_000, - ); - }, + 0x74 => harness.send_mpp_direct(0, 1, MppDirectChannels::RepeatedFirst, 1_000_000), - 0x80 => nodes[0].bump_fee_estimate(chan_type), - 0x81 => nodes[0].reset_fee_estimate(), - 0x84 => nodes[1].bump_fee_estimate(chan_type), - 0x85 => nodes[1].reset_fee_estimate(), - 0x88 => nodes[2].bump_fee_estimate(chan_type), - 0x89 => nodes[2].reset_fee_estimate(), + 0x80 => harness.nodes[0].bump_fee_estimate(harness.chan_type), + 0x81 => harness.nodes[0].reset_fee_estimate(), + 0x84 => harness.nodes[1].bump_fee_estimate(harness.chan_type), + 0x85 => harness.nodes[1].reset_fee_estimate(), + 0x88 => harness.nodes[2].bump_fee_estimate(harness.chan_type), + 0x89 => harness.nodes[2].reset_fee_estimate(), 0xa0 => { if !cfg!(splicing) { break 'fuzz_loop; } - let cp_node_id = nodes[1].get_our_node_id(); - nodes[0].splice_in(&cp_node_id, &chan_a_id); + let cp_node_id = harness.nodes[1].get_our_node_id(); + harness.nodes[0].splice_in(&cp_node_id, &harness.chan_a_id()); }, 0xa1 => { if !cfg!(splicing) { break 'fuzz_loop; } - let cp_node_id = nodes[0].get_our_node_id(); - nodes[1].splice_in(&cp_node_id, &chan_a_id); + let cp_node_id = harness.nodes[0].get_our_node_id(); + harness.nodes[1].splice_in(&cp_node_id, &harness.chan_a_id()); }, 0xa2 => { if !cfg!(splicing) { break 'fuzz_loop; } - let cp_node_id = nodes[2].get_our_node_id(); - nodes[1].splice_in(&cp_node_id, &chan_b_id); + let cp_node_id = harness.nodes[2].get_our_node_id(); + harness.nodes[1].splice_in(&cp_node_id, &harness.chan_b_id()); }, 0xa3 => { if !cfg!(splicing) { break 'fuzz_loop; } - let cp_node_id = nodes[1].get_our_node_id(); - nodes[2].splice_in(&cp_node_id, &chan_b_id); + let cp_node_id = harness.nodes[1].get_our_node_id(); + harness.nodes[2].splice_in(&cp_node_id, &harness.chan_b_id()); }, 0xa4 => { if !cfg!(splicing) { break 'fuzz_loop; } - let cp_node_id = nodes[1].get_our_node_id(); - nodes[0].splice_out(&cp_node_id, &chan_a_id); + let cp_node_id = harness.nodes[1].get_our_node_id(); + harness.nodes[0].splice_out(&cp_node_id, &harness.chan_a_id()); }, 0xa5 => { if !cfg!(splicing) { break 'fuzz_loop; } - let cp_node_id = nodes[0].get_our_node_id(); - nodes[1].splice_out(&cp_node_id, &chan_a_id); + let cp_node_id = harness.nodes[0].get_our_node_id(); + harness.nodes[1].splice_out(&cp_node_id, &harness.chan_a_id()); }, 0xa6 => { if !cfg!(splicing) { break 'fuzz_loop; } - let cp_node_id = nodes[2].get_our_node_id(); - nodes[1].splice_out(&cp_node_id, &chan_b_id); + let cp_node_id = harness.nodes[2].get_our_node_id(); + harness.nodes[1].splice_out(&cp_node_id, &harness.chan_b_id()); }, 0xa7 => { if !cfg!(splicing) { break 'fuzz_loop; } - let cp_node_id = nodes[1].get_our_node_id(); - nodes[2].splice_out(&cp_node_id, &chan_b_id); + let cp_node_id = harness.nodes[1].get_our_node_id(); + harness.nodes[2].splice_out(&cp_node_id, &harness.chan_b_id()); }, // Sync node by 1 block to cover confirmation of a transaction. 0xa8 => { - chain_state.confirm_pending_txs(); - nodes[0].sync_with_chain_state(&chain_state, Some(1)); + harness.chain_state.confirm_pending_txs(); + harness.nodes[0].sync_with_chain_state(&harness.chain_state, Some(1)); }, 0xa9 => { - chain_state.confirm_pending_txs(); - nodes[1].sync_with_chain_state(&chain_state, Some(1)); + harness.chain_state.confirm_pending_txs(); + harness.nodes[1].sync_with_chain_state(&harness.chain_state, Some(1)); }, 0xaa => { - chain_state.confirm_pending_txs(); - nodes[2].sync_with_chain_state(&chain_state, Some(1)); + harness.chain_state.confirm_pending_txs(); + harness.nodes[2].sync_with_chain_state(&harness.chain_state, Some(1)); }, // Sync node to chain tip to cover confirmation of a transaction post-reorg-risk. 0xab => { - chain_state.confirm_pending_txs(); - nodes[0].sync_with_chain_state(&chain_state, None); + harness.chain_state.confirm_pending_txs(); + harness.nodes[0].sync_with_chain_state(&harness.chain_state, None); }, 0xac => { - chain_state.confirm_pending_txs(); - nodes[1].sync_with_chain_state(&chain_state, None); + harness.chain_state.confirm_pending_txs(); + harness.nodes[1].sync_with_chain_state(&harness.chain_state, None); }, 0xad => { - chain_state.confirm_pending_txs(); - nodes[2].sync_with_chain_state(&chain_state, None); + harness.chain_state.confirm_pending_txs(); + harness.nodes[2].sync_with_chain_state(&harness.chain_state, None); }, 0xb0 | 0xb1 | 0xb2 => { // Restart node A, picking among the in-flight `ChannelMonitor`s to use based on // the value of `v` we're matching. - ab_link.disconnect_for_reload(0, &nodes, &mut queues); - nodes[0].reload(v, &out, &router, chan_type); + harness.restart_node(0, v, &router); }, 0xb3..=0xbb => { // Restart node B, picking among the in-flight `ChannelMonitor`s to use based on // the value of `v` we're matching. - ab_link.disconnect_for_reload(1, &nodes, &mut queues); - bc_link.disconnect_for_reload(1, &nodes, &mut queues); - nodes[1].reload(v, &out, &router, chan_type); + harness.restart_node(1, v, &router); }, 0xbc | 0xbd | 0xbe => { // Restart node C, picking among the in-flight `ChannelMonitor`s to use based on // the value of `v` we're matching. - bc_link.disconnect_for_reload(2, &nodes, &mut queues); - nodes[2].reload(v, &out, &router, chan_type); + harness.restart_node(2, v, &router); }, - 0xc0 => nodes[0].keys_manager.disable_supported_ops_for_all_signers(), - 0xc1 => nodes[1].keys_manager.disable_supported_ops_for_all_signers(), - 0xc2 => nodes[2].keys_manager.disable_supported_ops_for_all_signers(), + 0xc0 => harness.nodes[0].keys_manager.disable_supported_ops_for_all_signers(), + 0xc1 => harness.nodes[1].keys_manager.disable_supported_ops_for_all_signers(), + 0xc2 => harness.nodes[2].keys_manager.disable_supported_ops_for_all_signers(), 0xc3 => { - nodes[0] + harness.nodes[0] .keys_manager .enable_op_for_all_signers(SignerOp::SignCounterpartyCommitment); - nodes[0].signer_unblocked(None); + harness.nodes[0].signer_unblocked(None); }, 0xc4 => { - nodes[1] + harness.nodes[1] .keys_manager .enable_op_for_all_signers(SignerOp::SignCounterpartyCommitment); - let filter = Some((nodes[0].get_our_node_id(), chan_a_id)); - nodes[1].signer_unblocked(filter); + let filter = Some((harness.nodes[0].get_our_node_id(), harness.chan_a_id())); + harness.nodes[1].signer_unblocked(filter); }, 0xc5 => { - nodes[1] + harness.nodes[1] .keys_manager .enable_op_for_all_signers(SignerOp::SignCounterpartyCommitment); - let filter = Some((nodes[2].get_our_node_id(), chan_b_id)); - nodes[1].signer_unblocked(filter); + let filter = Some((harness.nodes[2].get_our_node_id(), harness.chan_b_id())); + harness.nodes[1].signer_unblocked(filter); }, 0xc6 => { - nodes[2] + harness.nodes[2] .keys_manager .enable_op_for_all_signers(SignerOp::SignCounterpartyCommitment); - nodes[2].signer_unblocked(None); + harness.nodes[2].signer_unblocked(None); }, 0xc7 => { - nodes[0].keys_manager.enable_op_for_all_signers(SignerOp::GetPerCommitmentPoint); - nodes[0].signer_unblocked(None); + harness.nodes[0] + .keys_manager + .enable_op_for_all_signers(SignerOp::GetPerCommitmentPoint); + harness.nodes[0].signer_unblocked(None); }, 0xc8 => { - nodes[1].keys_manager.enable_op_for_all_signers(SignerOp::GetPerCommitmentPoint); - let filter = Some((nodes[0].get_our_node_id(), chan_a_id)); - nodes[1].signer_unblocked(filter); + harness.nodes[1] + .keys_manager + .enable_op_for_all_signers(SignerOp::GetPerCommitmentPoint); + let filter = Some((harness.nodes[0].get_our_node_id(), harness.chan_a_id())); + harness.nodes[1].signer_unblocked(filter); }, 0xc9 => { - nodes[1].keys_manager.enable_op_for_all_signers(SignerOp::GetPerCommitmentPoint); - let filter = Some((nodes[2].get_our_node_id(), chan_b_id)); - nodes[1].signer_unblocked(filter); + harness.nodes[1] + .keys_manager + .enable_op_for_all_signers(SignerOp::GetPerCommitmentPoint); + let filter = Some((harness.nodes[2].get_our_node_id(), harness.chan_b_id())); + harness.nodes[1].signer_unblocked(filter); }, 0xca => { - nodes[2].keys_manager.enable_op_for_all_signers(SignerOp::GetPerCommitmentPoint); - nodes[2].signer_unblocked(None); + harness.nodes[2] + .keys_manager + .enable_op_for_all_signers(SignerOp::GetPerCommitmentPoint); + harness.nodes[2].signer_unblocked(None); }, 0xcb => { - nodes[0].keys_manager.enable_op_for_all_signers(SignerOp::ReleaseCommitmentSecret); - nodes[0].signer_unblocked(None); + harness.nodes[0] + .keys_manager + .enable_op_for_all_signers(SignerOp::ReleaseCommitmentSecret); + harness.nodes[0].signer_unblocked(None); }, 0xcc => { - nodes[1].keys_manager.enable_op_for_all_signers(SignerOp::ReleaseCommitmentSecret); - let filter = Some((nodes[0].get_our_node_id(), chan_a_id)); - nodes[1].signer_unblocked(filter); + harness.nodes[1] + .keys_manager + .enable_op_for_all_signers(SignerOp::ReleaseCommitmentSecret); + let filter = Some((harness.nodes[0].get_our_node_id(), harness.chan_a_id())); + harness.nodes[1].signer_unblocked(filter); }, 0xcd => { - nodes[1].keys_manager.enable_op_for_all_signers(SignerOp::ReleaseCommitmentSecret); - let filter = Some((nodes[2].get_our_node_id(), chan_b_id)); - nodes[1].signer_unblocked(filter); + harness.nodes[1] + .keys_manager + .enable_op_for_all_signers(SignerOp::ReleaseCommitmentSecret); + let filter = Some((harness.nodes[2].get_our_node_id(), harness.chan_b_id())); + harness.nodes[1].signer_unblocked(filter); }, 0xce => { - nodes[2].keys_manager.enable_op_for_all_signers(SignerOp::ReleaseCommitmentSecret); - nodes[2].signer_unblocked(None); + harness.nodes[2] + .keys_manager + .enable_op_for_all_signers(SignerOp::ReleaseCommitmentSecret); + harness.nodes[2].signer_unblocked(None); }, - 0xf0 => { - ab_link.complete_monitor_updates_for_node(0, &nodes, MonitorUpdateSelector::First) - }, - 0xf1 => { - ab_link.complete_monitor_updates_for_node(0, &nodes, MonitorUpdateSelector::Second) - }, - 0xf2 => { - ab_link.complete_monitor_updates_for_node(0, &nodes, MonitorUpdateSelector::Last) - }, + 0xf0 => harness.ab_link.complete_monitor_updates_for_node( + 0, + &harness.nodes, + MonitorUpdateSelector::First, + ), + 0xf1 => harness.ab_link.complete_monitor_updates_for_node( + 0, + &harness.nodes, + MonitorUpdateSelector::Second, + ), + 0xf2 => harness.ab_link.complete_monitor_updates_for_node( + 0, + &harness.nodes, + MonitorUpdateSelector::Last, + ), - 0xf4 => { - ab_link.complete_monitor_updates_for_node(1, &nodes, MonitorUpdateSelector::First) - }, - 0xf5 => { - ab_link.complete_monitor_updates_for_node(1, &nodes, MonitorUpdateSelector::Second) - }, - 0xf6 => { - ab_link.complete_monitor_updates_for_node(1, &nodes, MonitorUpdateSelector::Last) - }, + 0xf4 => harness.ab_link.complete_monitor_updates_for_node( + 1, + &harness.nodes, + MonitorUpdateSelector::First, + ), + 0xf5 => harness.ab_link.complete_monitor_updates_for_node( + 1, + &harness.nodes, + MonitorUpdateSelector::Second, + ), + 0xf6 => harness.ab_link.complete_monitor_updates_for_node( + 1, + &harness.nodes, + MonitorUpdateSelector::Last, + ), - 0xf8 => { - bc_link.complete_monitor_updates_for_node(1, &nodes, MonitorUpdateSelector::First) - }, - 0xf9 => { - bc_link.complete_monitor_updates_for_node(1, &nodes, MonitorUpdateSelector::Second) - }, - 0xfa => { - bc_link.complete_monitor_updates_for_node(1, &nodes, MonitorUpdateSelector::Last) - }, + 0xf8 => harness.bc_link.complete_monitor_updates_for_node( + 1, + &harness.nodes, + MonitorUpdateSelector::First, + ), + 0xf9 => harness.bc_link.complete_monitor_updates_for_node( + 1, + &harness.nodes, + MonitorUpdateSelector::Second, + ), + 0xfa => harness.bc_link.complete_monitor_updates_for_node( + 1, + &harness.nodes, + MonitorUpdateSelector::Last, + ), - 0xfc => { - bc_link.complete_monitor_updates_for_node(2, &nodes, MonitorUpdateSelector::First) - }, - 0xfd => { - bc_link.complete_monitor_updates_for_node(2, &nodes, MonitorUpdateSelector::Second) - }, - 0xfe => { - bc_link.complete_monitor_updates_for_node(2, &nodes, MonitorUpdateSelector::Last) - }, + 0xfc => harness.bc_link.complete_monitor_updates_for_node( + 2, + &harness.nodes, + MonitorUpdateSelector::First, + ), + 0xfd => harness.bc_link.complete_monitor_updates_for_node( + 2, + &harness.nodes, + MonitorUpdateSelector::Second, + ), + 0xfe => harness.bc_link.complete_monitor_updates_for_node( + 2, + &harness.nodes, + MonitorUpdateSelector::Last, + ), 0xff => { // Test that no channel is in a stuck state where neither party can send funds even // after we resolve all pending events. - - // First, make sure peers are all connected to each other - ab_link.reconnect(&nodes); - bc_link.reconnect(&nodes); - - for op in SUPPORTED_SIGNER_OPS { - nodes[0].keys_manager.enable_op_for_all_signers(op); - nodes[1].keys_manager.enable_op_for_all_signers(op); - nodes[2].keys_manager.enable_op_for_all_signers(op); - } - nodes[0].signer_unblocked(None); - nodes[1].signer_unblocked(None); - nodes[2].signer_unblocked(None); - - process_all_events!(); - - // Since MPP payments are supported, we wait until we fully settle the state of all - // channels to see if we have any committed HTLC parts of an MPP payment that need - // to be failed back. - for node in &nodes { - node.timer_tick_occurred(); - } - process_all_events!(); - - // Verify no payments are stuck - all should have resolved - payments.assert_all_resolved(); - // Verify that every payment claimed by a receiver resulted in a - // PaymentSent event at the sender. - payments.assert_claims_reported(); - - // Finally, make sure that at least one end of each channel can make a substantial payment - for &chan_id in ab_link.channel_ids() { - assert!( - payments.send(&nodes, 0, 1, chan_id, 10_000_000) - || payments.send(&nodes, 1, 0, chan_id, 10_000_000) - ); - } - for &chan_id in bc_link.channel_ids() { - assert!( - payments.send(&nodes, 1, 2, chan_id, 10_000_000) - || payments.send(&nodes, 2, 1, chan_id, 10_000_000) - ); - } - - nodes[0].record_last_htlc_clear_fee(); - nodes[1].record_last_htlc_clear_fee(); - nodes[2].record_last_htlc_clear_fee(); + harness.settle_all(); }, _ => break 'fuzz_loop, } - for node in &mut nodes { - node.refresh_serialized_manager(); - } + harness.refresh_serialized_managers(); } - assert_test_invariants(&nodes); + harness.finish(); } pub fn chanmon_consistency_test(data: &[u8], out: Out) { From f0edabbea870eda401937e95eb46a229576ac11b Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Thu, 7 May 2026 11:03:47 +0200 Subject: [PATCH 384/627] Add chanmon stuck HTLC invariant Assert that channel HTLC sets are empty after harness quiescence. --- fuzz/src/chanmon_consistency.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 8a90dc93e97..c37808d18ee 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -2726,6 +2726,23 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { // PaymentSent event at the sender. self.payments.assert_claims_reported(); + // All HTLCs should have been claimed or failed once we reach quiescence. + for (idx, node) in self.nodes.iter().enumerate() { + for chan in node.list_channels() { + assert!( + chan.pending_inbound_htlcs.is_empty() && chan.pending_outbound_htlcs.is_empty(), + "Node {} channel {:?} has stuck HTLCs after settling all state: \ + {} inbound {:?}, {} outbound {:?}", + idx, + chan.channel_id, + chan.pending_inbound_htlcs.len(), + chan.pending_inbound_htlcs, + chan.pending_outbound_htlcs.len(), + chan.pending_outbound_htlcs + ); + } + } + // Finally, make sure that at least one end of each channel can make a substantial payment. let chan_ab_ids = self.ab_link.channel_ids().clone(); let chan_bc_ids = self.bc_link.channel_ids().clone(); From b3544defd8e614c1ce88600064d3b12fe7e93679 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 5 May 2026 22:12:28 +0200 Subject: [PATCH 385/627] Reset LSPS5 `persistence_in_flight` counter on persist errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `LSPS5ServiceHandler::persist` incremented `persistence_in_flight` at the top as a single-runner gate, but only decremented it on the success path: each interior `?` on a `kv_store` future propagated the error out of the function while leaving the counter at >= 1. After one transient I/O failure (disk full, brief unavailability of a remote `KVStore`, EPERM, etc.) every subsequent `persist()` call hit the `fetch_add > 0` short-circuit and silently returned `Ok(false)`. The in-memory `needs_persist` flags then continued to grow without ever reaching disk, so webhook state, removals, and notification cooldowns were lost on the next process restart — including the spec-mandated webhook retention/pruning state — without any error surfaced to the operator. The counter is monotonic, so recovery required a process restart. Adopt the LSPS1 / LSPS2 pattern: split the body into an inner `do_persist` and an outer `persist` that unconditionally clears the counter via `store(0)` after the call returns, regardless of outcome. A failed write now still propagates `Err`, but the next `persist()` attempt actually retries the write instead of no-op'ing. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps5/service.rs | 135 +++++++------- .../tests/lsps5_integration_tests.rs | 170 ++++++++++++++++++ 2 files changed, 244 insertions(+), 61 deletions(-) diff --git a/lightning-liquidity/src/lsps5/service.rs b/lightning-liquidity/src/lsps5/service.rs index 4678d38dc9a..55d96e186d1 100644 --- a/lightning-liquidity/src/lsps5/service.rs +++ b/lightning-liquidity/src/lsps5/service.rs @@ -245,84 +245,97 @@ where // introduce some batching to upper-bound the number of requests inflight at any given // time. - let mut did_persist = false; - if self.persistence_in_flight.fetch_add(1, Ordering::AcqRel) > 0 { // If we're not the first event processor to get here, just return early, the increment // we just did will be treated as "go around again" at the end. - return Ok(did_persist); + return Ok(false); } + let mut did_persist = false; + loop { - let mut need_remove = Vec::new(); - let mut need_persist = Vec::new(); + match self.do_persist().await { + Ok(pass_did_persist) => did_persist |= pass_did_persist, + Err(e) => { + self.persistence_in_flight.store(0, Ordering::Release); + return Err(e); + }, + } - self.check_prune_stale_webhooks(&mut self.per_peer_state.write().unwrap()); - { - let outer_state_lock = self.per_peer_state.read().unwrap(); - - for (client_id, peer_state) in outer_state_lock.iter() { - let is_prunable = peer_state.is_prunable(); - let has_open_channel = self.client_has_open_channel(client_id); - if is_prunable && !has_open_channel { - need_remove.push(*client_id); - } else if peer_state.needs_persist { - need_persist.push(*client_id); - } - } + if self.persistence_in_flight.fetch_sub(1, Ordering::AcqRel) != 1 { + // If another thread incremented the state while we were running we should go + // around again, but only once. + self.persistence_in_flight.store(1, Ordering::Release); + continue; } + break; + } - for client_id in need_persist.into_iter() { - debug_assert!(!need_remove.contains(&client_id)); - self.persist_peer_state(client_id).await?; - did_persist = true; + Ok(did_persist) + } + + async fn do_persist(&self) -> Result { + let mut did_persist = false; + let mut need_remove = Vec::new(); + let mut need_persist = Vec::new(); + + self.check_prune_stale_webhooks(&mut self.per_peer_state.write().unwrap()); + { + let outer_state_lock = self.per_peer_state.read().unwrap(); + + for (client_id, peer_state) in outer_state_lock.iter() { + let is_prunable = peer_state.is_prunable(); + let has_open_channel = self.client_has_open_channel(client_id); + if is_prunable && !has_open_channel { + need_remove.push(*client_id); + } else if peer_state.needs_persist { + need_persist.push(*client_id); + } } + } - for client_id in need_remove { - let mut future_opt = None; - { - // We need to take the `per_peer_state` write lock to remove an entry, but also - // have to hold it until after the `remove` call returns (but not through - // future completion) to ensure that writes for the peer's state are - // well-ordered with other `persist_peer_state` calls even across the removal - // itself. - let mut per_peer_state = self.per_peer_state.write().unwrap(); - if let Entry::Occupied(mut entry) = per_peer_state.entry(client_id) { - let state = entry.get_mut(); - if state.is_prunable() && !self.client_has_open_channel(&client_id) { - entry.remove(); - let key = client_id.to_string(); - future_opt = Some(self.kv_store.remove( - LIQUIDITY_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, - LSPS5_SERVICE_PERSISTENCE_SECONDARY_NAMESPACE, - &key, - true, - )); - } else { - // If the peer was re-added, force a re-persist of the current state. - state.needs_persist = true; - } + for client_id in need_persist.into_iter() { + debug_assert!(!need_remove.contains(&client_id)); + self.persist_peer_state(client_id).await?; + did_persist = true; + } + + for client_id in need_remove { + let mut future_opt = None; + { + // We need to take the `per_peer_state` write lock to remove an entry, but also + // have to hold it until after the `remove` call returns (but not through + // future completion) to ensure that writes for the peer's state are + // well-ordered with other `persist_peer_state` calls even across the removal + // itself. + let mut per_peer_state = self.per_peer_state.write().unwrap(); + if let Entry::Occupied(mut entry) = per_peer_state.entry(client_id) { + let state = entry.get_mut(); + if state.is_prunable() && !self.client_has_open_channel(&client_id) { + entry.remove(); + let key = client_id.to_string(); + future_opt = Some(self.kv_store.remove( + LIQUIDITY_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS5_SERVICE_PERSISTENCE_SECONDARY_NAMESPACE, + &key, + true, + )); } else { - // This should never happen, we can only have one `persist` call - // in-progress at once and map entries are only removed by it. - debug_assert!(false); + // If the peer was re-added, force a re-persist of the current state. + state.needs_persist = true; } - } - if let Some(future) = future_opt { - future.await?; - did_persist = true; } else { - self.persist_peer_state(client_id).await?; + // This should never happen, we can only have one `persist` call + // in-progress at once and map entries are only removed by it. + debug_assert!(false); } } - - if self.persistence_in_flight.fetch_sub(1, Ordering::AcqRel) != 1 { - // If another thread incremented the state while we were running we should go - // around again, but only once. - self.persistence_in_flight.store(1, Ordering::Release); - continue; + if let Some(future) = future_opt { + future.await?; + did_persist = true; + } else { + self.persist_peer_state(client_id).await?; } - break; } Ok(did_persist) diff --git a/lightning-liquidity/tests/lsps5_integration_tests.rs b/lightning-liquidity/tests/lsps5_integration_tests.rs index 2b32b4dcbc6..deed6b2f8b8 100644 --- a/lightning-liquidity/tests/lsps5_integration_tests.rs +++ b/lightning-liquidity/tests/lsps5_integration_tests.rs @@ -1633,3 +1633,173 @@ fn lsps5_service_handler_persistence_across_restarts() { } } } + +struct FailableKVStore { + inner: TestStore, + fail_lsps5: std::sync::atomic::AtomicBool, +} + +impl FailableKVStore { + fn new() -> Self { + Self { inner: TestStore::new(false), fail_lsps5: std::sync::atomic::AtomicBool::new(false) } + } + + fn set_fail_lsps5(&self, fail: bool) { + self.fail_lsps5.store(fail, std::sync::atomic::Ordering::SeqCst); + } +} + +impl lightning::util::persist::KVStoreSync for FailableKVStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> lightning::io::Result> { + ::read( + &self.inner, + primary_namespace, + secondary_namespace, + key, + ) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> lightning::io::Result<()> { + if secondary_namespace == "lsps5_service" + && self.fail_lsps5.load(std::sync::atomic::Ordering::SeqCst) + { + return Err(lightning::io::Error::new( + lightning::io::ErrorKind::Other, + "intentional failure for lsps5 namespace", + )); + } + ::write( + &self.inner, + primary_namespace, + secondary_namespace, + key, + buf, + ) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> lightning::io::Result<()> { + if secondary_namespace == "lsps5_service" + && self.fail_lsps5.load(std::sync::atomic::Ordering::SeqCst) + { + return Err(lightning::io::Error::new( + lightning::io::ErrorKind::Other, + "intentional failure for lsps5 namespace", + )); + } + ::remove( + &self.inner, + primary_namespace, + secondary_namespace, + key, + lazy, + ) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> lightning::io::Result> { + ::list( + &self.inner, + primary_namespace, + secondary_namespace, + ) + } +} + +#[test] +fn lsps5_service_persist_resets_in_flight_counter_on_io_error() { + use lightning::ln::peer_handler::CustomMessageHandler; + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let service_kv_store = Arc::new(FailableKVStore::new()); + let client_kv_store = Arc::new(TestStore::new(false)); + + let service_config = LiquidityServiceConfig { + lsps1_service_config: None, + lsps2_service_config: None, + lsps5_service_config: Some(LSPS5ServiceConfig::default()), + advertise_service: true, + }; + let client_config = LiquidityClientConfig { + lsps1_client_config: None, + lsps2_client_config: None, + lsps5_client_config: Some(LSPS5ClientConfig::default()), + }; + let time_provider: Arc = Arc::new(DefaultTimeProvider); + + let service_lm = LiquidityManagerSync::new_with_custom_time_provider( + nodes[0].keys_manager, + nodes[0].keys_manager, + nodes[0].node, + Arc::clone(&service_kv_store), + nodes[0].tx_broadcaster, + Some(service_config), + None, + Arc::clone(&time_provider), + ) + .unwrap(); + + let client_lm = LiquidityManagerSync::new_with_custom_time_provider( + nodes[1].keys_manager, + nodes[1].keys_manager, + nodes[1].node, + client_kv_store, + nodes[1].tx_broadcaster, + None, + Some(client_config), + Arc::clone(&time_provider), + ) + .unwrap(); + + let service_node_id = nodes[0].node.get_our_node_id(); + let client_node_id = nodes[1].node.get_our_node_id(); + + create_chan_between_nodes(&nodes[0], &nodes[1]); + + let client_handler = client_lm.lsps5_client_handler().unwrap(); + client_handler + .set_webhook(service_node_id, "App".to_string(), "https://example.org/hook".to_string()) + .unwrap(); + + let req_msgs = client_lm.get_and_clear_pending_msg(); + assert_eq!(req_msgs.len(), 1); + let (_, request) = req_msgs.into_iter().next().unwrap(); + service_lm.handle_custom_message(request, client_node_id).unwrap(); + + // Consume the SendWebhookNotification event so pending events queue is drained. + let _ = service_lm.next_event(); + let _ = service_lm.get_and_clear_pending_msg(); + + // Initial persist should succeed and clear all needs_persist flags. + service_lm.persist().expect("initial persist should succeed"); + + // Now arrange for lsps5 writes to fail and dirty lsps5 state without dirtying + // pending_events (which lives in a different namespace). + service_kv_store.set_fail_lsps5(true); + service_lm.peer_disconnected(client_node_id); + + // First persist attempt should error out due to the failing kv_store. + let res1 = service_lm.persist(); + assert!(res1.is_err(), "persist should fail when lsps5 kv_store write fails"); + + // Second persist attempt must still attempt the write (and fail again). With the + // bug, the LSPS5 service handler's `persistence_in_flight` counter is left above + // zero on error so this returns Ok(false) immediately, silently dropping the + // pending state and breaking persistence forever. + let res2 = service_lm.persist(); + assert!( + res2.is_err(), + "after a failed persist, subsequent persist calls must still attempt to persist; got {:?}", + res2, + ); +} From 6f93dead64b0331a4d3417983ac9f141f43662f1 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 8 May 2026 11:05:30 +0200 Subject: [PATCH 386/627] Bump electrum-client to v0.25 --- lightning-transaction-sync/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning-transaction-sync/Cargo.toml b/lightning-transaction-sync/Cargo.toml index 4bc37d7ff48..2c991cd7827 100644 --- a/lightning-transaction-sync/Cargo.toml +++ b/lightning-transaction-sync/Cargo.toml @@ -38,7 +38,7 @@ lightning-macros = { version = "0.2", path = "../lightning-macros", default-feat bitcoin = { version = "0.32.2", default-features = false } futures = { version = "0.3", optional = true } esplora-client = { version = "0.12", default-features = false, optional = true } -electrum-client = { version = "0.24.0", optional = true, default-features = false, features = ["proxy"] } +electrum-client = { version = "0.25", optional = true, default-features = false, features = ["proxy"] } [dev-dependencies] lightning = { version = "0.3.0", path = "../lightning", default-features = false, features = ["std", "_test_utils"] } From 8882eddc10d621e567452cca1c49c2825ea2dc73 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 8 May 2026 11:10:50 +0200 Subject: [PATCH 387/627] Bump transaction sync dev dependencies --- lightning-transaction-sync/Cargo.toml | 4 ++-- lightning-transaction-sync/tests/integration_tests.rs | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lightning-transaction-sync/Cargo.toml b/lightning-transaction-sync/Cargo.toml index 2c991cd7827..d504cd239f2 100644 --- a/lightning-transaction-sync/Cargo.toml +++ b/lightning-transaction-sync/Cargo.toml @@ -45,8 +45,8 @@ lightning = { version = "0.3.0", path = "../lightning", default-features = false tokio = { version = "1.35.0", features = ["macros"] } [target.'cfg(not(target_os = "windows"))'.dev-dependencies] -electrsd = { version = "0.36.0", default-features = false, features = ["legacy"] } -corepc-node = { version = "0.10.0", default-features = false, features = ["28_0"] } +electrsd = { version = "0.38", default-features = false, features = ["legacy"] } +bitcoind = { version = "0.38", default-features = false, features = ["28_1"] } [lints.rust.unexpected_cfgs] level = "forbid" diff --git a/lightning-transaction-sync/tests/integration_tests.rs b/lightning-transaction-sync/tests/integration_tests.rs index 07b190ad30b..a5b303fdba6 100644 --- a/lightning-transaction-sync/tests/integration_tests.rs +++ b/lightning-transaction-sync/tests/integration_tests.rs @@ -18,8 +18,8 @@ use bitcoin::constants::genesis_block; use bitcoin::network::Network; use bitcoin::{Amount, BlockHash, Txid}; -use electrsd::corepc_node::Node as BitcoinD; -use electrsd::{corepc_node, ElectrsD}; +use bitcoind::BitcoinD; +use electrsd::ElectrsD; use std::collections::{HashMap, HashSet}; use std::env; @@ -28,10 +28,10 @@ use std::time::Duration; pub fn setup_bitcoind_and_electrsd() -> (BitcoinD, ElectrsD) { let bitcoind_exe = - env::var("BITCOIND_EXE").ok().or_else(|| corepc_node::downloaded_exe_path().ok()).expect( + env::var("BITCOIND_EXE").ok().or_else(|| bitcoind::downloaded_exe_path().ok()).expect( "you need to provide an env var BITCOIND_EXE or specify a bitcoind version feature", ); - let mut bitcoind_conf = corepc_node::Conf::default(); + let mut bitcoind_conf = bitcoind::Conf::default(); bitcoind_conf.network = "regtest"; let bitcoind = BitcoinD::with_conf(bitcoind_exe, &bitcoind_conf).unwrap(); From 946ee0957b884d2671bf18433dcfa5878ca8de02 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Fri, 8 May 2026 11:01:05 -0700 Subject: [PATCH 388/627] Include NegotiationFailureReason in InteractiveTxMsgError Each `SpliceNegotiationFailed` event originating from an `InteractiveTxMsgError` needs a `NegotiationFailureReason`, so it makes sense to track it in the same place. In most cases, the `NegotiationFailureReason` included uses the `NegotiationError` variant, but other cases may require their own specific variant, such as `LocallyCanceled` after calling `ChannelManager::cancel_funding_contributed`. --- lightning/src/ln/channel.rs | 83 ++++++++++++++++++++---------- lightning/src/ln/channelmanager.rs | 16 ++---- 2 files changed, 62 insertions(+), 37 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 3f3a6feb414..3720aaa7a3f 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -1187,6 +1187,35 @@ pub(super) struct InteractiveTxMsgError { /// If a splice was in progress when processing the message, this contains the splice funding /// information for emitting a `SpliceNegotiationFailed` event. pub(super) splice_funding_failed: Option, + /// The event reason to use if this error causes a `SpliceNegotiationFailed` event. + pub(super) negotiation_failure_reason: Option, +} + +impl InteractiveTxMsgError { + fn new(err: ChannelError, splice_funding_failed: Option) -> Self { + Self { err, splice_funding_failed, negotiation_failure_reason: None } + } + + fn with_negotiation_failure_reason(mut self, reason: NegotiationFailureReason) -> Self { + self.negotiation_failure_reason = Some(reason); + self + } + + pub(super) fn into_parts( + self, + ) -> (ChannelError, Option<(SpliceFundingFailed, NegotiationFailureReason)>) { + let Self { err, splice_funding_failed, negotiation_failure_reason } = self; + let splice_failure = splice_funding_failed.map(|splice_funding_failed| { + let reason = + negotiation_failure_reason.unwrap_or_else(|| Self::reason_from_channel_error(&err)); + (splice_funding_failed, reason) + }); + (err, splice_failure) + } + + fn reason_from_channel_error(err: &ChannelError) -> NegotiationFailureReason { + NegotiationFailureReason::NegotiationError { msg: format!("{:?}", err) } + } } /// The return value of `monitor_updating_restored` @@ -1850,7 +1879,7 @@ where }, }; - InteractiveTxMsgError { err: ChannelError::Abort(reason), splice_funding_failed } + InteractiveTxMsgError::new(ChannelError::Abort(reason), splice_funding_failed) } pub fn tx_add_input( @@ -1860,12 +1889,12 @@ where Some(interactive_tx_constructor) => interactive_tx_constructor .handle_tx_add_input(msg) .map_err(|reason| self.fail_interactive_tx_negotiation(reason, logger)), - None => Err(InteractiveTxMsgError { - err: ChannelError::WarnAndDisconnect( + None => Err(InteractiveTxMsgError::new( + ChannelError::WarnAndDisconnect( "Received unexpected interactive transaction negotiation message".to_owned(), ), - splice_funding_failed: None, - }), + None, + )), } } @@ -1876,12 +1905,12 @@ where Some(interactive_tx_constructor) => interactive_tx_constructor .handle_tx_add_output(msg) .map_err(|reason| self.fail_interactive_tx_negotiation(reason, logger)), - None => Err(InteractiveTxMsgError { - err: ChannelError::WarnAndDisconnect( + None => Err(InteractiveTxMsgError::new( + ChannelError::WarnAndDisconnect( "Received unexpected interactive transaction negotiation message".to_owned(), ), - splice_funding_failed: None, - }), + None, + )), } } @@ -1892,12 +1921,12 @@ where Some(interactive_tx_constructor) => interactive_tx_constructor .handle_tx_remove_input(msg) .map_err(|reason| self.fail_interactive_tx_negotiation(reason, logger)), - None => Err(InteractiveTxMsgError { - err: ChannelError::WarnAndDisconnect( + None => Err(InteractiveTxMsgError::new( + ChannelError::WarnAndDisconnect( "Received unexpected interactive transaction negotiation message".to_owned(), ), - splice_funding_failed: None, - }), + None, + )), } } @@ -1908,12 +1937,12 @@ where Some(interactive_tx_constructor) => interactive_tx_constructor .handle_tx_remove_output(msg) .map_err(|reason| self.fail_interactive_tx_negotiation(reason, logger)), - None => Err(InteractiveTxMsgError { - err: ChannelError::WarnAndDisconnect( + None => Err(InteractiveTxMsgError::new( + ChannelError::WarnAndDisconnect( "Received unexpected interactive transaction negotiation message".to_owned(), ), - splice_funding_failed: None, - }), + None, + )), } } @@ -1926,10 +1955,10 @@ where .map_err(|reason| self.fail_interactive_tx_negotiation(reason, logger))?, None => { let err = "Received unexpected interactive transaction negotiation message"; - return Err(InteractiveTxMsgError { - err: ChannelError::WarnAndDisconnect(err.to_owned()), - splice_funding_failed: None, - }); + return Err(InteractiveTxMsgError::new( + ChannelError::WarnAndDisconnect(err.to_owned()), + None, + )); }, }; @@ -12797,7 +12826,8 @@ where // quiescent for it. ChannelError::Ignore(str.into()) }; - return Ok(InteractiveTxMsgError { err, splice_funding_failed }); + return Ok(InteractiveTxMsgError::new(err, splice_funding_failed) + .with_negotiation_failure_reason(NegotiationFailureReason::LocallyCanceled)); } let funding_negotiation = self @@ -12864,10 +12894,11 @@ where debug_assert!(self.context.channel_state.is_quiescent()); let splice_funding_failed = self.reset_pending_splice_state(); debug_assert!(splice_funding_failed.is_some()); - Ok(InteractiveTxMsgError { - err: ChannelError::Abort(AbortReason::ManualIntervention), + Ok(InteractiveTxMsgError::new( + ChannelError::Abort(AbortReason::ManualIntervention), splice_funding_failed, - }) + ) + .with_negotiation_failure_reason(NegotiationFailureReason::LocallyCanceled)) } /// Checks during handling splice_init @@ -14520,7 +14551,7 @@ where debug_assert!(self.context.channel_state.is_quiescent()); self.exit_quiescence(); } - InteractiveTxMsgError { err, splice_funding_failed: None } + InteractiveTxMsgError::new(err, None) } pub fn remove_legacy_scids_before_block(&mut self, height: u32) -> alloc::vec::Drain<'_, u64> { diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 9920be84e6b..db64cc99a02 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -4982,7 +4982,6 @@ impl< *channel_id, counterparty_node_id, user_channel_id, - Some(events::NegotiationFailureReason::LocallyCanceled), ); let _ = self.handle_error(Err::<(), _>(err), *counterparty_node_id); self.event_persist_notifier.notify(); @@ -11956,9 +11955,10 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ fn handle_interactive_tx_msg_err( &self, err: InteractiveTxMsgError, channel_id: ChannelId, counterparty_node_id: &PublicKey, - user_channel_id: u128, reason: Option, + user_channel_id: u128, ) -> MsgHandleErrInternal { - if let Some(splice_funding_failed) = err.splice_funding_failed { + let (err, splice_failure) = err.into_parts(); + if let Some((splice_funding_failed, reason)) = splice_failure { let (funding_info, contribution) = splice_funding_failed.into_parts(); let pending_events = &mut self.pending_events.lock().unwrap(); if let Some(funding_info) = funding_info { @@ -11971,14 +11971,12 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ counterparty_node_id: *counterparty_node_id, user_channel_id, contribution, - reason: reason.unwrap_or(events::NegotiationFailureReason::NegotiationError { - msg: format!("{:?}", err.err), - }), + reason, }, None, )); } - MsgHandleErrInternal::from_chan_no_close(err.err, channel_id) + MsgHandleErrInternal::from_chan_no_close(err, channel_id) } fn internal_tx_msg< @@ -12009,7 +12007,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ channel_id, counterparty_node_id, user_channel_id, - None, )) }, } @@ -12145,7 +12142,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ msg.channel_id, &counterparty_node_id, user_channel_id, - None, )) }, } @@ -13391,7 +13387,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ msg.channel_id, counterparty_node_id, user_channel_id, - None, )) }, } @@ -13449,7 +13444,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ msg.channel_id, counterparty_node_id, user_channel_id, - None, )) }, } From 65e8cc8d5bb85af67efc29c006c613804ba1f44f Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 7 May 2026 18:33:12 +0000 Subject: [PATCH 389/627] Add an auto-generated unicode character category file 1a01b5ae4fb74bfff763b968719e362e546bd594 added detection of unicode format characters in `PrintableString`, but used a hard-coded table which may eventually become out of date. Here we switch to an auto-generated table, include all `General_Category` `Other` characters, and also ban unallocated code points. Finally, CI validates that the file is kept up to date. Written by Claude --- .github/workflows/check_unicode.yml | 26 + contrib/gen_unicode_general_category.py | 308 +++++++++ lightning-types/src/lib.rs | 1 + lightning-types/src/string.rs | 39 +- lightning-types/src/unicode.rs | 799 ++++++++++++++++++++++++ 5 files changed, 1139 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/check_unicode.yml create mode 100755 contrib/gen_unicode_general_category.py create mode 100644 lightning-types/src/unicode.rs diff --git a/.github/workflows/check_unicode.yml b/.github/workflows/check_unicode.yml new file mode 100644 index 00000000000..a01add3f814 --- /dev/null +++ b/.github/workflows/check_unicode.yml @@ -0,0 +1,26 @@ +name: Unicode listing up to date +on: + workflow_dispatch: + schedule: + - cron: '42 3 * * *' + +jobs: + check-unicode: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Checkout source code + uses: actions/checkout@v4 + - name: Check unicode file state + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + curl --proto '=https' --tlsv1.2 -fsSL -o /tmp/UnicodeData.txt https://www.unicode.org/Public/UCD/latest/ucd/UnicodeData.txt + contrib/gen_unicode_general_category.py /tmp/UnicodeData.txt -o /tmp/unicode.rs + if ! diff -u lightning-types/src/unicode.rs /tmp/unicode.rs; then + TITLE="Unicode listing out of date: ${{ github.workflow }}" + RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + BODY="The unicode character listing is out of date, see $RUN_URL" + gh issue create --title "$TITLE" --body "$BODY" + fi diff --git a/contrib/gen_unicode_general_category.py b/contrib/gen_unicode_general_category.py new file mode 100755 index 00000000000..4871e967b55 --- /dev/null +++ b/contrib/gen_unicode_general_category.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +# This file is Copyright its original authors, visible in version control +# history. +# +# This file is licensed under the Apache License, Version 2.0 or the MIT license +# , at your option. +# You may not use this file except in accordance with one or both of these +# licenses. + +"""Generate Unicode general-category predicates from `UnicodeData.txt`. + +Emits two `pub(crate)` functions taking a `char`, split into two disjoint +buckets across the Unicode top-level `C` ("Other") category so callers can +compose them: + + is_unicode_general_category_other — Cc / Cf / Cs / Co (assigned) + is_unicode_general_category_unassigned — Cn (plus codepoints above + U+10FFFF, which aren't + valid codepoints at all) + +`UnicodeData.txt` is the canonical machine-readable listing of every assigned +codepoint in the Unicode Character Database. Each line is `;`-separated; field +0 is the codepoint (hex), field 1 is the name, and field 2 is the two-letter +general category (e.g. `Lu`, `Cf`, `Mn`). Codepoints absent from the file have +category `Cn` (Unassigned) by convention. + +Two encoding details to preserve: + * Large blocks of contiguous same-category codepoints are written as two + consecutive entries whose names end in `, First>` and `, Last>`. Every + codepoint between First and Last (inclusive) shares the listed category. + * The codepoint range is U+0000..=U+10FFFF. + +Each `matches!` arm in the assigned-Other table carries an end-of-line comment +derived from the `UnicodeData.txt` name field — typically the longest common +word prefix or suffix across the names in the range, falling back to the set +of categories when the names share nothing meaningful. The unassigned table +omits per-arm comments since every range there has the same meaning by +construction. + +Usage: + contrib/gen_unicode_general_category.py UnicodeData.txt > out.rs +""" + +import argparse +import sys +from pathlib import Path + +MAX_CODEPOINT = 0x10FFFF + +LICENSE_HEADER = """\ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. +""" + +GENERATED_NOTICE = """\ +// Auto-generated from the Unicode Character Database (UnicodeData.txt) by +// contrib/gen_unicode_general_category.py. Do not edit by hand; rerun the +// generator with an updated UnicodeData.txt to refresh the table. +""" + + +def _normalize_name(name): + """Strip the `<...>` wrapping and `, First` / `, Last` range markers so + that, e.g., `` becomes + `Non Private Use High Surrogate` and `` becomes `control`. + """ + if name.startswith("<") and name.endswith(">"): + inner = name[1:-1] + for suffix in (", First", ", Last"): + if inner.endswith(suffix): + inner = inner[: -len(suffix)] + return inner + return name + + +def parse_categories(path): + """Return `(cats, names)` mapping every codepoint listed in `path` to its + general category and to its (normalised) name. Codepoints absent from the + returned dicts have category `Cn` (Unassigned) and no name. + """ + cats = {} + names = {} + pending_first = None # (first_cp, first_cat, normalised_name) once a range opens. + with path.open() as f: + for lineno, raw in enumerate(f, 1): + line = raw.rstrip("\n") + if not line: + continue + fields = line.split(";") + if len(fields) < 3: + raise ValueError(f"{path}:{lineno}: expected at least 3 fields, got {len(fields)}") + cp = int(fields[0], 16) + name = fields[1] + cat = fields[2] + if pending_first is not None: + first_cp, first_cat, first_name = pending_first + if not name.endswith(", Last>"): + raise ValueError( + f"{path}:{lineno}: expected `, Last>` to close range " + f"opened at U+{first_cp:04X}, got name {name!r}" + ) + if cat != first_cat: + raise ValueError( + f"{path}:{lineno}: range U+{first_cp:04X}..=U+{cp:04X} " + f"has mismatched categories {first_cat!r} / {cat!r}" + ) + for x in range(first_cp, cp + 1): + cats[x] = cat + names[x] = first_name + pending_first = None + elif name.endswith(", First>"): + pending_first = (cp, cat, _normalize_name(name)) + else: + cats[cp] = cat + names[cp] = _normalize_name(name) + if pending_first is not None: + raise ValueError(f"{path}: dangling `, First>` entry at U+{pending_first[0]:04X}") + return cats, names + + +ASSIGNED_OTHER_CATS = frozenset({"Cc", "Cf", "Cs", "Co"}) + + +def coalesce_ranges(cats, names, target_cats, *, label): + """Walk U+0000..=U+10FFFF and return a list of `(start, end, label)` for + every contiguous run of codepoints whose general category is in + `target_cats`. Codepoints absent from `cats` are treated as `Cn`. + + If `label` is `True`, attach a comment summarising the codepoint names in + each range; otherwise every range gets an empty label. + """ + ranges = [] + start = None + for cp in range(MAX_CODEPOINT + 1): + in_target = cats.get(cp, "Cn") in target_cats + if in_target and start is None: + start = cp + elif not in_target and start is not None: + ranges.append((start, cp - 1)) + start = None + if start is not None: + ranges.append((start, MAX_CODEPOINT)) + + if not label: + return [(s, e, "") for s, e in ranges] + + labelled = [] + for s, e in ranges: + range_names = [] + range_cats = set() + for cp in range(s, e + 1): + range_cats.add(cats.get(cp, "Cn")) + n = names.get(cp) + if n is not None: + range_names.append(n) + labelled.append((s, e, _make_label(range_names, range_cats))) + return labelled + + +def _common_word_run(names, *, from_end): + """Return the longest sequence of words shared by every name, taken from + either the start (`from_end=False`) or the end (`from_end=True`) of each + name's whitespace-split tokens. + """ + if not names: + return "" + tokenised = [n.split() for n in names] + if from_end: + tokenised = [list(reversed(t)) for t in tokenised] + limit = min(len(t) for t in tokenised) + common = [] + for i in range(limit): + token = tokenised[0][i] + if all(t[i] == token for t in tokenised): + common.append(token) + else: + break + if from_end: + common.reverse() + return " ".join(common) + + +def _make_label(names, cats_in_range): + """Build a short human-readable label for a coalesced range. Applied to + the assigned-Other buckets only; each range there is `Cc`, `Cf`, `Cs`, + `Co`, or some contiguous union thereof. + + Rules, in order: + 1. All names identical → that name (e.g. `control`). + 2. Common leading or trailing words → the longer of the two. + 3. Otherwise, list the categories present (e.g. `Co / Cs`). + """ + unique = list(dict.fromkeys(names)) + if len(unique) == 1: + return unique[0] + + prefix = _common_word_run(names, from_end=False) + suffix = _common_word_run(names, from_end=True) + # Pick whichever is more informative; when both are non-empty, prefer the + # longer one. A multi-word prefix beats a single-word suffix. + label = prefix if len(prefix) >= len(suffix) else suffix + if label: + return label + return " / ".join(sorted(cats_in_range)) + + +def fmt_codepoint(cp): + # `UnicodeData.txt` uses 4-digit hex for the BMP and wider for higher + # planes; mirror that so the output stays readable next to the source data. + return f"0x{cp:04X}" if cp <= 0xFFFF else f"0x{cp:X}" + + +def _pattern(start, end): + if start == end: + return fmt_codepoint(start) + return f"{fmt_codepoint(start)}..={fmt_codepoint(end)}" + + +def _emit_matches_body(lines, arms): + """Append a `matches!(c as u32, ...)` body to `lines`, with one + `(pattern, label)` tuple per arm. The first arm sits at the `matches!` + argument indent and continuation `| ...` arms indent one level deeper, + matching the rustfmt convention used elsewhere in the tree. + """ + lines.append("\tmatches!(") + lines.append("\t\tc as u32,") + for i, (pattern, label) in enumerate(arms): + prefix = "\t\t" if i == 0 else "\t\t\t| " + comment = f" // {label}" if label else "" + lines.append(f"{prefix}{pattern}{comment}") + lines.append("\t)") + + +def render_rust(other_ranges, unassigned_ranges): + """Render the final Rust source defining both `char`-taking predicates. + + `other_ranges` and `unassigned_ranges` are lists of `(start, end, label)`. + The unassigned function additionally gets a synthetic final arm catching + `u32` values above U+10FFFF — these aren't valid Unicode codepoints, so + by definition they have no general category and the unassigned bucket is + the closest match. + """ + lines = [LICENSE_HEADER, GENERATED_NOTICE] + + lines.append("/// Returns `true` if `c` is in Unicode general category `Cc` (Control), `Cf`") + lines.append("/// (Format), `Cs` (Surrogate), or `Co` (Private Use) — the assigned codepoints") + lines.append("/// in the top-level `C` (\"Other\") category. The `Cs` portion of the table is") + lines.append("/// unreachable for `char` input (a `char` cannot hold a surrogate) but is kept") + lines.append("/// so the table mirrors the source UCD data verbatim. The disjoint `Cn`") + lines.append("/// (Unassigned) bucket is `is_unicode_general_category_unassigned`.") + lines.append("#[allow(dead_code)]") + lines.append("pub(crate) fn is_unicode_general_category_other(c: char) -> bool {") + other_arms = [(_pattern(s, e), label) for s, e, label in other_ranges] + _emit_matches_body(lines, other_arms) + lines.append("}") + lines.append("") + + lines.append("/// Returns `true` if `c` is in Unicode general category `Cn` (Unassigned), or") + lines.append("/// strictly above U+10FFFF. The trailing `0x110000..=u32::MAX` arm is") + lines.append("/// unreachable for `char` input (a `char` is bounded to U+10FFFF) but is kept") + lines.append("/// for defensive coverage of the underlying `u32`. The disjoint Cc / Cf / Cs /") + lines.append("/// Co bucket is `is_unicode_general_category_other`.") + lines.append("#[allow(dead_code)]") + lines.append("pub(crate) fn is_unicode_general_category_unassigned(c: char) -> bool {") + unassigned_arms = [(_pattern(s, e), label) for s, e, label in unassigned_ranges] + unassigned_arms.append(("0x110000..=u32::MAX", "above U+10FFFF — unreachable for `char`")) + _emit_matches_body(lines, unassigned_arms) + lines.append("}") + lines.append("") + + return "\n".join(lines) + + +def main(argv): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("unicode_data", type=Path, help="Path to UnicodeData.txt") + ap.add_argument( + "-o", "--output", type=Path, default=None, + help="Output Rust file (default: stdout)", + ) + args = ap.parse_args(argv) + + cats, names = parse_categories(args.unicode_data) + other = coalesce_ranges(cats, names, ASSIGNED_OTHER_CATS, label=True) + unassigned = coalesce_ranges(cats, names, frozenset({"Cn"}), label=False) + rust = render_rust(other, unassigned) + + if args.output is None: + sys.stdout.write(rust) + else: + args.output.write_text(rust) + print( + f"Wrote {args.output} " + f"({len(other)} assigned-Other ranges, " + f"{len(unassigned)} unassigned ranges).", + file=sys.stderr, + ) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/lightning-types/src/lib.rs b/lightning-types/src/lib.rs index 7f72d6d2671..6a526adaed2 100644 --- a/lightning-types/src/lib.rs +++ b/lightning-types/src/lib.rs @@ -27,3 +27,4 @@ pub mod features; pub mod payment; pub mod routing; pub mod string; +mod unicode; diff --git a/lightning-types/src/string.rs b/lightning-types/src/string.rs index e45c17d8586..a21cad411be 100644 --- a/lightning-types/src/string.rs +++ b/lightning-types/src/string.rs @@ -12,6 +12,8 @@ use alloc::string::String; use core::fmt; +use crate::unicode::*; + /// Struct to `Display` fields in a safe way using `PrintableString` #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Default)] pub struct UntrustedString(pub String); @@ -31,7 +33,9 @@ impl<'a> fmt::Display for PrintableString<'a> { fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { use core::fmt::Write; for c in self.0.chars() { - let c = if c.is_control() || is_format_char(c) { + let is_other = is_unicode_general_category_other(c); + let is_unassigned = is_unicode_general_category_unassigned(c); + let c = if c.is_control() || is_other || is_unassigned { core::char::REPLACEMENT_CHARACTER } else { c @@ -43,39 +47,6 @@ impl<'a> fmt::Display for PrintableString<'a> { } } -// Codepoints in Unicode general category `Cf` (Format), per Unicode standard. These are not -// matched by `char::is_control` (which only covers `Cc`), but include the bidirectional override / -// isolate controls (e.g. U+202E RLO) and zero-width characters behind the "Trojan Source" attack -// family (CVE-2021-42574), where an attacker-supplied string renders to a human reader as -// something other than its byte content. Strip them alongside `Cc` characters when sanitising -// untrusted input. -fn is_format_char(c: char) -> bool { - matches!( - c as u32, - 0x00AD - | 0x0600..=0x0605 - | 0x061C - | 0x06DD - | 0x070F - | 0x0890..=0x0891 - | 0x08E2 - | 0x180E - | 0x200B..=0x200F - | 0x202A..=0x202E - | 0x2060..=0x2064 - | 0x2066..=0x206F - | 0xFEFF - | 0xFFF9..=0xFFFB - | 0x110BD - | 0x110CD - | 0x13430..=0x1343F - | 0x1BCA0..=0x1BCA3 - | 0x1D173..=0x1D17A - | 0xE0001 - | 0xE0020..=0xE007F - ) -} - #[cfg(test)] mod tests { use super::PrintableString; diff --git a/lightning-types/src/unicode.rs b/lightning-types/src/unicode.rs new file mode 100644 index 00000000000..22b21969365 --- /dev/null +++ b/lightning-types/src/unicode.rs @@ -0,0 +1,799 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +// Auto-generated from the Unicode Character Database (UnicodeData.txt) by +// contrib/gen_unicode_general_category.py. Do not edit by hand; rerun the +// generator with an updated UnicodeData.txt to refresh the table. + +/// Returns `true` if `c` is in Unicode general category `Cc` (Control), `Cf` +/// (Format), `Cs` (Surrogate), or `Co` (Private Use) — the assigned codepoints +/// in the top-level `C` ("Other") category. The `Cs` portion of the table is +/// unreachable for `char` input (a `char` cannot hold a surrogate) but is kept +/// so the table mirrors the source UCD data verbatim. The disjoint `Cn` +/// (Unassigned) bucket is `is_unicode_general_category_unassigned`. +#[allow(dead_code)] +pub(crate) fn is_unicode_general_category_other(c: char) -> bool { + matches!( + c as u32, + 0x0000..=0x001F // control + | 0x007F..=0x009F // control + | 0x00AD // SOFT HYPHEN + | 0x0600..=0x0605 // ARABIC + | 0x061C // ARABIC LETTER MARK + | 0x06DD // ARABIC END OF AYAH + | 0x070F // SYRIAC ABBREVIATION MARK + | 0x0890..=0x0891 // MARK ABOVE + | 0x08E2 // ARABIC DISPUTED END OF AYAH + | 0x180E // MONGOLIAN VOWEL SEPARATOR + | 0x200B..=0x200F // Cf + | 0x202A..=0x202E // Cf + | 0x2060..=0x2064 // Cf + | 0x2066..=0x206F // Cf + | 0xD800..=0xF8FF // Co / Cs + | 0xFEFF // ZERO WIDTH NO-BREAK SPACE + | 0xFFF9..=0xFFFB // INTERLINEAR ANNOTATION + | 0x110BD // KAITHI NUMBER SIGN + | 0x110CD // KAITHI NUMBER SIGN ABOVE + | 0x13430..=0x1343F // EGYPTIAN HIEROGLYPH + | 0x1BCA0..=0x1BCA3 // SHORTHAND FORMAT + | 0x1D173..=0x1D17A // MUSICAL SYMBOL + | 0xE0001 // LANGUAGE TAG + | 0xE0020..=0xE007F // Cf + | 0xF0000..=0xFFFFD // Plane 15 Private Use + | 0x100000..=0x10FFFD // Plane 16 Private Use + ) +} + +/// Returns `true` if `c` is in Unicode general category `Cn` (Unassigned), or +/// strictly above U+10FFFF. The trailing `0x110000..=u32::MAX` arm is +/// unreachable for `char` input (a `char` is bounded to U+10FFFF) but is kept +/// for defensive coverage of the underlying `u32`. The disjoint Cc / Cf / Cs / +/// Co bucket is `is_unicode_general_category_other`. +#[allow(dead_code)] +pub(crate) fn is_unicode_general_category_unassigned(c: char) -> bool { + matches!( + c as u32, + 0x0378..=0x0379 + | 0x0380..=0x0383 + | 0x038B + | 0x038D + | 0x03A2 + | 0x0530 + | 0x0557..=0x0558 + | 0x058B..=0x058C + | 0x0590 + | 0x05C8..=0x05CF + | 0x05EB..=0x05EE + | 0x05F5..=0x05FF + | 0x070E + | 0x074B..=0x074C + | 0x07B2..=0x07BF + | 0x07FB..=0x07FC + | 0x082E..=0x082F + | 0x083F + | 0x085C..=0x085D + | 0x085F + | 0x086B..=0x086F + | 0x0892..=0x0896 + | 0x0984 + | 0x098D..=0x098E + | 0x0991..=0x0992 + | 0x09A9 + | 0x09B1 + | 0x09B3..=0x09B5 + | 0x09BA..=0x09BB + | 0x09C5..=0x09C6 + | 0x09C9..=0x09CA + | 0x09CF..=0x09D6 + | 0x09D8..=0x09DB + | 0x09DE + | 0x09E4..=0x09E5 + | 0x09FF..=0x0A00 + | 0x0A04 + | 0x0A0B..=0x0A0E + | 0x0A11..=0x0A12 + | 0x0A29 + | 0x0A31 + | 0x0A34 + | 0x0A37 + | 0x0A3A..=0x0A3B + | 0x0A3D + | 0x0A43..=0x0A46 + | 0x0A49..=0x0A4A + | 0x0A4E..=0x0A50 + | 0x0A52..=0x0A58 + | 0x0A5D + | 0x0A5F..=0x0A65 + | 0x0A77..=0x0A80 + | 0x0A84 + | 0x0A8E + | 0x0A92 + | 0x0AA9 + | 0x0AB1 + | 0x0AB4 + | 0x0ABA..=0x0ABB + | 0x0AC6 + | 0x0ACA + | 0x0ACE..=0x0ACF + | 0x0AD1..=0x0ADF + | 0x0AE4..=0x0AE5 + | 0x0AF2..=0x0AF8 + | 0x0B00 + | 0x0B04 + | 0x0B0D..=0x0B0E + | 0x0B11..=0x0B12 + | 0x0B29 + | 0x0B31 + | 0x0B34 + | 0x0B3A..=0x0B3B + | 0x0B45..=0x0B46 + | 0x0B49..=0x0B4A + | 0x0B4E..=0x0B54 + | 0x0B58..=0x0B5B + | 0x0B5E + | 0x0B64..=0x0B65 + | 0x0B78..=0x0B81 + | 0x0B84 + | 0x0B8B..=0x0B8D + | 0x0B91 + | 0x0B96..=0x0B98 + | 0x0B9B + | 0x0B9D + | 0x0BA0..=0x0BA2 + | 0x0BA5..=0x0BA7 + | 0x0BAB..=0x0BAD + | 0x0BBA..=0x0BBD + | 0x0BC3..=0x0BC5 + | 0x0BC9 + | 0x0BCE..=0x0BCF + | 0x0BD1..=0x0BD6 + | 0x0BD8..=0x0BE5 + | 0x0BFB..=0x0BFF + | 0x0C0D + | 0x0C11 + | 0x0C29 + | 0x0C3A..=0x0C3B + | 0x0C45 + | 0x0C49 + | 0x0C4E..=0x0C54 + | 0x0C57 + | 0x0C5B + | 0x0C5E..=0x0C5F + | 0x0C64..=0x0C65 + | 0x0C70..=0x0C76 + | 0x0C8D + | 0x0C91 + | 0x0CA9 + | 0x0CB4 + | 0x0CBA..=0x0CBB + | 0x0CC5 + | 0x0CC9 + | 0x0CCE..=0x0CD4 + | 0x0CD7..=0x0CDB + | 0x0CDF + | 0x0CE4..=0x0CE5 + | 0x0CF0 + | 0x0CF4..=0x0CFF + | 0x0D0D + | 0x0D11 + | 0x0D45 + | 0x0D49 + | 0x0D50..=0x0D53 + | 0x0D64..=0x0D65 + | 0x0D80 + | 0x0D84 + | 0x0D97..=0x0D99 + | 0x0DB2 + | 0x0DBC + | 0x0DBE..=0x0DBF + | 0x0DC7..=0x0DC9 + | 0x0DCB..=0x0DCE + | 0x0DD5 + | 0x0DD7 + | 0x0DE0..=0x0DE5 + | 0x0DF0..=0x0DF1 + | 0x0DF5..=0x0E00 + | 0x0E3B..=0x0E3E + | 0x0E5C..=0x0E80 + | 0x0E83 + | 0x0E85 + | 0x0E8B + | 0x0EA4 + | 0x0EA6 + | 0x0EBE..=0x0EBF + | 0x0EC5 + | 0x0EC7 + | 0x0ECF + | 0x0EDA..=0x0EDB + | 0x0EE0..=0x0EFF + | 0x0F48 + | 0x0F6D..=0x0F70 + | 0x0F98 + | 0x0FBD + | 0x0FCD + | 0x0FDB..=0x0FFF + | 0x10C6 + | 0x10C8..=0x10CC + | 0x10CE..=0x10CF + | 0x1249 + | 0x124E..=0x124F + | 0x1257 + | 0x1259 + | 0x125E..=0x125F + | 0x1289 + | 0x128E..=0x128F + | 0x12B1 + | 0x12B6..=0x12B7 + | 0x12BF + | 0x12C1 + | 0x12C6..=0x12C7 + | 0x12D7 + | 0x1311 + | 0x1316..=0x1317 + | 0x135B..=0x135C + | 0x137D..=0x137F + | 0x139A..=0x139F + | 0x13F6..=0x13F7 + | 0x13FE..=0x13FF + | 0x169D..=0x169F + | 0x16F9..=0x16FF + | 0x1716..=0x171E + | 0x1737..=0x173F + | 0x1754..=0x175F + | 0x176D + | 0x1771 + | 0x1774..=0x177F + | 0x17DE..=0x17DF + | 0x17EA..=0x17EF + | 0x17FA..=0x17FF + | 0x181A..=0x181F + | 0x1879..=0x187F + | 0x18AB..=0x18AF + | 0x18F6..=0x18FF + | 0x191F + | 0x192C..=0x192F + | 0x193C..=0x193F + | 0x1941..=0x1943 + | 0x196E..=0x196F + | 0x1975..=0x197F + | 0x19AC..=0x19AF + | 0x19CA..=0x19CF + | 0x19DB..=0x19DD + | 0x1A1C..=0x1A1D + | 0x1A5F + | 0x1A7D..=0x1A7E + | 0x1A8A..=0x1A8F + | 0x1A9A..=0x1A9F + | 0x1AAE..=0x1AAF + | 0x1ADE..=0x1ADF + | 0x1AEC..=0x1AFF + | 0x1B4D + | 0x1BF4..=0x1BFB + | 0x1C38..=0x1C3A + | 0x1C4A..=0x1C4C + | 0x1C8B..=0x1C8F + | 0x1CBB..=0x1CBC + | 0x1CC8..=0x1CCF + | 0x1CFB..=0x1CFF + | 0x1F16..=0x1F17 + | 0x1F1E..=0x1F1F + | 0x1F46..=0x1F47 + | 0x1F4E..=0x1F4F + | 0x1F58 + | 0x1F5A + | 0x1F5C + | 0x1F5E + | 0x1F7E..=0x1F7F + | 0x1FB5 + | 0x1FC5 + | 0x1FD4..=0x1FD5 + | 0x1FDC + | 0x1FF0..=0x1FF1 + | 0x1FF5 + | 0x1FFF + | 0x2065 + | 0x2072..=0x2073 + | 0x208F + | 0x209D..=0x209F + | 0x20C2..=0x20CF + | 0x20F1..=0x20FF + | 0x218C..=0x218F + | 0x242A..=0x243F + | 0x244B..=0x245F + | 0x2B74..=0x2B75 + | 0x2CF4..=0x2CF8 + | 0x2D26 + | 0x2D28..=0x2D2C + | 0x2D2E..=0x2D2F + | 0x2D68..=0x2D6E + | 0x2D71..=0x2D7E + | 0x2D97..=0x2D9F + | 0x2DA7 + | 0x2DAF + | 0x2DB7 + | 0x2DBF + | 0x2DC7 + | 0x2DCF + | 0x2DD7 + | 0x2DDF + | 0x2E5E..=0x2E7F + | 0x2E9A + | 0x2EF4..=0x2EFF + | 0x2FD6..=0x2FEF + | 0x3040 + | 0x3097..=0x3098 + | 0x3100..=0x3104 + | 0x3130 + | 0x318F + | 0x31E6..=0x31EE + | 0x321F + | 0xA48D..=0xA48F + | 0xA4C7..=0xA4CF + | 0xA62C..=0xA63F + | 0xA6F8..=0xA6FF + | 0xA7DD..=0xA7F0 + | 0xA82D..=0xA82F + | 0xA83A..=0xA83F + | 0xA878..=0xA87F + | 0xA8C6..=0xA8CD + | 0xA8DA..=0xA8DF + | 0xA954..=0xA95E + | 0xA97D..=0xA97F + | 0xA9CE + | 0xA9DA..=0xA9DD + | 0xA9FF + | 0xAA37..=0xAA3F + | 0xAA4E..=0xAA4F + | 0xAA5A..=0xAA5B + | 0xAAC3..=0xAADA + | 0xAAF7..=0xAB00 + | 0xAB07..=0xAB08 + | 0xAB0F..=0xAB10 + | 0xAB17..=0xAB1F + | 0xAB27 + | 0xAB2F + | 0xAB6C..=0xAB6F + | 0xABEE..=0xABEF + | 0xABFA..=0xABFF + | 0xD7A4..=0xD7AF + | 0xD7C7..=0xD7CA + | 0xD7FC..=0xD7FF + | 0xFA6E..=0xFA6F + | 0xFADA..=0xFAFF + | 0xFB07..=0xFB12 + | 0xFB18..=0xFB1C + | 0xFB37 + | 0xFB3D + | 0xFB3F + | 0xFB42 + | 0xFB45 + | 0xFDD0..=0xFDEF + | 0xFE1A..=0xFE1F + | 0xFE53 + | 0xFE67 + | 0xFE6C..=0xFE6F + | 0xFE75 + | 0xFEFD..=0xFEFE + | 0xFF00 + | 0xFFBF..=0xFFC1 + | 0xFFC8..=0xFFC9 + | 0xFFD0..=0xFFD1 + | 0xFFD8..=0xFFD9 + | 0xFFDD..=0xFFDF + | 0xFFE7 + | 0xFFEF..=0xFFF8 + | 0xFFFE..=0xFFFF + | 0x1000C + | 0x10027 + | 0x1003B + | 0x1003E + | 0x1004E..=0x1004F + | 0x1005E..=0x1007F + | 0x100FB..=0x100FF + | 0x10103..=0x10106 + | 0x10134..=0x10136 + | 0x1018F + | 0x1019D..=0x1019F + | 0x101A1..=0x101CF + | 0x101FE..=0x1027F + | 0x1029D..=0x1029F + | 0x102D1..=0x102DF + | 0x102FC..=0x102FF + | 0x10324..=0x1032C + | 0x1034B..=0x1034F + | 0x1037B..=0x1037F + | 0x1039E + | 0x103C4..=0x103C7 + | 0x103D6..=0x103FF + | 0x1049E..=0x1049F + | 0x104AA..=0x104AF + | 0x104D4..=0x104D7 + | 0x104FC..=0x104FF + | 0x10528..=0x1052F + | 0x10564..=0x1056E + | 0x1057B + | 0x1058B + | 0x10593 + | 0x10596 + | 0x105A2 + | 0x105B2 + | 0x105BA + | 0x105BD..=0x105BF + | 0x105F4..=0x105FF + | 0x10737..=0x1073F + | 0x10756..=0x1075F + | 0x10768..=0x1077F + | 0x10786 + | 0x107B1 + | 0x107BB..=0x107FF + | 0x10806..=0x10807 + | 0x10809 + | 0x10836 + | 0x10839..=0x1083B + | 0x1083D..=0x1083E + | 0x10856 + | 0x1089F..=0x108A6 + | 0x108B0..=0x108DF + | 0x108F3 + | 0x108F6..=0x108FA + | 0x1091C..=0x1091E + | 0x1093A..=0x1093E + | 0x1095A..=0x1097F + | 0x109B8..=0x109BB + | 0x109D0..=0x109D1 + | 0x10A04 + | 0x10A07..=0x10A0B + | 0x10A14 + | 0x10A18 + | 0x10A36..=0x10A37 + | 0x10A3B..=0x10A3E + | 0x10A49..=0x10A4F + | 0x10A59..=0x10A5F + | 0x10AA0..=0x10ABF + | 0x10AE7..=0x10AEA + | 0x10AF7..=0x10AFF + | 0x10B36..=0x10B38 + | 0x10B56..=0x10B57 + | 0x10B73..=0x10B77 + | 0x10B92..=0x10B98 + | 0x10B9D..=0x10BA8 + | 0x10BB0..=0x10BFF + | 0x10C49..=0x10C7F + | 0x10CB3..=0x10CBF + | 0x10CF3..=0x10CF9 + | 0x10D28..=0x10D2F + | 0x10D3A..=0x10D3F + | 0x10D66..=0x10D68 + | 0x10D86..=0x10D8D + | 0x10D90..=0x10E5F + | 0x10E7F + | 0x10EAA + | 0x10EAE..=0x10EAF + | 0x10EB2..=0x10EC1 + | 0x10EC8..=0x10ECF + | 0x10ED9..=0x10EF9 + | 0x10F28..=0x10F2F + | 0x10F5A..=0x10F6F + | 0x10F8A..=0x10FAF + | 0x10FCC..=0x10FDF + | 0x10FF7..=0x10FFF + | 0x1104E..=0x11051 + | 0x11076..=0x1107E + | 0x110C3..=0x110CC + | 0x110CE..=0x110CF + | 0x110E9..=0x110EF + | 0x110FA..=0x110FF + | 0x11135 + | 0x11148..=0x1114F + | 0x11177..=0x1117F + | 0x111E0 + | 0x111F5..=0x111FF + | 0x11212 + | 0x11242..=0x1127F + | 0x11287 + | 0x11289 + | 0x1128E + | 0x1129E + | 0x112AA..=0x112AF + | 0x112EB..=0x112EF + | 0x112FA..=0x112FF + | 0x11304 + | 0x1130D..=0x1130E + | 0x11311..=0x11312 + | 0x11329 + | 0x11331 + | 0x11334 + | 0x1133A + | 0x11345..=0x11346 + | 0x11349..=0x1134A + | 0x1134E..=0x1134F + | 0x11351..=0x11356 + | 0x11358..=0x1135C + | 0x11364..=0x11365 + | 0x1136D..=0x1136F + | 0x11375..=0x1137F + | 0x1138A + | 0x1138C..=0x1138D + | 0x1138F + | 0x113B6 + | 0x113C1 + | 0x113C3..=0x113C4 + | 0x113C6 + | 0x113CB + | 0x113D6 + | 0x113D9..=0x113E0 + | 0x113E3..=0x113FF + | 0x1145C + | 0x11462..=0x1147F + | 0x114C8..=0x114CF + | 0x114DA..=0x1157F + | 0x115B6..=0x115B7 + | 0x115DE..=0x115FF + | 0x11645..=0x1164F + | 0x1165A..=0x1165F + | 0x1166D..=0x1167F + | 0x116BA..=0x116BF + | 0x116CA..=0x116CF + | 0x116E4..=0x116FF + | 0x1171B..=0x1171C + | 0x1172C..=0x1172F + | 0x11747..=0x117FF + | 0x1183C..=0x1189F + | 0x118F3..=0x118FE + | 0x11907..=0x11908 + | 0x1190A..=0x1190B + | 0x11914 + | 0x11917 + | 0x11936 + | 0x11939..=0x1193A + | 0x11947..=0x1194F + | 0x1195A..=0x1199F + | 0x119A8..=0x119A9 + | 0x119D8..=0x119D9 + | 0x119E5..=0x119FF + | 0x11A48..=0x11A4F + | 0x11AA3..=0x11AAF + | 0x11AF9..=0x11AFF + | 0x11B0A..=0x11B5F + | 0x11B68..=0x11BBF + | 0x11BE2..=0x11BEF + | 0x11BFA..=0x11BFF + | 0x11C09 + | 0x11C37 + | 0x11C46..=0x11C4F + | 0x11C6D..=0x11C6F + | 0x11C90..=0x11C91 + | 0x11CA8 + | 0x11CB7..=0x11CFF + | 0x11D07 + | 0x11D0A + | 0x11D37..=0x11D39 + | 0x11D3B + | 0x11D3E + | 0x11D48..=0x11D4F + | 0x11D5A..=0x11D5F + | 0x11D66 + | 0x11D69 + | 0x11D8F + | 0x11D92 + | 0x11D99..=0x11D9F + | 0x11DAA..=0x11DAF + | 0x11DDC..=0x11DDF + | 0x11DEA..=0x11EDF + | 0x11EF9..=0x11EFF + | 0x11F11 + | 0x11F3B..=0x11F3D + | 0x11F5B..=0x11FAF + | 0x11FB1..=0x11FBF + | 0x11FF2..=0x11FFE + | 0x1239A..=0x123FF + | 0x1246F + | 0x12475..=0x1247F + | 0x12544..=0x12F8F + | 0x12FF3..=0x12FFF + | 0x13456..=0x1345F + | 0x143FB..=0x143FF + | 0x14647..=0x160FF + | 0x1613A..=0x167FF + | 0x16A39..=0x16A3F + | 0x16A5F + | 0x16A6A..=0x16A6D + | 0x16ABF + | 0x16ACA..=0x16ACF + | 0x16AEE..=0x16AEF + | 0x16AF6..=0x16AFF + | 0x16B46..=0x16B4F + | 0x16B5A + | 0x16B62 + | 0x16B78..=0x16B7C + | 0x16B90..=0x16D3F + | 0x16D7A..=0x16E3F + | 0x16E9B..=0x16E9F + | 0x16EB9..=0x16EBA + | 0x16ED4..=0x16EFF + | 0x16F4B..=0x16F4E + | 0x16F88..=0x16F8E + | 0x16FA0..=0x16FDF + | 0x16FE5..=0x16FEF + | 0x16FF7..=0x16FFF + | 0x18CD6..=0x18CFE + | 0x18D1F..=0x18D7F + | 0x18DF3..=0x1AFEF + | 0x1AFF4 + | 0x1AFFC + | 0x1AFFF + | 0x1B123..=0x1B131 + | 0x1B133..=0x1B14F + | 0x1B153..=0x1B154 + | 0x1B156..=0x1B163 + | 0x1B168..=0x1B16F + | 0x1B2FC..=0x1BBFF + | 0x1BC6B..=0x1BC6F + | 0x1BC7D..=0x1BC7F + | 0x1BC89..=0x1BC8F + | 0x1BC9A..=0x1BC9B + | 0x1BCA4..=0x1CBFF + | 0x1CCFD..=0x1CCFF + | 0x1CEB4..=0x1CEB9 + | 0x1CED1..=0x1CEDF + | 0x1CEF1..=0x1CEFF + | 0x1CF2E..=0x1CF2F + | 0x1CF47..=0x1CF4F + | 0x1CFC4..=0x1CFFF + | 0x1D0F6..=0x1D0FF + | 0x1D127..=0x1D128 + | 0x1D1EB..=0x1D1FF + | 0x1D246..=0x1D2BF + | 0x1D2D4..=0x1D2DF + | 0x1D2F4..=0x1D2FF + | 0x1D357..=0x1D35F + | 0x1D379..=0x1D3FF + | 0x1D455 + | 0x1D49D + | 0x1D4A0..=0x1D4A1 + | 0x1D4A3..=0x1D4A4 + | 0x1D4A7..=0x1D4A8 + | 0x1D4AD + | 0x1D4BA + | 0x1D4BC + | 0x1D4C4 + | 0x1D506 + | 0x1D50B..=0x1D50C + | 0x1D515 + | 0x1D51D + | 0x1D53A + | 0x1D53F + | 0x1D545 + | 0x1D547..=0x1D549 + | 0x1D551 + | 0x1D6A6..=0x1D6A7 + | 0x1D7CC..=0x1D7CD + | 0x1DA8C..=0x1DA9A + | 0x1DAA0 + | 0x1DAB0..=0x1DEFF + | 0x1DF1F..=0x1DF24 + | 0x1DF2B..=0x1DFFF + | 0x1E007 + | 0x1E019..=0x1E01A + | 0x1E022 + | 0x1E025 + | 0x1E02B..=0x1E02F + | 0x1E06E..=0x1E08E + | 0x1E090..=0x1E0FF + | 0x1E12D..=0x1E12F + | 0x1E13E..=0x1E13F + | 0x1E14A..=0x1E14D + | 0x1E150..=0x1E28F + | 0x1E2AF..=0x1E2BF + | 0x1E2FA..=0x1E2FE + | 0x1E300..=0x1E4CF + | 0x1E4FA..=0x1E5CF + | 0x1E5FB..=0x1E5FE + | 0x1E600..=0x1E6BF + | 0x1E6DF + | 0x1E6F6..=0x1E6FD + | 0x1E700..=0x1E7DF + | 0x1E7E7 + | 0x1E7EC + | 0x1E7EF + | 0x1E7FF + | 0x1E8C5..=0x1E8C6 + | 0x1E8D7..=0x1E8FF + | 0x1E94C..=0x1E94F + | 0x1E95A..=0x1E95D + | 0x1E960..=0x1EC70 + | 0x1ECB5..=0x1ED00 + | 0x1ED3E..=0x1EDFF + | 0x1EE04 + | 0x1EE20 + | 0x1EE23 + | 0x1EE25..=0x1EE26 + | 0x1EE28 + | 0x1EE33 + | 0x1EE38 + | 0x1EE3A + | 0x1EE3C..=0x1EE41 + | 0x1EE43..=0x1EE46 + | 0x1EE48 + | 0x1EE4A + | 0x1EE4C + | 0x1EE50 + | 0x1EE53 + | 0x1EE55..=0x1EE56 + | 0x1EE58 + | 0x1EE5A + | 0x1EE5C + | 0x1EE5E + | 0x1EE60 + | 0x1EE63 + | 0x1EE65..=0x1EE66 + | 0x1EE6B + | 0x1EE73 + | 0x1EE78 + | 0x1EE7D + | 0x1EE7F + | 0x1EE8A + | 0x1EE9C..=0x1EEA0 + | 0x1EEA4 + | 0x1EEAA + | 0x1EEBC..=0x1EEEF + | 0x1EEF2..=0x1EFFF + | 0x1F02C..=0x1F02F + | 0x1F094..=0x1F09F + | 0x1F0AF..=0x1F0B0 + | 0x1F0C0 + | 0x1F0D0 + | 0x1F0F6..=0x1F0FF + | 0x1F1AE..=0x1F1E5 + | 0x1F203..=0x1F20F + | 0x1F23C..=0x1F23F + | 0x1F249..=0x1F24F + | 0x1F252..=0x1F25F + | 0x1F266..=0x1F2FF + | 0x1F6D9..=0x1F6DB + | 0x1F6ED..=0x1F6EF + | 0x1F6FD..=0x1F6FF + | 0x1F7DA..=0x1F7DF + | 0x1F7EC..=0x1F7EF + | 0x1F7F1..=0x1F7FF + | 0x1F80C..=0x1F80F + | 0x1F848..=0x1F84F + | 0x1F85A..=0x1F85F + | 0x1F888..=0x1F88F + | 0x1F8AE..=0x1F8AF + | 0x1F8BC..=0x1F8BF + | 0x1F8C2..=0x1F8CF + | 0x1F8D9..=0x1F8FF + | 0x1FA58..=0x1FA5F + | 0x1FA6E..=0x1FA6F + | 0x1FA7D..=0x1FA7F + | 0x1FA8B..=0x1FA8D + | 0x1FAC7 + | 0x1FAC9..=0x1FACC + | 0x1FADD..=0x1FADE + | 0x1FAEB..=0x1FAEE + | 0x1FAF9..=0x1FAFF + | 0x1FB93 + | 0x1FBFB..=0x1FFFF + | 0x2A6E0..=0x2A6FF + | 0x2B81E..=0x2B81F + | 0x2CEAE..=0x2CEAF + | 0x2EBE1..=0x2EBEF + | 0x2EE5E..=0x2F7FF + | 0x2FA1E..=0x2FFFF + | 0x3134B..=0x3134F + | 0x3347A..=0xE0000 + | 0xE0002..=0xE001F + | 0xE0080..=0xE00FF + | 0xE01F0..=0xEFFFF + | 0xFFFFE..=0xFFFFF + | 0x10FFFE..=0x10FFFF + | 0x110000..=u32::MAX // above U+10FFFF — unreachable for `char` + ) +} From e91090affc74655f9cf24baad7db24813e2393bd Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Fri, 27 Mar 2026 11:24:21 +0000 Subject: [PATCH 390/627] Refer to payment info as `info` in `inbound_payment` not `metadata` `payment_metadata` is a separate concept at the BOLT 11 layer (similar to payment secret, but arbitrary-sized) and at the BOLT 12 layer, so referring to payment information as "payment metadata" is confusing. Instead, use simply "payment info". --- lightning/src/ln/inbound_payment.rs | 127 ++++++++++++++-------------- 1 file changed, 62 insertions(+), 65 deletions(-) diff --git a/lightning/src/ln/inbound_payment.rs b/lightning/src/ln/inbound_payment.rs index a7597701768..b52518584f7 100644 --- a/lightning/src/ln/inbound_payment.rs +++ b/lightning/src/ln/inbound_payment.rs @@ -28,10 +28,10 @@ use crate::util::logger::Logger; use crate::prelude::*; pub(crate) const IV_LEN: usize = 16; -const METADATA_LEN: usize = 16; -const METADATA_KEY_LEN: usize = 32; +const INFO_LEN: usize = 16; +const INFO_KEY_LEN: usize = 32; const AMT_MSAT_LEN: usize = 8; -// Used to shift the payment type bits to take up the top 3 bits of the metadata bytes, or to +// Used to shift the payment type bits to take up the top 3 bits of the info bytes, or to // retrieve said payment type bits. const METHOD_TYPE_OFFSET: usize = 5; @@ -40,20 +40,20 @@ const METHOD_TYPE_OFFSET: usize = 5; /// [`NodeSigner::get_expanded_key`]: crate::sign::NodeSigner::get_expanded_key #[derive(Hash, Copy, Clone, PartialEq, Eq, Debug)] pub struct ExpandedKey { - /// The key used to encrypt the bytes containing the payment metadata (i.e. the amount and + /// The key used to encrypt the bytes containing the payment info (i.e. the amount and /// expiry, included for payment verification on decryption). - metadata_key: [u8; 32], - /// The key used to authenticate an LDK-provided payment hash and metadata as previously + info_key: [u8; 32], + /// The key used to authenticate an LDK-provided payment hash and info as previously /// registered with LDK. ldk_pmt_hash_key: [u8; 32], - /// The key used to authenticate a user-provided payment hash and metadata as previously + /// The key used to authenticate a user-provided payment hash and info as previously /// registered with LDK. user_pmt_hash_key: [u8; 32], /// The base key used to derive signing keys and authenticate messages for BOLT 12 Offers. offers_base_key: [u8; 32], /// The key used to encrypt message metadata for BOLT 12 Offers. offers_encryption_key: [u8; 32], - /// The key used to authenticate spontaneous payments' metadata as previously registered with LDK + /// The key used to authenticate spontaneous payments' info as previously registered with LDK /// for inclusion in a blinded path. spontaneous_pmt_key: [u8; 32], /// The key used to authenticate phantom-node-shared blinded paths as generated by us. Note @@ -68,7 +68,7 @@ impl ExpandedKey { /// It is recommended to cache this value and not regenerate it for each new inbound payment. pub fn new(key_material: [u8; 32]) -> ExpandedKey { let ( - metadata_key, + info_key, ldk_pmt_hash_key, user_pmt_hash_key, offers_base_key, @@ -77,7 +77,7 @@ impl ExpandedKey { phantom_node_blinded_path_key, ) = hkdf_extract_expand_7x(b"LDK Inbound Payment Key Expansion", &key_material); Self { - metadata_key, + info_key, ldk_pmt_hash_key, user_pmt_hash_key, offers_base_key, @@ -133,7 +133,7 @@ impl Method { } } -fn min_final_cltv_expiry_delta_from_metadata(bytes: [u8; METADATA_LEN]) -> u16 { +fn min_final_cltv_expiry_delta_from_info(bytes: [u8; INFO_LEN]) -> u16 { let expiry_bytes = &bytes[AMT_MSAT_LEN..]; u16::from_be_bytes([expiry_bytes[0], expiry_bytes[1]]) } @@ -156,7 +156,7 @@ pub fn create( keys: &ExpandedKey, min_value_msat: Option, invoice_expiry_delta_secs: u32, entropy_source: &ES, current_time: u64, min_final_cltv_expiry_delta: Option, ) -> Result<(PaymentHash, PaymentSecret), ()> { - let metadata_bytes = construct_metadata_bytes( + let info_bytes = construct_info_bytes( min_value_msat, if min_final_cltv_expiry_delta.is_some() { Method::LdkPaymentHashCustomFinalCltv @@ -174,11 +174,11 @@ pub fn create( let mut hmac = HmacEngine::::new(&keys.ldk_pmt_hash_key); hmac.input(&iv_bytes); - hmac.input(&metadata_bytes); + hmac.input(&info_bytes); let payment_preimage_bytes = Hmac::from_engine(hmac).to_byte_array(); let ldk_pmt_hash = PaymentHash(Sha256::hash(&payment_preimage_bytes).to_byte_array()); - let payment_secret = construct_payment_secret(&iv_bytes, &metadata_bytes, &keys.metadata_key); + let payment_secret = construct_payment_secret(&iv_bytes, &info_bytes, &keys.info_key); Ok((ldk_pmt_hash, payment_secret)) } @@ -196,7 +196,7 @@ pub fn create_from_hash( keys: &ExpandedKey, min_value_msat: Option, payment_hash: PaymentHash, invoice_expiry_delta_secs: u32, current_time: u64, min_final_cltv_expiry_delta: Option, ) -> Result { - let metadata_bytes = construct_metadata_bytes( + let info_bytes = construct_info_bytes( min_value_msat, if min_final_cltv_expiry_delta.is_some() { Method::UserPaymentHashCustomFinalCltv @@ -209,21 +209,21 @@ pub fn create_from_hash( )?; let mut hmac = HmacEngine::::new(&keys.user_pmt_hash_key); - hmac.input(&metadata_bytes); + hmac.input(&info_bytes); hmac.input(&payment_hash.0); let hmac_bytes = Hmac::from_engine(hmac).to_byte_array(); let mut iv_bytes = [0 as u8; IV_LEN]; iv_bytes.copy_from_slice(&hmac_bytes[..IV_LEN]); - Ok(construct_payment_secret(&iv_bytes, &metadata_bytes, &keys.metadata_key)) + Ok(construct_payment_secret(&iv_bytes, &info_bytes, &keys.info_key)) } pub(crate) fn create_for_spontaneous_payment( keys: &ExpandedKey, min_value_msat: Option, invoice_expiry_delta_secs: u32, current_time: u64, min_final_cltv_expiry_delta: Option, ) -> Result { - let metadata_bytes = construct_metadata_bytes( + let info_bytes = construct_info_bytes( min_value_msat, Method::SpontaneousPayment, invoice_expiry_delta_secs, @@ -232,13 +232,13 @@ pub(crate) fn create_for_spontaneous_payment( )?; let mut hmac = HmacEngine::::new(&keys.spontaneous_pmt_key); - hmac.input(&metadata_bytes); + hmac.input(&info_bytes); let hmac_bytes = Hmac::from_engine(hmac).to_byte_array(); let mut iv_bytes = [0 as u8; IV_LEN]; iv_bytes.copy_from_slice(&hmac_bytes[..IV_LEN]); - Ok(construct_payment_secret(&iv_bytes, &metadata_bytes, &keys.metadata_key)) + Ok(construct_payment_secret(&iv_bytes, &info_bytes, &keys.info_key)) } pub(crate) fn calculate_absolute_expiry( @@ -252,10 +252,10 @@ pub(crate) fn calculate_absolute_expiry( highest_seen_timestamp + invoice_expiry_delta_secs as u64 + 7200 } -fn construct_metadata_bytes( +fn construct_info_bytes( min_value_msat: Option, payment_type: Method, invoice_expiry_delta_secs: u32, highest_seen_timestamp: u64, min_final_cltv_expiry_delta: Option, -) -> Result<[u8; METADATA_LEN], ()> { +) -> Result<[u8; INFO_LEN], ()> { if min_value_msat.is_some() && min_value_msat.unwrap() > MAX_VALUE_MSAT { return Err(()); } @@ -290,29 +290,28 @@ fn construct_metadata_bytes( expiry_bytes[1] |= bytes[1]; } - let mut metadata_bytes: [u8; METADATA_LEN] = [0; METADATA_LEN]; + let mut info_bytes: [u8; INFO_LEN] = [0; INFO_LEN]; - metadata_bytes[..AMT_MSAT_LEN].copy_from_slice(&min_amt_msat_bytes); - metadata_bytes[AMT_MSAT_LEN..].copy_from_slice(&expiry_bytes); + info_bytes[..AMT_MSAT_LEN].copy_from_slice(&min_amt_msat_bytes); + info_bytes[AMT_MSAT_LEN..].copy_from_slice(&expiry_bytes); - Ok(metadata_bytes) + Ok(info_bytes) } fn construct_payment_secret( - iv_bytes: &[u8; IV_LEN], metadata_bytes: &[u8; METADATA_LEN], - metadata_key: &[u8; METADATA_KEY_LEN], + iv_bytes: &[u8; IV_LEN], info_bytes: &[u8; INFO_LEN], info_key: &[u8; INFO_KEY_LEN], ) -> PaymentSecret { let mut payment_secret_bytes: [u8; 32] = [0; 32]; - let (iv_slice, encrypted_metadata_slice) = payment_secret_bytes.split_at_mut(IV_LEN); + let (iv_slice, encrypted_info_slice) = payment_secret_bytes.split_at_mut(IV_LEN); iv_slice.copy_from_slice(iv_bytes); - encrypted_metadata_slice.copy_from_slice(metadata_bytes); + encrypted_info_slice.copy_from_slice(info_bytes); ChaCha20::new_from_block( - Key::new(*metadata_key), + Key::new(*info_key), Nonce::new(iv_bytes[4..].try_into().unwrap()), u32::from_le_bytes(iv_bytes[..4].try_into().unwrap()), ) - .apply_keystream(encrypted_metadata_slice); + .apply_keystream(encrypted_info_slice); PaymentSecret(payment_secret_bytes) } @@ -320,13 +319,13 @@ fn construct_payment_secret( /// Check that an inbound payment's `payment_data` field is sane. /// /// LDK does not store any data for pending inbound payments. Instead, we construct our payment -/// secret (and, if supplied by LDK, our payment preimage) to include encrypted metadata about the -/// payment. +/// secret (and, if supplied by LDK, our payment preimage) to include encrypted information about +/// the payment. /// -/// For payments without a custom `min_final_cltv_expiry_delta`, the metadata is constructed as: +/// For payments without a custom `min_final_cltv_expiry_delta`, the payment info is: /// payment method (3 bits) || payment amount (8 bytes - 3 bits) || expiry (8 bytes) /// -/// For payments including a custom `min_final_cltv_expiry_delta`, the metadata is constructed as: +/// For payments including a custom `min_final_cltv_expiry_delta`, the payment info is: /// payment method (3 bits) || payment amount (8 bytes - 3 bits) || min_final_cltv_expiry_delta (2 bytes) || expiry (6 bytes) /// /// In both cases the result is then encrypted using a key derived from [`NodeSigner::get_expanded_key`]. @@ -339,14 +338,14 @@ fn construct_payment_secret( /// method is called, then the payment method bits mentioned above are represented internally as /// [`Method::LdkPaymentHash`]. If the latter, [`Method::UserPaymentHash`]. /// -/// For the former method, the payment preimage is constructed as an HMAC of payment metadata and -/// random bytes. Because the payment secret is also encoded with these random bytes and metadata -/// (with the metadata encrypted with a block cipher), we're able to authenticate the preimage on +/// For the former method, the payment preimage is constructed as an HMAC of payment info and +/// random bytes. Because the payment secret is also encoded with these random bytes and info +/// (with the info encrypted with a block cipher), we're able to authenticate the preimage on /// payment receipt. /// /// For the latter, the payment secret instead contains an HMAC of the user-provided payment hash -/// and payment metadata (encrypted with a block cipher), allowing us to authenticate the payment -/// hash and metadata on payment receipt. +/// and payment info (encrypted with a block cipher), allowing us to authenticate the payment +/// hash and info on payment receipt. /// /// See [`ExpandedKey`] docs for more info on the individual keys used. /// @@ -357,14 +356,13 @@ pub(super) fn verify( payment_hash: PaymentHash, payment_data: &msgs::FinalOnionHopData, highest_seen_timestamp: u64, keys: &ExpandedKey, logger: &L, ) -> Result<(Option, Option), ()> { - let (iv_bytes, metadata_bytes) = decrypt_metadata(payment_data.payment_secret, keys); + let (iv_bytes, info_bytes) = decrypt_info(payment_data.payment_secret, keys); - let payment_type_res = - Method::from_bits((metadata_bytes[0] & 0b1110_0000) >> METHOD_TYPE_OFFSET); + let payment_type_res = Method::from_bits((info_bytes[0] & 0b1110_0000) >> METHOD_TYPE_OFFSET); let mut amt_msat_bytes = [0; AMT_MSAT_LEN]; - let mut expiry_bytes = [0; METADATA_LEN - AMT_MSAT_LEN]; - amt_msat_bytes.copy_from_slice(&metadata_bytes[..AMT_MSAT_LEN]); - expiry_bytes.copy_from_slice(&metadata_bytes[AMT_MSAT_LEN..]); + let mut expiry_bytes = [0; INFO_LEN - AMT_MSAT_LEN]; + amt_msat_bytes.copy_from_slice(&info_bytes[..AMT_MSAT_LEN]); + expiry_bytes.copy_from_slice(&info_bytes[AMT_MSAT_LEN..]); // Zero out the bits reserved to indicate the payment type. amt_msat_bytes[0] &= 0b00011111; let mut min_final_cltv_expiry_delta = None; @@ -375,7 +373,7 @@ pub(super) fn verify( match payment_type_res { Ok(Method::UserPaymentHash) | Ok(Method::UserPaymentHashCustomFinalCltv) => { let mut hmac = HmacEngine::::new(&keys.user_pmt_hash_key); - hmac.input(&metadata_bytes[..]); + hmac.input(&info_bytes[..]); hmac.input(&payment_hash.0); if !fixed_time_eq( &iv_bytes, @@ -390,7 +388,7 @@ pub(super) fn verify( } }, Ok(Method::LdkPaymentHash) | Ok(Method::LdkPaymentHashCustomFinalCltv) => { - match derive_ldk_payment_preimage(payment_hash, &iv_bytes, &metadata_bytes, keys) { + match derive_ldk_payment_preimage(payment_hash, &iv_bytes, &info_bytes, keys) { Ok(preimage) => payment_preimage = Some(preimage), Err(bad_preimage_bytes) => { log_trace!( @@ -405,7 +403,7 @@ pub(super) fn verify( }, Ok(Method::SpontaneousPayment) => { let mut hmac = HmacEngine::::new(&keys.spontaneous_pmt_key); - hmac.input(&metadata_bytes[..]); + hmac.input(&info_bytes[..]); if !fixed_time_eq( &iv_bytes, &Hmac::from_engine(hmac).to_byte_array().split_at_mut(IV_LEN).0, @@ -427,8 +425,7 @@ pub(super) fn verify( match payment_type_res { Ok(Method::UserPaymentHashCustomFinalCltv) | Ok(Method::LdkPaymentHashCustomFinalCltv) => { - min_final_cltv_expiry_delta = - Some(min_final_cltv_expiry_delta_from_metadata(metadata_bytes)); + min_final_cltv_expiry_delta = Some(min_final_cltv_expiry_delta_from_info(info_bytes)); // Zero out first two bytes of expiry reserved for `min_final_cltv_expiry_delta`. expiry_bytes[0] &= 0; expiry_bytes[1] &= 0; @@ -455,11 +452,11 @@ pub(super) fn verify( pub(super) fn get_payment_preimage( payment_hash: PaymentHash, payment_secret: PaymentSecret, keys: &ExpandedKey, ) -> Result { - let (iv_bytes, metadata_bytes) = decrypt_metadata(payment_secret, keys); + let (iv_bytes, info_bytes) = decrypt_info(payment_secret, keys); - match Method::from_bits((metadata_bytes[0] & 0b1110_0000) >> METHOD_TYPE_OFFSET) { + match Method::from_bits((info_bytes[0] & 0b1110_0000) >> METHOD_TYPE_OFFSET) { Ok(Method::LdkPaymentHash) | Ok(Method::LdkPaymentHashCustomFinalCltv) => { - derive_ldk_payment_preimage(payment_hash, &iv_bytes, &metadata_bytes, keys).map_err( + derive_ldk_payment_preimage(payment_hash, &iv_bytes, &info_bytes, keys).map_err( |bad_preimage_bytes| APIError::APIMisuseError { err: format!( "Payment hash {} did not match decoded preimage {}", @@ -484,34 +481,34 @@ pub(super) fn get_payment_preimage( } } -fn decrypt_metadata( +fn decrypt_info( payment_secret: PaymentSecret, keys: &ExpandedKey, -) -> ([u8; IV_LEN], [u8; METADATA_LEN]) { +) -> ([u8; IV_LEN], [u8; INFO_LEN]) { let mut iv_bytes = [0; IV_LEN]; - let (iv_slice, encrypted_metadata_bytes) = payment_secret.0.split_at(IV_LEN); + let (iv_slice, encrypted_info_bytes) = payment_secret.0.split_at(IV_LEN); iv_bytes.copy_from_slice(iv_slice); - let mut metadata_bytes: [u8; METADATA_LEN] = [0; METADATA_LEN]; - metadata_bytes.copy_from_slice(encrypted_metadata_bytes); + let mut info_bytes: [u8; INFO_LEN] = [0; INFO_LEN]; + info_bytes.copy_from_slice(encrypted_info_bytes); ChaCha20::new_from_block( - Key::new(keys.metadata_key), + Key::new(keys.info_key), Nonce::new(iv_bytes[4..].try_into().unwrap()), u32::from_le_bytes(iv_bytes[..4].try_into().unwrap()), ) - .apply_keystream(&mut metadata_bytes); + .apply_keystream(&mut info_bytes); - (iv_bytes, metadata_bytes) + (iv_bytes, info_bytes) } // Errors if the payment preimage doesn't match `payment_hash`. Returns the bad preimage bytes in // this case. fn derive_ldk_payment_preimage( - payment_hash: PaymentHash, iv_bytes: &[u8; IV_LEN], metadata_bytes: &[u8; METADATA_LEN], + payment_hash: PaymentHash, iv_bytes: &[u8; IV_LEN], info_bytes: &[u8; INFO_LEN], keys: &ExpandedKey, ) -> Result { let mut hmac = HmacEngine::::new(&keys.ldk_pmt_hash_key); hmac.input(iv_bytes); - hmac.input(metadata_bytes); + hmac.input(info_bytes); let decoded_payment_preimage = Hmac::from_engine(hmac).to_byte_array(); if !fixed_time_eq(&payment_hash.0, &Sha256::hash(&decoded_payment_preimage).to_byte_array()) { return Err(decoded_payment_preimage); From 657ac8f58e51af74c610375cb65cdad6f7a18c6b Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 11 May 2026 00:08:11 +0000 Subject: [PATCH 391/627] Commit to payment_metadata in inbound payment HMAC When payment_metadata is set in a BOLT 11 invoice, users expect to receive it back as-is in the payment onion. In order to ensure it isn't tampered with, they presumably will add an HMAC, or worse, not add one and forget that it can be tampered with. Instead, here we include it in the HMAC computation for the payment secret. This ensures that the sender must relay the correct metadata for the payment to be accepted by the receiver, binding the metadata to the payment cryptographically. The metadata is only included in the HMAC when present, so existing payments without metadata continue to verify correctly. However, this does break receiving payments with metadata today. On an upgrade this seems acceptable to me given we have seen almost no use of payment metadata in practice. Co-Authored-By: Claude Opus 4.6 (1M context) --- fuzz/src/chanmon_consistency.rs | 2 +- fuzz/src/full_stack.rs | 5 +- .../tests/lsps2_integration_tests.rs | 2 +- lightning/src/ln/bolt11_payment_tests.rs | 4 +- lightning/src/ln/channelmanager.rs | 49 ++++++++++++---- lightning/src/ln/functional_test_utils.rs | 1 + lightning/src/ln/functional_tests.rs | 38 ++++++++----- lightning/src/ln/inbound_payment.rs | 56 ++++++++++++++----- lightning/src/ln/invoice_utils.rs | 9 ++- .../src/ln/max_payment_path_len_tests.rs | 44 +++++++++++++-- lightning/src/ln/payment_tests.rs | 30 +++++++--- pending_changelog/matt-commit-to-metadata.txt | 6 ++ 12 files changed, 183 insertions(+), 63 deletions(-) create mode 100644 pending_changelog/matt-commit-to-metadata.txt diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 8a90dc93e97..2667b7359b2 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1370,7 +1370,7 @@ impl PaymentTracker { payment_preimage.0[0..8].copy_from_slice(&self.payment_ctr.to_be_bytes()); let hash = PaymentHash(Sha256::hash(&payment_preimage.0).to_byte_array()); let secret = dest - .create_inbound_payment_for_hash(hash, None, 3600, None) + .create_inbound_payment_for_hash(hash, None, 3600, None, None) .expect("create_inbound_payment_for_hash failed"); assert!(self.payment_preimages.insert(hash, payment_preimage).is_none()); let mut id = PaymentId([0; 32]); diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index e79bef7c5ec..58509bb9b08 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -837,11 +837,10 @@ pub fn do_test(mut data: &[u8], logger: &Arc }, 16 => { let payment_preimage = PaymentPreimage(keys_manager.get_secure_random_bytes()); - let payment_hash = - PaymentHash(Sha256::hash(&payment_preimage.0[..]).to_byte_array()); + let hash = PaymentHash(Sha256::hash(&payment_preimage.0[..]).to_byte_array()); // Note that this may fail - our hashes may collide and we'll end up trying to // double-register the same payment_hash. - let _ = channelmanager.create_inbound_payment_for_hash(payment_hash, None, 1, None); + let _ = channelmanager.create_inbound_payment_for_hash(hash, None, 1, None, None); }, 9 => { for payment in payments_received.drain(..) { diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index fbff2eae4cd..92e6b33ebb6 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -122,7 +122,7 @@ fn create_jit_invoice( let min_final_cltv_expiry_delta = MIN_FINAL_CLTV_EXPIRY_DELTA + 2; let (payment_hash, payment_secret) = node .node - .create_inbound_payment(None, expiry_secs, Some(min_final_cltv_expiry_delta)) + .create_inbound_payment(None, expiry_secs, Some(min_final_cltv_expiry_delta), None) .map_err(|e| { log_error!(node.logger, "Failed to register inbound payment: {:?}", e); })?; diff --git a/lightning/src/ln/bolt11_payment_tests.rs b/lightning/src/ln/bolt11_payment_tests.rs index 8c2ac155ce7..733e26d0f1b 100644 --- a/lightning/src/ln/bolt11_payment_tests.rs +++ b/lightning/src/ln/bolt11_payment_tests.rs @@ -31,7 +31,7 @@ fn payment_metadata_end_to_end_for_invoice_with_amount() { let payment_metadata = vec![42, 43, 44, 45, 46, 47, 48, 49, 42]; let (payment_hash, payment_secret) = - nodes[1].node.create_inbound_payment(None, 7200, None).unwrap(); + nodes[1].node.create_inbound_payment(None, 7200, None, Some(&payment_metadata)).unwrap(); let timestamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(); let invoice = InvoiceBuilder::new(Currency::Bitcoin) @@ -98,7 +98,7 @@ fn payment_metadata_end_to_end_for_invoice_with_no_amount() { let payment_metadata = vec![42, 43, 44, 45, 46, 47, 48, 49, 42]; let (payment_hash, payment_secret) = - nodes[1].node.create_inbound_payment(None, 7200, None).unwrap(); + nodes[1].node.create_inbound_payment(None, 7200, None, Some(&payment_metadata)).unwrap(); let timestamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(); let invoice = InvoiceBuilder::new(Currency::Bitcoin) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 9920be84e6b..a05d620274e 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8595,6 +8595,7 @@ impl< let verify_res = inbound_payment::verify( payment_hash, &payment_data, + onion_fields.payment_metadata.as_deref(), self.highest_seen_timestamp.load(Ordering::Acquire) as u64, &self.inbound_payment_key, &self.logger, @@ -14261,7 +14262,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ ) -> Result> { let Bolt11InvoiceParameters { amount_msats, description, invoice_expiry_delta_secs, min_final_cltv_expiry_delta, - payment_hash, + payment_hash, payment_metadata, } = params; let currency = @@ -14294,6 +14295,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ payment_hash, amount_msats, invoice_expiry_delta_secs.unwrap_or(DEFAULT_EXPIRY_TIME as u32), min_final_cltv_expiry_delta, + payment_metadata.as_deref(), ) .map_err(|()| SignOrCreationError::CreationError(CreationError::InvalidAmount))?; (payment_hash, payment_secret) @@ -14303,6 +14305,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ .create_inbound_payment( amount_msats, invoice_expiry_delta_secs.unwrap_or(DEFAULT_EXPIRY_TIME as u32), min_final_cltv_expiry_delta, + payment_metadata.as_deref(), ) .map_err(|()| SignOrCreationError::CreationError(CreationError::InvalidAmount))? }, @@ -14341,7 +14344,11 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ invoice = invoice.private_route(hint); } - let raw_invoice = invoice.build_raw().map_err(|e| SignOrCreationError::CreationError(e))?; + let raw_invoice = if let Some(payment_metadata) = payment_metadata { + invoice.payment_metadata(payment_metadata).build_raw() + } else { + invoice.build_raw() + }.map_err(|e| SignOrCreationError::CreationError(e))?; let signature = self.node_signer.sign_invoice(&raw_invoice, Recipient::Node); raw_invoice @@ -14420,6 +14427,14 @@ pub struct Bolt11InvoiceParameters { /// involving another protocol where the payment hash is also involved outside the scope of /// lightning. pub payment_hash: Option, + + /// The `payment_metadata` to include in the invoice. This is provided back to us in the payment + /// onion by the sender, available as [`RecipientOnionFields::payment_metadata`] via + /// [`Event::PaymentClaimable::onion_fields`]. + /// + /// Note that because it is exposed to the sender in the invoice you should consider encrypting + /// it. It is committed to, however, so cannot be modified by the sender. + pub payment_metadata: Option>, } impl Default for Bolt11InvoiceParameters { @@ -14430,6 +14445,7 @@ impl Default for Bolt11InvoiceParameters { invoice_expiry_delta_secs: None, min_final_cltv_expiry_delta: None, payment_hash: None, + payment_metadata: None, } } } @@ -14921,7 +14937,7 @@ impl< refund, self.list_usable_channels(), |amount_msats, relative_expiry| { - self.create_inbound_payment(Some(amount_msats), relative_expiry, None) + self.create_inbound_payment(Some(amount_msats), relative_expiry, None, None) .map_err(|()| Bolt12SemanticError::InvalidAmount) }, )?; @@ -14964,7 +14980,7 @@ impl< /// [`create_inbound_payment_for_hash`]: Self::create_inbound_payment_for_hash pub fn create_inbound_payment( &self, min_value_msat: Option, invoice_expiry_delta_secs: u32, - min_final_cltv_expiry_delta: Option, + min_final_cltv_expiry_delta: Option, payment_metadata: Option<&[u8]>, ) -> Result<(PaymentHash, PaymentSecret), ()> { inbound_payment::create( &self.inbound_payment_key, @@ -14973,6 +14989,7 @@ impl< &self.entropy_source, self.highest_seen_timestamp.load(Ordering::Acquire) as u64, min_final_cltv_expiry_delta, + payment_metadata, ) } @@ -14992,6 +15009,9 @@ impl< /// before a [`PaymentClaimable`] event will be generated, ensuring that we do not provide the /// sender "proof-of-payment" unless they have paid the required amount. /// + /// The returned secret commits to the `payment_metadata` and thus the invoice's metadata must + /// match what is provided here. + /// /// `invoice_expiry_delta_secs` describes the number of seconds that the invoice is valid for /// in excess of the current time. This should roughly match the expiry time set in the invoice. /// After this many seconds, we will remove the inbound payment, resulting in any attempts to @@ -15025,6 +15045,7 @@ impl< pub fn create_inbound_payment_for_hash( &self, payment_hash: PaymentHash, min_value_msat: Option, invoice_expiry_delta_secs: u32, min_final_cltv_expiry: Option, + payment_metadata: Option<&[u8]>, ) -> Result { inbound_payment::create_from_hash( &self.inbound_payment_key, @@ -15033,18 +15054,25 @@ impl< invoice_expiry_delta_secs, self.highest_seen_timestamp.load(Ordering::Acquire) as u64, min_final_cltv_expiry, + payment_metadata, ) } - /// Gets an LDK-generated payment preimage from a payment hash and payment secret that were + /// Gets an LDK-generated payment preimage from a payment hash, metadata and secret that were /// previously returned from [`create_inbound_payment`]. /// /// [`create_inbound_payment`]: Self::create_inbound_payment pub fn get_payment_preimage( &self, payment_hash: PaymentHash, payment_secret: PaymentSecret, + payment_metadata: Option<&[u8]>, ) -> Result { let expanded_key = &self.inbound_payment_key; - inbound_payment::get_payment_preimage(payment_hash, payment_secret, expanded_key) + inbound_payment::get_payment_preimage( + payment_hash, + payment_secret, + payment_metadata, + expanded_key, + ) } /// [`BlindedMessagePath`]s for an async recipient to communicate with this node and interactively @@ -17113,7 +17141,8 @@ impl< self.create_inbound_payment( Some(amount_msats), relative_expiry, - None + None, + None, ).map_err(|_| Bolt12SemanticError::InvalidAmount) }; @@ -21325,7 +21354,7 @@ mod tests { // payment verification fails as expected. let mut bad_payment_hash = payment_hash.clone(); bad_payment_hash.0[0] += 1; - match inbound_payment::verify(bad_payment_hash, &payment_data, nodes[0].node.highest_seen_timestamp.load(Ordering::Acquire) as u64, &nodes[0].node.inbound_payment_key, &nodes[0].logger) { + match inbound_payment::verify(bad_payment_hash, &payment_data, None, nodes[0].node.highest_seen_timestamp.load(Ordering::Acquire) as u64, &nodes[0].node.inbound_payment_key, &nodes[0].logger) { Ok(_) => panic!("Unexpected ok"), Err(()) => { nodes[0].logger.assert_log_contains("lightning::ln::inbound_payment", "Failing HTLC with user-generated payment_hash", 1); @@ -21333,7 +21362,7 @@ mod tests { } // Check that using the original payment hash succeeds. - assert!(inbound_payment::verify(payment_hash, &payment_data, nodes[0].node.highest_seen_timestamp.load(Ordering::Acquire) as u64, &nodes[0].node.inbound_payment_key, &nodes[0].logger).is_ok()); + assert!(inbound_payment::verify(payment_hash, &payment_data, None, nodes[0].node.highest_seen_timestamp.load(Ordering::Acquire) as u64, &nodes[0].node.inbound_payment_key, &nodes[0].logger).is_ok()); } fn check_not_connected_to_peer_error( @@ -22006,7 +22035,7 @@ pub mod bench { payment_preimage.0[0..8].copy_from_slice(&payment_count.to_le_bytes()); payment_count += 1; let payment_hash = PaymentHash(Sha256::hash(&payment_preimage.0[..]).to_byte_array()); - let payment_secret = $node_b.create_inbound_payment_for_hash(payment_hash, None, 7200, None).unwrap(); + let payment_secret = $node_b.create_inbound_payment_for_hash(payment_hash, None, 7200, None, None).unwrap(); $node_a.send_payment(payment_hash, RecipientOnionFields::secret_only(payment_secret, 10_000), PaymentId(payment_hash.0), diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index f89fdd0572b..3dd3018964a 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -2807,6 +2807,7 @@ pub fn get_payment_preimage_hash( min_value_msat, 7200, min_final_cltv_expiry_delta, + None, ) .unwrap(); (payment_preimage, payment_hash, payment_secret) diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index 8bbb9b99479..7393f354010 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -293,8 +293,10 @@ pub fn test_duplicate_htlc_different_direction_onchain() { let (payment_preimage, payment_hash, ..) = route_payment(&nodes[0], &[&nodes[1]], 900_000); let (route, _, _, _) = get_route_and_payment_hash!(nodes[1], nodes[0], payment_value_msats); - let node_a_payment_secret = - nodes[0].node.create_inbound_payment_for_hash(payment_hash, None, 7200, None).unwrap(); + let node_a_payment_secret = nodes[0] + .node + .create_inbound_payment_for_hash(payment_hash, None, 7200, None, None) + .unwrap(); send_along_route_with_secret( &nodes[1], route, @@ -4157,8 +4159,10 @@ pub fn test_duplicate_payment_hash_one_failure_one_success() { let (our_payment_preimage, dup_payment_hash, ..) = route_payment(&nodes[0], &[&nodes[1], &nodes[2], &nodes[3]], 900_000); - let payment_secret = - nodes[4].node.create_inbound_payment_for_hash(dup_payment_hash, None, 7200, None).unwrap(); + let payment_secret = nodes[4] + .node + .create_inbound_payment_for_hash(dup_payment_hash, None, 7200, None, None) + .unwrap(); let payment_params = PaymentParameters::from_node_id(node_e_id, TEST_FINAL_CLTV) .with_bolt11_features(nodes[4].node.bolt11_invoice_features()) .unwrap(); @@ -4425,13 +4429,13 @@ fn do_test_fail_backwards_unrevoked_remote_announce(deliver_last_raa: bool, anno // 2nd HTLC (not added - smaller than dust limit + HTLC tx fee): let path_5: &[&[_]] = &[&[&nodes[2], &nodes[3], &nodes[5]]]; let payment_secret = - nodes[5].node.create_inbound_payment_for_hash(hash_1, None, 7200, None).unwrap(); + nodes[5].node.create_inbound_payment_for_hash(hash_1, None, 7200, None, None).unwrap(); let route = route_to_5.clone(); send_along_route_with_secret(&nodes[1], route, path_5, dust_limit_msat, hash_1, payment_secret); // 3rd HTLC (not added - smaller than dust limit + HTLC tx fee): let payment_secret = - nodes[5].node.create_inbound_payment_for_hash(hash_2, None, 7200, None).unwrap(); + nodes[5].node.create_inbound_payment_for_hash(hash_2, None, 7200, None, None).unwrap(); let route = route_to_5; send_along_route_with_secret(&nodes[1], route, path_5, dust_limit_msat, hash_2, payment_secret); @@ -4444,12 +4448,12 @@ fn do_test_fail_backwards_unrevoked_remote_announce(deliver_last_raa: bool, anno // 6th HTLC: let payment_secret = - nodes[5].node.create_inbound_payment_for_hash(hash_3, None, 7200, None).unwrap(); + nodes[5].node.create_inbound_payment_for_hash(hash_3, None, 7200, None, None).unwrap(); send_along_route_with_secret(&nodes[1], route.clone(), path_5, 1000000, hash_3, payment_secret); // 7th HTLC: let payment_secret = - nodes[5].node.create_inbound_payment_for_hash(hash_4, None, 7200, None).unwrap(); + nodes[5].node.create_inbound_payment_for_hash(hash_4, None, 7200, None, None).unwrap(); send_along_route_with_secret(&nodes[1], route, path_5, 1000000, hash_4, payment_secret); // 8th HTLC: @@ -4458,7 +4462,7 @@ fn do_test_fail_backwards_unrevoked_remote_announce(deliver_last_raa: bool, anno // 9th HTLC (not added - smaller than dust limit + HTLC tx fee): let (route, _, _, _) = get_route_and_payment_hash!(nodes[1], nodes[5], dust_limit_msat); let payment_secret = - nodes[5].node.create_inbound_payment_for_hash(hash_5, None, 7200, None).unwrap(); + nodes[5].node.create_inbound_payment_for_hash(hash_5, None, 7200, None, None).unwrap(); send_along_route_with_secret(&nodes[1], route, path_5, dust_limit_msat, hash_5, payment_secret); // 10th HTLC (not added - smaller than dust limit + HTLC tx fee): @@ -4467,7 +4471,7 @@ fn do_test_fail_backwards_unrevoked_remote_announce(deliver_last_raa: bool, anno // 11th HTLC: let (route, _, _, _) = get_route_and_payment_hash!(nodes[1], nodes[5], 1000000); let payment_secret = - nodes[5].node.create_inbound_payment_for_hash(hash_6, None, 7200, None).unwrap(); + nodes[5].node.create_inbound_payment_for_hash(hash_6, None, 7200, None, None).unwrap(); send_along_route_with_secret(&nodes[1], route, path_5, 1000000, hash_6, payment_secret); // Double-check that six of the new HTLC were added @@ -6062,7 +6066,7 @@ pub fn test_check_htlc_underpaying() { let (_, our_payment_hash, _) = get_payment_preimage_hash(&nodes[0], None, None); let our_payment_secret = nodes[1] .node - .create_inbound_payment_for_hash(our_payment_hash, Some(100_000), 7200, None) + .create_inbound_payment_for_hash(our_payment_hash, Some(100_000), 7200, None, None) .unwrap(); let onion = RecipientOnionFields::secret_only(our_payment_secret, route.get_total_amount()); let id = PaymentId(our_payment_hash.0); @@ -7230,7 +7234,7 @@ pub fn test_preimage_storage() { { let (payment_hash, payment_secret) = - nodes[1].node.create_inbound_payment(Some(100_000), 7200, None).unwrap(); + nodes[1].node.create_inbound_payment(Some(100_000), 7200, None, None).unwrap(); let (route, _, _, _) = get_route_and_payment_hash!(nodes[0], nodes[1], 100_000); let onion = RecipientOnionFields::secret_only(payment_secret, 100_000); let id = PaymentId(payment_hash.0); @@ -7275,7 +7279,7 @@ pub fn test_bad_secret_hash() { let random_hash = PaymentHash([42; 32]); let random_secret = PaymentSecret([43; 32]); let (our_payment_hash, our_payment_secret) = - nodes[1].node.create_inbound_payment(Some(100_000), 2, None).unwrap(); + nodes[1].node.create_inbound_payment(Some(100_000), 2, None, None).unwrap(); let (route, _, _, _) = get_route_and_payment_hash!(nodes[0], nodes[1], 100_000); // All the below cases should end up being handled exactly identically, so we macro the @@ -9494,9 +9498,13 @@ fn do_payment_with_custom_min_final_cltv_expiry(valid_delta: bool, use_user_hash } else { let (hash, payment_secret) = nodes[1] .node - .create_inbound_payment(Some(recv_value), 7200, Some(min_cltv_expiry_delta)) + .create_inbound_payment(Some(recv_value), 7200, Some(min_cltv_expiry_delta), None) .unwrap(); - (hash, nodes[1].node.get_payment_preimage(hash, payment_secret).unwrap(), payment_secret) + ( + hash, + nodes[1].node.get_payment_preimage(hash, payment_secret, None).unwrap(), + payment_secret, + ) }; let route = get_route!(nodes[0], payment_parameters, recv_value).unwrap(); let onion = RecipientOnionFields::secret_only(payment_secret, recv_value); diff --git a/lightning/src/ln/inbound_payment.rs b/lightning/src/ln/inbound_payment.rs index b52518584f7..b81c111f7a1 100644 --- a/lightning/src/ln/inbound_payment.rs +++ b/lightning/src/ln/inbound_payment.rs @@ -155,6 +155,7 @@ fn min_final_cltv_expiry_delta_from_info(bytes: [u8; INFO_LEN]) -> u16 { pub fn create( keys: &ExpandedKey, min_value_msat: Option, invoice_expiry_delta_secs: u32, entropy_source: &ES, current_time: u64, min_final_cltv_expiry_delta: Option, + payment_metadata: Option<&[u8]>, ) -> Result<(PaymentHash, PaymentSecret), ()> { let info_bytes = construct_info_bytes( min_value_msat, @@ -175,6 +176,10 @@ pub fn create( let mut hmac = HmacEngine::::new(&keys.ldk_pmt_hash_key); hmac.input(&iv_bytes); hmac.input(&info_bytes); + if let Some(metadata) = payment_metadata { + hmac.input(&(metadata.len() as u64).to_le_bytes()); + hmac.input(metadata); + } let payment_preimage_bytes = Hmac::from_engine(hmac).to_byte_array(); let ldk_pmt_hash = PaymentHash(Sha256::hash(&payment_preimage_bytes).to_byte_array()); @@ -195,6 +200,7 @@ pub fn create( pub fn create_from_hash( keys: &ExpandedKey, min_value_msat: Option, payment_hash: PaymentHash, invoice_expiry_delta_secs: u32, current_time: u64, min_final_cltv_expiry_delta: Option, + payment_metadata: Option<&[u8]>, ) -> Result { let info_bytes = construct_info_bytes( min_value_msat, @@ -211,6 +217,10 @@ pub fn create_from_hash( let mut hmac = HmacEngine::::new(&keys.user_pmt_hash_key); hmac.input(&info_bytes); hmac.input(&payment_hash.0); + if let Some(metadata) = payment_metadata { + hmac.input(&(metadata.len() as u64).to_le_bytes()); + hmac.input(metadata); + } let hmac_bytes = Hmac::from_engine(hmac).to_byte_array(); let mut iv_bytes = [0 as u8; IV_LEN]; @@ -353,8 +363,8 @@ fn construct_payment_secret( /// [`create_inbound_payment`]: crate::ln::channelmanager::ChannelManager::create_inbound_payment /// [`create_inbound_payment_for_hash`]: crate::ln::channelmanager::ChannelManager::create_inbound_payment_for_hash pub(super) fn verify( - payment_hash: PaymentHash, payment_data: &msgs::FinalOnionHopData, highest_seen_timestamp: u64, - keys: &ExpandedKey, logger: &L, + payment_hash: PaymentHash, payment_data: &msgs::FinalOnionHopData, + payment_metadata: Option<&[u8]>, highest_seen_timestamp: u64, keys: &ExpandedKey, logger: &L, ) -> Result<(Option, Option), ()> { let (iv_bytes, info_bytes) = decrypt_info(payment_data.payment_secret, keys); @@ -375,6 +385,10 @@ pub(super) fn verify( let mut hmac = HmacEngine::::new(&keys.user_pmt_hash_key); hmac.input(&info_bytes[..]); hmac.input(&payment_hash.0); + if let Some(metadata) = payment_metadata { + hmac.input(&(metadata.len() as u64).to_le_bytes()); + hmac.input(metadata); + } if !fixed_time_eq( &iv_bytes, &Hmac::from_engine(hmac).to_byte_array().split_at_mut(IV_LEN).0, @@ -388,7 +402,13 @@ pub(super) fn verify( } }, Ok(Method::LdkPaymentHash) | Ok(Method::LdkPaymentHashCustomFinalCltv) => { - match derive_ldk_payment_preimage(payment_hash, &iv_bytes, &info_bytes, keys) { + match derive_ldk_payment_preimage( + payment_hash, + &iv_bytes, + &info_bytes, + payment_metadata, + keys, + ) { Ok(preimage) => payment_preimage = Some(preimage), Err(bad_preimage_bytes) => { log_trace!( @@ -450,21 +470,27 @@ pub(super) fn verify( } pub(super) fn get_payment_preimage( - payment_hash: PaymentHash, payment_secret: PaymentSecret, keys: &ExpandedKey, + payment_hash: PaymentHash, payment_secret: PaymentSecret, payment_metadata: Option<&[u8]>, + keys: &ExpandedKey, ) -> Result { let (iv_bytes, info_bytes) = decrypt_info(payment_secret, keys); match Method::from_bits((info_bytes[0] & 0b1110_0000) >> METHOD_TYPE_OFFSET) { Ok(Method::LdkPaymentHash) | Ok(Method::LdkPaymentHashCustomFinalCltv) => { - derive_ldk_payment_preimage(payment_hash, &iv_bytes, &info_bytes, keys).map_err( - |bad_preimage_bytes| APIError::APIMisuseError { - err: format!( - "Payment hash {} did not match decoded preimage {}", - &payment_hash, - log_bytes!(bad_preimage_bytes) - ), - }, + derive_ldk_payment_preimage( + payment_hash, + &iv_bytes, + &info_bytes, + payment_metadata, + keys, ) + .map_err(|bad_preimage_bytes| APIError::APIMisuseError { + err: format!( + "Payment hash {} did not match decoded preimage {}", + &payment_hash, + log_bytes!(bad_preimage_bytes) + ), + }) }, Ok(Method::UserPaymentHash) | Ok(Method::UserPaymentHashCustomFinalCltv) => { Err(APIError::APIMisuseError { @@ -504,11 +530,15 @@ fn decrypt_info( // this case. fn derive_ldk_payment_preimage( payment_hash: PaymentHash, iv_bytes: &[u8; IV_LEN], info_bytes: &[u8; INFO_LEN], - keys: &ExpandedKey, + payment_metadata: Option<&[u8]>, keys: &ExpandedKey, ) -> Result { let mut hmac = HmacEngine::::new(&keys.ldk_pmt_hash_key); hmac.input(iv_bytes); hmac.input(info_bytes); + if let Some(metadata) = payment_metadata { + hmac.input(&(metadata.len() as u64).to_le_bytes()); + hmac.input(metadata); + } let decoded_payment_preimage = Hmac::from_engine(hmac).to_byte_array(); if !fixed_time_eq(&payment_hash.0, &Sha256::hash(&decoded_payment_preimage).to_byte_array()) { return Err(decoded_payment_preimage); diff --git a/lightning/src/ln/invoice_utils.rs b/lightning/src/ln/invoice_utils.rs index 63ad110bba0..564203bf524 100644 --- a/lightning/src/ln/invoice_utils.rs +++ b/lightning/src/ln/invoice_utils.rs @@ -191,6 +191,7 @@ fn _create_phantom_invoice( invoice_expiry_delta_secs, duration_since_epoch.as_secs(), min_final_cltv_expiry_delta, + None, ) .map_err(|_| SignOrCreationError::CreationError(CreationError::InvalidAmount))?; (payment_hash, payment_secret) @@ -202,6 +203,7 @@ fn _create_phantom_invoice( &entropy_source, duration_since_epoch.as_secs(), min_final_cltv_expiry_delta, + None, ) .map_err(|_| SignOrCreationError::CreationError(CreationError::InvalidAmount))? }; @@ -670,7 +672,8 @@ mod test { let (payment_hash, payment_secret) = (invoice.payment_hash(), *invoice.payment_secret()); - let preimage = nodes[1].node.get_payment_preimage(payment_hash, payment_secret).unwrap(); + let preimage = + nodes[1].node.get_payment_preimage(payment_hash, payment_secret, None).unwrap(); // Invoice SCIDs should always use inbound SCID aliases over the real channel ID, if one is // available. @@ -1255,7 +1258,7 @@ mod test { let payment_preimage = if user_generated_pmt_hash { user_payment_preimage } else { - nodes[1].node.get_payment_preimage(payment_hash, payment_secret).unwrap() + nodes[1].node.get_payment_preimage(payment_hash, payment_secret, None).unwrap() }; assert_eq!(invoice.min_final_cltv_expiry_delta(), MIN_FINAL_CLTV_EXPIRY_DELTA as u64); @@ -1363,7 +1366,7 @@ mod test { let payment_amt = 20_000; let (payment_hash, _payment_secret) = - nodes[1].node.create_inbound_payment(Some(payment_amt), 3600, None).unwrap(); + nodes[1].node.create_inbound_payment(Some(payment_amt), 3600, None, None).unwrap(); let route_hints = vec![nodes[1].node.get_phantom_route_hints(), nodes[2].node.get_phantom_route_hints()]; diff --git a/lightning/src/ln/max_payment_path_len_tests.rs b/lightning/src/ln/max_payment_path_len_tests.rs index 0515a5290d7..17580b09b95 100644 --- a/lightning/src/ln/max_payment_path_len_tests.rs +++ b/lightning/src/ln/max_payment_path_len_tests.rs @@ -32,7 +32,7 @@ use crate::routing::router::{ }; use crate::sign::NodeSigner; use crate::types::features::BlindedHopFeatures; -use crate::types::payment::PaymentSecret; +use crate::types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; use crate::util::errors::APIError; use crate::util::ser::Writeable; use crate::util::test_utils; @@ -80,9 +80,33 @@ fn large_payment_metadata() { - final_payload_len_without_metadata; let mut payment_metadata = vec![42; max_metadata_len]; + let mut counter = 42; + macro_rules! get_payment_hash { + ($node: expr, $metadata: expr) => {{ + let payment_preimage = PaymentPreimage([counter; 32]); + #[allow(unused_assignments)] + { + counter += 1; + } + let payment_hash: PaymentHash = payment_preimage.into(); + let payment_secret = $node + .node + .create_inbound_payment_for_hash( + payment_hash, + Some(amt_msat), + 7200, + None, + Some($metadata), + ) + .unwrap(); + (payment_hash, payment_preimage, payment_secret) + }}; + } + // Check that the maximum-size metadata is sendable. - let (mut route_0_1, payment_hash, payment_preimage, payment_secret) = - get_route_and_payment_hash!(&nodes[0], &nodes[1], amt_msat); + let (payment_hash, payment_preimage, payment_secret) = + get_payment_hash!(nodes[1], &payment_metadata); + let (mut route_0_1, ..) = get_route_and_payment_hash!(&nodes[0], &nodes[1], amt_msat); let mut max_sized_onion = RecipientOnionFields { payment_secret: Some(payment_secret), payment_metadata: Some(payment_metadata.clone()), @@ -112,14 +136,17 @@ fn large_payment_metadata() { // Check that the payment parameter for max path length will prevent us from routing past our // next-hop peer given the payment_metadata size. - let (mut route_0_2, payment_hash_2, payment_preimage_2, payment_secret_2) = - get_route_and_payment_hash!(&nodes[0], &nodes[2], amt_msat); + + let (payment_hash_2, _, payment_secret_2) = + get_payment_hash!(nodes[2], &max_sized_onion.payment_metadata.as_ref().unwrap()); + let (mut route_0_2, ..) = get_route_and_payment_hash!(&nodes[0], &nodes[2], amt_msat); let mut route_params_0_2 = route_0_2.route_params.clone().unwrap(); route_params_0_2.payment_params.max_path_length = 1; nodes[0].router.expect_find_route_query(route_params_0_2); + max_sized_onion.payment_secret = Some(payment_secret_2); let id = PaymentId(payment_hash_2.0); - let route_params = route_0_2.route_params.clone().unwrap(); + let mut route_params = route_0_2.route_params.clone().unwrap(); let err = nodes[0] .node .send_payment(payment_hash_2, max_sized_onion.clone(), id, route_params, Retry::Attempts(0)) @@ -130,6 +157,9 @@ fn large_payment_metadata() { let mut too_large_onion = max_sized_onion.clone(); too_large_onion.payment_metadata.as_mut().map(|mut md| md.push(42)); too_large_onion.total_mpp_amount_msat = MIN_FINAL_VALUE_ESTIMATE_WITH_OVERPAY; + let (payment_hash_2, _, payment_secret_2) = + get_payment_hash!(nodes[2], &too_large_onion.payment_metadata.as_ref().unwrap()); + too_large_onion.payment_secret = Some(payment_secret_2); // First confirm we'll fail to create the onion packet directly. let secp_ctx = Secp256k1::signing_only(); @@ -164,6 +194,8 @@ fn large_payment_metadata() { // If we remove enough payment_metadata bytes to allow for 2 hops, we're now able to send to // nodes[2]. let two_hop_metadata = vec![42; max_metadata_len - INTERMED_PAYLOAD_LEN_ESTIMATE]; + let (payment_hash_2, payment_preimage_2, payment_secret_2) = + get_payment_hash!(nodes[2], &two_hop_metadata); let mut onion_allowing_2_hops = RecipientOnionFields { payment_secret: Some(payment_secret_2), payment_metadata: Some(two_hop_metadata.clone()), diff --git a/lightning/src/ln/payment_tests.rs b/lightning/src/ln/payment_tests.rs index e80fcea33aa..2eb5d4ee85c 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -1548,7 +1548,7 @@ fn get_ldk_payment_preimage() { let amt_msat = 60_000; let expiry_secs = 60 * 60; let (payment_hash, payment_secret) = - nodes[1].node.create_inbound_payment(Some(amt_msat), expiry_secs, None).unwrap(); + nodes[1].node.create_inbound_payment(Some(amt_msat), expiry_secs, None, None).unwrap(); let payment_params = PaymentParameters::from_node_id(node_b_id, TEST_FINAL_CLTV) .with_bolt11_features(nodes[1].node.bolt11_invoice_features()) @@ -1561,7 +1561,8 @@ fn get_ldk_payment_preimage() { check_added_monitors(&nodes[0], 1); // Make sure to use `get_payment_preimage` - let preimage = Some(nodes[1].node.get_payment_preimage(payment_hash, payment_secret).unwrap()); + let preimage = + Some(nodes[1].node.get_payment_preimage(payment_hash, payment_secret, None).unwrap()); let mut events = nodes[0].node.get_and_clear_pending_msg_events(); assert_eq!(events.len(), 1); let event = events.pop().unwrap(); @@ -2305,7 +2306,7 @@ fn do_test_intercepted_payment(test: InterceptTest) { let route = get_route(&nodes[0], &route_params).unwrap(); let (hash, payment_secret) = - nodes[2].node.create_inbound_payment(Some(amt_msat), 60 * 60, None).unwrap(); + nodes[2].node.create_inbound_payment(Some(amt_msat), 60 * 60, None, None).unwrap(); let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let id = PaymentId(hash.0); nodes[0].node.send_payment_with_route(route.clone(), hash, onion, id).unwrap(); @@ -2414,7 +2415,8 @@ fn do_test_intercepted_payment(test: InterceptTest) { do_commitment_signed_dance(&nodes[2], &nodes[1], commitment, false, true); expect_and_process_pending_htlcs(&nodes[2], false); - let preimage = Some(nodes[2].node.get_payment_preimage(hash, payment_secret).unwrap()); + let preimage = + Some(nodes[2].node.get_payment_preimage(hash, payment_secret, None).unwrap()); expect_payment_claimable!(&nodes[2], hash, payment_secret, amt_msat, preimage, node_c_id); let path: &[&[_]] = &[&[&nodes[1], &nodes[2]]]; @@ -2541,7 +2543,7 @@ fn do_accept_underpaying_htlcs_config(num_mpp_parts: usize) { .unwrap(); let route_params = RouteParameters::from_payment_params_and_value(payment_params, amt_msat); let (payment_hash, payment_secret) = - nodes[2].node.create_inbound_payment(Some(amt_msat), 60 * 60, None).unwrap(); + nodes[2].node.create_inbound_payment(Some(amt_msat), 60 * 60, None, None).unwrap(); let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let id = PaymentId(payment_hash.0); @@ -2597,7 +2599,7 @@ fn do_accept_underpaying_htlcs_config(num_mpp_parts: usize) { // Claim the payment and check that the skimmed fee is as expected. let payment_preimage = - nodes[2].node.get_payment_preimage(payment_hash, payment_secret).unwrap(); + nodes[2].node.get_payment_preimage(payment_hash, payment_secret, None).unwrap(); let events = nodes[2].node.get_and_clear_pending_events(); assert_eq!(events.len(), 1); match events[0] { @@ -4885,10 +4887,20 @@ fn do_test_payment_metadata_consistency(do_reload: bool, do_modify: bool) { // Pay more than half of each channel's max, requiring MPP let amt_msat = 750_000_000; - let (payment_preimage, payment_hash, payment_secret) = - get_payment_preimage_hash(&nodes[3], Some(amt_msat), None); - let payment_id = PaymentId(payment_hash.0); let payment_metadata = vec![44, 49, 52, 142]; + let payment_preimage = PaymentPreimage([42; 32]); + let payment_hash: PaymentHash = payment_preimage.into(); + let payment_secret = nodes[3] + .node + .create_inbound_payment_for_hash( + payment_hash, + Some(amt_msat), + 7200, + None, + Some(&payment_metadata), + ) + .unwrap(); + let payment_id = PaymentId(payment_hash.0); let payment_params = PaymentParameters::from_node_id(node_d_id, TEST_FINAL_CLTV) .with_bolt11_features(nodes[1].node.bolt11_invoice_features()) diff --git a/pending_changelog/matt-commit-to-metadata.txt b/pending_changelog/matt-commit-to-metadata.txt new file mode 100644 index 00000000000..5e13e134f88 --- /dev/null +++ b/pending_changelog/matt-commit-to-metadata.txt @@ -0,0 +1,6 @@ +# Backwards compat + * Payment metadata is now committed to in the HMAC used to build payment secrets. + As such, any existing BOLT 11 invoices issued with payment metadata will be + implicitly invalidated on upgrade and any BOLT 11 invoices issued with payment + metadata will be invalidated on downgrade. If this is problematic for you + please reach out. From 44828f7260a490d133fd63f37a57e931b71f48a8 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Wed, 6 May 2026 19:21:29 +0000 Subject: [PATCH 392/627] Default to requiring `payment_metadata` when building BOLT 11s Now that we commit to payment metadata fields and require them implicitly as a part of payments, we should match that in `lightning-invoice` - instead marking them as required by default. --- lightning-invoice/src/lib.rs | 30 ++++++++++++++++-------------- lightning-invoice/tests/ser_de.rs | 2 -- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/lightning-invoice/src/lib.rs b/lightning-invoice/src/lib.rs index 4ee9acb5f27..6c18e600b55 100644 --- a/lightning-invoice/src/lib.rs +++ b/lightning-invoice/src/lib.rs @@ -880,11 +880,10 @@ impl { /// Sets the payment metadata. /// - /// By default features are set to *optionally* allow the sender to include the payment metadata. - /// If you wish to require that the sender include the metadata (and fail to parse the invoice if - /// they don't support payment metadata fields), you need to call - /// [`InvoiceBuilder::require_payment_metadata`] after this. - pub fn payment_metadata( + /// This marks the payment metadata as optional, allowing a legacy sender that doesn't + /// understand payment metadata to ignore it. Note that LDK by default commits to the payment + /// metadata in its payment secret, implicitly making it required. + pub fn optional_payment_metadata( mut self, payment_metadata: Vec, ) -> InvoiceBuilder { self.tagged_fields.push(TaggedField::PaymentMetadata(payment_metadata)); @@ -902,20 +901,23 @@ impl } self.set_flags() } -} -impl - InvoiceBuilder -{ - /// Sets forwarding of payment metadata as required. A reader of the invoice which does not - /// support sending payment metadata will fail to read the invoice. - pub fn require_payment_metadata(mut self) -> InvoiceBuilder { - for field in self.tagged_fields.iter_mut() { + /// Sets the payment metadata. + /// + /// By default features are set to *require* the sender to include the payment metadata. + /// If you wish to support legacy senders that ignore the metadata, you can call + /// [`InvoiceBuilder::optional_payment_metadata`] instead. Note that LDK by default commits to + /// the payment metadata in its payment secret, implicitly making it required. + pub fn payment_metadata( + self, payment_metadata: Vec, + ) -> InvoiceBuilder { + let mut res = self.optional_payment_metadata(payment_metadata); + for field in res.tagged_fields.iter_mut() { if let TaggedField::Features(f) = field { f.set_payment_metadata_required(); } } - self + res } } diff --git a/lightning-invoice/tests/ser_de.rs b/lightning-invoice/tests/ser_de.rs index 353878a9c52..be173912a78 100644 --- a/lightning-invoice/tests/ser_de.rs +++ b/lightning-invoice/tests/ser_de.rs @@ -418,7 +418,6 @@ fn get_test_tuples() -> Vec<(String, SignedRawBolt11Invoice, bool, bool)> { )) .description("payment metadata inside".to_owned()) .payment_metadata(>::from_hex("01fafaf0").unwrap()) - .require_payment_metadata() .payee_pub_key(PublicKey::from_slice(&>::from_hex( "03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad" ).unwrap()).unwrap()) @@ -450,7 +449,6 @@ fn get_test_tuples() -> Vec<(String, SignedRawBolt11Invoice, bool, bool)> { )) .description("payment metadata inside".to_owned()) .payment_metadata(>::from_hex("01fafaf0").unwrap()) - .require_payment_metadata() .payment_secret(PaymentSecret([0x11; 32])) .build_raw() .unwrap() From df624dba9a845d160597355633514987873cd958 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 23 Apr 2026 05:41:36 +0000 Subject: [PATCH 393/627] Don't trim HTLCs when calculating the reserved commit tx fee We previously accounted for HTLC trims at the spiked feerate when calculating the reserved commitment transaction fees. This could cause an underestimate of the real current commitment fee at the current channel feerate. This is because a 2x increase in the feerate could trim enough HTLCs to result in a smaller commitment transaction fee. Also, the previous code only reserved the fee for an exact 2x increase in the feerate, instead of reserving the fee for any increase in the feerate between 1x to 2x. Fixes #4563. --- lightning/src/ln/htlc_reserve_unit_tests.rs | 143 ++++++++++++++++++++ lightning/src/sign/tx_builder.rs | 32 +++-- 2 files changed, 167 insertions(+), 8 deletions(-) diff --git a/lightning/src/ln/htlc_reserve_unit_tests.rs b/lightning/src/ln/htlc_reserve_unit_tests.rs index 45d3cf5950f..cbb67e9f05c 100644 --- a/lightning/src/ln/htlc_reserve_unit_tests.rs +++ b/lightning/src/ln/htlc_reserve_unit_tests.rs @@ -11,6 +11,7 @@ use crate::ln::channel::{ FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE, MIN_AFFORDABLE_HTLC_COUNT, MIN_CHAN_DUST_LIMIT_SATOSHIS, }; +use crate::ln::channel_state::ChannelDetails; use crate::ln::channelmanager::{PaymentId, RAACommitmentOrder, TrustedChannelFeatures}; use crate::ln::functional_test_utils::*; use crate::ln::msgs::{self, BaseMessageHandler, ChannelMessageHandler, MessageSendEvent}; @@ -3406,3 +3407,145 @@ fn test_0reserve_zero_conf_combined() { assert_eq!(node_1_max_htlc, node_0_max_htlc - node_1_reserve * 1000); send_payment(&nodes[1], &[&nodes[0]], node_1_max_htlc); } + +#[xtest(feature = "_externalize_tests")] +fn test_outbound_vs_available_capacity_outbound_htlc_limit_spiked_feerate() { + let mut config = test_default_channel_config(); + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; + + let channel_type = ChannelTypeFeatures::only_static_remote_key(); + + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let _node_a_id = nodes[0].node.get_our_node_id(); + let _node_b_id = nodes[1].node.get_our_node_id(); + + const FEERATE: u32 = 253; + const MULTIPLE: u32 = FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32; + const SPIKED_FEERATE: u32 = FEERATE * MULTIPLE; + const DUST_LIMIT_MSAT: u64 = 354 * 1000; + const CHANNEL_VALUE_MSAT: u64 = 10_000 * 1000; + const NODE_0_VALUE_TO_SELF_MSAT: u64 = 5000 * 1000; + const NODE_1_VALUE_TO_SELF_MSAT: u64 = 5000 * 1000; + const CHANNEL_RESERVE_MSAT: u64 = 1000 * 1000; + + // Find the HTLC amount that will be non-dust at the current feerate, but dust at the spiked feerate + const SPIKED_DUST_HTLC_MSAT: u64 = 688 * 1000; + const HTLC_SPIKE_DUST_LIMIT_MSAT: u64 = 689 * 1000; + let htlc_timeout_spike_tx_fee_msat = + second_stage_tx_fees_sat(&channel_type, SPIKED_FEERATE).1 * 1000; + assert_eq!(HTLC_SPIKE_DUST_LIMIT_MSAT, DUST_LIMIT_MSAT + htlc_timeout_spike_tx_fee_msat); + + let channel_id = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, CHANNEL_VALUE_MSAT / 1000, 0) + .2; + assert_eq!(nodes[0].node.list_channels()[0].channel_type.as_ref().unwrap(), &channel_type); + { + // Quick double-check on the dust limit to make sure HTLCs would be dust at 2x the + // feerate... + let mut per_peer_lock; + let mut peer_state_lock; + + let channel = + get_channel_ref!(nodes[0], nodes[1], per_peer_lock, peer_state_lock, channel_id); + assert_eq!(channel.context().holder_dust_limit_satoshis * 1000, DUST_LIMIT_MSAT); + } + + // Balance the channel so each side has 5_000 sats + send_payment(&nodes[0], &[&nodes[1]], NODE_1_VALUE_TO_SELF_MSAT); + + let count_total_htlcs = |details: &ChannelDetails| { + details.pending_outbound_htlcs.len() + details.pending_inbound_htlcs.len() + }; + let count_node_0_nondust_htlcs = || { + let mut txs = get_local_commitment_txn!(nodes[0], channel_id); + let commitment_tx = &txs[0]; + commitment_tx + .output + .iter() + .filter(|output| output.value.to_sat() * 1000 == SPIKED_DUST_HTLC_MSAT) + .count() + }; + let count_node_1_nondust_htlcs = || { + let mut txs = get_local_commitment_txn!(nodes[1], channel_id); + let commitment_tx = &txs[0]; + commitment_tx + .output + .iter() + .filter(|output| output.value.to_sat() * 1000 == SPIKED_DUST_HTLC_MSAT) + .count() + }; + + // Sanity check + { + let reserved_fee_sat = commit_tx_fee_sat(SPIKED_FEERATE, 2, &channel_type); + let node_0_outbound_capacity_msat = NODE_0_VALUE_TO_SELF_MSAT - CHANNEL_RESERVE_MSAT; + let node_0_available_capacity_msat = + node_0_outbound_capacity_msat - reserved_fee_sat * 1000; + let node_0_details = &nodes[0].node.list_channels()[0]; + assert_eq!(node_0_details.outbound_capacity_msat, node_0_outbound_capacity_msat); + assert_eq!(node_0_details.next_outbound_htlc_limit_msat, node_0_available_capacity_msat); + assert_eq!(count_total_htlcs(&node_0_details), 0); + assert_eq!(count_node_0_nondust_htlcs(), 0); + } + + // Route 2 688sat HTLCs from node 0 to node 1 + for i in 1..3 { + route_payment(&nodes[0], &[&nodes[1]], SPIKED_DUST_HTLC_MSAT); + + let max_reserved_fee_msat = commit_tx_fee_sat(SPIKED_FEERATE, 2 + i, &channel_type) * 1000; + let node_0_outbound_capacity_msat = + NODE_0_VALUE_TO_SELF_MSAT - SPIKED_DUST_HTLC_MSAT * i as u64 - CHANNEL_RESERVE_MSAT; + let node_0_available_capacity_msat = node_0_outbound_capacity_msat - max_reserved_fee_msat; + // Node 0 can send non-dust HTLCs throughout + assert!(node_0_available_capacity_msat >= HTLC_SPIKE_DUST_LIMIT_MSAT); + let node_0_details = &nodes[0].node.list_channels()[0]; + assert_eq!(node_0_details.outbound_capacity_msat, node_0_outbound_capacity_msat); + assert_eq!(node_0_details.next_outbound_htlc_limit_msat, node_0_available_capacity_msat); + assert_eq!(count_total_htlcs(&node_0_details), i); + assert_eq!(count_node_0_nondust_htlcs(), i); + } + + let node_0_details = &nodes[0].node.list_channels()[0]; + let local_nondust_htlc_count = 2; + assert_eq!(count_total_htlcs(&node_0_details), local_nondust_htlc_count); + assert_eq!(count_node_0_nondust_htlcs(), local_nondust_htlc_count); + assert_eq!(count_node_1_nondust_htlcs(), local_nondust_htlc_count); + + let node_0_outbound_capacity_msat = node_0_details.outbound_capacity_msat; + + // Route 2 688sat HTLCs from node 1 to node 0 + for i in 1..3 { + route_payment(&nodes[1], &[&nodes[0]], SPIKED_DUST_HTLC_MSAT); + + let node_1_outbound_capacity_msat = + NODE_1_VALUE_TO_SELF_MSAT - SPIKED_DUST_HTLC_MSAT * i as u64 - CHANNEL_RESERVE_MSAT; + assert!(node_1_outbound_capacity_msat >= HTLC_SPIKE_DUST_LIMIT_MSAT); + let node_1_details = &nodes[1].node.list_channels()[0]; + assert_eq!(node_1_details.outbound_capacity_msat, node_1_outbound_capacity_msat); + assert_eq!(node_1_details.next_outbound_htlc_limit_msat, node_1_outbound_capacity_msat); + + let nondust_htlc_count = 2 + i; + // At the current feerate, 688sat HTLCs are present on both commitments + assert_eq!(count_node_0_nondust_htlcs(), nondust_htlc_count); + assert_eq!(count_node_1_nondust_htlcs(), nondust_htlc_count); + + assert_eq!( + nodes[0].node.list_channels()[0].outbound_capacity_msat, + node_0_outbound_capacity_msat + ); + let max_reserved_fee_msat = + commit_tx_fee_sat(SPIKED_FEERATE, nondust_htlc_count + 2, &channel_type) * 1000; + assert_eq!( + nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat, + node_0_outbound_capacity_msat - max_reserved_fee_msat + ); + } +} diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index ffb01c571b7..6c70f6ea6c6 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -455,6 +455,17 @@ fn get_available_balances( ); let local_nondust_htlc_count = pending_htlcs + .iter() + .filter(|htlc| { + !htlc.is_dust( + true, + feerate_per_kw, + channel_constraints.holder_dust_limit_satoshis, + channel_type, + ) + }) + .count(); + let local_spiked_nondust_htlc_count = pending_htlcs .iter() .filter(|htlc| { !htlc.is_dust( @@ -465,6 +476,10 @@ fn get_available_balances( ) }) .count(); + + // Note here we use the htlc count at the current feerate together with the spiked feerate; + // this makes sure that the holder can afford any fee bump between 1x to 2x from the current + // feerate. let local_max_commit_tx_fee_sat = commit_tx_fee_sat( spiked_feerate, local_nondust_htlc_count + fee_spike_buffer_htlc + 1, @@ -528,7 +543,7 @@ fn get_available_balances( remote_balance_before_fee_msat, spiked_feerate, // The number of non-dust HTLCs on the local commitment at the spiked feerate - local_nondust_htlc_count, + local_spiked_nondust_htlc_count, // The post-splice minimum balance of the holder if is_outbound_from_holder { local_min_commit_tx_fee_sat } else { 0 }, &channel_constraints, @@ -661,7 +676,7 @@ fn get_available_balances( // Now adjust our min and max size HTLC to make sure both the local and the remote commitments still have // at least one output at the spiked feerate. - let remote_nondust_htlc_count = pending_htlcs + let remote_spiked_nondust_htlc_count = pending_htlcs .iter() .filter(|htlc| { !htlc.is_dust( @@ -679,8 +694,8 @@ fn get_available_balances( is_outbound_from_holder, local_balance_before_fee_msat, remote_balance_before_fee_msat, - local_nondust_htlc_count, spiked_feerate, + local_spiked_nondust_htlc_count, channel_constraints.holder_dust_limit_satoshis, channel_type, next_outbound_htlc_minimum_msat, @@ -693,8 +708,8 @@ fn get_available_balances( is_outbound_from_holder, local_balance_before_fee_msat, remote_balance_before_fee_msat, - remote_nondust_htlc_count, spiked_feerate, + remote_spiked_nondust_htlc_count, channel_constraints.counterparty_dust_limit_satoshis, channel_type, next_outbound_htlc_minimum_msat, @@ -715,9 +730,10 @@ fn get_available_balances( fn adjust_boundaries_if_max_dust_htlc_produces_no_output( local: bool, is_outbound_from_holder: bool, holder_balance_before_fee_msat: u64, - counterparty_balance_before_fee_msat: u64, nondust_htlc_count: usize, spiked_feerate: u32, - dust_limit_satoshis: u64, channel_type: &ChannelTypeFeatures, - next_outbound_htlc_minimum_msat: u64, available_capacity_msat: u64, + counterparty_balance_before_fee_msat: u64, spiked_feerate: u32, + spiked_feerate_nondust_htlc_count: usize, dust_limit_satoshis: u64, + channel_type: &ChannelTypeFeatures, next_outbound_htlc_minimum_msat: u64, + available_capacity_msat: u64, ) -> (u64, u64) { // First, determine the biggest dust HTLC we could send let (htlc_success_tx_fee_sat, htlc_timeout_tx_fee_sat) = @@ -733,7 +749,7 @@ fn adjust_boundaries_if_max_dust_htlc_produces_no_output( holder_balance_before_fee_msat.saturating_sub(max_dust_htlc_msat), counterparty_balance_before_fee_msat, spiked_feerate, - nondust_htlc_count, + spiked_feerate_nondust_htlc_count, dust_limit_satoshis, channel_type, ) { From 01d55dc1651ceffa560cd79c8993dbc7755383e8 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Wed, 6 May 2026 19:54:19 +0000 Subject: [PATCH 394/627] Add reload test for stuck MPP fulfill Add a characterization test for a claimed MPP payment whose preimage monitor updates are only partially persisted before restart. The test drives both channels through a held fee-update commitment dance, claims with async monitor persistence, reloads one fresh and one stale monitor, and verifies that we don't leave a sender-side HTLC stuck after reconnect. --- lightning/src/ln/reload_tests.rs | 352 +++++++++++++++++++++++++++++++ 1 file changed, 352 insertions(+) diff --git a/lightning/src/ln/reload_tests.rs b/lightning/src/ln/reload_tests.rs index 16ba896685e..9da90d95109 100644 --- a/lightning/src/ln/reload_tests.rs +++ b/lightning/src/ln/reload_tests.rs @@ -937,6 +937,358 @@ fn test_partial_claim_before_restart() { do_test_partial_claim_before_restart(true, true); } +#[test] +fn test_mpp_claim_htlc_fulfills_unblocked_on_reload() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let persister; + let new_chain_monitor; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes_1_deserialized; + let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + // Open two independent channels between the same nodes. The payment below is large enough to + // force the router to split it across both channels, which is what makes the MPP claim depend + // on both ChannelMonitors durably learning the preimage. + let chan_a = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); + let chan_b = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); + let chan_id_a = chan_a.2; + let chan_id_b = chan_b.2; + let scid_a = chan_a.0.contents.short_channel_id; + let scid_b = chan_b.0.contents.short_channel_id; + + // Send an MPP payment to nodes[1]. `send_along_route_with_secret` leaves the payment + // claimable but unclaimed, so nodes[1] still has both inbound HTLCs live when we start + // manipulating monitor persistence below. + let amt_msat = 50_000_000; + let (route, payment_hash, payment_preimage, payment_secret) = + get_route_and_payment_hash!(nodes[0], nodes[1], amt_msat); + assert_eq!(route.paths.len(), 2); + send_along_route_with_secret( + &nodes[0], route, &[&[&nodes[1]], &[&nodes[1]]], amt_msat, payment_hash, + payment_secret, + ); + + // Move both channels into `AWAITING_REMOTE_REVOKE` by having nodes[0] send fee updates and + // withholding nodes[1]'s responding `commitment_signed`s. When nodes[1] later claims the + // payment, the fulfill updates cannot be sent immediately and instead sit in each channel's + // holding cell. + { + let mut fee_est = chanmon_cfgs[0].fee_estimator.sat_per_kw.lock().unwrap(); + *fee_est *= 2; + } + nodes[0].node.timer_tick_occurred(); + check_added_monitors(&nodes[0], 2); + + let node_0_id = nodes[0].node.get_our_node_id(); + let node_1_id = nodes[1].node.get_our_node_id(); + + let fee_msgs = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(fee_msgs.len(), 2); + for ev in &fee_msgs { + match ev { + MessageSendEvent::UpdateHTLCs { updates, .. } => { + nodes[1].node.handle_update_fee(node_0_id, updates.update_fee.as_ref().unwrap()); + nodes[1].node.handle_commitment_signed_batch_test( + node_0_id, &updates.commitment_signed, + ); + check_added_monitors(&nodes[1], 1); + }, + _ => panic!("Unexpected message: {:?}", ev), + } + } + + // nodes[1] responds to each fee update with a `revoke_and_ack` and a new + // `commitment_signed`. Deliver only the `revoke_and_ack`s for now. The held + // `commitment_signed`s are delivered after nodes[1] claims the payment, creating the blocked + // post-claim monitor updates whose release is exercised after reload. + let node_1_msgs = nodes[1].node.get_and_clear_pending_msg_events(); + let mut commitment_signed_msgs = Vec::new(); + for ev in &node_1_msgs { + match ev { + MessageSendEvent::SendRevokeAndACK { msg, .. } => { + nodes[0].node.handle_revoke_and_ack(node_1_id, msg); + check_added_monitors(&nodes[0], 1); + }, + MessageSendEvent::UpdateHTLCs { updates, .. } => { + commitment_signed_msgs.push(updates.commitment_signed.clone()); + }, + _ => panic!("Unexpected message: {:?}", ev), + } + } + + let node_0_msgs = nodes[0].node.get_and_clear_pending_msg_events(); + for ev in &node_0_msgs { + match ev { + MessageSendEvent::SendRevokeAndACK { msg, .. } => { + nodes[1].node.handle_revoke_and_ack(node_0_id, msg); + check_added_monitors(&nodes[1], 1); + }, + _ => panic!("Unexpected message: {:?}", ev), + } + } + + // Snapshot channel B before the claim. The in-memory ChainMonitor applies updates even when + // the persister returns `InProgress`, so taking this snapshot after the claim would not model a + // crash between two separate monitor writes. + let mon_b_serialized = get_monitor!(nodes[1], chan_id_b).encode(); + + // Make both preimage monitor writes asynchronous. `claim_funds` attaches an in-memory MPP RAA + // blocker so neither channel can release later monitor updates until all channels have the + // preimage durably persisted. + chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress); + chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress); + nodes[1].node.claim_funds(payment_preimage); + check_added_monitors(&nodes[1], 2); + + // Complete only channel A's preimage update. Channel B will be reloaded from the stale snapshot + // above, simulating a crash where one monitor write completed and the other did not. + let (update_id_a, _) = get_latest_mon_update_id(&nodes[1], chan_id_a); + nodes[1].chain_monitor.chain_monitor.force_channel_monitor_updated(chan_id_a, update_id_a); + + // Now finish the fee-update commitment dance we held back. nodes[1] receives nodes[0]'s + // `revoke_and_ack`s while the MPP RAA blocker is still in place, so the resulting monitor + // updates are blocked behind state that is not serialized in the ChannelManager. + for commitment_signed in &commitment_signed_msgs { + nodes[0].node.handle_commitment_signed_batch_test(node_1_id, commitment_signed); + check_added_monitors(&nodes[0], 1); + } + let node_0_msgs = nodes[0].node.get_and_clear_pending_msg_events(); + for ev in &node_0_msgs { + match ev { + MessageSendEvent::SendRevokeAndACK { msg, .. } => { + nodes[1].node.handle_revoke_and_ack(node_0_id, msg); + check_added_monitors(&nodes[1], 0); + }, + _ => panic!("Unexpected message: {:?}", ev), + } + } + + // Persist the ChannelManager after the blocked post-claim monitor updates have been recorded. + // Reload with channel A's up-to-date monitor and channel B's stale monitor. The preimage update + // for B is replayed during reload, putting both channels' preimages on disk. The remaining state + // under test is the blocked post-claim `revoke_and_ack` monitor updates after the in-memory MPP + // RAA blocker that created them is gone. + let node_1_serialized = nodes[1].node.encode(); + let mon_a_serialized = get_monitor!(nodes[1], chan_id_a).encode(); + + nodes[0].node.peer_disconnected(node_1_id); + reload_node!( + nodes[1], + node_1_serialized, + &[&mon_a_serialized, &mon_b_serialized], + persister, + new_chain_monitor, + nodes_1_deserialized + ); + + // Reconnect both peers by manually exchanging `channel_reestablish`s. This avoids relying on a + // more general reconnect helper while the channels intentionally have asymmetric monitor state. + let node_1_id = nodes[1].node.get_our_node_id(); + nodes[0].node.peer_connected(node_1_id, &msgs::Init { + features: nodes[1].node.init_features(), networks: None, remote_network_address: None, + }, true).unwrap(); + nodes[1].node.peer_connected(node_0_id, &msgs::Init { + features: nodes[0].node.init_features(), networks: None, remote_network_address: None, + }, false).unwrap(); + + let reestablish_0 = nodes[0].node.get_and_clear_pending_msg_events(); + let reestablish_1 = nodes[1].node.get_and_clear_pending_msg_events(); + let mut reestablish_0_chan_ids = Vec::new(); + let mut reestablish_1_chan_ids = Vec::new(); + for ev in &reestablish_1 { + match ev { + MessageSendEvent::SendChannelReestablish { node_id, msg } => { + assert_eq!(*node_id, node_0_id); + reestablish_1_chan_ids.push(msg.channel_id); + nodes[0].node.handle_channel_reestablish(node_1_id, msg); + }, + _ => panic!("Unexpected message: {:?}", ev), + } + } + for ev in &reestablish_0 { + match ev { + MessageSendEvent::SendChannelReestablish { node_id, msg } => { + assert_eq!(*node_id, node_1_id); + reestablish_0_chan_ids.push(msg.channel_id); + nodes[1].node.handle_channel_reestablish(node_0_id, msg); + }, + _ => panic!("Unexpected message: {:?}", ev), + } + } + assert_eq!(reestablish_0_chan_ids.len(), 2); + assert!(reestablish_0_chan_ids.contains(&chan_id_a)); + assert!(reestablish_0_chan_ids.contains(&chan_id_b)); + assert_eq!(reestablish_1_chan_ids.len(), 2); + assert!(reestablish_1_chan_ids.contains(&chan_id_a)); + assert!(reestablish_1_chan_ids.contains(&chan_id_b)); + // Only nodes[1] was reloaded with stale monitor state. nodes[0] responds to the + // `channel_reestablish`s without touching its monitors. nodes[1] applies the replayed channel B + // preimage update, releases channel A's held RAA update, and frees channel A's held fulfill + // during startup processing. + check_added_monitors(&nodes[0], 0); + check_added_monitors(&nodes[1], 3); + + // The first message batch after reconnect contains channel updates from both nodes. nodes[1] + // also sends the channel A fulfill that startup processing released from the holding cell. + let restart_msgs_0 = nodes[0].node.get_and_clear_pending_msg_events(); + let restart_msgs_1 = nodes[1].node.get_and_clear_pending_msg_events(); + let mut restart_scids_0 = Vec::new(); + let mut restart_scids_1 = Vec::new(); + let mut startup_fulfill_chan_ids = Vec::new(); + for ev in &restart_msgs_0 { + match ev { + MessageSendEvent::SendChannelUpdate { node_id, msg } => { + assert_eq!(*node_id, node_1_id); + restart_scids_0.push(msg.contents.short_channel_id); + }, + _ => panic!("Unexpected restart message from node 0: {:?}", ev), + } + } + for ev in &restart_msgs_1 { + match ev { + MessageSendEvent::SendChannelUpdate { node_id, msg } => { + assert_eq!(*node_id, node_0_id); + restart_scids_1.push(msg.contents.short_channel_id); + }, + MessageSendEvent::UpdateHTLCs { node_id, channel_id, updates } => { + assert_eq!(*node_id, node_0_id); + startup_fulfill_chan_ids.push(*channel_id); + assert_eq!(updates.update_fulfill_htlcs.len(), 1); + assert!(updates.update_add_htlcs.is_empty()); + assert!(updates.update_fail_htlcs.is_empty()); + assert!(updates.update_fail_malformed_htlcs.is_empty()); + assert!(updates.update_fee.is_none()); + for fulfill in &updates.update_fulfill_htlcs { + nodes[0].node.handle_update_fulfill_htlc(node_1_id, fulfill.clone()); + } + // Complete the standard commitment handshake for the released fulfill. The helper + // checks nodes[0]'s incoming commitment monitor update, nodes[1]'s response monitor + // updates, and nodes[0]'s held final monitor update. + do_commitment_signed_dance( + &nodes[0], &nodes[1], &updates.commitment_signed, false, false, + ); + }, + _ => panic!("Unexpected restart message from node 1: {:?}", ev), + } + } + assert_eq!(restart_scids_0.len(), 2); + assert!(restart_scids_0.contains(&scid_a)); + assert!(restart_scids_0.contains(&scid_b)); + assert_eq!(restart_scids_1.len(), 2); + assert!(restart_scids_1.contains(&scid_a)); + assert!(restart_scids_1.contains(&scid_b)); + assert_eq!(startup_fulfill_chan_ids, vec![chan_id_a]); + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + check_added_monitors(&nodes[0], 0); + check_added_monitors(&nodes[1], 0); + + // Receiving the startup-released fulfill gives nodes[0] the payment preimage. That is enough to + // emit `PaymentSent`, even though channel B's path-level success still needs its own fulfill. + let startup_payment_events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(startup_payment_events.len(), 2); + let mut saw_startup_payment_sent = false; + let mut startup_success_scids = Vec::new(); + for ev in &startup_payment_events { + match ev { + Event::PaymentSent { + payment_preimage: sent_preimage, + payment_hash: sent_hash, + amount_msat: sent_amount, + fee_paid_msat, + .. + } => { + assert_eq!(*sent_preimage, payment_preimage); + assert_eq!(*sent_hash, payment_hash); + assert_eq!(*sent_amount, Some(amt_msat)); + assert_eq!(*fee_paid_msat, Some(0)); + saw_startup_payment_sent = true; + }, + Event::PaymentPathSuccessful { payment_hash: Some(path_hash), path, .. } => { + assert_eq!(*path_hash, payment_hash); + assert_eq!(path.hops.len(), 1); + startup_success_scids.push(path.hops[0].short_channel_id); + }, + _ => panic!("Unexpected startup payment event: {:?}", ev), + } + } + assert!(saw_startup_payment_sent); + assert_eq!(startup_success_scids, vec![scid_a]); + + // Handling the claim event runs the event-completion action that releases the remaining + // RAA-blocked monitor update. The startup unblock path already released channel A, so channel B + // is the only fulfill that should be emitted here. + let claim_events = nodes[1].node.get_and_clear_pending_events(); + assert_eq!(claim_events.len(), 1); + match &claim_events[0] { + Event::PaymentClaimed { payment_hash: claimed_hash, amount_msat, htlcs, .. } => { + assert_eq!(*claimed_hash, payment_hash); + assert_eq!(*amount_msat, amt_msat); + assert_eq!(htlcs.len(), 2); + }, + _ => panic!("Unexpected event: {:?}", claim_events[0]), + } + // The `PaymentSent` event above releases the monitor update that nodes[0] held after the final + // channel A startup revocation. + check_added_monitors(&nodes[0], 1); + // Handling `PaymentClaimed` releases channel B's held revocation update and then the fulfill + // that was waiting behind it. + check_added_monitors(&nodes[1], 2); + + // Channel A's fulfill was already sent during startup. The `PaymentClaimed` completion action + // now frees channel B's held fulfill, and no other HTLC update should be bundled with it. + let fulfill_msgs = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(fulfill_msgs.len(), 1); + match &fulfill_msgs[0] { + MessageSendEvent::UpdateHTLCs { node_id, channel_id, updates } => { + assert_eq!(*node_id, node_0_id); + assert_eq!(*channel_id, chan_id_b); + assert_eq!(updates.update_fulfill_htlcs.len(), 1); + assert!(updates.update_add_htlcs.is_empty()); + assert!(updates.update_fail_htlcs.is_empty()); + assert!(updates.update_fail_malformed_htlcs.is_empty()); + assert!(updates.update_fee.is_none()); + for fulfill in &updates.update_fulfill_htlcs { + nodes[0].node.handle_update_fulfill_htlc(node_1_id, fulfill.clone()); + } + // Complete the same commitment handshake for channel B. Here nodes[0]'s final monitor + // update is persisted immediately because `PaymentSent` already ran for channel A. + do_commitment_signed_dance( + &nodes[0], &nodes[1], &updates.commitment_signed, false, false, + ); + }, + _ => panic!("Unexpected fulfill message: {:?}", fulfill_msgs[0]), + } + check_added_monitors(&nodes[1], 0); + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + + let final_payment_events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(final_payment_events.len(), 1); + match &final_payment_events[0] { + Event::PaymentPathSuccessful { payment_hash: Some(path_hash), path, .. } => { + assert_eq!(*path_hash, payment_hash); + assert_eq!(path.hops.len(), 1); + assert_eq!(path.hops[0].short_channel_id, scid_b); + }, + _ => panic!("Unexpected final payment event: {:?}", final_payment_events[0]), + } + check_added_monitors(&nodes[0], 0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); + check_added_monitors(&nodes[0], 0); + check_added_monitors(&nodes[1], 0); + + // Both MPP parts should have been fulfilled back to nodes[0]. If either channel still has a + // pending outbound HTLC, its fulfill remained stuck in nodes[1]'s holding cell after reload. + let pending: Vec<_> = nodes[0].node.list_channels().iter() + .filter(|channel| channel.channel_id == chan_id_a || channel.channel_id == chan_id_b) + .filter(|channel| !channel.pending_outbound_htlcs.is_empty()) + .map(|channel| channel.channel_id) + .collect(); + assert!(pending.is_empty(), "HTLC fulfills remained stuck on channels {:?}", pending); +} + fn do_forwarded_payment_no_manager_persistence(use_cs_commitment: bool, claim_htlc: bool, use_intercept: bool) { if !use_cs_commitment { assert!(!claim_htlc); } // If we go to forward a payment, and the ChannelMonitor persistence completes, but the From f408b174405b4fc05363af53fef7eb845e9f6be5 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Wed, 29 Apr 2026 11:50:54 -0700 Subject: [PATCH 395/627] Support async signing of splice shared input While user signatures may be provided whenever ready at the user's discretion when handling a `FundingTransactionReadyForSigning` event, it does not cover the user's signature for the 2-of-2 multisig input in a splice. This signature is obtained via the `EcdsaChannelSigner`, which did not support providing it asynchronously. Since the splice shared input signature is part of the `tx_signatures` message, we're not allowed to send the message until it's complete. This results in us needing to explicitly handle the signature exchange logic when the signer unblocks the shared input signature. --- lightning/src/ln/async_signer_tests.rs | 77 ++++++++++ lightning/src/ln/channel.rs | 171 ++++++++++++++-------- lightning/src/ln/channelmanager.rs | 89 +++++++++-- lightning/src/ln/interactivetxs.rs | 88 ++++++++--- lightning/src/sign/ecdsa.rs | 8 +- lightning/src/sign/mod.rs | 4 +- lightning/src/util/dyn_signer.rs | 2 +- lightning/src/util/test_channel_signer.rs | 8 +- 8 files changed, 350 insertions(+), 97 deletions(-) diff --git a/lightning/src/ln/async_signer_tests.rs b/lightning/src/ln/async_signer_tests.rs index ae73dd830ac..8edff2094c6 100644 --- a/lightning/src/ln/async_signer_tests.rs +++ b/lightning/src/ln/async_signer_tests.rs @@ -1742,3 +1742,80 @@ fn test_async_splice_initial_commit_sig_waits_for_monitor_before_tx_signatures() let _ = get_event!(initiator, Event::SpliceNegotiated); let _ = get_event!(acceptor, Event::SpliceNegotiated); } + +#[test] +fn test_async_splice_shared_input_signature_released_on_unblock() { + // Test that we can provide the signature of a splice's shared input asynchronously, and check + // that the holding cell is freed after exiting quiescence due to exchanging `tx_signatures`. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let channel_id = create_announced_chan_between_nodes(&nodes, 0, 1).2; + + let (initiator, acceptor) = (&nodes[0], &nodes[1]); + let initiator_node_id = initiator.node.get_our_node_id(); + let acceptor_node_id = acceptor.node.get_our_node_id(); + + initiator.disable_channel_signer_op( + &acceptor_node_id, + &channel_id, + SignerOp::SignSpliceSharedInput, + ); + + let outputs = vec![TxOut { + value: Amount::from_sat(1_000), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]; + let contribution = initiate_splice_out(initiator, acceptor, channel_id, outputs).unwrap(); + negotiate_splice_tx(initiator, acceptor, channel_id, contribution); + + let event = get_event!(initiator, Event::FundingTransactionReadyForSigning); + if let Event::FundingTransactionReadyForSigning { unsigned_transaction, .. } = event { + let partially_signed_tx = initiator.wallet_source.sign_tx(unsigned_transaction).unwrap(); + initiator + .node + .funding_transaction_signed(&channel_id, &acceptor_node_id, partially_signed_tx) + .unwrap(); + } + + let initiator_commit_sig = get_htlc_update_msgs(initiator, &acceptor_node_id); + acceptor + .node + .handle_commitment_signed(initiator_node_id, &initiator_commit_sig.commitment_signed[0]); + check_added_monitors(acceptor, 1); + + let acceptor_msg_events = acceptor.node.get_and_clear_pending_msg_events(); + assert_eq!(acceptor_msg_events.len(), 2, "{acceptor_msg_events:?}"); + for msg_event in &acceptor_msg_events { + match msg_event { + MessageSendEvent::UpdateHTLCs { updates, .. } => { + initiator + .node + .handle_commitment_signed(acceptor_node_id, &updates.commitment_signed[0]); + check_added_monitors(initiator, 1); + }, + MessageSendEvent::SendTxSignatures { msg, .. } => { + initiator.node.handle_tx_signatures(acceptor_node_id, msg); + }, + _ => panic!("Unexpected event"), + } + } + + assert!(initiator.node.get_and_clear_pending_msg_events().is_empty()); + + initiator.enable_channel_signer_op( + &acceptor_node_id, + &channel_id, + SignerOp::SignSpliceSharedInput, + ); + initiator.node.signer_unblocked(None); + + let tx_signatures = + get_event_msg!(initiator, MessageSendEvent::SendTxSignatures, acceptor_node_id); + acceptor.node.handle_tx_signatures(initiator_node_id, &tx_signatures); + + let _ = get_event!(initiator, Event::SpliceNegotiated); + let _ = get_event!(acceptor, Event::SpliceNegotiated); +} diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 8075699c758..3d6342ce4d0 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -1249,8 +1249,7 @@ pub(super) struct SignerResumeUpdates { pub accept_channel: Option, pub funding_created: Option, pub funding_signed: Option, - pub funding_commit_sig: Option, - pub tx_signatures: Option, + pub funding_tx_signed: Option, pub channel_ready: Option, pub order: RAACommitmentOrder, pub closing_signed: Option, @@ -1683,11 +1682,11 @@ where #[rustfmt::skip] pub fn signer_maybe_unblocked( - &mut self, chain_hash: ChainHash, logger: &L, path_for_release_htlc: CBP + &mut self, chain_hash: ChainHash, best_block_height: u32, logger: &L, path_for_release_htlc: CBP ) -> Result, ChannelError> where CBP: Fn(u64) -> BlindedMessagePath { match &mut self.phase { ChannelPhase::Undefined => unreachable!(), - ChannelPhase::Funded(chan) => chan.signer_maybe_unblocked(logger, path_for_release_htlc).map(|r| Some(r)), + ChannelPhase::Funded(chan) => chan.signer_maybe_unblocked(best_block_height, logger, path_for_release_htlc).map(|r| Some(r)), ChannelPhase::UnfundedOutboundV1(chan) => { let (open_channel, funding_created) = chan.signer_maybe_unblocked(chain_hash, logger); Ok(Some(SignerResumeUpdates { @@ -1697,8 +1696,7 @@ where accept_channel: None, funding_created, funding_signed: None, - funding_commit_sig: None, - tx_signatures: None, + funding_tx_signed: None, channel_ready: None, order: chan.context.resend_order.clone(), closing_signed: None, @@ -1715,8 +1713,7 @@ where accept_channel, funding_created: None, funding_signed: None, - funding_commit_sig: None, - tx_signatures: None, + funding_tx_signed: None, channel_ready: None, order: chan.context.resend_order.clone(), closing_signed: None, @@ -2217,9 +2214,7 @@ where .unwrap_or(false)); } - if signing_session.has_holder_tx_signatures() { - // Our `tx_signatures` either should've been the first time we processed them, - // or we're waiting for our counterparty to send theirs first. + if signing_session.has_holder_witnesses() { return Ok(FundingTxSigned { commitment_signed: None, counterparty_initial_commitment_signed_result: None, @@ -2248,36 +2243,42 @@ where return Err(APIError::APIMisuseError { err }); }; - let tx = signing_session.unsigned_tx().tx(); - if funding_txid_signed != tx.compute_txid() { - return Err(APIError::APIMisuseError { - err: "Transaction was malleated prior to signing".to_owned(), - }); - } + let (mut tx_signatures, mut funding_tx) = signing_session + .provide_holder_witnesses( + context.channel_id, + funding_txid_signed, + witnesses, + &context.secp_ctx, + ) + .map_err(|err| APIError::APIMisuseError { err })?; - let shared_input_signature = - if let Some(splice_input_index) = signing_session.unsigned_tx().shared_input_index() { - let sig = context.holder_signer.sign_splice_shared_input( + debug_assert_eq!( + pending_splice.is_some(), + signing_session.unsigned_tx().shared_input_index().is_some() + ); + if let Some(splice_input_index) = signing_session.unsigned_tx().shared_input_index() { + let sig = context + .holder_signer + .sign_splice_shared_input( &funding.channel_transaction_parameters, - tx, + signing_session.unsigned_tx().tx(), splice_input_index as usize, &context.secp_ctx, - ); - Some(sig) + ) + .ok(); + if let Some(sig) = sig { + (tx_signatures, funding_tx) = signing_session + .provide_holder_shared_input_signature(sig) + .map_err(|err| APIError::APIMisuseError { err })?; } else { - None - }; - debug_assert_eq!(pending_splice.is_some(), shared_input_signature.is_some()); - - let tx_signatures = msgs::TxSignatures { - channel_id: context.channel_id, - tx_hash: funding_txid_signed, - witnesses, - shared_input_signature, - }; - let (tx_signatures, funding_tx) = signing_session - .provide_holder_witnesses(tx_signatures, &context.secp_ctx) - .map_err(|err| APIError::APIMisuseError { err })?; + log_debug!( + logger, + "Splice shared input signature not available, waiting on async signer" + ); + debug_assert!(tx_signatures.is_none()); + debug_assert!(funding_tx.is_none()); + } + } let logger = WithChannelContext::from(logger, &context, None); if tx_signatures.is_some() { @@ -2409,18 +2410,17 @@ where // which must always come after the initial commitment signed is sent. .unwrap_or(true); let res = if has_negotiated_pending_splice && !session_received_commitment_signed { - let has_holder_tx_signatures = funded_channel + let has_holder_witnesses = funded_channel .context .interactive_tx_signing_session .as_ref() - .map(|session| session.has_holder_tx_signatures()) + .map(|session| session.has_holder_witnesses()) .unwrap_or(false); // We delay processing this until the user manually approves the splice via - // [`Channel::funding_transaction_signed`], as otherwise, there would be a - // [`ChannelMonitorUpdateStep::RenegotiatedFunding`] committed that we would - // need to undo if they no longer wish to proceed. - if has_holder_tx_signatures { + // [`Channel::funding_transaction_signed`], as otherwise, it would prevent the + // user from canceling their contribution if they no longer wish to proceed. + if has_holder_witnesses { funded_channel .splice_initial_commitment_signed(msg, fee_estimator, logger) .map(|monitor_update_opt| (None, monitor_update_opt)) @@ -5179,7 +5179,7 @@ impl ChannelContext { ChannelState::FundingNegotiated(_) => self .interactive_tx_signing_session .as_ref() - .map(|signing_session| signing_session.has_holder_tx_signatures()) + .map(|signing_session| signing_session.has_holder_witnesses()) .unwrap_or(false), ChannelState::AwaitingChannelReady(flags) => !flags.is_waiting_for_batch(), _ => true, @@ -7910,7 +7910,7 @@ where .interactive_tx_signing_session .as_ref() .map(|signing_session| { - signing_session.has_holder_tx_signatures() + signing_session.has_holder_witnesses() || signing_session.has_received_tx_signatures() }) .unwrap_or(false); @@ -9584,6 +9584,8 @@ where } } + let awaiting_holder_shared_input_signature = + signing_session.awaiting_holder_shared_input_signature(); let (holder_tx_signatures, funding_tx) = signing_session.received_tx_signatures(msg).map_err(|msg| ChannelError::Warn(msg))?; @@ -9622,6 +9624,11 @@ where best_block_height, &logger, ); + } else if awaiting_holder_shared_input_signature { + log_debug!( + logger, + "Waiting for funding transaction shared input signature before finalizing negotiation" + ); } else { debug_assert!( false, @@ -10016,7 +10023,7 @@ where /// blocked. #[rustfmt::skip] pub fn signer_maybe_unblocked( - &mut self, logger: &L, path_for_release_htlc: CBP + &mut self, best_block_height: u32, logger: &L, path_for_release_htlc: CBP ) -> Result where CBP: Fn(u64) -> BlindedMessagePath { if let Some((commitment_number, commitment_secret)) = self.context.signer_pending_stale_state_verification.clone() { if let Ok(expected_point) = self @@ -10072,16 +10079,65 @@ where None }; - let tx_signatures = if funding_commit_sig.is_some() { + let mut shared_input_signature_unblocked = false; + { + if let Some(signing_session) = self.context.interactive_tx_signing_session.as_mut() { + if signing_session.awaiting_holder_shared_input_signature() { + let splice_input_index = signing_session + .unsigned_tx() + .shared_input_index() + .expect("Missing shared input index while awaiting a splice signature"); + log_trace!(logger, "Attempting to generate pending splice shared input signature..."); + if let Ok(shared_input_signature) = self.context.holder_signer.sign_splice_shared_input( + &self.funding.channel_transaction_parameters, + signing_session.unsigned_tx().tx(), + splice_input_index as usize, + &self.context.secp_ctx, + ) { + shared_input_signature_unblocked = true; + signing_session + .provide_holder_shared_input_signature(shared_input_signature) + .map_err(ChannelError::close)?; + } + } + } + } + + let mut tx_signatures = None; + let mut funding_tx = None; + if funding_commit_sig.is_some() || shared_input_signature_unblocked { if let Some(signing_session) = self.context.interactive_tx_signing_session.as_ref() { - signing_session.holder_tx_signatures().filter(|_| !self.is_awaiting_monitor_update()) + if !self.is_awaiting_monitor_update() && !self.context.signer_pending_funding { + tx_signatures = signing_session.holder_tx_signatures(); + funding_tx = tx_signatures.as_ref().and_then(|_| signing_session.signed_tx()); + } } else { debug_assert!(false); - None } - } else { - None - }; + } + + let mut funding_tx_signed = None; + if funding_commit_sig.is_some() || tx_signatures.is_some() || funding_tx.is_some() { + let mut resumed = FundingTxSigned { + commitment_signed: funding_commit_sig, + counterparty_initial_commitment_signed_result: None, + tx_signatures, + funding_tx: None, + splice_negotiated: None, + splice_locked: None, + }; + if let Some(funding_tx) = funding_tx { + let funding_logger = WithChannelContext::from(logger, &self.context, None); + debug_assert!(resumed.tx_signatures.is_some()); + self.on_tx_signatures_exchange( + &mut resumed, + funding_tx, + best_block_height, + &funding_logger, + ); + } + funding_tx_signed = Some(resumed); + } // Provide a `channel_ready` message if we need to, but only if we're _not_ still pending // funding. @@ -10147,8 +10203,8 @@ where if revoke_and_ack.is_some() { "a" } else { "no" }, self.context.resend_order, if funding_signed.is_some() { "a" } else { "no" }, - if funding_commit_sig.is_some() { "a" } else { "no" }, - if tx_signatures.is_some() { "a" } else { "no" }, + if funding_tx_signed.as_ref().map(|v| v.commitment_signed.is_some()).unwrap_or(false) { "a" } else { "no" }, + if funding_tx_signed.as_ref().map(|v| v.tx_signatures.is_some()).unwrap_or(false) { "a" } else { "no" }, if channel_ready.is_some() { "a" } else { "no" }, if closing_signed.is_some() { "a" } else { "no" }, if signed_closing_tx.is_some() { "a" } else { "no" }, @@ -10161,8 +10217,7 @@ where accept_channel: None, funding_created: None, funding_signed, - funding_commit_sig, - tx_signatures, + funding_tx_signed, channel_ready, order: self.context.resend_order.clone(), closing_signed, @@ -10512,7 +10567,7 @@ where } else { tx_signatures = Some(holder_tx_signatures); } - } else if !session.has_holder_tx_signatures() { + } else if !session.has_holder_witnesses() { log_debug!(logger, "Waiting for funding transaction signatures to be provided"); } } else { @@ -10948,7 +11003,7 @@ where matches!(self.context.channel_state, ChannelState::NegotiatingFunding(_)); if matches!(self.context.channel_state, ChannelState::FundingNegotiated(_)) { if let Some(signing_session) = self.context.interactive_tx_signing_session.as_ref() { - if !signing_session.has_holder_tx_signatures() { + if !signing_session.has_holder_witnesses() { // If we're a V1 channel or we haven't yet sent our `tx_signatures` for a dual // funded channel, the funding tx couldn't be broadcasted yet, so just short-circuit // the shutdown logic. @@ -12919,7 +12974,7 @@ where .interactive_tx_signing_session .as_ref() .expect("We have a pending splice awaiting signatures") - .has_holder_tx_signatures(); + .has_holder_witnesses(); if already_signed { return Err(APIError::APIMisuseError { err: format!( diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index db64cc99a02..1fc8a714388 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -11106,13 +11106,13 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ TransactionType::Funding { channels: vec![(counterparty_node_id, channel.context.channel_id())] }, )]); } - } else if let Some((splice_tx, tx_type)) = funding_tx_signed + } else if let Some((tx, tx_type)) = funding_tx_signed .as_mut() .and_then(|v| v.funding_tx.take()) .filter(|(_, tx_type)| matches!(tx_type, TransactionType::InteractiveFunding { .. })) { - log_info!(logger, "Broadcasting signed splice transaction with txid {}", splice_tx.compute_txid()); - self.tx_broadcaster.broadcast_transactions(&[(&splice_tx, tx_type)]); + log_info!(logger, "Broadcasting interactively funded transaction with txid {}", tx.compute_txid()); + self.tx_broadcaster.broadcast_transactions(&[(&tx, tx_type)]); } { @@ -13872,20 +13872,24 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ /// [`ChannelSigner`]: crate::sign::ChannelSigner pub fn signer_unblocked(&self, channel_opt: Option<(PublicKey, ChannelId)>) { let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); + let mut needs_holding_cell_release = false; // Returns whether we should remove this channel as it's just been closed. let unblock_chan = |chan: &mut Channel, - pending_msg_events: &mut Vec| + pending_msg_events: &mut Vec, + needs_holding_cell_release: &mut bool| -> Result, ChannelError> { let channel_id = chan.context().channel_id(); let outbound_scid_alias = chan.context().outbound_scid_alias(); let logger = WithChannelContext::from(&self.logger, &chan.context(), None); let node_id = chan.context().get_counterparty_node_id(); + let best_block_height = self.best_block.read().unwrap().height; let cbp = |htlc_id| { self.path_for_release_held_htlc(htlc_id, outbound_scid_alias, &channel_id, &node_id) }; - let msgs = chan.signer_maybe_unblocked(self.chain_hash, &&logger, cbp)?; - if let Some(msgs) = msgs { + let msgs = + chan.signer_maybe_unblocked(self.chain_hash, best_block_height, &&logger, cbp)?; + if let Some(mut msgs) = msgs { if chan.context().is_connected() { if let Some(msg) = msgs.open_channel { pending_msg_events.push(MessageSendEvent::SendOpenChannel { node_id, msg }); @@ -13925,7 +13929,11 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ pending_msg_events .push(MessageSendEvent::SendFundingSigned { node_id, msg }); } - if let Some(msg) = msgs.funding_commit_sig { + if let Some(msg) = msgs + .funding_tx_signed + .as_mut() + .and_then(|funding_tx_signed| funding_tx_signed.commitment_signed.take()) + { pending_msg_events.push(MessageSendEvent::UpdateHTLCs { node_id, channel_id, @@ -13939,7 +13947,11 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }, }); } - if let Some(msg) = msgs.tx_signatures { + if let Some(msg) = msgs + .funding_tx_signed + .as_mut() + .and_then(|funding_tx_signed| funding_tx_signed.tx_signatures.take()) + { pending_msg_events .push(MessageSendEvent::SendTxSignatures { node_id, msg }); } @@ -13952,6 +13964,55 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ if let Some(msg) = msgs.channel_ready { self.send_channel_ready(pending_msg_events, funded_chan, msg); } + debug_assert!(msgs + .funding_tx_signed + .as_ref() + .and_then(|funding_tx_signed| { + funding_tx_signed.counterparty_initial_commitment_signed_result.as_ref() + }) + .is_none()); + if let Some(msg) = msgs + .funding_tx_signed + .as_mut() + .and_then(|funding_tx_signed| funding_tx_signed.splice_locked.take()) + { + pending_msg_events + .push(MessageSendEvent::SendSpliceLocked { node_id, msg }); + } + if let Some((tx, tx_type)) = msgs + .funding_tx_signed + .as_mut() + .and_then(|funding_tx_signed| funding_tx_signed.funding_tx.take()) + { + debug_assert!(matches!( + tx_type, + TransactionType::InteractiveFunding { .. } + )); + log_info!( + logger, + "Broadcasting interactively funded transaction with txid {}", + tx.compute_txid(), + ); + self.tx_broadcaster.broadcast_transactions(&[(&tx, tx_type)]); + } + if let Some(splice_negotiated) = msgs + .funding_tx_signed + .as_mut() + .and_then(|funding_tx_signed| funding_tx_signed.splice_negotiated.take()) + { + *needs_holding_cell_release = true; + self.pending_events.lock().unwrap().push_back(( + events::Event::SpliceNegotiated { + channel_id, + counterparty_node_id: node_id, + user_channel_id: funded_chan.context.get_user_id(), + new_funding_txo: splice_negotiated.funding_txo, + channel_type: splice_negotiated.channel_type, + new_funding_redeem_script: splice_negotiated.funding_redeem_script, + }, + None, + )); + } if let Some(broadcast_tx) = msgs.signed_closing_tx { log_info!(logger, "Broadcasting closing tx {}", log_tx!(broadcast_tx)); self.tx_broadcaster.broadcast_transactions(&[( @@ -13966,6 +14027,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ // We don't know how to handle a channel_ready or signed_closing_tx for a // non-funded channel. debug_assert!(msgs.channel_ready.is_none()); + debug_assert!(msgs.funding_tx_signed.is_none()); debug_assert!(msgs.signed_closing_tx.is_none()); } Ok(msgs.shutdown_result) @@ -13989,7 +14051,11 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ peer_state.channel_by_id.retain(|_, chan| { let shutdown_result = match channel_opt { Some((_, channel_id)) if chan.context().channel_id() != channel_id => None, - _ => match unblock_chan(chan, &mut peer_state.pending_msg_events) { + _ => match unblock_chan( + chan, + &mut peer_state.pending_msg_events, + &mut needs_holding_cell_release, + ) { Ok(shutdown_result) => shutdown_result, Err(err) => { let (_, err) = self.locked_handle_force_close( @@ -14030,6 +14096,9 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }); } drop(per_peer_state); + if needs_holding_cell_release { + self.check_free_holding_cells(); + } for (err, counterparty_node_id) in shutdown_results { let _ = self.handle_error(err, counterparty_node_id); } @@ -20188,7 +20257,7 @@ impl< if let Some(signing_session) = chan.context().interactive_tx_signing_session.as_ref() { - if !signing_session.has_holder_tx_signatures() + if !signing_session.has_holder_witnesses() && signing_session.has_local_contribution() { let unsigned_transaction = signing_session.unsigned_tx().tx().clone(); diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index 6396298b08c..faa352f1f07 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -18,6 +18,7 @@ use bitcoin::constants::WITNESS_SCALE_FACTOR; use bitcoin::ecdsa::Signature as BitcoinSignature; use bitcoin::key::Secp256k1; use bitcoin::policy::MAX_STANDARD_TX_WEIGHT; +use bitcoin::secp256k1::ecdsa::Signature; use bitcoin::secp256k1::{Message, PublicKey}; use bitcoin::sighash::SighashCache; use bitcoin::transaction::Version; @@ -432,12 +433,12 @@ impl ConstructedTransaction { } fn finalize( - &self, holder_tx_signatures: &TxSignatures, counterparty_tx_signatures: &TxSignatures, - shared_input_sig: Option<&SharedInputSignature>, + &self, holder_tx_signatures: TxSignatures, counterparty_tx_signatures: TxSignatures, + shared_input_sig: Option, ) -> Option { let mut tx = self.tx.clone(); - self.add_local_witnesses(&mut tx, holder_tx_signatures.witnesses.clone()); - self.add_remote_witnesses(&mut tx, counterparty_tx_signatures.witnesses.clone()); + self.add_local_witnesses(&mut tx, holder_tx_signatures.witnesses); + self.add_remote_witnesses(&mut tx, counterparty_tx_signatures.witnesses); if let Some(shared_input_index) = self.shared_input_index { let holder_shared_input_sig = @@ -568,13 +569,25 @@ impl InteractiveTxSigningSession { self.counterparty_tx_signatures.is_some() } - pub fn has_holder_tx_signatures(&self) -> bool { + pub fn has_holder_witnesses(&self) -> bool { self.holder_tx_signatures.is_some() } + pub fn awaiting_holder_shared_input_signature(&self) -> bool { + self.holder_tx_signatures + .as_ref() + .map(|tx_signatures| { + self.shared_input().is_some() && tx_signatures.shared_input_signature.is_none() + }) + .unwrap_or(false) + } + pub fn holder_tx_signatures(&self) -> Option { self.holder_tx_signatures .as_ref() + .filter(|tx_signatures| { + self.shared_input().is_none() || tx_signatures.shared_input_signature.is_some() + }) .filter(|_| { (self.has_received_commitment_signed && self.holder_sends_tx_signatures_first) || self.has_received_tx_signatures() @@ -615,51 +628,78 @@ impl InteractiveTxSigningSession { self.counterparty_tx_signatures = Some(tx_signatures.clone()); - let holder_tx_signatures = if !self.holder_sends_tx_signatures_first { - self.holder_tx_signatures.clone() - } else { - None - }; + let holder_tx_signatures = + if !self.holder_sends_tx_signatures_first { self.holder_tx_signatures() } else { None }; let funding_tx_opt = self.signed_tx(); Ok((holder_tx_signatures, funding_tx_opt)) } - /// Provides the holder witnesses for the unsigned transaction. + /// Provides the holder witnesses for the unsigned transaction's non-shared inputs. + /// + /// For splices, call [`Self::provide_holder_shared_input_signature`] separately after the + /// shared input signature is available. /// /// Returns an error if the witness count does not equal the holder's input count in the /// unsigned transaction. pub fn provide_holder_witnesses( - &mut self, tx_signatures: TxSignatures, secp_ctx: &Secp256k1, + &mut self, channel_id: ChannelId, funding_txid_signed: Txid, witnesses: Vec, + secp_ctx: &Secp256k1, ) -> Result<(Option, Option), String> { if self.holder_tx_signatures.is_some() { return Err("Holder witnesses were already provided".to_string()); } + if funding_txid_signed != self.unsigned_tx().compute_txid() { + return Err("Transaction was malleated prior to signing".to_string()); + } + let local_inputs_count = self.local_inputs_count(); - if tx_signatures.witnesses.len() != local_inputs_count { + if witnesses.len() != local_inputs_count { return Err(format!( "Provided witness count of {} does not match required count for {} non-shared inputs", - tx_signatures.witnesses.len(), + witnesses.len(), local_inputs_count )); } - self.verify_interactive_tx_signatures(secp_ctx, &tx_signatures.witnesses)?; + self.verify_interactive_tx_signatures(secp_ctx, &witnesses)?; - self.holder_tx_signatures = Some(tx_signatures); + self.holder_tx_signatures = Some(TxSignatures { + channel_id, + tx_hash: funding_txid_signed, + witnesses, + shared_input_signature: None, + }); + let holder_tx_signatures = self.holder_tx_signatures(); let funding_tx_opt = self.signed_tx(); - let holder_tx_signatures = (self.has_received_commitment_signed - && (self.holder_sends_tx_signatures_first || self.has_received_tx_signatures())) - .then(|| { - self.holder_tx_signatures.clone().expect("Holder tx_signatures were just provided") - }); Ok((holder_tx_signatures, funding_tx_opt)) } + pub fn provide_holder_shared_input_signature( + &mut self, shared_input_signature: Signature, + ) -> Result<(Option, Option), String> { + if self.shared_input().is_none() { + return Err("No shared input exists for this transaction".to_string()); + } + + let holder_tx_signatures = self.holder_tx_signatures.as_mut().ok_or_else(|| { + "Holder witnesses must be provided before the shared input signature".to_string() + })?; + if holder_tx_signatures.shared_input_signature.is_some() { + return Err("The shared input signature was already provided".to_string()); + } + + holder_tx_signatures.shared_input_signature = Some(shared_input_signature); + + let funding_tx_opt = self.signed_tx(); + let holder_tx_signatures = self.holder_tx_signatures(); + Ok((holder_tx_signatures, funding_tx_opt)) + } + pub fn remote_inputs_count(&self) -> usize { let shared_index = self.unsigned_tx.shared_input_index.as_ref(); self.unsigned_tx @@ -710,9 +750,9 @@ impl InteractiveTxSigningSession { /// Returns `Some` with the fully signed transaction if both holder and counterparty signatures /// are available. pub fn signed_tx(&self) -> Option { - let holder_tx_signatures = self.holder_tx_signatures.as_ref()?; - let counterparty_tx_signatures = self.counterparty_tx_signatures.as_ref()?; - let shared_input_signature = self.shared_input_signature.as_ref(); + let holder_tx_signatures = self.holder_tx_signatures()?; + let counterparty_tx_signatures = self.counterparty_tx_signatures.clone()?; + let shared_input_signature = self.shared_input_signature.clone(); self.unsigned_tx.finalize( holder_tx_signatures, counterparty_tx_signatures, diff --git a/lightning/src/sign/ecdsa.rs b/lightning/src/sign/ecdsa.rs index e13285722af..c0bd3759caa 100644 --- a/lightning/src/sign/ecdsa.rs +++ b/lightning/src/sign/ecdsa.rs @@ -254,8 +254,14 @@ pub trait EcdsaChannelSigner: ChannelSigner { /// /// `input_index`: The index of the input within the new funding transaction `tx`, /// spending the previous funding transaction's output + /// + /// An `Err` can be returned to signal that the signer is unavailable/cannot produce a valid + /// signature and should be retried later. Once the signer is ready to provide a signature after + /// previously returning an `Err`, [`ChannelManager::signer_unblocked`] must be called. + /// + /// [`ChannelManager::signer_unblocked`]: crate::ln::channelmanager::ChannelManager::signer_unblocked fn sign_splice_shared_input( &self, channel_parameters: &ChannelTransactionParameters, tx: &Transaction, input_index: usize, secp_ctx: &Secp256k1, - ) -> Signature; + ) -> Result; } diff --git a/lightning/src/sign/mod.rs b/lightning/src/sign/mod.rs index 374ad38b2ce..a3dc72042cc 100644 --- a/lightning/src/sign/mod.rs +++ b/lightning/src/sign/mod.rs @@ -1928,7 +1928,7 @@ impl EcdsaChannelSigner for InMemorySigner { fn sign_splice_shared_input( &self, channel_parameters: &ChannelTransactionParameters, tx: &Transaction, input_index: usize, secp_ctx: &Secp256k1, - ) -> Signature { + ) -> Result { assert!(channel_parameters.is_populated(), "Channel parameters must be fully populated"); assert_eq!( tx.input[input_index].previous_output, @@ -1954,7 +1954,7 @@ impl EcdsaChannelSigner for InMemorySigner { ) .unwrap()[..]; let msg = hash_to_message!(sighash); - sign(secp_ctx, &msg, &funding_key) + Ok(sign(secp_ctx, &msg, &funding_key)) } } diff --git a/lightning/src/util/dyn_signer.rs b/lightning/src/util/dyn_signer.rs index 436eaabda34..5da284d25a4 100644 --- a/lightning/src/util/dyn_signer.rs +++ b/lightning/src/util/dyn_signer.rs @@ -90,7 +90,7 @@ delegate!(DynSigner, EcdsaChannelSigner, inner, fn sign_holder_htlc_transaction(, htlc_tx: &Transaction, input: usize, htlc_descriptor: &HTLCDescriptor, secp_ctx: &Secp256k1) -> Result, fn sign_splice_shared_input(, channel_parameters: &ChannelTransactionParameters, - tx: &Transaction, input_index: usize, secp_ctx: &Secp256k1) -> Signature + tx: &Transaction, input_index: usize, secp_ctx: &Secp256k1) -> Result ); delegate!(DynSigner, ChannelSigner, diff --git a/lightning/src/util/test_channel_signer.rs b/lightning/src/util/test_channel_signer.rs index 8435e7fa437..668bbebad05 100644 --- a/lightning/src/util/test_channel_signer.rs +++ b/lightning/src/util/test_channel_signer.rs @@ -103,6 +103,7 @@ pub enum SignerOp { SignClosingTransaction, SignHolderAnchorInput, SignChannelAnnouncementWithFundingKey, + SignSpliceSharedInput, } impl SignerOp { @@ -120,6 +121,7 @@ impl SignerOp { SignerOp::SignClosingTransaction, SignerOp::SignHolderAnchorInput, SignerOp::SignChannelAnnouncementWithFundingKey, + SignerOp::SignSpliceSharedInput, ] } } @@ -507,7 +509,11 @@ impl EcdsaChannelSigner for TestChannelSigner { fn sign_splice_shared_input( &self, channel_parameters: &ChannelTransactionParameters, tx: &Transaction, input_index: usize, secp_ctx: &Secp256k1, - ) -> Signature { + ) -> Result { + #[cfg(any(test, feature = "_test_utils"))] + if !self.is_signer_available(SignerOp::SignSpliceSharedInput) { + return Err(()); + } self.inner.sign_splice_shared_input(channel_parameters, tx, input_index, secp_ctx) } } From e90d524edfb55eb63766b2d9c41ee2c829a20410 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Tue, 21 Apr 2026 16:42:27 +0000 Subject: [PATCH 396/627] Move the calculation of the spiked feerate to `tx_builder` In the next commit, we will make changes to how the fee spike buffer is calculated which require the real feerate to always be passed to `tx_builder::get_next_commitment_stats`, even in the case where we include a fee spike multiple. --- lightning/src/ln/channel.rs | 60 +++++++++++++++++--------------- lightning/src/sign/tx_builder.rs | 31 ++++++++++------- 2 files changed, 50 insertions(+), 41 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index e07ee7fceab..e52c5b2bf57 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -4198,6 +4198,7 @@ impl ChannelContext { include_counterparty_unknown_htlcs, addl_nondust_htlc_count, channel_context.feerate_per_kw, + false, dust_exposure_limiting_feerate, ) .map_err(|()| { @@ -4494,6 +4495,7 @@ impl ChannelContext { include_counterparty_unknown_htlcs, addl_nondust_htlc_count, channel_context.feerate_per_kw, + false, dust_exposure_limiting_feerate, ) .map_err(|()| APIError::APIMisuseError { @@ -5284,7 +5286,7 @@ impl ChannelContext { fn get_next_local_commitment_stats( &self, funding: &FundingScope, htlc_candidate: Option, include_counterparty_unknown_htlcs: bool, addl_nondust_htlc_count: usize, - feerate_per_kw: u32, dust_exposure_limiting_feerate: Option, + feerate_per_kw: u32, assume_fee_spike: bool, dust_exposure_limiting_feerate: Option, ) -> Result<(ChannelStats, Vec), ()> { let next_commitment_htlcs = self.get_next_commitment_htlcs( true, @@ -5306,6 +5308,7 @@ impl ChannelContext { &next_commitment_htlcs, addl_nondust_htlc_count, feerate_per_kw, + assume_fee_spike, dust_exposure_limiting_feerate, max_dust_htlc_exposure_msat, channel_constraints, @@ -5330,6 +5333,7 @@ impl ChannelContext { &next_commitment_htlcs, 0, feerate_per_kw, + false, dust_exposure_limiting_feerate, max_dust_htlc_exposure_msat, channel_constraints, @@ -5351,7 +5355,7 @@ impl ChannelContext { fn get_next_remote_commitment_stats( &self, funding: &FundingScope, htlc_candidate: Option, include_counterparty_unknown_htlcs: bool, addl_nondust_htlc_count: usize, - feerate_per_kw: u32, dust_exposure_limiting_feerate: Option, + feerate_per_kw: u32, assume_fee_spike: bool, dust_exposure_limiting_feerate: Option, ) -> Result<(ChannelStats, Vec), ()> { let next_commitment_htlcs = self.get_next_commitment_htlcs( false, @@ -5373,6 +5377,7 @@ impl ChannelContext { &next_commitment_htlcs, addl_nondust_htlc_count, feerate_per_kw, + assume_fee_spike, dust_exposure_limiting_feerate, max_dust_htlc_exposure_msat, channel_constraints, @@ -5397,6 +5402,7 @@ impl ChannelContext { &next_commitment_htlcs, 0, feerate_per_kw, + false, dust_exposure_limiting_feerate, max_dust_htlc_exposure_msat, channel_constraints, @@ -5439,6 +5445,7 @@ impl ChannelContext { include_counterparty_unknown_htlcs, fee_spike_buffer_htlc, self.feerate_per_kw, + false, dust_exposure_limiting_feerate, ) .map_err(|()| { @@ -5497,6 +5504,7 @@ impl ChannelContext { include_counterparty_unknown_htlcs, fee_spike_buffer_htlc, self.feerate_per_kw, + false, dust_exposure_limiting_feerate, ) .map_err(|()| { @@ -5523,6 +5531,7 @@ impl ChannelContext { include_counterparty_unknown_htlcs, 0, new_feerate_per_kw, + false, dust_exposure_limiting_feerate, ) .map_err(|()| { @@ -5544,6 +5553,7 @@ impl ChannelContext { include_counterparty_unknown_htlcs, 0, new_feerate_per_kw, + false, dust_exposure_limiting_feerate, ) .map_err(|()| { @@ -5724,6 +5734,7 @@ impl ChannelContext { include_counterparty_unknown_htlcs, CONCURRENT_INBOUND_HTLC_FEE_BUFFER as usize, feerate_per_kw, + false, dust_exposure_limiting_feerate, ) { stats @@ -5763,6 +5774,7 @@ impl ChannelContext { include_counterparty_unknown_htlcs, CONCURRENT_INBOUND_HTLC_FEE_BUFFER as usize, feerate_per_kw, + false, dust_exposure_limiting_feerate, ) { stats @@ -5810,6 +5822,7 @@ impl ChannelContext { include_counterparty_unknown_htlcs, fee_spike_buffer_htlc, feerate, + false, dust_exposure_limiting_feerate, ) .map_err(|()| { @@ -5826,6 +5839,7 @@ impl ChannelContext { include_counterparty_unknown_htlcs, fee_spike_buffer_htlc, feerate, + false, dust_exposure_limiting_feerate, ) .map_err(|()| { @@ -5862,21 +5876,14 @@ impl ChannelContext { if !funding.is_outbound() { // Note that with anchor outputs we are no longer as sensitive to fee spikes, so we don't need // to account for them. - let fee_spike_multiple = - if !funding.get_channel_type().supports_anchors_zero_fee_htlc_tx() { - FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 - } else { - 1 - }; - // Note that the feerate is 0 in zero-fee commitment channels, so this statement is a noop - let spiked_feerate = feerate.saturating_mul(fee_spike_multiple); let (remote_stats, _remote_htlcs) = self .get_next_remote_commitment_stats( funding, None, include_counterparty_unknown_htlcs, fee_spike_buffer_htlc, - spiked_feerate, + feerate, + true, dust_exposure_limiting_feerate, ) .map_err(|()| { @@ -6231,6 +6238,7 @@ impl ChannelContext { include_counterparty_unknown_htlcs, addl_nondust_htlc_count, self.feerate_per_kw, + false, dust_exposure_limiting_feerate, ) .map(|(remote_stats, _)| remote_stats.available_balances)?; @@ -6252,6 +6260,7 @@ impl ChannelContext { include_counterparty_unknown_htlcs, addl_nondust_htlc_count, self.feerate_per_kw, + false, dust_exposure_limiting_feerate, ) .unwrap(); @@ -13372,16 +13381,6 @@ where // We are not interested in dust exposure let dust_exposure_limiting_feerate = None; - // Note that the feerate is 0 in zero-fee commitment channels, so this statement is a noop - let feerate_per_kw = if !funding.get_channel_type().supports_anchors_zero_fee_htlc_tx() { - // Similar to HTLC additions, require the funder to have enough funds reserved for - // fees such that the feerate can jump without rendering the channel useless. - let spike_mul = FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32; - self.context.feerate_per_kw.saturating_mul(spike_mul) - } else { - self.context.feerate_per_kw - }; - // Different dust limits on the local and remote commitments cause the commitment // transaction fee to be different depending on the commitment, so we grab the floor // of both balances across both commitments here. @@ -13399,7 +13398,8 @@ where None, // htlc_candidate include_counterparty_unknown_htlcs, addl_nondust_htlc_count, - feerate_per_kw, + self.context.feerate_per_kw, + true, dust_exposure_limiting_feerate, ) .map_err(|()| "Balance exhausted on local commitment")?; @@ -13411,7 +13411,8 @@ where None, // htlc_candidate include_counterparty_unknown_htlcs, addl_nondust_htlc_count, - feerate_per_kw, + self.context.feerate_per_kw, + true, dust_exposure_limiting_feerate, ) .map_err(|()| "Balance exhausted on remote commitment")?; @@ -13451,6 +13452,7 @@ where include_counterparty_unknown_htlcs, 0, self.context.feerate_per_kw, + false, dust_exposure_limiting_feerate, ) .map_err(|()| "Balance exhausted on remote commitment")?; @@ -17186,7 +17188,7 @@ mod tests { // Make sure when Node A calculates their local commitment transaction, none of the HTLCs pass // the dust limit check. let htlc_candidate = HTLCAmountDirection { amount_msat: htlc_amount_msat, outbound: true }; - let local_commit_tx_fee = node_a_chan.context.get_next_local_commitment_stats(&node_a_chan.funding, Some(htlc_candidate), false, 0, node_a_chan.context.feerate_per_kw, None).unwrap().0.commitment_stats.commit_tx_fee_sat * 1000; + let local_commit_tx_fee = node_a_chan.context.get_next_local_commitment_stats(&node_a_chan.funding, Some(htlc_candidate), false, 0, node_a_chan.context.feerate_per_kw, false, None).unwrap().0.commitment_stats.commit_tx_fee_sat * 1000; let local_commit_fee_0_htlcs = commit_tx_fee_sat(node_a_chan.context.feerate_per_kw, 0, node_a_chan.funding.get_channel_type()) * 1000; assert_eq!(local_commit_tx_fee, local_commit_fee_0_htlcs); @@ -17195,7 +17197,7 @@ mod tests { node_a_chan.funding.channel_transaction_parameters.is_outbound_from_holder = false; let remote_commit_fee_3_htlcs = commit_tx_fee_sat(node_a_chan.context.feerate_per_kw, 3, node_a_chan.funding.get_channel_type()) * 1000; let htlc_candidate = HTLCAmountDirection { amount_msat: htlc_amount_msat, outbound: true }; - let remote_commit_tx_fee = node_a_chan.context.get_next_remote_commitment_stats(&node_a_chan.funding, Some(htlc_candidate), false, 0, node_a_chan.context.feerate_per_kw, None).unwrap().0.commitment_stats.commit_tx_fee_sat * 1000; + let remote_commit_tx_fee = node_a_chan.context.get_next_remote_commitment_stats(&node_a_chan.funding, Some(htlc_candidate), false, 0, node_a_chan.context.feerate_per_kw, false, None).unwrap().0.commitment_stats.commit_tx_fee_sat * 1000; assert_eq!(remote_commit_tx_fee, remote_commit_fee_3_htlcs); } @@ -17230,13 +17232,13 @@ mod tests { // counted as dust when it shouldn't be. let htlc_amt_above_timeout = (htlc_timeout_tx_fee_sat + chan.context.holder_dust_limit_satoshis + 1) * 1000; let htlc_candidate = HTLCAmountDirection { amount_msat: htlc_amt_above_timeout, outbound: true }; - let commitment_tx_fee = chan.context.get_next_local_commitment_stats(&chan.funding, Some(htlc_candidate), false, 0, chan.context.feerate_per_kw, None).unwrap().0.commitment_stats.commit_tx_fee_sat * 1000; + let commitment_tx_fee = chan.context.get_next_local_commitment_stats(&chan.funding, Some(htlc_candidate), false, 0, chan.context.feerate_per_kw, false, None).unwrap().0.commitment_stats.commit_tx_fee_sat * 1000; assert_eq!(commitment_tx_fee, commitment_tx_fee_1_htlc); // If swapped: this HTLC would be counted as non-dust when it shouldn't be. let dust_htlc_amt_below_success = (htlc_success_tx_fee_sat + chan.context.holder_dust_limit_satoshis - 1) * 1000; let htlc_candidate = HTLCAmountDirection { amount_msat: dust_htlc_amt_below_success, outbound: false }; - let commitment_tx_fee = chan.context.get_next_local_commitment_stats(&chan.funding, Some(htlc_candidate), false, 0, chan.context.feerate_per_kw, None).unwrap().0.commitment_stats.commit_tx_fee_sat * 1000; + let commitment_tx_fee = chan.context.get_next_local_commitment_stats(&chan.funding, Some(htlc_candidate), false, 0, chan.context.feerate_per_kw, false, None).unwrap().0.commitment_stats.commit_tx_fee_sat * 1000; assert_eq!(commitment_tx_fee, commitment_tx_fee_0_htlcs); chan.funding.channel_transaction_parameters.is_outbound_from_holder = false; @@ -17244,13 +17246,13 @@ mod tests { // If swapped: this HTLC would be counted as non-dust when it shouldn't be. let dust_htlc_amt_above_timeout = (htlc_timeout_tx_fee_sat + chan.context.counterparty_dust_limit_satoshis + 1) * 1000; let htlc_candidate = HTLCAmountDirection { amount_msat: dust_htlc_amt_above_timeout, outbound: true }; - let commitment_tx_fee = chan.context.get_next_remote_commitment_stats(&chan.funding, Some(htlc_candidate), false, 0, chan.context.feerate_per_kw, None).unwrap().0.commitment_stats.commit_tx_fee_sat * 1000; + let commitment_tx_fee = chan.context.get_next_remote_commitment_stats(&chan.funding, Some(htlc_candidate), false, 0, chan.context.feerate_per_kw, false, None).unwrap().0.commitment_stats.commit_tx_fee_sat * 1000; assert_eq!(commitment_tx_fee, commitment_tx_fee_0_htlcs); // If swapped: this HTLC would be counted as dust when it shouldn't be. let htlc_amt_below_success = (htlc_success_tx_fee_sat + chan.context.counterparty_dust_limit_satoshis - 1) * 1000; let htlc_candidate = HTLCAmountDirection { amount_msat: htlc_amt_below_success, outbound: false }; - let commitment_tx_fee = chan.context.get_next_remote_commitment_stats(&chan.funding, Some(htlc_candidate), false, 0, chan.context.feerate_per_kw, None).unwrap().0.commitment_stats.commit_tx_fee_sat * 1000; + let commitment_tx_fee = chan.context.get_next_remote_commitment_stats(&chan.funding, Some(htlc_candidate), false, 0, chan.context.feerate_per_kw, false, None).unwrap().0.commitment_stats.commit_tx_fee_sat * 1000; assert_eq!(commitment_tx_fee, commitment_tx_fee_1_htlc); } diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index 6c70f6ea6c6..e1b9521c2b9 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -11,7 +11,7 @@ use crate::ln::chan_utils::{ }; use crate::ln::channel::{ get_v2_channel_reserve_satoshis, CommitmentStats, ANCHOR_OUTPUT_VALUE_SATOSHI, - MIN_CHANNEL_VALUE_SATOSHIS, + FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE, MIN_CHANNEL_VALUE_SATOSHIS, }; use crate::prelude::*; use crate::types::features::ChannelTypeFeatures; @@ -219,7 +219,7 @@ fn has_output( fn get_next_commitment_stats( local: bool, is_outbound_from_holder: bool, channel_value_satoshis: u64, value_to_holder_msat: u64, next_commitment_htlcs: &[HTLCAmountDirection], - addl_nondust_htlc_count: usize, feerate_per_kw: u32, + addl_nondust_htlc_count: usize, feerate_per_kw: u32, assume_fee_spike: bool, dust_exposure_limiting_feerate: Option, broadcaster_dust_limit_satoshis: u64, channel_type: &ChannelTypeFeatures, ) -> Result { @@ -270,11 +270,16 @@ fn get_next_commitment_stats( channel_type, ); - // Calculate fees on commitment transaction - let nondust_htlc_count = next_commitment_htlcs + let spiked_feerate = if assume_fee_spike && !channel_type.supports_anchors_zero_fee_htlc_tx() { + feerate_per_kw.saturating_mul(FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32) + } else { + feerate_per_kw + }; + + let spiked_nondust_htlc_count = next_commitment_htlcs .iter() .filter(|htlc| { - !htlc.is_dust(local, feerate_per_kw, broadcaster_dust_limit_satoshis, channel_type) + !htlc.is_dust(local, spiked_feerate, broadcaster_dust_limit_satoshis, channel_type) }) .count(); @@ -284,8 +289,8 @@ fn get_next_commitment_stats( is_outbound_from_holder, holder_balance_before_fee_msat, counterparty_balance_before_fee_msat, - feerate_per_kw, - nondust_htlc_count, + spiked_feerate, + spiked_nondust_htlc_count, broadcaster_dust_limit_satoshis, channel_type, ) { @@ -296,8 +301,8 @@ fn get_next_commitment_stats( // this bigger transaction fee ? The funder can dip below their dust limit to cover this case, as the // commitment will have at least one output: the non-dust fee spike buffer HTLC offered by the counterparty. let commit_tx_fee_sat = commit_tx_fee_sat( - feerate_per_kw, - nondust_htlc_count + addl_nondust_htlc_count, + spiked_feerate, + spiked_nondust_htlc_count + addl_nondust_htlc_count, channel_type, ); let (holder_balance_msat, counterparty_balance_msat) = checked_sub_from_funder( @@ -312,7 +317,7 @@ fn get_next_commitment_stats( counterparty_balance_msat, dust_exposure_msat, #[cfg(any(test, fuzzing))] - nondust_htlc_count: nondust_htlc_count + addl_nondust_htlc_count, + nondust_htlc_count: spiked_nondust_htlc_count + addl_nondust_htlc_count, #[cfg(any(test, fuzzing))] commit_tx_fee_sat, }) @@ -802,7 +807,7 @@ pub(crate) trait TxBuilder { fn get_channel_stats( &self, local: bool, is_outbound_from_holder: bool, channel_value_satoshis: u64, value_to_holder_msat: u64, pending_htlcs: &[HTLCAmountDirection], - addl_nondust_htlc_count: usize, feerate_per_kw: u32, + addl_nondust_htlc_count: usize, feerate_per_kw: u32, assume_fee_spike: bool, dust_exposure_limiting_feerate: Option, max_dust_htlc_exposure_msat: u64, channel_constraints: ChannelConstraints, channel_type: &ChannelTypeFeatures, ) -> Result; @@ -820,7 +825,7 @@ impl TxBuilder for SpecTxBuilder { fn get_channel_stats( &self, local: bool, is_outbound_from_holder: bool, channel_value_satoshis: u64, value_to_holder_msat: u64, pending_htlcs: &[HTLCAmountDirection], - addl_nondust_htlc_count: usize, feerate_per_kw: u32, + addl_nondust_htlc_count: usize, feerate_per_kw: u32, assume_fee_spike: bool, dust_exposure_limiting_feerate: Option, max_dust_htlc_exposure_msat: u64, channel_constraints: ChannelConstraints, channel_type: &ChannelTypeFeatures, ) -> Result { @@ -833,6 +838,7 @@ impl TxBuilder for SpecTxBuilder { pending_htlcs, addl_nondust_htlc_count, feerate_per_kw, + assume_fee_spike, dust_exposure_limiting_feerate, channel_constraints.holder_dust_limit_satoshis, channel_type, @@ -846,6 +852,7 @@ impl TxBuilder for SpecTxBuilder { pending_htlcs, addl_nondust_htlc_count, feerate_per_kw, + assume_fee_spike, dust_exposure_limiting_feerate, channel_constraints.counterparty_dust_limit_satoshis, channel_type, From deb51aec112dd0d2a28ac67ecd0c0cfc397cf9fb Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Tue, 21 Apr 2026 16:46:11 +0000 Subject: [PATCH 397/627] Don't trim HTLCs when calculating the fee spike commit tx fee We previously accounted for HTLC trims at the spiked feerate when calculating the commitment transaction fee including the fee spike multiple. This only ensured that the funder of the channel could afford the commitment transaction fee for an exact 2x increase in the feerate. Now, we check that the funder can cover any increase in the feerate between 1x to 2x. --- lightning/src/ln/htlc_reserve_unit_tests.rs | 268 +++++++++++++++++--- lightning/src/sign/tx_builder.rs | 13 +- 2 files changed, 244 insertions(+), 37 deletions(-) diff --git a/lightning/src/ln/htlc_reserve_unit_tests.rs b/lightning/src/ln/htlc_reserve_unit_tests.rs index cbb67e9f05c..54b27caa0cc 100644 --- a/lightning/src/ln/htlc_reserve_unit_tests.rs +++ b/lightning/src/ln/htlc_reserve_unit_tests.rs @@ -3,8 +3,8 @@ use crate::events::{ClosureReason, Event, HTLCHandlingFailureType, PaymentPurpose}; use crate::ln::chan_utils::{ self, commit_tx_fee_sat, commitment_tx_base_weight, second_stage_tx_fees_sat, - shared_anchor_script_pubkey, CommitmentTransaction, COMMITMENT_TX_WEIGHT_PER_HTLC, - TRUC_CHILD_MAX_WEIGHT, + shared_anchor_script_pubkey, CommitmentTransaction, HTLCOutputInCommitment, + COMMITMENT_TX_WEIGHT_PER_HTLC, TRUC_CHILD_MAX_WEIGHT, }; use crate::ln::channel::{ get_holder_selected_channel_reserve_satoshis, Channel, ANCHOR_OUTPUT_VALUE_SATOSHI, @@ -888,7 +888,7 @@ pub fn do_test_fee_spike_buffer(cfg: Option, htlc_fails: bool) { // Build the remote commitment transaction so we can sign it, and then later use the // signature for the commitment_signed message. - let accepted_htlc_info = chan_utils::HTLCOutputInCommitment { + let accepted_htlc_info = HTLCOutputInCommitment { offered: false, amount_msat: payment_amt_msat, cltv_expiry: htlc_cltv, @@ -2143,7 +2143,7 @@ pub fn do_test_dust_limit_fee_accounting(can_afford: bool) { let (_payment_preimage, payment_hash, ..) = route_payment(&nodes[0], &[&nodes[1]], HTLC_AMT_SAT * 1000); // Grab a snapshot of these HTLCs to manually build the commitment transaction later... - let accepted_htlc = chan_utils::HTLCOutputInCommitment { + let accepted_htlc = HTLCOutputInCommitment { offered: false, amount_msat: HTLC_AMT_SAT * 1000, // Hard-coded to match the expected value @@ -2257,7 +2257,7 @@ pub fn do_test_dust_limit_fee_accounting(can_afford: bool) { &channel_type, ); - let accepted_htlc_info = chan_utils::HTLCOutputInCommitment { + let accepted_htlc_info = HTLCOutputInCommitment { offered: false, amount_msat: HTLC_AMT_SAT * 1000, cltv_expiry, @@ -2838,21 +2838,30 @@ fn do_test_0reserve_no_outputs_legacy(no_outputs_case: LegacyChannelsNoOutputs) return; } + let htlcs_in_commitment = vec![HTLCOutputInCommitment { + offered: false, + amount_msat: receiver_amount_msat, + cltv_expiry: htlc_cltv, + payment_hash, + transaction_output_index: Some(1), + }]; + manually_trigger_update_fail_htlc( &nodes, channel_id, - channel_value_sat, + channel_value_sat * 1000, dust_limit_satoshis, - receiver_amount_msat, - htlc_cltv, payment_hash, + htlcs_in_commitment, + false, ); } } fn manually_trigger_update_fail_htlc<'a, 'b, 'c, 'd>( - nodes: &'a Vec>, channel_id: ChannelId, channel_value_sat: u64, - dust_limit_satoshis: u64, receiver_amount_msat: u64, htlc_cltv: u32, payment_hash: PaymentHash, + nodes: &'a Vec>, channel_id: ChannelId, value_to_self_msat: u64, + dust_limit_satoshis: u64, payment_hash: PaymentHash, + htlcs_in_commitment: Vec, can_afford_but_reserve_is_breached: bool, ) { let node_a_id = nodes[0].node.get_our_node_id(); let node_b_id = nodes[1].node.get_our_node_id(); @@ -2864,8 +2873,6 @@ fn manually_trigger_update_fail_htlc<'a, 'b, 'c, 'd>( let feerate_per_kw = get_feerate!(nodes[0], nodes[1], channel_id); - const INITIAL_COMMITMENT_NUMBER: u64 = (1 << 48) - 1; - let (local_secret, next_local_point) = { let per_peer_state = nodes[0].node.per_peer_state.read().unwrap(); let chan_lock = per_peer_state.get(&node_b_id).unwrap().lock().unwrap(); @@ -2873,36 +2880,29 @@ fn manually_trigger_update_fail_htlc<'a, 'b, 'c, 'd>( chan_lock.channel_by_id.get(&channel_id).and_then(Channel::as_funded).unwrap(); let chan_signer = local_chan.get_signer(); // Make the signer believe we validated another commitment, so we can release the secret + let commit_number = chan_signer.get_enforcement_state().last_holder_commitment; chan_signer.get_enforcement_state().last_holder_commitment -= 1; ( - chan_signer.release_commitment_secret(INITIAL_COMMITMENT_NUMBER).unwrap(), - chan_signer.get_per_commitment_point(INITIAL_COMMITMENT_NUMBER - 2, &secp_ctx).unwrap(), + chan_signer.release_commitment_secret(commit_number).unwrap(), + chan_signer.get_per_commitment_point(commit_number - 2, &secp_ctx).unwrap(), ) }; - let remote_point = { + let (remote_commit_number, remote_point) = { let per_peer_lock; let mut peer_state_lock; let channel = get_channel_ref!(nodes[1], nodes[0], per_peer_lock, peer_state_lock, channel_id); let chan_signer = channel.as_funded().unwrap().get_signer(); - chan_signer.get_per_commitment_point(INITIAL_COMMITMENT_NUMBER - 1, &secp_ctx).unwrap() + let commit_number = chan_signer.get_enforcement_state().last_holder_commitment; + let remote_point = + chan_signer.get_per_commitment_point(commit_number - 1, &secp_ctx).unwrap(); + (commit_number - 1, remote_point) }; // Build the remote commitment transaction so we can sign it, and then later use the // signature for the commitment_signed message. - let accepted_htlc_info = chan_utils::HTLCOutputInCommitment { - offered: false, - amount_msat: receiver_amount_msat, - cltv_expiry: htlc_cltv, - payment_hash, - transaction_output_index: Some(1), - }; - - let local_chan_balance_msat = channel_value_sat * 1000; - let commitment_number = INITIAL_COMMITMENT_NUMBER - 1; - let res = { let per_peer_lock; let mut peer_state_lock; @@ -2913,12 +2913,12 @@ fn manually_trigger_update_fail_htlc<'a, 'b, 'c, 'd>( let (commitment_tx, _stats) = SpecTxBuilder {}.build_commitment_transaction( false, - commitment_number, + remote_commit_number, &remote_point, &channel.funding().channel_transaction_parameters, &secp_ctx, - local_chan_balance_msat, - vec![accepted_htlc_info], + value_to_self_msat, + htlcs_in_commitment, feerate_per_kw, dust_limit_satoshis, &nodes[0].logger, @@ -2969,11 +2969,13 @@ fn manually_trigger_update_fail_htlc<'a, 'b, 'c, 'd>( }, _ => panic!("Unexpected event"), }; - nodes[1].logger.assert_log( - "lightning::ln::channel", - "Attempting to fail HTLC due to balance exhausted on remote commitment".to_string(), - 1, - ); + let log_string = + if can_afford_but_reserve_is_breached { + String::from("Attempting to fail HTLC due to fee spike buffer violation. Rebalancing is required.") + } else { + String::from("Attempting to fail HTLC due to balance exhausted on remote commitment") + }; + nodes[1].logger.assert_log("lightning::ln::channel", log_string, 1); check_added_monitors(&nodes[1], 3); } @@ -3549,3 +3551,199 @@ fn test_outbound_vs_available_capacity_outbound_htlc_limit_spiked_feerate() { ); } } + +/// Make sure that we do not account for HTLCs going from non-dust to dust at the spiked feerate +/// when checking the fee spike buffer in `can_accept_incoming_htlc`. This is required to make sure +/// that we can afford *any* increase in the feerate between 1x to 2x, instead of checking whether +/// we can afford only the 2x increase in the feerate. +#[xtest(feature = "_externalize_tests")] +fn test_fail_cannot_afford_dust_htlcs_at_spike_multiple_if_nondust_at_base_feerate() { + let mut config = test_default_channel_config(); + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; + + let channel_type = ChannelTypeFeatures::only_static_remote_key(); + + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_a_id = nodes[0].node.get_our_node_id(); + let _node_b_id = nodes[1].node.get_our_node_id(); + + const FEERATE: u32 = 253; + const MULTIPLE: u32 = FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32; + const SPIKED_FEERATE: u32 = FEERATE * MULTIPLE; + const DUST_LIMIT_MSAT: u64 = 354 * 1000; + const CHANNEL_VALUE_MSAT: u64 = 10_000 * 1000; + const NODE_0_VALUE_TO_SELF_MSAT: u64 = 5_000 * 1000; + const NODE_1_VALUE_TO_SELF_MSAT: u64 = 5_000 * 1000; + const CHANNEL_RESERVE_MSAT: u64 = 1_000 * 1_000; + + let channel_id = create_announced_chan_between_nodes_with_value( + &nodes, + 0, + 1, + CHANNEL_VALUE_MSAT / 1000, + NODE_1_VALUE_TO_SELF_MSAT, + ) + .2; + assert_eq!(nodes[0].node.list_channels()[0].channel_type.as_ref().unwrap(), &channel_type); + + // Find the HTLC amount that will be non-dust at the current feerate, + // but dust at the spiked feerate. + const SPIKED_DUST_HTLC_MSAT: u64 = 688 * 1000; + const HTLC_SPIKE_DUST_LIMIT_MSAT: u64 = 689 * 1000; + // When checking the fee spike buffer in `can_accept_incoming_htlc`, we check the remote + // commitment, hence inbound HTLCs will be offered HTLCs, and use the timeout dust limit. + let htlc_timeout_spike_tx_fee_msat = + second_stage_tx_fees_sat(&channel_type, SPIKED_FEERATE).1 * 1000; + assert_eq!(HTLC_SPIKE_DUST_LIMIT_MSAT, DUST_LIMIT_MSAT + htlc_timeout_spike_tx_fee_msat); + + // Calculate here the dust limit at the current feerate so we know when node 0 cannot send + // any further non-dust HTLCs at the current feerate. + let htlc_timeout_tx_fee_msat = second_stage_tx_fees_sat(&channel_type, FEERATE).1 * 1000; + let htlc_dust_limit_msat = DUST_LIMIT_MSAT + htlc_timeout_tx_fee_msat; + // Make sure the HTLC will be non-dust at the current feerate + assert!(SPIKED_DUST_HTLC_MSAT > htlc_dust_limit_msat); + + // Place a few non-dust HTLCs on the commitment, these HTLCs would get trimmed upon a 2x + // increase in the feerate. + let mut sent_htlcs_count: usize = 0; + let mut payment_hashes = Vec::new(); + while nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat >= htlc_dust_limit_msat { + let (_preimage, hash, _secret, _id) = + route_payment(&nodes[0], &[&nodes[1]], SPIKED_DUST_HTLC_MSAT); + payment_hashes.push(hash); + sent_htlcs_count += 1; + } + assert_eq!(sent_htlcs_count, 4); + + // Check the outbound and available capacities + let node_0_outbound_capacity_msat = NODE_0_VALUE_TO_SELF_MSAT + - sent_htlcs_count as u64 * SPIKED_DUST_HTLC_MSAT + - CHANNEL_RESERVE_MSAT; + let node_0_details = &nodes[0].node.list_channels()[0]; + assert_eq!(node_0_details.outbound_capacity_msat, node_0_outbound_capacity_msat); + // Node 0 can now only send dust HTLCs, so we reserve the fees for a single additional + // inbound non-dust HTLC. + let min_reserved_fee_msat = + commit_tx_fee_sat(SPIKED_FEERATE, sent_htlcs_count + 1, &channel_type) * 1000; + let node_0_available_capacity_msat = node_0_outbound_capacity_msat - min_reserved_fee_msat; + assert_eq!(node_0_details.next_outbound_htlc_limit_msat, node_0_available_capacity_msat); + + // Then send an identical, 5th non-dust HTLC, bypass the validation from the holder, and + // check that the counterparty fails it due to a fee spike buffer violation. + + // First check the maths + + // Node 0 can afford an exact 2x increase in the feerate + let spiked_commit_tx_fee_msat = commit_tx_fee_sat(SPIKED_FEERATE, 0, &channel_type) * 1000; + assert!((node_0_outbound_capacity_msat - SPIKED_DUST_HTLC_MSAT) + .checked_sub(spiked_commit_tx_fee_msat) + .is_some()); + // Node 0 can afford a 5th non-dust HTLC at the current feerate, so `update_add_htlc` + // validation will pass. + let real_commit_tx_fee_msat = commit_tx_fee_sat(FEERATE, 5, &channel_type) * 1000; + assert!((node_0_outbound_capacity_msat - SPIKED_DUST_HTLC_MSAT) + .checked_sub(real_commit_tx_fee_msat) + .is_some()); + // But we don't account for the HTLC trimming effect of the spike multiple feerate increase, + // so the 5th HTLC should be rejected at `can_accept_incoming_htlc`! + let expected_commit_tx_fee_msat = commit_tx_fee_sat(SPIKED_FEERATE, 5, &channel_type) * 1000; + assert!((node_0_outbound_capacity_msat - SPIKED_DUST_HTLC_MSAT) + .checked_sub(expected_commit_tx_fee_msat) + .is_none()); + + // Then run the experiment + + let sender_amount_msat = node_0_available_capacity_msat; + let receiver_amount_msat = SPIKED_DUST_HTLC_MSAT; + let (route, payment_hash, _, payment_secret) = + get_route_and_payment_hash!(nodes[0], nodes[1], sender_amount_msat); + let secp_ctx = Secp256k1::new(); + let session_priv = SecretKey::from_slice(&[42; 32]).unwrap(); + let cur_height = nodes[0].node.best_block.read().unwrap().height + 1; + let onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route.paths[0], &session_priv); + let recipient_onion_fields = + RecipientOnionFields::secret_only(payment_secret, sender_amount_msat); + let (onion_payloads, htlc_msat, htlc_cltv) = onion_utils::test_build_onion_payloads( + &route.paths[0], + &recipient_onion_fields, + cur_height, + &None, + None, + None, + ) + .unwrap(); + assert_eq!(htlc_msat, sender_amount_msat); + let onion_packet = + onion_utils::construct_onion_packet(onion_payloads, onion_keys, [0; 32], &payment_hash) + .unwrap(); + let msg = msgs::UpdateAddHTLC { + channel_id, + htlc_id: sent_htlcs_count as u64, + amount_msat: receiver_amount_msat, + payment_hash, + cltv_expiry: htlc_cltv, + onion_routing_packet: onion_packet, + skimmed_fee_msat: None, + blinding_point: None, + hold_htlc: None, + accountable: None, + }; + + nodes[1].node.handle_update_add_htlc(node_a_id, &msg); + + let htlcs_in_tx = vec![ + HTLCOutputInCommitment { + offered: false, + cltv_expiry: 81, + payment_hash: payment_hashes.iter().find(|hash| hash.0[0] == 0x75).unwrap().clone(), + amount_msat: 688_000, + transaction_output_index: Some(0), + }, + HTLCOutputInCommitment { + offered: false, + cltv_expiry: 81, + payment_hash: payment_hashes.iter().find(|hash| hash.0[0] == 0x64).unwrap().clone(), + amount_msat: 688_000, + transaction_output_index: Some(1), + }, + HTLCOutputInCommitment { + offered: false, + cltv_expiry: 81, + payment_hash, + amount_msat: 688_000, + transaction_output_index: Some(2), + }, + HTLCOutputInCommitment { + offered: false, + cltv_expiry: 81, + payment_hash: payment_hashes.iter().find(|hash| hash.0[0] == 0x72).unwrap().clone(), + amount_msat: 688_000, + transaction_output_index: Some(3), + }, + HTLCOutputInCommitment { + offered: false, + cltv_expiry: 81, + payment_hash: payment_hashes.iter().find(|hash| hash.0[0] == 0x66).unwrap().clone(), + amount_msat: 688_000, + transaction_output_index: Some(4), + }, + ]; + + manually_trigger_update_fail_htlc( + &nodes, + channel_id, + NODE_0_VALUE_TO_SELF_MSAT, + DUST_LIMIT_MSAT / 1000, + payment_hash, + htlcs_in_tx, + true, + ); +} diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index e1b9521c2b9..4bf6962f248 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -300,9 +300,18 @@ fn get_next_commitment_stats( // 2) Now including any additional non-dust HTLCs (usually the fee spike buffer HTLC), does the funder cover // this bigger transaction fee ? The funder can dip below their dust limit to cover this case, as the // commitment will have at least one output: the non-dust fee spike buffer HTLC offered by the counterparty. + let nondust_htlc_count = next_commitment_htlcs + .iter() + .filter(|htlc| { + !htlc.is_dust(local, feerate_per_kw, broadcaster_dust_limit_satoshis, channel_type) + }) + .count(); + // Note here we use the htlc count at the current feerate together with the spiked feerate; + // this makes sure that the holder can afford any fee bump between 1x to 2x from the current + // feerate if the fee spike multiple is included. let commit_tx_fee_sat = commit_tx_fee_sat( spiked_feerate, - spiked_nondust_htlc_count + addl_nondust_htlc_count, + nondust_htlc_count + addl_nondust_htlc_count, channel_type, ); let (holder_balance_msat, counterparty_balance_msat) = checked_sub_from_funder( @@ -317,7 +326,7 @@ fn get_next_commitment_stats( counterparty_balance_msat, dust_exposure_msat, #[cfg(any(test, fuzzing))] - nondust_htlc_count: spiked_nondust_htlc_count + addl_nondust_htlc_count, + nondust_htlc_count: nondust_htlc_count + addl_nondust_htlc_count, #[cfg(any(test, fuzzing))] commit_tx_fee_sat, }) From e46794ccc62e0c26adbc7d615148c390fa484250 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Tue, 12 May 2026 23:47:49 +0000 Subject: [PATCH 398/627] Run `cargo fmt` on `maybe_downgrade_channel_features` --- lightning/src/ln/channel.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 58dd6ea30c0..863256f3493 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -6537,17 +6537,15 @@ impl ChannelContext { /// If we receive an error message when attempting to open a channel, it may only be a rejection /// of the channel type we tried, not of our ability to open any channel at all. We can see if a /// downgrade of channel features would be possible so that we can still open the channel. - #[rustfmt::skip] pub(crate) fn maybe_downgrade_channel_features( &mut self, funding: &mut FundingScope, fee_estimator: &LowerBoundedFeeEstimator, user_config: &UserConfig, their_features: &InitFeatures, ) -> Result<(), ()> { - if !funding.is_outbound() || - !matches!( + if !funding.is_outbound() + || !matches!( self.channel_state, ChannelState::NegotiatingFunding(flags) if flags == NegotiatingFundingFlags::OUR_INIT_SENT - ) - { + ) { return Err(()); } if funding.get_channel_type() == &ChannelTypeFeatures::only_static_remote_key() { @@ -6579,10 +6577,9 @@ impl ChannelContext { let next_channel_type = get_initial_channel_type(user_config, &eligible_features); - self.feerate_per_kw = selected_commitment_sat_per_1000_weight( - &fee_estimator, &next_channel_type, - ); - funding.channel_transaction_parameters.channel_type_features = next_channel_type; + self.feerate_per_kw = + selected_commitment_sat_per_1000_weight(&fee_estimator, &next_channel_type); + funding.channel_transaction_parameters.channel_type_features = next_channel_type; Ok(()) } From 7c3259062688f9143835e74a92eb323a0c36567e Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 30 Apr 2026 19:28:54 +0000 Subject: [PATCH 399/627] Add a `payment_metadata` map in blinded payment path contexts Similar to how BOLT 11 payments can use a `payment_metadata` to provide arbitrary bytes in the invoice to be communicated back to them when receiving, its useful to be able to provide some bytes which are communicated back upon receiving a payment. Here we do so in the BOLT 12 blinded path contexts, offering a `BTreeMap>` instead to enable more easily including multiple sets of data. Also note that a `Router` building a blinded path is allowed to modify the `payment_metadata` without breaking the payment. Tests by claude --- fuzz/src/invoice_request_deser.rs | 1 + fuzz/src/refund_deser.rs | 3 +- lightning/src/blinded_path/payment.rs | 111 ++++++++++++++++-- lightning/src/ln/async_payments_tests.rs | 91 +++++++++++++- lightning/src/ln/blinded_payment_tests.rs | 22 ++-- lightning/src/ln/channelmanager.rs | 6 +- .../src/ln/max_payment_path_len_tests.rs | 4 +- lightning/src/ln/offers_tests.rs | 86 +++++++++++++- lightning/src/offers/flow.rs | 19 ++- lightning/src/routing/router.rs | 6 + lightning/src/util/ser.rs | 31 +++++ lightning/src/util/test_utils.rs | 20 +++- 12 files changed, 368 insertions(+), 32 deletions(-) diff --git a/fuzz/src/invoice_request_deser.rs b/fuzz/src/invoice_request_deser.rs index a21303debd7..c4b31942843 100644 --- a/fuzz/src/invoice_request_deser.rs +++ b/fuzz/src/invoice_request_deser.rs @@ -104,6 +104,7 @@ fn build_response( let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { offer_id: OfferId([42; 32]), invoice_request: invoice_request_fields, + payment_metadata: None, }); let payee_tlvs = ReceiveTlvs { payment_secret: PaymentSecret([42; 32]), diff --git a/fuzz/src/refund_deser.rs b/fuzz/src/refund_deser.rs index 446ac704455..c705bda1a2f 100644 --- a/fuzz/src/refund_deser.rs +++ b/fuzz/src/refund_deser.rs @@ -69,7 +69,8 @@ fn build_response( ) -> Result { let entropy_source = Randomness {}; let receive_auth_key = ReceiveAuthKey([41; 32]); - let payment_context = PaymentContext::Bolt12Refund(Bolt12RefundContext {}); + let payment_context = + PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata: None }); let payee_tlvs = ReceiveTlvs { payment_secret: PaymentSecret([42; 32]), payment_constraints: PaymentConstraints { diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs index f06c91bf6e0..a01ee230c31 100644 --- a/lightning/src/blinded_path/payment.rs +++ b/lightning/src/blinded_path/payment.rs @@ -9,6 +9,8 @@ //! Data structures and methods for constructing [`BlindedPaymentPath`]s to send a payment over. +use alloc::collections::BTreeMap; + use bitcoin::secp256k1::ecdh::SharedSecret; use bitcoin::secp256k1::{self, PublicKey, Secp256k1, SecretKey}; @@ -29,8 +31,8 @@ use crate::types::features::BlindedHopFeatures; use crate::types::payment::PaymentSecret; use crate::types::routing::RoutingFees; use crate::util::ser::{ - FixedLengthReader, HighZeroBytesDroppedBigSize, LengthReadableArgs, Readable, WithoutLength, - Writeable, Writer, + BigSizeKeyedMap, FixedLengthReader, HighZeroBytesDroppedBigSize, LengthReadableArgs, Readable, + WithoutLength, Writeable, Writer, }; #[allow(unused_imports)] @@ -572,6 +574,20 @@ pub enum PaymentContext { /// [`Refund`]: crate::offers::refund::Refund Bolt12Refund(Bolt12RefundContext), } +impl PaymentContext { + /// Returns the additional payment metadata stored alongside this payment context, if any. + /// + /// Payment metadata is stored as a map from a numeric key to an arbitrary byte array value. + /// This allows for several types of metadata to be stored attached to a single payment. In the + /// future some optional features of LDK may use some keys. + pub fn payment_metadata(&self) -> Option<&BTreeMap>> { + match self { + Self::Bolt12Offer(Bolt12OfferContext { payment_metadata, .. }) + | Self::AsyncBolt12Offer(AsyncBolt12OfferContext { payment_metadata, .. }) + | Self::Bolt12Refund(Bolt12RefundContext { payment_metadata, .. }) => payment_metadata.as_ref(), + } + } +} // Used when writing PaymentContext in Event::PaymentClaimable to avoid cloning. pub(crate) enum PaymentContextRef<'a> { @@ -594,6 +610,27 @@ pub struct Bolt12OfferContext { /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice pub invoice_request: InvoiceRequestFields, + + /// Additional data about this payment which is not used in LDK and can be used for any + /// purpose. + /// + /// This is analogous to the BOLT 11 [`RecipientOnionFields::payment_metadata`] (which is + /// provided to payers via [`Bolt11Invoice::payment_metadata`]) and can be used any time data + /// needs to be "stored" by a payment recipient for their own internal use, provided back to + /// them with the payment. + /// + /// Payment metadata is stored as a map from a numeric key to an arbitrary byte array value. + /// This allows for several types of metadata to be stored attached to a single payment. In the + /// future some optional features of LDK may use some keys. For the sake of conflict + /// reduction, those features will attempt to use keys in the range 128-256. + /// + /// Note that because this is included in the payment onion, its size must be tightly + /// constrained. More than a few hundred bytes and the payment will be entirely unpayable (with + /// limited routing options as size increases). + /// + /// [`RecipientOnionFields::payment_metadata`]: crate::ln::outbound_payment::RecipientOnionFields::payment_metadata + /// [`Bolt11Invoice::payment_metadata`]: lightning_invoice::Bolt11Invoice::payment_metadata + pub payment_metadata: Option>>, } /// The context of a payment made for a static invoice requested from a BOLT 12 [`Offer`]. @@ -606,13 +643,55 @@ pub struct AsyncBolt12OfferContext { /// /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest pub offer_nonce: Nonce, + + /// Additional data about this payment which is not used in LDK and can be used for any + /// purpose. + /// + /// This is analogous to the BOLT 11 [`RecipientOnionFields::payment_metadata`] (which is + /// provided to payers via [`Bolt11Invoice::payment_metadata`]) and can be used any time data + /// needs to be "stored" by a payment recipient for their own internal use, provided back to + /// them with the payment. + /// + /// Payment metadata is stored as a map from a numeric key to an arbitrary byte array value. + /// This allows for several types of metadata to be stored attached to a single payment. In the + /// future some optional features of LDK may use some keys. For the sake of conflict + /// reduction, those features will attempt to use keys in the range 128-256. + /// + /// Note that because this is included in the payment onion, its size must be tightly + /// constrained. More than a few hundred bytes and the payment will be entirely unpayable (with + /// limited routing options as size increases). + /// + /// [`RecipientOnionFields::payment_metadata`]: crate::ln::outbound_payment::RecipientOnionFields::payment_metadata + /// [`Bolt11Invoice::payment_metadata`]: lightning_invoice::Bolt11Invoice::payment_metadata + pub payment_metadata: Option>>, } /// The context of a payment made for an invoice sent for a BOLT 12 [`Refund`]. /// /// [`Refund`]: crate::offers::refund::Refund #[derive(Clone, Debug, Eq, PartialEq)] -pub struct Bolt12RefundContext {} +pub struct Bolt12RefundContext { + /// Additional data about this payment which is not used in LDK and can be used for any + /// purpose. + /// + /// This is analogous to the BOLT 11 [`RecipientOnionFields::payment_metadata`] (which is + /// provided to payers via [`Bolt11Invoice::payment_metadata`]) and can be used any time data + /// needs to be "stored" by a payment recipient for their own internal use, provided back to + /// them with the payment. + /// + /// Payment metadata is stored as a map from a numeric key to an arbitrary byte array value. + /// This allows for several types of metadata to be stored attached to a single payment. In the + /// future some optional features of LDK may use some keys. For the sake of conflict + /// reduction, those features will attempt to use keys in the range 128-256. + /// + /// Note that because this is included in the payment onion, its size must be tightly + /// constrained. More than a few hundred bytes and the payment will be entirely unpayable (with + /// limited routing options as size increases). + /// + /// [`RecipientOnionFields::payment_metadata`]: crate::ln::outbound_payment::RecipientOnionFields::payment_metadata + /// [`Bolt11Invoice::payment_metadata`]: lightning_invoice::Bolt11Invoice::payment_metadata + pub payment_metadata: Option>>, +} impl TryFrom for PaymentRelay { type Error = (); @@ -1031,14 +1110,18 @@ impl<'a> Writeable for PaymentContextRef<'a> { impl_writeable_tlv_based!(Bolt12OfferContext, { (0, offer_id, required), + (1, payment_metadata, (option, encoding: (BTreeMap>, BigSizeKeyedMap))), (2, invoice_request, required), }); impl_writeable_tlv_based!(AsyncBolt12OfferContext, { (0, offer_nonce, required), + (1, payment_metadata, (option, encoding: (BTreeMap>, BigSizeKeyedMap))), }); -impl_writeable_tlv_based!(Bolt12RefundContext, {}); +impl_writeable_tlv_based!(Bolt12RefundContext, { + (1, payment_metadata, (option, encoding: (BTreeMap>, BigSizeKeyedMap))), +}); #[cfg(test)] mod tests { @@ -1097,7 +1180,9 @@ mod tests { let recv_tlvs = ReceiveTlvs { payment_secret: PaymentSecret([0; 32]), payment_constraints: PaymentConstraints { max_cltv_expiry: 0, htlc_minimum_msat: 1 }, - payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { + payment_metadata: None, + }), }; let htlc_maximum_msat = 100_000; let blinded_payinfo = @@ -1115,7 +1200,9 @@ mod tests { let recv_tlvs = ReceiveTlvs { payment_secret: PaymentSecret([0; 32]), payment_constraints: PaymentConstraints { max_cltv_expiry: 0, htlc_minimum_msat: 1 }, - payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { + payment_metadata: None, + }), }; let blinded_payinfo = super::compute_payinfo::( &[], @@ -1178,7 +1265,9 @@ mod tests { let recv_tlvs = ReceiveTlvs { payment_secret: PaymentSecret([0; 32]), payment_constraints: PaymentConstraints { max_cltv_expiry: 0, htlc_minimum_msat: 3 }, - payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { + payment_metadata: None, + }), }; let htlc_maximum_msat = 100_000; let blinded_payinfo = super::compute_payinfo( @@ -1238,7 +1327,9 @@ mod tests { let recv_tlvs = ReceiveTlvs { payment_secret: PaymentSecret([0; 32]), payment_constraints: PaymentConstraints { max_cltv_expiry: 0, htlc_minimum_msat: 1 }, - payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { + payment_metadata: None, + }), }; let htlc_minimum_msat = 3798; assert!(super::compute_payinfo( @@ -1309,7 +1400,9 @@ mod tests { let recv_tlvs = ReceiveTlvs { payment_secret: PaymentSecret([0; 32]), payment_constraints: PaymentConstraints { max_cltv_expiry: 0, htlc_minimum_msat: 1 }, - payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { + payment_metadata: None, + }), }; let blinded_payinfo = super::compute_payinfo( diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index bd07d13c13d..817e130e976 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -7,6 +7,8 @@ // You may not use this file except in accordance with one or both of these // licenses. +use alloc::collections::BTreeMap; + use crate::blinded_path::message::{ BlindedMessagePath, MessageContext, NextMessageHop, OffersContext, }; @@ -299,6 +301,7 @@ fn create_static_invoice_builder<'a>( relative_expiry_secs, recipient.node.list_usable_channels(), recipient.node.test_get_peers_for_blinded_path(), + None, ) .unwrap() } @@ -1150,6 +1153,88 @@ fn async_receive_flow_success() { assert_eq!(res, Some(PaidBolt12Invoice::StaticInvoice(static_invoice))); } +#[test] +fn async_payment_delivers_payment_metadata() { + // Test that `payment_metadata` set in the `AsyncBolt12OfferContext` of a static invoice's + // blinded payment paths is surfaced via `Event::PaymentClaimable` when the async recipient + // receives the keysend payment. + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + + let mut allow_priv_chan_fwds_cfg = test_default_channel_config(); + allow_priv_chan_fwds_cfg.accept_forwards_to_priv_channels = true; + let node_chanmgrs = + create_node_chanmgrs(3, &node_cfgs, &[None, Some(allow_priv_chan_fwds_cfg), None]); + + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); + create_unannounced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0); + + let recipient_id = vec![42; 32]; + let inv_server_paths = + nodes[1].node.blinded_paths_for_async_recipient(recipient_id.clone(), None).unwrap(); + nodes[2].node.set_paths_to_static_invoice_server(inv_server_paths).unwrap(); + expect_offer_paths_requests(&nodes[2], &[&nodes[0], &nodes[1]]); + + // Configure the recipient's router to inject `payment_metadata` into the + // `AsyncBolt12OfferContext` of the static invoice's blinded payment paths. The + // `pass_static_invoice_server_messages` flow below builds the static invoice via this router, + // at which point the override is consumed. + let mut expected_metadata = BTreeMap::new(); + expected_metadata.insert(0u64, vec![1, 2, 3, 4]); + expected_metadata.insert(7u64, vec![0xab, 0xcd]); + nodes[2].router.set_next_payment_context_metadata(expected_metadata.clone()); + + let invoice_flow_res = + pass_static_invoice_server_messages(&nodes[1], &nodes[2], recipient_id.clone()); + let static_invoice = invoice_flow_res.invoice; + let offer = nodes[2].node.get_async_receive_offer().unwrap(); + let amt_msat = 5000; + let payment_id = PaymentId([1; 32]); + nodes[0].node.pay_for_offer(&offer, Some(amt_msat), payment_id, Default::default()).unwrap(); + let release_held_htlc_om = pass_async_payments_oms( + static_invoice.clone(), + &nodes[0], + &nodes[1], + &nodes[2], + recipient_id, + invoice_flow_res.invoice_request_path, + ) + .1; + nodes[0] + .onion_messenger + .handle_onion_message(nodes[2].node.get_our_node_id(), &release_held_htlc_om); + + let mut events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + let ev = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events); + let payment_hash = extract_payment_hash(&ev); + check_added_monitors(&nodes[0], 1); + + let route: &[&[&Node]] = &[&[&nodes[1], &nodes[2]]]; + let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev) + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); + let claimable_ev = do_pass_along_path(args).unwrap(); + + // Verify the `payment_metadata` we injected is surfaced via the `Bolt12OfferContext` of + // the `PaymentPurpose`. The recipient converts `AsyncBolt12OfferContext` to + // `Bolt12OfferContext` when constructing the `PaymentPurpose` for keysend payments. + match &claimable_ev { + Event::PaymentClaimable { + purpose: PaymentPurpose::Bolt12OfferPayment { payment_context, .. }, + .. + } => { + assert_eq!(payment_context.payment_metadata.as_ref(), Some(&expected_metadata)); + }, + _ => panic!("Unexpected event: {:?}", claimable_ev), + } + + let keysend_preimage = extract_payment_preimage(&claimable_ev); + let (res, _) = + claim_payment_along_route(ClaimAlongRouteArgs::new(&nodes[0], route, keysend_preimage)); + assert_eq!(res, Some(PaidBolt12Invoice::StaticInvoice(static_invoice))); +} + #[cfg_attr(feature = "std", ignore)] #[test] fn expired_static_invoice_fail() { @@ -1591,6 +1676,7 @@ fn reject_bad_payment_secret() { PaymentContext::AsyncBolt12Offer(AsyncBolt12OfferContext { // We don't reach the point of checking the invreq nonce due to the invalid payment secret offer_nonce: Nonce([i; Nonce::LENGTH]), + payment_metadata: None, }), u32::MAX, ) @@ -3123,7 +3209,10 @@ fn intercepted_hold_htlc() { .unwrap(); let mut offer_nonce = Nonce([0; Nonce::LENGTH]); offer_nonce.0.copy_from_slice(&hardcoded_random_bytes[..Nonce::LENGTH]); - let payment_context = PaymentContext::AsyncBolt12Offer(AsyncBolt12OfferContext { offer_nonce }); + let payment_context = PaymentContext::AsyncBolt12Offer(AsyncBolt12OfferContext { + offer_nonce, + payment_metadata: None, + }); let blinded_payment_path_with_jit_channel_scid = recipient .node .flow diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index 621c5103353..32c0709ed5c 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -83,7 +83,7 @@ pub fn blinded_payment_path( htlc_minimum_msat: intro_node_min_htlc_opt.unwrap_or_else(|| channel_upds.last().unwrap().htlc_minimum_msat), }, - payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata: None }), }; let receive_auth_key = keys_manager.get_receive_auth_key(); @@ -172,7 +172,7 @@ fn do_one_hop_blinded_path(success: bool) { max_cltv_expiry: u32::max_value(), htlc_minimum_msat: chan_upd.htlc_minimum_msat, }, - payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata: None }), }; let receive_auth_key = chanmon_cfgs[1].keys_manager.get_receive_auth_key(); @@ -216,7 +216,9 @@ fn one_hop_blinded_path_with_dummy_hops() { max_cltv_expiry: u32::max_value(), htlc_minimum_msat: chan_upd.htlc_minimum_msat, }, - payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { + payment_metadata: None, + }), }; let receive_auth_key = chanmon_cfgs[1].keys_manager.get_receive_auth_key(); let dummy_tlvs = [DummyTlvs::default(); 2]; @@ -296,7 +298,7 @@ fn mpp_to_one_hop_blinded_path() { max_cltv_expiry: u32::max_value(), htlc_minimum_msat: chan_upd_1_3.htlc_minimum_msat, }, - payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata: None }), }; let receive_auth_key = chanmon_cfgs[3].keys_manager.get_receive_auth_key(); let blinded_path = BlindedPaymentPath::new( @@ -1419,7 +1421,7 @@ fn custom_tlvs_to_blinded_path() { max_cltv_expiry: u32::max_value(), htlc_minimum_msat: chan_upd.htlc_minimum_msat, }, - payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata: None }), }; let receive_auth_key = chanmon_cfgs[1].keys_manager.get_receive_auth_key(); @@ -1473,7 +1475,7 @@ fn fails_receive_tlvs_authentication() { max_cltv_expiry: u32::max_value(), htlc_minimum_msat: chan_upd.htlc_minimum_msat, }, - payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata: None }), }; let receive_auth_key = chanmon_cfgs[1].keys_manager.get_receive_auth_key(); @@ -1503,7 +1505,7 @@ fn fails_receive_tlvs_authentication() { max_cltv_expiry: u32::max_value(), htlc_minimum_msat: chan_upd.htlc_minimum_msat, }, - payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata: None }), }; // Use a mismatched ReceiveAuthKey to force auth failure: let mismatched_receive_auth_key = ReceiveAuthKey([0u8; 32]); @@ -2286,7 +2288,7 @@ fn do_test_trampoline_single_hop_receive(success: bool) { max_cltv_expiry: u32::max_value(), htlc_minimum_msat: amt_msat, }, - payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata: None }), }; let receive_auth_key = nodes[2].keys_manager.get_receive_auth_key(); let blinded_path = BlindedPaymentPath::new(&[], carol_node_id, receive_auth_key, payee_tlvs, u64::MAX, 0, nodes[2].keys_manager, &secp_ctx).unwrap(); @@ -2607,7 +2609,9 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { max_cltv_expiry: u32::max_value(), htlc_minimum_msat: original_amt_msat, }, - payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { + payment_metadata: None, + }), }, original_trampoline_cltv, excess_final_cltv, diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 1f32423507f..9ceae85bb85 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8665,7 +8665,7 @@ impl< }, OnionPayload::Spontaneous(keysend_preimage) => { let purpose = if let Some(PaymentContext::AsyncBolt12Offer( - AsyncBolt12OfferContext { offer_nonce }, + AsyncBolt12OfferContext { offer_nonce, payment_metadata }, )) = payment_context { let payment_data = match payment_data { @@ -8707,6 +8707,7 @@ impl< PaymentContext::Bolt12Offer(Bolt12OfferContext { offer_id: verified_invreq.offer_id(), invoice_request: verified_invreq.fields(), + payment_metadata, }); let from_parts_res = events::PaymentPurpose::from_parts( Some(keysend_preimage), @@ -14933,6 +14934,7 @@ impl< self.create_inbound_payment(Some(amount_msats), relative_expiry, None) .map_err(|()| Bolt12SemanticError::InvalidAmount) }, + None, )?; let invoice = builder.allow_mpp().build_and_sign(secp_ctx)?; @@ -17117,6 +17119,7 @@ impl< &request, self.list_usable_channels(), get_payment_info, + None, ); match result { @@ -17141,6 +17144,7 @@ impl< &request, self.list_usable_channels(), get_payment_info, + None, ); match result { diff --git a/lightning/src/ln/max_payment_path_len_tests.rs b/lightning/src/ln/max_payment_path_len_tests.rs index 0515a5290d7..4d0abb6bfac 100644 --- a/lightning/src/ln/max_payment_path_len_tests.rs +++ b/lightning/src/ln/max_payment_path_len_tests.rs @@ -222,7 +222,9 @@ fn one_hop_blinded_path_with_custom_tlv() { max_cltv_expiry: u32::max_value(), htlc_minimum_msat: chan_upd_1_2.htlc_minimum_msat, }, - payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { + payment_metadata: None, + }), }; let receive_auth_key = chanmon_cfgs[2].keys_manager.get_receive_auth_key(); let mut secp_ctx = Secp256k1::new(); diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index de08af5d276..d1ec9b4d89a 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -42,6 +42,8 @@ //! Nodes without channels are disconnected and connected as needed to ensure that deterministic //! blinded paths are used. +use alloc::collections::BTreeMap; + use bitcoin::network::Network; use bitcoin::secp256k1::{PublicKey, Secp256k1}; use core::time::Duration; @@ -728,6 +730,7 @@ fn creates_and_pays_for_offer_using_two_hop_blinded_path() { payer_note_truncated: None, human_readable_name: None, }, + payment_metadata: None, }); assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); assert_ne!(invoice_request.payer_signing_pubkey(), david_id); @@ -814,7 +817,7 @@ fn creates_and_pays_for_refund_using_two_hop_blinded_path() { } expect_recent_payment!(david, RecentPaymentDetails::AwaitingInvoice, payment_id); - let payment_context = PaymentContext::Bolt12Refund(Bolt12RefundContext {}); + let payment_context = PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata: None }); let expected_invoice = alice.node.request_refund_payment(&refund).unwrap(); connect_peers(alice, charlie); @@ -886,6 +889,7 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() { payer_note_truncated: None, human_readable_name: None, }, + payment_metadata: None, }); assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); assert_ne!(invoice_request.payer_signing_pubkey(), bob_id); @@ -910,6 +914,75 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() { expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); } +/// Checks that a `Router` can attach `payment_metadata` to the [`PaymentContext`] of a blinded +/// payment path while building it in response to an invoice request, and that the metadata is +/// surfaced back via [`Event::PaymentClaimable`] when the payment is received. +#[test] +fn router_modifies_payment_metadata_in_blinded_path() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + + let alice = &nodes[0]; + let alice_id = alice.node.get_our_node_id(); + let bob = &nodes[1]; + let bob_id = bob.node.get_our_node_id(); + + // Configure Alice's router to inject `payment_metadata` into the `PaymentContext` of the + // `ReceiveTlvs` it builds blinded payment paths from. This simulates a recipient-side router + // that ties extra recipient data (e.g. an order ID) to the blinded path created in response to + // an inbound invoice request. + let mut expected_metadata = BTreeMap::new(); + expected_metadata.insert(0u64, vec![1, 2, 3, 4]); + expected_metadata.insert(7u64, vec![0xab, 0xcd]); + alice.router.set_next_payment_context_metadata(expected_metadata.clone()); + + let offer = alice.node + .create_offer_builder().unwrap() + .amount_msats(10_000_000) + .build().unwrap(); + + let payment_id = PaymentId([1; 32]); + bob.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + expect_recent_payment!(bob, RecentPaymentDetails::AwaitingInvoice, payment_id); + + // Bob -> Alice: invoice_request. When Alice handles it, her flow asks the router for blinded + // payment paths; the router applies the configured metadata override before the path is built + // and embedded in the invoice. + let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap(); + alice.onion_messenger.handle_onion_message(bob_id, &onion_message); + + let (invoice_request, _) = extract_invoice_request(alice, &onion_message); + + // Alice -> Bob: invoice (carrying the blinded path with the modified payment_context). + let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); + bob.onion_messenger.handle_onion_message(alice_id, &onion_message); + + let (invoice, _) = extract_invoice(bob, &onion_message); + + let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id: offer.id(), + invoice_request: InvoiceRequestFields { + payer_signing_pubkey: invoice_request.payer_signing_pubkey(), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + }, + payment_metadata: Some(expected_metadata), + }); + + route_bolt12_payment(bob, &[alice], &invoice); + expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); + + // Verifies that Alice's `Event::PaymentClaimable` carries the `payment_metadata` injected by + // the router (via the `expected_payment_context` equality check inside this helper). + claim_bolt12_payment(bob, &[alice], payment_context, &invoice); + expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); +} + /// Checks that a refund can be paid through a one-hop blinded path and that ephemeral pubkeys are /// used rather than exposing a node's pubkey. However, the node's pubkey is still used as the /// introduction node of the blinded path. @@ -942,7 +1015,7 @@ fn creates_and_pays_for_refund_using_one_hop_blinded_path() { } expect_recent_payment!(bob, RecentPaymentDetails::AwaitingInvoice, payment_id); - let payment_context = PaymentContext::Bolt12Refund(Bolt12RefundContext {}); + let payment_context = PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata: None }); let expected_invoice = alice.node.request_refund_payment(&refund).unwrap(); let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); @@ -1007,6 +1080,7 @@ fn pays_for_offer_without_blinded_paths() { payer_note_truncated: None, human_readable_name: None, }, + payment_metadata: None, }); let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); @@ -1047,7 +1121,7 @@ fn pays_for_refund_without_blinded_paths() { assert!(refund.paths().is_empty()); expect_recent_payment!(bob, RecentPaymentDetails::AwaitingInvoice, payment_id); - let payment_context = PaymentContext::Bolt12Refund(Bolt12RefundContext {}); + let payment_context = PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata: None }); let expected_invoice = alice.node.request_refund_payment(&refund).unwrap(); let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); @@ -1275,6 +1349,7 @@ fn creates_and_pays_for_offer_with_retry() { payer_note_truncated: None, human_readable_name: None, }, + payment_metadata: None, }); assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); assert_ne!(invoice_request.payer_signing_pubkey(), bob_id); @@ -1340,6 +1415,7 @@ fn pays_bolt12_invoice_asynchronously() { payer_note_truncated: None, human_readable_name: None, }, + payment_metadata: None, }); let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); @@ -1437,6 +1513,7 @@ fn creates_offer_with_blinded_path_using_unannounced_introduction_node() { payer_note_truncated: None, human_readable_name: None, }, + payment_metadata: None, }); assert_ne!(invoice_request.payer_signing_pubkey(), bob_id); assert_eq!(reply_path.introduction_node(), &IntroductionNode::NodeId(alice_id)); @@ -2280,7 +2357,7 @@ fn fails_paying_invoice_more_than_once() { david.onion_messenger.handle_onion_message(charlie_id, &onion_message); // David initiates paying the first invoice - let payment_context = PaymentContext::Bolt12Refund(Bolt12RefundContext {}); + let payment_context = PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata: None }); let (invoice1, _) = extract_invoice(david, &onion_message); route_bolt12_payment(david, &[charlie, bob, alice], &invoice1); @@ -2648,6 +2725,7 @@ fn creates_and_pays_for_phantom_offer() { payer_note_truncated: None, human_readable_name: None, }, + payment_metadata: None, }); let onion_message = diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 6c1b7a5befe..e3bf66cb92c 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -10,6 +10,8 @@ //! Provides data structures and functions for creating and managing Offers messages, //! facilitating communication, and handling BOLT12 messages and payments. +use alloc::collections::BTreeMap; + use core::sync::atomic::{AtomicUsize, Ordering}; use core::time::Duration; @@ -828,13 +830,15 @@ impl OffersMessageFlow { pub fn create_static_invoice_builder<'a, R: Router>( &self, router: &R, offer: &'a Offer, offer_nonce: Nonce, payment_secret: PaymentSecret, relative_expiry_secs: u32, usable_channels: Vec, - peers: Vec, + peers: Vec, payment_metadata: Option>>, ) -> Result, Bolt12SemanticError> { let expanded_key = &self.inbound_payment_key; let secp_ctx = &self.secp_ctx; - let payment_context = - PaymentContext::AsyncBolt12Offer(AsyncBolt12OfferContext { offer_nonce }); + let payment_context = PaymentContext::AsyncBolt12Offer(AsyncBolt12OfferContext { + offer_nonce, + payment_metadata, + }); let amount_msat = offer.amount().and_then(|amount| match amount { Amount::Bitcoin { amount_msats } => Some(amount_msats), @@ -896,6 +900,7 @@ impl OffersMessageFlow { pub fn create_invoice_builder_from_refund<'a, ES: EntropySource, R: Router, F>( &'a self, router: &R, entropy_source: ES, refund: &'a Refund, usable_channels: Vec, get_payment_info: F, + payment_metadata: Option>>, ) -> Result, Bolt12SemanticError> where F: Fn(u64, u32) -> Result<(PaymentHash, PaymentSecret), Bolt12SemanticError>, @@ -912,7 +917,8 @@ impl OffersMessageFlow { let (payment_hash, payment_secret) = get_payment_info(amount_msats, relative_expiry)?; - let payment_context = PaymentContext::Bolt12Refund(Bolt12RefundContext {}); + let payment_context = + PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata }); let payment_paths = self .create_blinded_payment_paths( router, @@ -963,6 +969,7 @@ impl OffersMessageFlow { pub fn create_invoice_builder_from_invoice_request_with_keys<'a, R: Router, F>( &self, router: &R, invoice_request: &'a VerifiedInvoiceRequest, usable_channels: Vec, get_payment_info: F, + payment_metadata: Option>>, ) -> Result<(InvoiceBuilder<'a, DerivedSigningPubkey>, MessageContext), Bolt12SemanticError> where F: Fn(u64, u32) -> Result<(PaymentHash, PaymentSecret), Bolt12SemanticError>, @@ -977,6 +984,7 @@ impl OffersMessageFlow { let context = PaymentContext::Bolt12Offer(Bolt12OfferContext { offer_id: invoice_request.offer_id, invoice_request: invoice_request.fields(), + payment_metadata, }); let payment_paths = self @@ -1022,6 +1030,7 @@ impl OffersMessageFlow { pub fn create_invoice_builder_from_invoice_request_without_keys<'a, R: Router, F>( &self, router: &R, invoice_request: &'a VerifiedInvoiceRequest, usable_channels: Vec, get_payment_info: F, + payment_metadata: Option>>, ) -> Result<(InvoiceBuilder<'a, ExplicitSigningPubkey>, MessageContext), Bolt12SemanticError> where F: Fn(u64, u32) -> Result<(PaymentHash, PaymentSecret), Bolt12SemanticError>, @@ -1036,6 +1045,7 @@ impl OffersMessageFlow { let context = PaymentContext::Bolt12Offer(Bolt12OfferContext { offer_id: invoice_request.offer_id, invoice_request: invoice_request.fields(), + payment_metadata, }); let payment_paths = self @@ -1643,6 +1653,7 @@ impl OffersMessageFlow { offer_relative_expiry, usable_channels, peers.clone(), + None, ) .and_then(|builder| builder.build_and_sign(secp_ctx)) .map_err(|_| ())?; diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index edb048c8c7d..f7da1855120 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -283,6 +283,12 @@ pub trait Router { /// Creates [`BlindedPaymentPath`]s for payment to the `recipient` node. The channels in `first_hops` /// are assumed to be with the `recipient`'s peers. The payment secret and any constraints are /// given in `tlvs`. The `local_node_receive_key` is required to authenticate the blinded payment paths. + /// + /// While payments will fail if most of `tlvs` is modified, modifying + /// [`ReceiveTlvs::payment_context`]'s [`PaymentContext::payment_metadata`] fields prior to + /// blinded path construction is allowed. + /// + /// [`PaymentContext::payment_metadata`]: crate::blinded_path::payment::PaymentContext::payment_metadata fn create_blinded_payment_paths( &self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey, first_hops: Vec, tlvs: ReceiveTlvs, amount_msats: Option, diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs index 4c40382517b..0f93df22cd2 100644 --- a/lightning/src/util/ser.rs +++ b/lightning/src/util/ser.rs @@ -969,6 +969,37 @@ macro_rules! impl_for_map { impl_for_map!(BTreeMap, Ord, |_| BTreeMap::new()); impl_for_map!(HashMap, Hash, |len| hash_map_with_capacity(len)); +/// A wrapper used to serialize a `BTreeMap>` with a few less bytes. +pub(crate) struct BigSizeKeyedMap(pub T); + +impl Writeable for BigSizeKeyedMap<&BTreeMap>> { + #[inline] + fn write(&self, w: &mut W) -> Result<(), io::Error> { + BigSize(self.0.len() as u64).write(w)?; + for (key, value) in self.0.iter() { + BigSize(*key).write(w)?; + value.write(w)?; + } + Ok(()) + } +} + +impl LengthReadable for BigSizeKeyedMap>> { + #[inline] + fn read_from_fixed_length_buffer(r: &mut R) -> Result { + let len: BigSize = Readable::read(r)?; + let mut ret = BTreeMap::new(); + for _ in 0..len.0 { + let key: BigSize = Readable::read(r)?; + let value: Vec = Readable::read(r)?; + if ret.insert(key.0, value).is_some() { + return Err(DecodeError::InvalidValue); + } + } + Ok(BigSizeKeyedMap(ret)) + } +} + // HashSet impl Writeable for HashSet where diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index d7320ff2ba9..892c9f4169d 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -7,9 +7,11 @@ // You may not use this file except in accordance with one or both of these // licenses. +use alloc::collections::BTreeMap; + use crate::blinded_path::message::MessageContext; use crate::blinded_path::message::{BlindedMessagePath, MessageForwardNode}; -use crate::blinded_path::payment::{BlindedPaymentPath, ReceiveTlvs}; +use crate::blinded_path::payment::{BlindedPaymentPath, PaymentContext, ReceiveTlvs}; use crate::chain; use crate::chain::chaininterface; #[cfg(any(test, feature = "_externalize_tests"))] @@ -178,6 +180,7 @@ pub struct TestRouter<'a> { pub network_graph: Arc>, pub next_routes: Mutex>)>>, pub next_blinded_payment_paths: Mutex>, + pub next_payment_context_metadata: Mutex>>>, pub scorer: &'a RwLock, } @@ -189,6 +192,7 @@ impl<'a> TestRouter<'a> { let entropy_source = Arc::new(RandomBytes::new([42; 32])); let next_routes = Mutex::new(VecDeque::new()); let next_blinded_payment_paths = Mutex::new(Vec::new()); + let next_payment_context_metadata = Mutex::new(None); Self { router: DefaultRouter::new( Arc::clone(&network_graph), @@ -200,10 +204,15 @@ impl<'a> TestRouter<'a> { network_graph, next_routes, next_blinded_payment_paths, + next_payment_context_metadata, scorer, } } + pub fn set_next_payment_context_metadata(&self, metadata: BTreeMap>) { + *self.next_payment_context_metadata.lock().unwrap() = Some(metadata); + } + pub fn expect_find_route(&self, query: RouteParameters, result: Result) { let mut expected_routes = self.next_routes.lock().unwrap(); expected_routes.push_back((query, Some(result))); @@ -319,9 +328,16 @@ impl<'a> Router for TestRouter<'a> { fn create_blinded_payment_paths( &self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey, - first_hops: Vec, tlvs: ReceiveTlvs, amount_msats: Option, + first_hops: Vec, mut tlvs: ReceiveTlvs, amount_msats: Option, secp_ctx: &Secp256k1, ) -> Result, ()> { + if let Some(metadata) = self.next_payment_context_metadata.lock().unwrap().take() { + match &mut tlvs.payment_context { + PaymentContext::Bolt12Offer(ctx) => ctx.payment_metadata = Some(metadata), + PaymentContext::AsyncBolt12Offer(ctx) => ctx.payment_metadata = Some(metadata), + PaymentContext::Bolt12Refund(ctx) => ctx.payment_metadata = Some(metadata), + } + } let mut expected_paths = self.next_blinded_payment_paths.lock().unwrap(); if expected_paths.is_empty() { self.router.create_blinded_payment_paths( From f2c41678d99c345302c1adbbd846463f8adc5409 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 30 Apr 2026 20:58:32 +0000 Subject: [PATCH 400/627] Add a `payment_metadata` map in BOLT 12 blinded message path ctxs Similar to how BOLT 11 payments can use a `payment_metadata` to provide arbitrary bytes in the invoice to be communicated back to them when receiving, its useful to be able to provide some bytes which are communicated back upon receiving a payment. Here we do so in the BOLT 12 blinded message path contexts, offering a `BTreeMap>` instead to enable more easily including multiple sets of data. We don't yet wire it up to the public `ChannelManager` API, but do allow selecting values for those using the manual `OffersMessageFlow`. Tests by claude --- lightning/src/blinded_path/message.rs | 29 +++++++- lightning/src/ln/async_payments_tests.rs | 15 +++- lightning/src/ln/channelmanager.rs | 11 ++- lightning/src/ln/offers_tests.rs | 90 +++++++++++++++++++++++- lightning/src/offers/flow.rs | 10 ++- lightning/src/onion_message/messenger.rs | 5 ++ 6 files changed, 148 insertions(+), 12 deletions(-) diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs index 7bcbe80a965..bd2b59c2d15 100644 --- a/lightning/src/blinded_path/message.rs +++ b/lightning/src/blinded_path/message.rs @@ -9,6 +9,8 @@ //! Data structures and methods for constructing [`BlindedMessagePath`]s to send a message over. +use alloc::collections::BTreeMap; + use bitcoin::secp256k1::{self, PublicKey, Secp256k1, SecretKey}; #[allow(unused_imports)] @@ -29,7 +31,9 @@ use crate::routing::gossip::{NodeId, ReadOnlyNetworkGraph}; use crate::sign::{EntropySource, NodeSigner, ReceiveAuthKey, Recipient}; use crate::types::payment::PaymentHash; use crate::util::scid_utils; -use crate::util::ser::{FixedLengthReader, LengthReadableArgs, Readable, Writeable, Writer}; +use crate::util::ser::{ + BigSizeKeyedMap, FixedLengthReader, LengthReadableArgs, Readable, Writeable, Writer, +}; use core::time::Duration; use core::{cmp, mem}; @@ -391,6 +395,28 @@ pub enum OffersContext { /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest /// [`Offer`]: crate::offers::offer::Offer nonce: Nonce, + + /// Additional data about this payment which is not used in LDK and can be used for any + /// purpose. + /// + /// This is analogous to the BOLT 11 [`RecipientOnionFields::payment_metadata`] (which is + /// provided to payers via [`Bolt11Invoice::payment_metadata`]) and can be used any time data + /// needs to be "stored" by a payment recipient for their own internal use, provided back to + /// them with the payment. + /// + /// Payment metadata is stored as a map from a numeric key to an arbitrary byte array value. + /// This allows for several types of metadata to be stored attached to a single payment. In the + /// future some optional features of LDK may use some keys. For the sake of conflict + /// reduction, those features will attempt to use keys in the range 128-256. + /// + /// Note that because this is included in the payment onion, its size must be tightly + /// constrained. More than a few hundred bytes and the payment will be entirely unpayable (with + /// limited routing options as size increases). Further, any data placed here will increase + /// the size of the offer which may make it difficult to fit in QR codes. + /// + /// [`RecipientOnionFields::payment_metadata`]: crate::ln::outbound_payment::RecipientOnionFields::payment_metadata + /// [`Bolt11Invoice::payment_metadata`]: lightning_invoice::Bolt11Invoice::payment_metadata + payment_metadata: Option>>, }, /// Context used by a [`BlindedMessagePath`] within the [`Offer`] of an async recipient. /// @@ -648,6 +674,7 @@ impl_writeable_tlv_based_enum!(MessageContext, impl_writeable_tlv_based_enum!(OffersContext, (0, InvoiceRequest) => { (0, nonce, required), + (1, payment_metadata, (option, encoding: (BTreeMap>, BigSizeKeyedMap))), }, (1, OutboundPaymentForRefund) => { (0, payment_id, required), diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index 817e130e976..7bd745dab0e 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -317,7 +317,10 @@ fn create_static_invoice( .create_blinded_paths( always_online_counterparty.node.get_our_node_id(), always_online_counterparty.keys_manager.get_receive_auth_key(), - MessageContext::Offers(OffersContext::InvoiceRequest { nonce: Nonce([42; 16]) }), + MessageContext::Offers(OffersContext::InvoiceRequest { + nonce: Nonce([42; 16]), + payment_metadata: None, + }), Vec::new(), &secp_ctx, ) @@ -688,7 +691,10 @@ fn static_invoice_unknown_required_features() { .create_blinded_paths( nodes[1].node.get_our_node_id(), nodes[1].keys_manager.get_receive_auth_key(), - MessageContext::Offers(OffersContext::InvoiceRequest { nonce: Nonce([42; 16]) }), + MessageContext::Offers(OffersContext::InvoiceRequest { + nonce: Nonce([42; 16]), + payment_metadata: None, + }), Vec::new(), &secp_ctx, ) @@ -1755,7 +1761,10 @@ fn invalid_async_receive_with_retry( .create_blinded_paths( nodes[1].node.get_our_node_id(), nodes[1].keys_manager.get_receive_auth_key(), - MessageContext::Offers(OffersContext::InvoiceRequest { nonce: Nonce([42; 16]) }), + MessageContext::Offers(OffersContext::InvoiceRequest { + nonce: Nonce([42; 16]), + payment_metadata: None, + }), Vec::new(), &secp_ctx, ) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 9ceae85bb85..ec09235d7f9 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -17092,6 +17092,13 @@ impl< None => return None, }; + let payment_metadata = + if let Some(OffersContext::InvoiceRequest { payment_metadata, .. }) = &context { + payment_metadata.clone() + } else { + None + }; + let invoice_request = match self.flow.verify_invoice_request(invoice_request, context) { Ok(InvreqResponseInstructions::SendInvoice(invoice_request)) => invoice_request, Ok(InvreqResponseInstructions::SendStaticInvoice { recipient_id, invoice_slot, invoice_request }) => { @@ -17119,7 +17126,7 @@ impl< &request, self.list_usable_channels(), get_payment_info, - None, + payment_metadata, ); match result { @@ -17144,7 +17151,7 @@ impl< &request, self.list_usable_channels(), get_payment_info, - None, + payment_metadata, ); match result { diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index d1ec9b4d89a..5eaf64b838b 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -50,7 +50,7 @@ use core::time::Duration; use crate::blinded_path::IntroductionNode; use crate::blinded_path::message::BlindedMessagePath; use crate::blinded_path::payment::{Bolt12OfferContext, Bolt12RefundContext, DummyTlvs, PaymentContext}; -use crate::blinded_path::message::OffersContext; +use crate::blinded_path::message::{MessageContext, OffersContext}; use crate::events::{ClosureReason, Event, HTLCHandlingFailureType, PaidBolt12Invoice, PaymentFailureReason, PaymentPurpose}; use crate::ln::channelmanager::{PaymentId, RecentPaymentDetails, self}; use crate::ln::outbound_payment::{Bolt12PaymentError, RecipientOnionFields, Retry}; @@ -62,8 +62,9 @@ use crate::offers::invoice::Bolt12Invoice; use crate::offers::invoice_error::InvoiceError; use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestFields, InvoiceRequestVerifiedFromOffer}; use crate::offers::nonce::Nonce; +use crate::offers::offer::OfferBuilder; use crate::offers::parse::Bolt12SemanticError; -use crate::onion_message::messenger::{DefaultMessageRouter, Destination, MessageSendInstructions, NodeIdMessageRouter, NullMessageRouter, PeeledOnion, DUMMY_HOPS_PATH_LENGTH, QR_CODED_DUMMY_HOPS_PATH_LENGTH}; +use crate::onion_message::messenger::{DefaultMessageRouter, Destination, MessageRouter, MessageSendInstructions, NodeIdMessageRouter, NullMessageRouter, PeeledOnion, DUMMY_HOPS_PATH_LENGTH, QR_CODED_DUMMY_HOPS_PATH_LENGTH}; use crate::onion_message::offers::OffersMessage; use crate::routing::gossip::{NodeAlias, NodeId}; use crate::routing::router::{DEFAULT_PAYMENT_DUMMY_HOPS, PaymentParameters, RouteParameters, RouteParametersConfig}; @@ -258,7 +259,7 @@ fn claim_bolt12_payment_with_extra_fees<'a, 'b, 'c>( fn extract_offer_nonce<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, message: &OnionMessage) -> Nonce { match node.onion_messenger.peel_onion_message(message) { - Ok(PeeledOnion::Offers(_, Some(OffersContext::InvoiceRequest { nonce }), _)) => nonce, + Ok(PeeledOnion::Offers(_, Some(OffersContext::InvoiceRequest { nonce, payment_metadata: _ }), _)) => nonce, Ok(PeeledOnion::Offers(_, context, _)) => panic!("Unexpected onion message context: {:?}", context), Ok(PeeledOnion::Forward(_, _)) => panic!("Unexpected onion message forward"), Ok(_) => panic!("Unexpected onion message"), @@ -983,6 +984,89 @@ fn router_modifies_payment_metadata_in_blinded_path() { expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); } +/// Checks that `payment_metadata` set in the [`OffersContext::InvoiceRequest`] of an offer's +/// blinded message path is propagated to the [`Bolt12OfferContext`] in the resulting invoice's +/// blinded payment paths and surfaced via [`Event::PaymentClaimable`] when the payment is received. +#[test] +fn pays_for_offer_with_payment_metadata_in_invoice_request_context() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + + let alice = &nodes[0]; + let alice_id = alice.node.get_our_node_id(); + let bob = &nodes[1]; + let bob_id = bob.node.get_our_node_id(); + + // Manually build an offer whose blinded message path carries `payment_metadata` in its + // `OffersContext::InvoiceRequest` context. The HEAD commit causes Alice's `ChannelManager` to + // copy this metadata onto the `Bolt12OfferContext` when she handles the inbound invoice + // request, embedding it in the invoice's blinded payment paths. + let mut expected_metadata = BTreeMap::new(); + expected_metadata.insert(0u64, vec![1, 2, 3, 4]); + expected_metadata.insert(7u64, vec![0xab, 0xcd]); + + let secp_ctx = Secp256k1::new(); + let nonce = Nonce::from_entropy_source(alice.keys_manager); + let context = MessageContext::Offers(OffersContext::InvoiceRequest { + nonce, + payment_metadata: Some(expected_metadata.clone()), + }); + let paths = alice.message_router.create_blinded_paths( + alice_id, + alice.keys_manager.get_receive_auth_key(), + context, + alice.node.test_get_peers_for_blinded_path(), + &secp_ctx, + ).unwrap(); + assert!(!paths.is_empty()); + + let expanded_key = alice.keys_manager.get_expanded_key(); + let mut builder = OfferBuilder::deriving_signing_pubkey(alice_id, &expanded_key, nonce, &secp_ctx) + .chain(Network::Testnet) + .amount_msats(10_000_000); + for path in paths { + builder = builder.path(path); + } + let offer = builder.build().unwrap(); + + let payment_id = PaymentId([1; 32]); + bob.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + expect_recent_payment!(bob, RecentPaymentDetails::AwaitingInvoice, payment_id); + + let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap(); + alice.onion_messenger.handle_onion_message(bob_id, &onion_message); + + let (invoice_request, _) = extract_invoice_request(alice, &onion_message); + + let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); + bob.onion_messenger.handle_onion_message(alice_id, &onion_message); + + let (invoice, _) = extract_invoice(bob, &onion_message); + + let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id: offer.id(), + invoice_request: InvoiceRequestFields { + payer_signing_pubkey: invoice_request.payer_signing_pubkey(), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + }, + payment_metadata: Some(expected_metadata), + }); + + route_bolt12_payment(bob, &[alice], &invoice); + expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); + + // `claim_bolt12_payment` asserts the surfaced `PaymentContext` matches `payment_context` + // above, including the embedded `payment_metadata`. + claim_bolt12_payment(bob, &[alice], payment_context, &invoice); + expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); +} + /// Checks that a refund can be paid through a one-hop blinded path and that ephemeral pubkeys are /// used rather than exposing a node's pubkey. However, the node's pubkey is still used as the /// introduction node of the blinded path. diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index e3bf66cb92c..bdc3475b554 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -454,7 +454,7 @@ impl OffersMessageFlow { let nonce = match context { None if invoice_request.metadata().is_some() => None, - Some(OffersContext::InvoiceRequest { nonce }) => Some(nonce), + Some(OffersContext::InvoiceRequest { nonce, payment_metadata: _ }) => Some(nonce), Some(OffersContext::StaticInvoiceRequested { recipient_id, invoice_slot, @@ -561,7 +561,8 @@ impl OffersMessageFlow { let secp_ctx = &self.secp_ctx; let nonce = Nonce::from_entropy_source(entropy); - let context = MessageContext::Offers(OffersContext::InvoiceRequest { nonce }); + let context = + MessageContext::Offers(OffersContext::InvoiceRequest { nonce, payment_metadata: None }); let mut builder = OfferBuilder::deriving_signing_pubkey(node_id, expanded_key, nonce, secp_ctx) @@ -1658,7 +1659,10 @@ impl OffersMessageFlow { .and_then(|builder| builder.build_and_sign(secp_ctx)) .map_err(|_| ())?; - let context = MessageContext::Offers(OffersContext::InvoiceRequest { nonce: offer_nonce }); + let context = MessageContext::Offers(OffersContext::InvoiceRequest { + nonce: offer_nonce, + payment_metadata: None, + }); let forward_invoice_request_path = self .create_blinded_paths(peers, context) .and_then(|paths| paths.into_iter().next().ok_or(()))?; diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index 7ef4e4a66a8..98a54e21b17 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -469,6 +469,11 @@ pub trait MessageRouter { /// Creates [`BlindedMessagePath`]s to the `recipient` node. The nodes in `peers` are assumed to /// be direct peers with the `recipient`. + /// + /// While payments will fail if most of `context` is modified, modifying + /// [`OffersContext::InvoiceRequest::payment_metadata`] prior to blinded path construction is + /// allowed. + /// fn create_blinded_paths( &self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey, context: MessageContext, peers: Vec, secp_ctx: &Secp256k1, From b82b6a46ac07bd5d224d85ae00e00bd6e527cd09 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Tue, 5 May 2026 17:48:24 -0400 Subject: [PATCH 401/627] Don't persist inbound committed onions in prod A few PRs ago, we started persisting inbound committed HTLC onions in Channels. These onions were persisted to lay groundwork for reconstructing the ChannelManager's pending HTLC maps from them in a future version. However, we've since discovered a different direction where we can instead reconstruct these same maps using persistent monitor events, which may mean that we don't need to persist these onions. Since persisting a bunch of onions on every manager write is a big commitment that we're not fully confident in yet, switch it off for now until we either confirm the monitor events direction and can delete all this onion persisting code OR realize that we definitely do need it. --- .../src/upgrade_downgrade_tests.rs | 9 ++++---- lightning/src/ln/channel.rs | 14 +++++++----- lightning/src/ln/channelmanager.rs | 22 +++++++++---------- 3 files changed, 23 insertions(+), 22 deletions(-) diff --git a/lightning-tests/src/upgrade_downgrade_tests.rs b/lightning-tests/src/upgrade_downgrade_tests.rs index 7f607bba848..7cc59227af4 100644 --- a/lightning-tests/src/upgrade_downgrade_tests.rs +++ b/lightning-tests/src/upgrade_downgrade_tests.rs @@ -540,11 +540,10 @@ fn upgrade_mid_htlc_intercept_forward() { } fn do_upgrade_mid_htlc_forward(test: MidHtlcForwardCase) { - // In 0.3, we started reconstructing the `ChannelManager`'s HTLC forwards maps from the HTLCs - // contained in `Channel`s, as part of removing the requirement to regularly persist the - // `ChannelManager`. However, HTLC forwards can only be reconstructed this way if they were - // received on 0.3 or higher. Test that HTLC forwards that were serialized on <=0.2 will still - // succeed when read on 0.3+. + // In an upcoming version, we plan to start reconstructing the `ChannelManager`'s HTLC forwards + // maps from the HTLCs contained in `Channel`s, as part of removing the requirement to regularly + // persist the `ChannelManager`. Preemptively test that HTLC forwards that were serialized on + // <=0.2 will still succeed when read on this upcoming version. let (node_a_ser, node_b_ser, node_c_ser, mon_a_1_ser, mon_b_1_ser, mon_b_2_ser, mon_c_1_ser); let (node_a_id, node_b_id, node_c_id); let (payment_secret_bytes, payment_hash_bytes, payment_preimage_bytes); diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index e07ee7fceab..63d96fc1682 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -368,8 +368,7 @@ enum InboundUpdateAdd { blinded_failure: Option, outbound_hop: OutboundHop, }, - /// This HTLC was received pre-LDK 0.3, before we started persisting the onion for inbound - /// committed HTLCs. + /// This HTLC was received before we started persisting the onion for inbound committed HTLCs. Legacy, } @@ -7982,8 +7981,9 @@ where Ok(()) } - /// Returns true if any committed inbound HTLCs were received pre-LDK 0.3 and cannot be used - /// during `ChannelManager` deserialization to reconstruct the set of pending HTLCs. + /// Returns true if any committed inbound HTLCs were received before we started serializing + /// inbound committed payment onions in `Channel` and cannot be used during `ChannelManager` + /// deserialization to reconstruct the set of pending HTLCs. pub(super) fn has_legacy_inbound_htlcs(&self) -> bool { self.context.pending_inbound_htlcs.iter().any(|htlc| { matches!( @@ -15570,6 +15570,7 @@ impl Writeable for FundedChannel { } } let mut removed_htlc_attribution_data: Vec<&Option> = Vec::new(); + #[cfg_attr(not(test), allow(unused_mut))] let mut inbound_committed_update_adds: Vec<&InboundUpdateAdd> = Vec::new(); (self.context.pending_inbound_htlcs.len() as u64 - dropped_inbound_htlcs).write(writer)?; for htlc in self.context.pending_inbound_htlcs.iter() { @@ -15590,9 +15591,10 @@ impl Writeable for FundedChannel { 2u8.write(writer)?; htlc_resolution.write(writer)?; }, - &InboundHTLCState::Committed { ref update_add_htlc } => { + &InboundHTLCState::Committed { update_add_htlc: ref _update_add } => { 3u8.write(writer)?; - inbound_committed_update_adds.push(update_add_htlc); + #[cfg(test)] + inbound_committed_update_adds.push(_update_add); }, &InboundHTLCState::LocalRemoved(ref removal_reason) => { 4u8.write(writer)?; diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index a7a0942f0c8..b047c289271 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -17600,16 +17600,16 @@ pub fn provided_init_features(config: &UserConfig) -> InitFeatures { const SERIALIZATION_VERSION: u8 = 1; const MIN_SERIALIZATION_VERSION: u8 = 1; -// We plan to start writing this version in 0.5. +// We plan to start writing this version a few versions after we start writing inbound committed +// payment onions in `Channel`, which is already done in tests but not yet switched on in prod. // -// LDK 0.5+ will reconstruct the set of pending HTLCs from `Channel{Monitor}` data that started -// being written in 0.3, ignoring legacy `ChannelManager` HTLC maps on read and not writing them. -// LDK 0.5+ will automatically fail to read if the pending HTLC set cannot be reconstructed, i.e. -// if we were last written with pending HTLCs on 0.2- or if the new 0.3+ fields are missing. +// If we see this version on read, we will use said onions when reconstructing the set of pending +// HTLCs, ignoring legacy `ChannelManager` HTLC maps on read and not writing them. We'll also +// automatically fail to read if the pending HTLC set cannot be reconstructed, i.e. if the new +// payment onion field is missing. // -// If 0.3 or 0.4 reads this manager version, it knows that the legacy maps were not written and -// acts accordingly. -const RECONSTRUCT_HTLCS_FROM_CHANS_VERSION: u8 = 2; +// Left as `None` for now until we are committed to writing inbound committed onions in `Channel`s. +const RECONSTRUCT_HTLCS_FROM_CHANS_VERSION: Option = None; impl_writeable_tlv_based!(PhantomRouteHints, { (2, channels, required_vec), @@ -18435,7 +18435,7 @@ impl<'a, ES: EntropySource, SP: SignerProvider, L: Logger> } let forward_htlcs_legacy: HashMap> = - if version < RECONSTRUCT_HTLCS_FROM_CHANS_VERSION { + if RECONSTRUCT_HTLCS_FROM_CHANS_VERSION.map_or(true, |v| version < v) { let forward_htlcs_count: u64 = Readable::read(reader)?; let mut fwds = hash_map_with_capacity(cmp::min(forward_htlcs_count as usize, 128)); for _ in 0..forward_htlcs_count { @@ -19573,7 +19573,8 @@ impl< // `reconstruct_manager_from_monitors` is set below. Currently we set in tests randomly to // ensure the legacy codepaths also have test coverage. #[cfg(not(test))] - let reconstruct_manager_from_monitors = _version >= RECONSTRUCT_HTLCS_FROM_CHANS_VERSION; + let reconstruct_manager_from_monitors = + RECONSTRUCT_HTLCS_FROM_CHANS_VERSION.is_some_and(|v| _version >= v); #[cfg(test)] let reconstruct_manager_from_monitors = args.reconstruct_manager_from_monitors.unwrap_or_else(|| { @@ -19636,7 +19637,6 @@ impl< if reconstruct_manager_from_monitors { if let Some(chan) = peer_state.channel_by_id.get(channel_id) { if let Some(funded_chan) = chan.as_funded() { - // Legacy HTLCs are from pre-LDK 0.3 and cannot be reconstructed. if funded_chan.has_legacy_inbound_htlcs() { return Err(DecodeError::InvalidValue); } From 1c7fcb76a1413eef55173b58bdef1e258a509402 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 12 May 2026 21:07:10 +0000 Subject: [PATCH 402/627] Bound sync loops in lightning-transaction-sync If we start syncing from an electrum or esplora server and find that the chain moved during our sync, we reset and start fresh. However, if that happens repeatedly, we probably shouldn't just spin forever. Here we give up after ten attempts and just hope we can sync properly later. --- lightning-transaction-sync/src/electrum.rs | 8 +++++++- lightning-transaction-sync/src/esplora.rs | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lightning-transaction-sync/src/electrum.rs b/lightning-transaction-sync/src/electrum.rs index 9d643f48511..cb937248f41 100644 --- a/lightning-transaction-sync/src/electrum.rs +++ b/lightning-transaction-sync/src/electrum.rs @@ -96,7 +96,13 @@ impl ElectrumSyncClient { let mut tip_header = tip_notification.header; let mut tip_height = tip_notification.height as u32; - loop { + for i in 0..100 { + if i >= 10 { + log_debug!(self.logger, "Giving up trying to sync transactions after 10 attempts."); + sync_state.pending_sync = true; + return Err(TxSyncError::Failed); + } + let pending_registrations = self.queue.lock().unwrap().process_queues(&mut sync_state); let tip_is_new = Some(tip_header.block_hash()) != sync_state.last_sync_hash; diff --git a/lightning-transaction-sync/src/esplora.rs b/lightning-transaction-sync/src/esplora.rs index 7d3550d65b1..52cfb394464 100644 --- a/lightning-transaction-sync/src/esplora.rs +++ b/lightning-transaction-sync/src/esplora.rs @@ -100,7 +100,13 @@ impl EsploraSyncClient { let mut tip_hash = maybe_await!(self.client.get_tip_hash())?; - loop { + for i in 0..100 { + if i >= 10 { + log_debug!(self.logger, "Giving up trying to sync transactions after 10 attempts."); + sync_state.pending_sync = true; + return Err(TxSyncError::Failed); + } + let pending_registrations = self.queue.lock().unwrap().process_queues(&mut sync_state); let tip_is_new = Some(tip_hash) != sync_state.last_sync_hash; From 6c2090d373e1fa391a0c2a5c1a5afde7c2168301 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 15 May 2026 17:17:43 +0000 Subject: [PATCH 403/627] Enforce `min_funding_satoshis` after splices In inbound channels, we already enforce this minimum at channel open, so it makes sense to also enforce this minimum on any splices in which the counterparty's contribution is negative. Codex wrote the tests. --- lightning/src/ln/channel.rs | 54 ++++-- lightning/src/ln/channelmanager.rs | 4 + lightning/src/ln/splicing_tests.rs | 291 +++++++++++++++++++++++++++++ lightning/src/util/config.rs | 3 +- 4 files changed, 339 insertions(+), 13 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 58dd6ea30c0..fde56a9adcb 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2778,7 +2778,7 @@ impl FundingScope { fn for_splice( prev_funding: &Self, context: &ChannelContext, our_funding_contribution: SignedAmount, their_funding_contribution: SignedAmount, counterparty_funding_pubkey: PublicKey, - our_new_holder_keys: ChannelPublicKeys, + our_new_holder_keys: ChannelPublicKeys, min_funding_satoshis: u64, ) -> Result { if our_funding_contribution.unsigned_abs() > Amount::MAX_MONEY { return Err(format!( @@ -2817,13 +2817,27 @@ impl FundingScope { ), )?; - let post_channel_value_sat = prev_funding.get_value_satoshis() + let post_channel_value_sat = prev_funding + .get_value_satoshis() .checked_add_signed(our_funding_contribution.to_sat()) .and_then(|v| v.checked_add_signed(their_funding_contribution.to_sat())) - .ok_or(format!("The sum of contributions {our_funding_contribution} and {their_funding_contribution} is greater than the channel's value"))?; + .ok_or(format!( + "The sum of contributions {our_funding_contribution} and \ + {their_funding_contribution} is greater than the channel's value" + ))?; if post_channel_value_sat < MIN_CHANNEL_VALUE_SATOSHIS { return Err(format!( - "Spliced channel value must be at least 1000 satoshis. It would be {post_channel_value_sat}", + "Spliced channel value must be at least 1000 satoshis. It would be \ + {post_channel_value_sat}" + )); + } + if post_channel_value_sat < min_funding_satoshis + && their_funding_contribution.is_negative() + && !prev_funding.is_outbound() + { + return Err(format!( + "Spliced channel value {post_channel_value_sat} would be smaller \ + than the configured min_funding_satoshis {min_funding_satoshis}" )); } @@ -13048,6 +13062,7 @@ where fn validate_splice_contributions( &self, our_funding_contribution: SignedAmount, their_funding_contribution: SignedAmount, counterparty_funding_pubkey: PublicKey, our_new_holder_keys: ChannelPublicKeys, + min_funding_satoshis: u64, ) -> Result { let candidate_scope = FundingScope::for_splice( &self.funding, @@ -13056,6 +13071,7 @@ where their_funding_contribution, counterparty_funding_pubkey, our_new_holder_keys, + min_funding_satoshis, ) .map_err(|e| format!("Channel {} cannot be spliced; {}", self.context.channel_id(), e))?; @@ -13166,7 +13182,7 @@ where pub(crate) fn splice_init( &mut self, msg: &msgs::SpliceInit, entropy_source: &ES, holder_node_id: &PublicKey, - logger: &L, + min_funding_satoshis: u64, logger: &L, ) -> Result { self.validate_splice_init(msg).map_err(|e| self.quiescent_negotiation_err(e))?; @@ -13199,6 +13215,7 @@ where their_funding_contribution, msg.funding_pubkey, holder_pubkeys, + min_funding_satoshis, ) .map_err(|e| self.quiescent_negotiation_err(ChannelError::WarnAndDisconnect(e)))?; @@ -13334,7 +13351,7 @@ where pub(crate) fn tx_init_rbf( &mut self, msg: &msgs::TxInitRbf, entropy_source: &ES, holder_node_id: &PublicKey, - fee_estimator: &LowerBoundedFeeEstimator, logger: &L, + fee_estimator: &LowerBoundedFeeEstimator, min_funding_satoshis: u64, logger: &L, ) -> Result { let (holder_pubkeys, counterparty_funding_pubkey) = self .validate_tx_init_rbf(msg, fee_estimator) @@ -13381,6 +13398,7 @@ where their_funding_contribution, counterparty_funding_pubkey, holder_pubkeys, + min_funding_satoshis, ) .map_err(|e| self.quiescent_negotiation_err(ChannelError::WarnAndDisconnect(e)))?; @@ -13452,7 +13470,9 @@ where }) } - fn validate_tx_ack_rbf(&self, msg: &msgs::TxAckRbf) -> Result { + fn validate_tx_ack_rbf( + &self, msg: &msgs::TxAckRbf, min_funding_satoshis: u64, + ) -> Result { let pending_splice = self .pending_splice .as_ref() @@ -13478,6 +13498,7 @@ where their_funding_contribution, counterparty_funding_pubkey, holder_pubkeys, + min_funding_satoshis, ) .map_err(|e| ChannelError::WarnAndDisconnect(e))?; @@ -13486,9 +13507,9 @@ where pub(crate) fn tx_ack_rbf( &mut self, msg: &msgs::TxAckRbf, entropy_source: &ES, holder_node_id: &PublicKey, - logger: &L, + min_funding_satoshis: u64, logger: &L, ) -> Result, ChannelError> { - let rbf_funding = self.validate_tx_ack_rbf(msg)?; + let rbf_funding = self.validate_tx_ack_rbf(msg, min_funding_satoshis)?; log_info!( logger, @@ -13519,9 +13540,9 @@ where pub(crate) fn splice_ack( &mut self, msg: &msgs::SpliceAck, entropy_source: &ES, holder_node_id: &PublicKey, - logger: &L, + min_funding_satoshis: u64, logger: &L, ) -> Result, ChannelError> { - let splice_funding = self.validate_splice_ack(msg)?; + let splice_funding = self.validate_splice_ack(msg, min_funding_satoshis)?; log_info!( logger, @@ -13553,7 +13574,9 @@ where Ok(tx_msg_opt) } - fn validate_splice_ack(&self, msg: &msgs::SpliceAck) -> Result { + fn validate_splice_ack( + &self, msg: &msgs::SpliceAck, min_funding_satoshis: u64, + ) -> Result { // TODO(splicing): Add check that we are the splice (quiescence) initiator let pending_splice = self @@ -13576,6 +13599,7 @@ where their_funding_contribution, msg.funding_pubkey, new_keys, + min_funding_satoshis, ) .map_err(|e| ChannelError::WarnAndDisconnect(e))?; @@ -13692,6 +13716,9 @@ where SignedAmount::ZERO, funding.counterparty_funding_pubkey().clone(), funding.get_holder_pubkeys().clone(), + // When the counterparty's contribution is non-negative, we don't validate + // the post splice channel value against `min_funding_satoshis` + 0, ) .unwrap(); // Splice-out an additional satoshi, and validation fails! @@ -13700,6 +13727,9 @@ where SignedAmount::ZERO, funding.counterparty_funding_pubkey().clone(), funding.get_holder_pubkeys().clone(), + // When the counterparty's contribution is non-negative, we don't validate + // the post splice channel value against `min_funding_satoshis` + 0, ) .unwrap_err(); } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 19fd2f96797..35754147b0e 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -13384,6 +13384,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ msg, &self.entropy_source, &self.get_our_node_id(), + self.config.read().unwrap().channel_handshake_limits.min_funding_satoshis, &self.logger, ) { Ok(splice_ack_msg) => { @@ -13441,6 +13442,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ &self.entropy_source, &self.get_our_node_id(), &self.fee_estimator, + self.config.read().unwrap().channel_handshake_limits.min_funding_satoshis, &self.logger, ) { Ok(tx_ack_rbf_msg) => { @@ -13497,6 +13499,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ msg, &self.entropy_source, &self.get_our_node_id(), + self.config.read().unwrap().channel_handshake_limits.min_funding_satoshis, &self.logger, ); let tx_msg_opt = @@ -13541,6 +13544,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ msg, &self.entropy_source, &self.get_our_node_id(), + self.config.read().unwrap().channel_handshake_limits.min_funding_satoshis, &self.logger, ); let tx_msg_opt = diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 6bd5d5224f7..a6823e39aed 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -163,6 +163,35 @@ impl CoinSelectionSourceSync for TightBudgetWallet { } } +#[cfg(test)] +fn config_with_min_funding_satoshis(min_funding_satoshis: u64) -> UserConfig { + let mut config = test_default_channel_config(); + config.channel_handshake_limits.min_funding_satoshis = min_funding_satoshis; + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; + config +} + +#[cfg(test)] +fn assert_min_funding_error<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, min_funding_satoshis: u64) { + let msg_events = node.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + match &msg_events[0] { + MessageSendEvent::HandleError { + action: msgs::ErrorAction::DisconnectPeerWithWarning { msg }, + .. + } => { + assert!( + msg.data + .contains(&format!("configured min_funding_satoshis {min_funding_satoshis}")), + "unexpected warning: {}", + msg.data + ); + }, + _ => panic!("Expected HandleError with warning, got {:?}", msg_events[0]), + } +} + pub fn negotiate_splice_tx<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, funding_contribution: FundingContribution, @@ -1209,6 +1238,268 @@ fn test_splice_in() { let _ = send_payment(&nodes[0], &[&nodes[1]], htlc_limit_msat); } +#[test] +fn test_min_funding_satoshis_allows_splice_init_with_positive_counterparty_contribution() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let min_funding_satoshis = 150_000; + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + nodes[1].node.set_current_config(config_with_min_funding_satoshis(min_funding_satoshis)); + + let added_value = Amount::from_sat(10_000); + provide_utxo_reserves(&nodes, 1, Amount::from_sat(100_000)); + let _funding_contribution = initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + + let stfu_init = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_init); + let stfu_ack = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_ack); + + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + assert!(splice_init.funding_contribution_satoshis > 0); + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + let _splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); +} + +#[test] +fn test_min_funding_satoshis_rejects_splice_init_with_negative_counterparty_contribution() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let min_funding_satoshis = 150_000; + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + nodes[1].node.set_current_config(config_with_min_funding_satoshis(min_funding_satoshis)); + + let outputs = vec![TxOut { + value: Amount::from_sat(10_000), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]; + let _funding_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); + + let stfu_init = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_init); + let stfu_ack = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_ack); + + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + assert!(splice_init.funding_contribution_satoshis < 0); + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + assert_min_funding_error(&nodes[1], min_funding_satoshis); +} + +#[test] +fn test_min_funding_satoshis_allows_outbound_splice_ack_with_negative_counterparty_contribution() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let min_funding_satoshis = 150_000; + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value( + &nodes, + 0, + 1, + initial_channel_value_sat, + 50_000_000, + ); + nodes[0].node.set_current_config(config_with_min_funding_satoshis(min_funding_satoshis)); + + let added_value = Amount::from_sat(10_000); + provide_utxo_reserves(&nodes, 1, Amount::from_sat(100_000)); + let _node_0_contribution = initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let outputs = vec![TxOut { + value: Amount::from_sat(10_000), + script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), + }]; + let _node_1_contribution = + initiate_splice_out(&nodes[1], &nodes[0], channel_id, outputs).unwrap(); + + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + assert!(splice_ack.funding_contribution_satoshis < 0); + nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); + + let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert!(!msg_events.is_empty(), "{msg_events:?}"); + assert!( + !msg_events.iter().any(|event| matches!(event, MessageSendEvent::HandleError { .. })), + "{msg_events:?}" + ); +} + +#[test] +fn test_min_funding_satoshis_rejects_splice_ack_with_negative_counterparty_contribution() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let min_funding_satoshis = 150_000; + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value( + &nodes, + 1, + 0, + initial_channel_value_sat, + 50_000_000, + ); + nodes[0].node.set_current_config(config_with_min_funding_satoshis(min_funding_satoshis)); + + let added_value = Amount::from_sat(10_000); + provide_utxo_reserves(&nodes, 1, Amount::from_sat(100_000)); + let _node_0_contribution = initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + + let stfu_init = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_init); + + let outputs = vec![TxOut { + value: Amount::from_sat(10_000), + script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), + }]; + let _node_1_contribution = + initiate_splice_out(&nodes[1], &nodes[0], channel_id, outputs).unwrap(); + + let stfu_ack = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + assert!(!stfu_ack.initiator); + nodes[0].node.handle_stfu(node_id_1, &stfu_ack); + + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + assert!(splice_ack.funding_contribution_satoshis < 0); + nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); + assert_min_funding_error(&nodes[0], min_funding_satoshis); +} + +#[test] +fn test_min_funding_satoshis_rejects_tx_init_rbf_with_negative_counterparty_contribution() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let min_funding_satoshis = 150_000; + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + nodes[1].node.set_current_config(config_with_min_funding_satoshis(min_funding_satoshis)); + + let added_value = Amount::from_sat(10_000); + provide_utxo_reserves(&nodes, 1, Amount::from_sat(100_000)); + let first_contribution = initiate_splice_in(&nodes[1], &nodes[0], channel_id, added_value); + let (_first_splice_tx, _) = + splice_channel(&nodes[1], &nodes[0], channel_id, first_contribution); + + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + let rbf_feerate = funding_template.min_rbf_feerate().unwrap(); + let outputs = vec![TxOut { + value: Amount::from_sat(10_000), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]; + let rbf_contribution = funding_template.splice_out(outputs, rbf_feerate, FeeRate::MAX).unwrap(); + nodes[0].node.funding_contributed(&channel_id, &node_id_1, rbf_contribution, None).unwrap(); + + let stfu_init = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_init); + let stfu_ack = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_ack); + + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + assert!(tx_init_rbf.funding_output_contribution.unwrap() < 0); + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + assert_min_funding_error(&nodes[1], min_funding_satoshis); +} + +#[test] +fn test_min_funding_satoshis_rejects_tx_ack_rbf_with_negative_counterparty_contribution() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let min_funding_satoshis = 150_000; + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value( + &nodes, + 1, + 0, + initial_channel_value_sat, + 50_000_000, + ); + nodes[0].node.set_current_config(config_with_min_funding_satoshis(min_funding_satoshis)); + + let added_value = Amount::from_sat(10_000); + provide_utxo_reserves(&nodes, 1, Amount::from_sat(100_000)); + let first_contribution = initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_first_splice_tx, _) = + splice_channel(&nodes[0], &nodes[1], channel_id, first_contribution); + + let funding_template_0 = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + let rbf_feerate = funding_template_0.min_rbf_feerate().unwrap(); + let node_0_contribution = + funding_template_0.with_prior_contribution(rbf_feerate, FeeRate::MAX).build().unwrap(); + nodes[0].node.funding_contributed(&channel_id, &node_id_1, node_0_contribution, None).unwrap(); + + let stfu_init = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_init); + + let outputs = vec![TxOut { + value: Amount::from_sat(10_000), + script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), + }]; + let funding_template_1 = nodes[1].node.splice_channel(&channel_id, &node_id_0).unwrap(); + let node_1_contribution = + funding_template_1.splice_out(outputs, rbf_feerate, FeeRate::MAX).unwrap(); + nodes[1].node.funding_contributed(&channel_id, &node_id_0, node_1_contribution, None).unwrap(); + + let stfu_ack = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + assert!(!stfu_ack.initiator); + nodes[0].node.handle_stfu(node_id_1, &stfu_ack); + + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + let tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0); + assert!(tx_ack_rbf.funding_output_contribution.unwrap() < 0); + nodes[0].node.handle_tx_ack_rbf(node_id_1, &tx_ack_rbf); + assert_min_funding_error(&nodes[0], min_funding_satoshis); +} + #[test] fn test_splice_out() { let chanmon_cfgs = create_chanmon_cfgs(2); diff --git a/lightning/src/util/config.rs b/lightning/src/util/config.rs index 78ab45d58c2..fa01f8e21b4 100644 --- a/lightning/src/util/config.rs +++ b/lightning/src/util/config.rs @@ -323,7 +323,8 @@ impl Readable for ChannelHandshakeConfig { #[derive(Copy, Clone, Debug)] pub struct ChannelHandshakeLimits { /// Minimum allowed satoshis when a channel is funded. This is supplied by the sender and so - /// only applies to inbound channels. + /// only applies to inbound channels. It is also enforced for inbound channels on splices in + /// which the counterparty's contribution is negative. /// /// Default value: `1000` /// From 48d6c835419f3cc4f8c0cd31b81968e071120657 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 8 May 2026 10:49:28 -0500 Subject: [PATCH 404/627] Pick NegotiationFailureReason at error construction QuiescentError::FailSplice was built with a placeholder NegotiationFailureReason::Unknown and expected callers to chain a with_negotiation_failure_reason builder. Sites that forgot the chain leaked Unknown into Event::SpliceNegotiationFailed, and the pattern forced splice-specific reason vocabulary into the generic QuiescentAction helper. Each call site in propose_quiescence now picks the reason at construction. The pending-quiescent-action branch is unreachable, so it asserts unconditionally; the match retains arms for both action variants so release builds return a sensible error if the invariant is violated. abandon_quiescent_action returns SpliceFundingFailed directly without round-tripping through QuiescentError, since the reason was always discarded there. Make funding_contributed's pending-quiescent-action check exhaustive on QuiescentAction. A future variant produces a compile error here and at the matching arm in propose_quiescence, forcing the author to decide how it interacts with funding contribution. Co-Authored-By: Claude Opus 4.7 (1M context) --- lightning/src/ln/channel.rs | 100 ++++++++++++++--------------- lightning/src/ln/splicing_tests.rs | 2 +- 2 files changed, 50 insertions(+), 52 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index fde56a9adcb..e1192041517 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -3250,16 +3250,6 @@ pub(super) enum QuiescentError { FailSplice(SpliceFundingFailed, NegotiationFailureReason), } -impl QuiescentError { - fn with_negotiation_failure_reason(mut self, reason: NegotiationFailureReason) -> Self { - match self { - QuiescentError::FailSplice(_, ref mut r) => *r = reason, - _ => debug_assert!(false, "Expected FailSplice variant"), - } - self - } -} - pub(crate) enum StfuResponse { Stfu(msgs::Stfu), SpliceInit(msgs::SpliceInit), @@ -7217,27 +7207,13 @@ where .expect("is_initiator is true so this always returns Some") } - fn quiescent_action_into_error(&self, action: QuiescentAction) -> QuiescentError { - match action { - QuiescentAction::Splice { contribution, .. } => QuiescentError::FailSplice( - self.splice_funding_failed_for(contribution), - NegotiationFailureReason::Unknown, - ), - #[cfg(any(test, fuzzing, feature = "_test_utils"))] - QuiescentAction::DoNothing => QuiescentError::DoNothing, - } - } - fn abandon_quiescent_action(&mut self) -> Option { - let action = self.quiescent_action.take()?; - match self.quiescent_action_into_error(action) { - QuiescentError::FailSplice(failed, _) => Some(failed), - #[cfg(any(test, fuzzing, feature = "_test_utils"))] - QuiescentError::DoNothing => None, - _ => { - debug_assert!(false); - None + match self.quiescent_action.take()? { + QuiescentAction::Splice { contribution, .. } => { + Some(self.splice_funding_failed_for(contribution)) }, + #[cfg(any(test, fuzzing, feature = "_test_utils"))] + QuiescentAction::DoNothing => None, } } @@ -12734,22 +12710,28 @@ where ) -> Result, QuiescentError> { debug_assert!(contribution.is_splice()); - if let Some(QuiescentAction::Splice { contribution: existing, .. }) = &self.quiescent_action - { - let pending_splice = self.pending_splice.as_ref(); - let prior_inputs = pending_splice - .into_iter() - .flat_map(|pending_splice| pending_splice.contributed_inputs()); - let prior_outputs = pending_splice - .into_iter() - .flat_map(|pending_splice| pending_splice.contributed_outputs()); - return match contribution.into_unique_contributions( - existing.contributed_inputs().chain(prior_inputs), - existing.contributed_outputs().chain(prior_outputs), - ) { - None => Err(QuiescentError::DoNothing), - Some((inputs, outputs)) => Err(QuiescentError::DiscardFunding { inputs, outputs }), - }; + match self.quiescent_action.as_ref() { + Some(QuiescentAction::Splice { contribution: existing, .. }) => { + let pending_splice = self.pending_splice.as_ref(); + let prior_inputs = pending_splice + .into_iter() + .flat_map(|pending_splice| pending_splice.contributed_inputs()); + let prior_outputs = pending_splice + .into_iter() + .flat_map(|pending_splice| pending_splice.contributed_outputs()); + return match contribution.into_unique_contributions( + existing.contributed_inputs().chain(prior_inputs), + existing.contributed_outputs().chain(prior_outputs), + ) { + None => Err(QuiescentError::DoNothing), + Some((inputs, outputs)) => { + Err(QuiescentError::DiscardFunding { inputs, outputs }) + }, + }; + }, + #[cfg(any(test, fuzzing, feature = "_test_utils"))] + Some(QuiescentAction::DoNothing) => unreachable!(), + None => {}, } let initiated_funding_negotiation = self @@ -14396,9 +14378,6 @@ where ) -> Result, QuiescentError> { log_debug!(logger, "Attempting to initiate quiescence"); - // TODO: NegotiationFailureReason is splice-specific, but propose_quiescence is - // generic. The reason should be selected by the caller, but it currently can't - // distinguish why quiescence failed. Revisit when a second quiescent protocol is added. if !self.context.is_usable() { debug_assert!( self.context.channel_state.is_local_shutdown_sent() @@ -14406,15 +14385,34 @@ where "splice_channel should have prevented reaching propose_quiescence on a non-ready channel" ); log_debug!(logger, "Channel is not in a usable state to propose quiescence"); - return Err(self.quiescent_action_into_error(action) - .with_negotiation_failure_reason(NegotiationFailureReason::ChannelClosing)); + return Err(match action { + QuiescentAction::Splice { contribution, .. } => QuiescentError::FailSplice( + self.splice_funding_failed_for(contribution), + NegotiationFailureReason::ChannelClosing, + ), + #[cfg(any(test, fuzzing, feature = "_test_utils"))] + QuiescentAction::DoNothing => QuiescentError::DoNothing, + }); } + if self.quiescent_action.is_some() { + debug_assert!( + false, + "callers must not invoke propose_quiescence with {:?} while quiescent_action is set", + action, + ); log_debug!( logger, "Channel already has a pending quiescent action and cannot start another", ); - return Err(self.quiescent_action_into_error(action)); + return Err(match action { + #[cfg(any(test, fuzzing, feature = "_test_utils"))] + QuiescentAction::DoNothing => QuiescentError::DoNothing, + QuiescentAction::Splice { contribution, .. } => QuiescentError::FailSplice( + self.splice_funding_failed_for(contribution), + NegotiationFailureReason::Unknown, + ), + }); } // Since we don't have a pending quiescent action, we should never be in a state where we // sent `stfu` without already having become quiescent. diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index a6823e39aed..519d52a479b 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -3847,7 +3847,7 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool, pending_ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0); // When testing with a prior pending splice, complete splice A first so that - // `quiescent_action_into_error` filters against `pending_splice.contributed_inputs/outputs`. + // `splice_funding_failed_for` filters against `pending_splice.contributed_inputs/outputs`. if pending_splice { let funding_contribution = do_initiate_splice_in( &nodes[0], From 2dec452b3d800b36199fcbc92e0d39b47a6a51b1 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 8 May 2026 11:14:25 -0500 Subject: [PATCH 405/627] Document SpliceNegotiationFailed contribution / DiscardFunding overlap The contribution returned in Event::SpliceNegotiationFailed may include inputs and outputs already committed to a prior negotiated (but not yet locked) splice transaction. Those overlapping items are intentionally omitted from the preceding Event::DiscardFunding to avoid prompting the user to reclaim UTXOs that are still in use elsewhere. The relationship was documented on the internal SpliceFundingFailed fields but lost when they were made private; surface it on the public event field doc. Co-Authored-By: Claude Opus 4.7 (1M context) --- lightning/src/events/mod.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index a15f8ce8cd9..271e135d51d 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -1706,6 +1706,12 @@ pub enum Event { /// Alternatively, call [`ChannelManager::splice_channel`] to obtain a fresh /// [`FundingTemplate`] and build a new contribution. /// + /// The contribution preserves the full set of inputs and outputs from the failed round, + /// including any that were also committed to a prior negotiated (but not yet locked) + /// splice transaction. Those overlapping inputs and outputs are intentionally omitted + /// from the preceding [`Event::DiscardFunding`], since they remain committed to that + /// prior splice. + /// /// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel /// [`FundingTemplate`]: crate::ln::funding::FundingTemplate From f4139dbfac17b87f818cdc2bef5702440c6d3756 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 8 May 2026 11:41:22 -0500 Subject: [PATCH 406/627] Document script_pubkey-only matching in into_unique_contributions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The function compares outputs by script_pubkey alone, not full TxOut, so any contribution output sharing a script with an existing output is filtered regardless of value. This is intentional — a change output's value may shift between rounds (e.g., for a new feerate) and should still match. But the consequence isn't obvious: multiple contribution outputs sharing a script are all filtered together when any existing output uses that script. Document it. Co-Authored-By: Claude Opus 4.7 (1M context) --- lightning/src/ln/funding.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 3a0b4fb0630..f73b4870166 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -761,6 +761,13 @@ impl FundingContribution { (contributed_inputs, contributed_outputs.map(|output| output.script_pubkey).collect()) } + /// Returns this contribution's inputs and outputs after removing any that overlap + /// with the provided `existing_inputs`/`existing_outputs`. + /// + /// Multiple contribution outputs sharing a `script_pubkey` are all dropped when any + /// existing output uses the same script. + /// + /// Returns `None` if every input and output was filtered as overlapping. pub(crate) fn into_unique_contributions<'a>( self, existing_inputs: impl Iterator, existing_outputs: impl Iterator, From 9246d868cf64dd3ec960956597e64448a59537c6 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 18 May 2026 13:44:20 -0500 Subject: [PATCH 407/627] Skip stale fs store artifacts The exhaustive filesystem store listing treated leftover temp and trash files as namespace directories after identifying them as non-keys. Skip those artifacts before recursing so migrations can ignore crash leftovers. --- lightning-persister/src/fs_store/common.rs | 38 +++++++++++++++------- lightning-persister/src/fs_store/v1.rs | 22 +++++++++++++ 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/lightning-persister/src/fs_store/common.rs b/lightning-persister/src/fs_store/common.rs index 77321f6f06f..6eaa0dbc455 100644 --- a/lightning-persister/src/fs_store/common.rs +++ b/lightning-persister/src/fs_store/common.rs @@ -653,6 +653,9 @@ impl FilesystemStoreState { 'primary_loop: for primary_entry in fs::read_dir(prefixed_dest)? { let primary_entry = primary_entry?; let primary_path = primary_entry.path(); + if dir_entry_is_store_artifact(&primary_path) { + continue 'primary_loop; + } if dir_entry_is_key(&primary_entry)? { let primary_namespace = String::new(); @@ -666,6 +669,9 @@ impl FilesystemStoreState { 'secondary_loop: for secondary_entry in fs::read_dir(&primary_path)? { let secondary_entry = secondary_entry?; let secondary_path = secondary_entry.path(); + if dir_entry_is_store_artifact(&secondary_path) { + continue 'secondary_loop; + } if dir_entry_is_key(&secondary_entry)? { let primary_namespace = get_key_from_dir_entry_path( @@ -683,6 +689,9 @@ impl FilesystemStoreState { for tertiary_entry in fs::read_dir(&secondary_path)? { let tertiary_entry = tertiary_entry?; let tertiary_path = tertiary_entry.path(); + if dir_entry_is_store_artifact(&tertiary_path) { + continue; + } if dir_entry_is_key(&tertiary_entry)? { let primary_namespace = get_key_from_dir_entry_path( @@ -720,20 +729,25 @@ impl FilesystemStoreState { } } +fn dir_entry_is_store_artifact(path: &Path) -> bool { + match path.extension().and_then(|ext| ext.to_str()) { + Some("tmp") => true, + Some("trash") => { + #[cfg(target_os = "windows")] + { + // Clean up any trash files lying around. + fs::remove_file(path).ok(); + } + true + }, + _ => false, + } +} + pub(crate) fn dir_entry_is_key(dir_entry: &fs::DirEntry) -> Result { let p = dir_entry.path(); - if let Some(ext) = p.extension() { - #[cfg(target_os = "windows")] - { - // Clean up any trash files lying around. - if ext == "trash" { - fs::remove_file(p).ok(); - return Ok(false); - } - } - if ext == "tmp" { - return Ok(false); - } + if dir_entry_is_store_artifact(&p) { + return Ok(false); } let file_type = dir_entry.file_type()?; diff --git a/lightning-persister/src/fs_store/v1.rs b/lightning-persister/src/fs_store/v1.rs index 776aba630c4..7f47c59a362 100644 --- a/lightning-persister/src/fs_store/v1.rs +++ b/lightning-persister/src/fs_store/v1.rs @@ -186,6 +186,28 @@ mod tests { assert_eq!(listed_keys.len(), 0); } + #[test] + fn list_all_keys_skips_leftover_store_artifacts() { + let mut temp_path = std::env::temp_dir(); + temp_path.push("test_list_all_keys_skips_leftover_store_artifacts"); + let fs_store = FilesystemStore::new(temp_path.clone()); + KVStoreSync::write(&fs_store, "primary", "secondary", "key", vec![1]).unwrap(); + + fs::write(temp_path.join("top_level.0.tmp"), b"stale").unwrap(); + fs::write(temp_path.join("top_level.0.trash"), b"stale").unwrap(); + + let primary_path = temp_path.join("primary"); + fs::write(primary_path.join("primary_level.0.tmp"), b"stale").unwrap(); + fs::write(primary_path.join("primary_level.0.trash"), b"stale").unwrap(); + + let secondary_path = primary_path.join("secondary"); + fs::write(secondary_path.join("secondary_level.0.tmp"), b"stale").unwrap(); + fs::write(secondary_path.join("secondary_level.0.trash"), b"stale").unwrap(); + + let keys = fs_store.list_all_keys().unwrap(); + assert_eq!(keys, vec![("primary".to_string(), "secondary".to_string(), "key".to_string())]); + } + #[test] fn test_data_migration() { let mut source_temp_path = std::env::temp_dir(); From f9269d1ae251d0d3709e3dbcc85258a4c3c20765 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Tue, 19 May 2026 16:35:58 +0200 Subject: [PATCH 408/627] Avoid grind signatures in fuzz builds Disable default lightning features in the fuzz crate and persister so fuzz builds do not inherit grind_signatures. Add a compile-time guard for fuzzing plus grind_signatures. Refresh the splice fuzz seed because the no-low-R weight model changes the signed funding transaction amount and fake-hash txid. --- fuzz/Cargo.toml | 2 +- fuzz/src/full_stack.rs | 18 +++++++++--------- lightning-persister/Cargo.toml | 2 +- lightning/src/lib.rs | 3 +++ 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 274b19d8ee4..76b4968f043 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -6,7 +6,7 @@ publish = false edition = "2021" [dependencies] -lightning = { path = "../lightning", features = ["regex", "_test_utils"] } +lightning = { path = "../lightning", default-features = false, features = ["std", "regex", "_test_utils"] } lightning-invoice = { path = "../lightning-invoice" } lightning-liquidity = { path = "../lightning-liquidity" } lightning-rapid-gossip-sync = { path = "../lightning-rapid-gossip-sync" } diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index 58509bb9b08..13506ee3107 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -1885,8 +1885,8 @@ fn splice_seed() -> Vec { // CommitmentSigned message with proper signature (r=f7, s=01...) and funding_txid TLV // signature r encodes sighash first byte f7, s follows the pattern from funding_created // TLV type 1 (odd/optional) for funding_txid as per impl_writeable_msg!(CommitmentSigned, ...) - // Note: txid is encoded in reverse byte order (Bitcoin standard), so to get display 0000...0031, encode 3100...0000 - ext_from_hex("0084 c000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000f7 0100000000000000000000000000000000000000000000000000000000000000 0000 01 20 3100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test); + // Note: txid is encoded in reverse byte order (Bitcoin standard), so to get display 0000...0032, encode 3200...0000 + ext_from_hex("0084 c000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000f7 0100000000000000000000000000000000000000000000000000000000000000 0000 01 20 3200000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test); // After commitment_signed exchange, we need to exchange tx_signatures. // Message type IDs: TxSignatures = 71 (0x0047) @@ -1899,19 +1899,19 @@ fn splice_seed() -> Vec { // inbound read from peer id 0 of len 150 (134 message + 16 MAC) ext_from_hex("030096", &mut test); // TxSignatures message with shared_input_signature TLV (type 0) - // txid must match the splice funding txid (0x31 in reverse byte order) + // txid must match the splice funding txid (0x32 in reverse byte order) // shared_input_signature: 64-byte fuzz signature for the shared input - ext_from_hex("0047 c000000000000000000000000000000000000000000000000000000000000000 3100000000000000000000000000000000000000000000000000000000000000 0000 00 40 00000000000000000000000000000000000000000000000000000000000000dc 0100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test); + ext_from_hex("0047 c000000000000000000000000000000000000000000000000000000000000000 3200000000000000000000000000000000000000000000000000000000000000 0000 00 40 00000000000000000000000000000000000000000000000000000000000000dc 0100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test); // Connect a block with the splice funding transaction to confirm it // The splice funding tx: version(4) + input_count(1) + txid(32) + vout(4) + script_len(1) + sequence(4) // + output_count(1) + value(8) + script_len(1) + script(34) + locktime(4) = 94 bytes = 0x5e // Transaction structure from FundingTransactionReadyForSigning: // - Input: spending c000...00:0 with sequence 0xfffffffd - // - Output: 115538 sats to OP_0 PUSH32 6e00...00 + // - Output: 115537 sats to OP_0 PUSH32 6e00...00 // - Locktime: 13 ext_from_hex("0c005e", &mut test); - ext_from_hex("02000000 01 c000000000000000000000000000000000000000000000000000000000000000 00000000 00 fdffffff 01 52c3010000000000 22 00206e00000000000000000000000000000000000000000000000000000000000000 0d000000", &mut test); + ext_from_hex("02000000 01 c000000000000000000000000000000000000000000000000000000000000000 00000000 00 fdffffff 01 51c3010000000000 22 00206e00000000000000000000000000000000000000000000000000000000000000 0d000000", &mut test); // Connect additional blocks to reach minimum_depth confirmations for _ in 0..5 { @@ -1928,8 +1928,8 @@ fn splice_seed() -> Vec { // inbound read from peer id 0 of len 82 (66 message + 16 MAC) ext_from_hex("030052", &mut test); // SpliceLocked message (type 77 = 0x004d): channel_id + splice_txid + mac - // splice_txid must match the splice funding txid (0x31 in reverse byte order) - ext_from_hex("004d c000000000000000000000000000000000000000000000000000000000000000 3100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test); + // splice_txid must match the splice funding txid (0x32 in reverse byte order) + ext_from_hex("004d c000000000000000000000000000000000000000000000000000000000000000 3200000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test); test } @@ -2059,6 +2059,6 @@ mod tests { // Splice locked assert_eq!(log_entries.get(&("lightning::ln::peer_handler".to_string(), "Handling SendSpliceLocked event in peer_handler for node 030000000000000000000000000000000000000000000000000000000000000002 for channel c000000000000000000000000000000000000000000000000000000000000000".to_string())), Some(&1)); - assert_eq!(log_entries.get(&("lightning::ln::channel".to_string(), "Promoting splice funding txid 0000000000000000000000000000000000000000000000000000000000000031".to_string())), Some(&1)); + assert_eq!(log_entries.get(&("lightning::ln::channel".to_string(), "Promoting splice funding txid 0000000000000000000000000000000000000000000000000000000000000032".to_string())), Some(&1)); } } diff --git a/lightning-persister/Cargo.toml b/lightning-persister/Cargo.toml index 19c5ac2545e..cb2aae556b6 100644 --- a/lightning-persister/Cargo.toml +++ b/lightning-persister/Cargo.toml @@ -20,7 +20,7 @@ tokio = ["dep:tokio"] [dependencies] bitcoin = "0.32.2" -lightning = { version = "0.3.0", path = "../lightning" } +lightning = { version = "0.3.0", path = "../lightning", default-features = false, features = ["std"] } tokio = { version = "1.35", optional = true, default-features = false, features = ["rt-multi-thread"] } [target.'cfg(windows)'.dependencies] diff --git a/lightning/src/lib.rs b/lightning/src/lib.rs index ee3b0f47a4d..496d1e5bb45 100644 --- a/lightning/src/lib.rs +++ b/lightning/src/lib.rs @@ -43,6 +43,9 @@ #[cfg(all(fuzzing, test))] compile_error!("Tests will always fail with cfg=fuzzing"); +#[cfg(all(fuzzing, feature = "grind_signatures"))] +compile_error!("Fuzz builds must not enable grind_signatures"); + #[macro_use] extern crate alloc; From 241ac47be6985373128e1ce1ff8151009d1c135a Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Tue, 19 May 2026 16:36:15 +0200 Subject: [PATCH 409/627] Account for fuzz signature weight When secp256k1_fuzz is active, dummy ECDSA signatures may serialize one byte larger per signature. Use fuzz-aware witness estimates for keyed-anchor bumping and HTLC resolution so debug weight assertions and aggregation limits use the fuzz signer bound. --- lightning/src/events/bump_transaction/mod.rs | 29 ++++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/lightning/src/events/bump_transaction/mod.rs b/lightning/src/events/bump_transaction/mod.rs index 6a5e9948653..79f5aced1b6 100644 --- a/lightning/src/events/bump_transaction/mod.rs +++ b/lightning/src/events/bump_transaction/mod.rs @@ -331,7 +331,13 @@ impl= max_tx_weight - USER_COINS_WEIGHT_BUDGET { @@ -649,9 +667,8 @@ impl selection, Err(()) => { - let htlcs_to_remove = USER_COINS_WEIGHT_BUDGET.div_ceil( - chan_utils::aggregated_htlc_timeout_input_output_pair_weight(channel_type), - ); + let htlcs_to_remove = + USER_COINS_WEIGHT_BUDGET.div_ceil(htlc_timeout_input_output_pair_weight); batch_size = batch_size.checked_sub(htlcs_to_remove as usize).ok_or(())?; if batch_size == 0 { return Err(()); From fd3623dfe5bce2d71b9f754dfc3000c437e30e4f Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Thu, 23 Apr 2026 10:07:44 -0700 Subject: [PATCH 410/627] Support manually selecting inputs consuming their entire value This commit introduces an alternative way of splicing in funds without coin selection by requiring the full UTXO to be provided. Each UTXO's entire value (minus fees) is allocated towards the channel, which provides unified balance wallets a more intuitive API when splicing funds into the channel, as they don't particularly care about maintaining a portion of their balance onchain. To simplify the implementation, we require that contributions are not allowed to mix coin-selected inputs with manually-selected ones. Users will need to start a fresh contribution if they want to change the funding input mode. --- lightning/src/ln/funding.rs | 1080 +++++++++++++++++++++++----- lightning/src/ln/splicing_tests.rs | 77 +- 2 files changed, 983 insertions(+), 174 deletions(-) diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 3a0b4fb0630..e31f7657207 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -147,6 +147,8 @@ pub enum FundingContributionError { /// the builder fall back to fresh coin selection, which may replace the prior input set instead /// of preserving it. MissingCoinSelectionSource, + /// The request cannot be satisfied using the manually selected inputs. + ManuallySelectedInputsInsufficient, /// This template cannot build an RBF contribution. NotRbfScenario, } @@ -172,6 +174,9 @@ impl core::fmt::Display for FundingContributionError { FundingContributionError::MissingCoinSelectionSource => { write!(f, "Coin selection source required to build this contribution") }, + FundingContributionError::ManuallySelectedInputsInsufficient => { + write!(f, "The request cannot be satisfied using the manually selected inputs") + }, FundingContributionError::NotRbfScenario => { write!(f, "This template cannot build an RBF contribution") }, @@ -336,13 +341,15 @@ impl FundingTemplate { /// least `min_feerate`. `wallet` is only consulted if the request cannot be satisfied by /// reusing/amending the prior contribution. When this template carries a prior contribution, /// increasing its value may therefore re-run coin selection and yield a different input set than - /// the prior contribution used. + /// the prior contribution used. This is not supported when the prior contribution used manually + /// selected inputs; use [`FundingTemplate::splice_in_inputs`] or + /// [`FundingTemplate::without_prior_contribution`] in that case. pub async fn splice_in( self, value_added: Amount, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, ) -> Result { self.with_prior_contribution(min_feerate, max_feerate) .with_coin_selection_source(wallet) - .add_value(value_added) + .add_value(value_added)? .build() .await } @@ -350,16 +357,40 @@ impl FundingTemplate { /// Creates a [`FundingContribution`] for adding funds to a channel. /// /// This is the synchronous variant of [`FundingTemplate::splice_in`]; `value_added`, - /// `min_feerate`, `max_feerate`, and `wallet` have the same meaning. + /// `min_feerate`, `max_feerate`, and `wallet` have the same meaning, including the restriction + /// on prior contributions with manually selected inputs. pub fn splice_in_sync( self, value_added: Amount, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, ) -> Result { self.with_prior_contribution(min_feerate, max_feerate) .with_coin_selection_source_sync(wallet) - .add_value(value_added) + .add_value(value_added)? .build() } + /// Creates a [`FundingContribution`] for adding funds to a channel using manually selected + /// inputs. + /// + /// This is a convenience wrapper around [`FundingTemplate::with_prior_contribution`] with no + /// wallet attached. Each input is fully consumed with no change output, so the amount added to + /// the channel is derived from the total input value minus the estimated fee. + /// + /// When a prior contribution with manually selected inputs is present, `inputs` are appended to + /// the prior [`FundingContribution::inputs`] instead of replacing them. Use + /// [`FundingTemplate::without_prior_contribution`] if you want to replace the prior request + /// instead. If the template carries a coin-selected prior contribution, manual inputs are + /// incompatible and this method returns [`FundingContributionError::InvalidSpliceValue`]. + /// + /// `inputs` are the additional manually selected inputs to fully consume. `min_feerate` is the + /// feerate used for fee estimation and must be at least [`FundingTemplate::min_rbf_feerate`] + /// when that is set. `max_feerate` is the highest feerate we are willing to tolerate if we end + /// up as the acceptor, and must be at least `min_feerate`. + pub fn splice_in_inputs( + self, inputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, + ) -> Result { + self.with_prior_contribution(min_feerate, max_feerate).add_inputs(inputs)?.build() + } + /// Creates a [`FundingContribution`] for removing funds from a channel. /// /// This is a convenience wrapper around [`FundingTemplate::with_prior_contribution`] with no @@ -527,25 +558,70 @@ fn validate_inputs(inputs: &[FundingTxInput]) -> Result<(), FundingContributionE Ok(()) } -/// Describes how an amended contribution should source its wallet-backed inputs. +/// Describes how a contribution request should source its wallet-backed inputs. +#[derive(Debug, Clone, PartialEq, Eq)] enum FundingInputs { - None, /// Reuses the contribution's existing inputs while targeting at least `value_added` added to /// the channel after fees. If dropping the change output leaves surplus value, it remains in /// the channel contribution. - CoinSelected { - value_added: Amount, - }, + CoinSelected { value_added: Amount }, + /// Replaces the contribution's inputs with the provided set and fully consumes them without a + /// change output. The amount added to the channel is recomputed from the input total minus fees, + /// while explicit withdrawal outputs still reduce the splice's net value. + ManuallySelected { inputs: Vec }, } +impl FundingInputs { + fn mode(&self) -> FundingInputMode { + match self { + FundingInputs::CoinSelected { .. } => FundingInputMode::CoinSelected, + FundingInputs::ManuallySelected { .. } => FundingInputMode::ManuallySelected, + } + } + + fn is_empty(&self) -> bool { + match self { + FundingInputs::CoinSelected { value_added } => *value_added == Amount::ZERO, + FundingInputs::ManuallySelected { inputs } => inputs.is_empty(), + } + } + + fn value_added(&self) -> Amount { + match self { + FundingInputs::CoinSelected { value_added } => *value_added, + FundingInputs::ManuallySelected { .. } => Amount::ZERO, + } + } + + fn manually_selected_inputs(&self) -> &[FundingTxInput] { + match self { + FundingInputs::ManuallySelected { inputs } => inputs, + FundingInputs::CoinSelected { .. } => &[], + } + } +} + +#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq)] +enum FundingInputMode { + CoinSelected, + ManuallySelected, +} + +impl_writeable_tlv_based_enum!(FundingInputMode, + (1, CoinSelected) => {}, + (3, ManuallySelected) => {} +); + /// The components of a funding transaction contributed by one party. #[derive(Debug, Clone, Hash, PartialEq, Eq)] pub struct FundingContribution { /// The estimate fees responsible to be paid for the contribution. estimated_fee: Amount, - /// The inputs included in the funding transaction to meet the contributed amount plus fees. Any - /// excess amount will be sent to a change output. + /// The inputs included in the funding transaction. + /// + /// For coin-selected contributions, excess value is returned via [`Self::change_output`]. For + /// manually selected inputs, the full input value is consumed and no change output is created. inputs: Vec, /// The outputs to include in the funding transaction. @@ -565,6 +641,12 @@ pub struct FundingContribution { /// Whether the contribution is for funding a splice. is_splice: bool, + + /// Whether this contribution currently uses coin-selected or manual-input semantics. + /// + /// This is `None` when the contribution has no inputs and is set accordingly based on the first + /// `add_value` or `add_input` call on the builder. + input_mode: Option, } impl_writeable_tlv_based!(FundingContribution, { @@ -575,6 +657,7 @@ impl_writeable_tlv_based!(FundingContribution, { (9, feerate, required), (11, max_feerate, required), (13, is_splice, required), + (15, input_mode, option), }); impl FundingContribution { @@ -593,11 +676,13 @@ impl FundingContribution { .map(|output| output.script_pubkey.as_script()) } - /// The value that will be added to the channel after fees. See [`Self::net_value`] for the net - /// value contribution to the channel. + /// The positive value added to the channel after explicit outputs and fees. + /// + /// This saturates at zero for net-negative contributions. See [`Self::net_value`] for the full + /// signed contribution to the channel. pub fn value_added(&self) -> Amount { let total_input_value = self.inputs.iter().map(|i| i.utxo.output.value).sum::(); - let total_output_value = self.outputs.iter().map(|output| output.value).sum::(); + let total_output_value = self.outputs.iter().map(|output| output.value).sum(); total_input_value .checked_sub(total_output_value) .and_then(|v| v.checked_sub(self.estimated_fee)) @@ -658,84 +743,91 @@ impl FundingContribution { /// Returns `None` if the request would require new wallet inputs or cannot accommodate the /// requested feerate. fn amend_without_coin_selection( - self, inputs: FundingInputs, outputs: &[TxOut], target_feerate: FeeRate, + self, funding_inputs: Option, outputs: &[TxOut], target_feerate: FeeRate, max_feerate: FeeRate, spliceable_balance: Amount, ) -> Option { // NOTE: The contribution returned is not guaranteed to be valid. We defer doing so until // `compute_feerate_adjustment`. - let adjust_for_inputs_and_outputs = - |contribution: Self, inputs: FundingInputs, outputs: &[TxOut]| -> Option { - let (target_value_added, inputs) = match inputs { - FundingInputs::None => (None, Vec::new()), - FundingInputs::CoinSelected { value_added } => { - (Some(value_added), contribution.inputs) - }, - }; - - if inputs.is_empty() && target_value_added.unwrap_or(Amount::ZERO) != Amount::ZERO { - // Prior contribution didn't have any inputs, but now we need some. - return None; - } + let adjust_for_inputs_and_outputs = |contribution: Self, + inputs: Option, + outputs: &[TxOut]| + -> Option { + let input_mode = inputs.as_ref().map(FundingInputs::mode); + let (target_value_added, inputs) = match inputs { + None => (None, Vec::new()), + Some(FundingInputs::CoinSelected { value_added }) => { + // We track the prior contribution's inputs here to see if they can cover the + // new `value_added` without running coin selection. + (Some(value_added), contribution.inputs) + }, + Some(FundingInputs::ManuallySelected { inputs }) => (None, inputs), + }; - // When inputs are coin-selected, adjust the existing change output, if any, to account - // for the requested value added and any explicit outputs that must also be funded by - // the inputs. - if let Some(value_added) = target_value_added { - let estimated_fee = estimate_transaction_fee( - &inputs, - &outputs, - contribution.change_output.as_ref(), - true, - contribution.is_splice, - contribution.feerate, - ); - let total_output_value: Amount = - outputs.iter().map(|output| output.value).sum(); - let required_value = - value_added.checked_add(total_output_value)?.checked_add(estimated_fee)?; - - if let Some(change_output) = contribution.change_output.as_ref() { - let dust_limit = change_output.script_pubkey.minimal_non_dust(); - let total_input_value: Amount = - inputs.iter().map(|input| input.utxo.output.value).sum(); - match total_input_value.checked_sub(required_value) { - Some(new_change_value) if new_change_value >= dust_limit => { - let new_change_output = TxOut { - value: new_change_value, - script_pubkey: change_output.script_pubkey.clone(), - }; - return Some(FundingContribution { - estimated_fee, - inputs, - outputs: outputs.to_vec(), - change_output: Some(new_change_output), - ..contribution - }); - }, - _ => {}, - } - } - } + if inputs.is_empty() && target_value_added.unwrap_or(Amount::ZERO) != Amount::ZERO { + // Prior contribution didn't have any inputs, but now we need some. + return None; + } - let estimated_fee_no_change = estimate_transaction_fee( + // When inputs are coin-selected, adjust the existing change output, if any, to account + // for the requested value added and any explicit outputs that must also be funded by + // the inputs. + if let Some(value_added) = target_value_added { + let estimated_fee = estimate_transaction_fee( &inputs, &outputs, - None, + contribution.change_output.as_ref(), true, contribution.is_splice, contribution.feerate, ); - Some(FundingContribution { - estimated_fee: estimated_fee_no_change, - outputs: outputs.to_vec(), - inputs, - change_output: None, - ..contribution - }) - }; + let total_output_value: Amount = outputs.iter().map(|output| output.value).sum(); + let required_value = + value_added.checked_add(total_output_value)?.checked_add(estimated_fee)?; + + if let Some(change_output) = contribution.change_output.as_ref() { + let dust_limit = change_output.script_pubkey.minimal_non_dust(); + let total_input_value: Amount = + inputs.iter().map(|input| input.utxo.output.value).sum(); + match total_input_value.checked_sub(required_value) { + Some(new_change_value) if new_change_value >= dust_limit => { + let new_change_output = TxOut { + value: new_change_value, + script_pubkey: change_output.script_pubkey.clone(), + }; + return Some(FundingContribution { + estimated_fee, + inputs, + outputs: outputs.to_vec(), + change_output: Some(new_change_output), + input_mode, + ..contribution + }); + }, + _ => {}, + } + } + } + + let estimated_fee_no_change = estimate_transaction_fee( + &inputs, + &outputs, + None, + true, + contribution.is_splice, + contribution.feerate, + ); + Some(FundingContribution { + estimated_fee: estimated_fee_no_change, + outputs: outputs.to_vec(), + inputs, + change_output: None, + input_mode, + ..contribution + }) + }; let new_contribution_at_current_feerate = - adjust_for_inputs_and_outputs(self, inputs, outputs)?; + adjust_for_inputs_and_outputs(self, funding_inputs, outputs)?; let mut new_contribution_at_target_feerate = new_contribution_at_current_feerate .at_feerate(target_feerate, spliceable_balance, true) .ok()?; @@ -847,7 +939,9 @@ impl FundingContribution { target_feerate, ); - if !self.inputs.is_empty() { + if !self.inputs.is_empty() && self.input_mode == Some(FundingInputMode::CoinSelected) { + // Any withdrawal outputs and fees always come from the coin-selected inputs, as we want + // to guarantee the net contribution adds the desired value. let fee_buffer = self .estimated_fee .checked_add( @@ -893,18 +987,22 @@ impl FundingContribution { }) } } else { - // Without coin-selected inputs, both the withdrawals and the fee come from the channel - // balance. - let value_removed: Amount = self.outputs.iter().map(|o| o.value).sum(); - let total_cost = target_fee - .checked_add(value_removed) - .ok_or(FeeRateAdjustmentError::FeeBufferOverflow)?; - if total_cost > spliceable_balance { + // Manually selected inputs may either add value to the channel or offset some of the + // withdrawal outputs. Any remaining fee cost must come from the channel balance. + let net_value_without_fee = self.net_value_without_fee(); + let fee_buffer = if net_value_without_fee.is_negative() { + spliceable_balance + .checked_sub(net_value_without_fee.unsigned_abs()) + .unwrap_or(Amount::ZERO) + } else { + spliceable_balance + .checked_add(net_value_without_fee.unsigned_abs()) + .ok_or(FeeRateAdjustmentError::FeeBufferOverflow)? + }; + if fee_buffer < target_fee { return Err(FeeRateAdjustmentError::FeeBufferInsufficient { - source: "channel balance - withdrawal outputs", - available: spliceable_balance - .checked_sub(value_removed) - .unwrap_or(Amount::ZERO), + source: "channel balance", + available: fee_buffer, required: target_fee, }); } @@ -1051,7 +1149,7 @@ struct FundingBuilderInner { shared_input: Option, min_rbf_feerate: Option, prior_contribution: Option, - value_added: Amount, + funding_inputs: Option, outputs: Vec, feerate: FeeRate, max_feerate: FeeRate, @@ -1060,43 +1158,62 @@ struct FundingBuilderInner { /// A builder for composing or amending a [`FundingContribution`]. /// -/// The builder tracks a requested amount to add to the channel together with any explicit -/// withdrawal outputs. Building without an attached wallet only succeeds when the request can be -/// satisfied by reusing or amending a prior contribution, or by constructing a pure splice-out -/// that pays fees from the channel balance. +/// The builder tracks either a requested amount to add to the channel or a fixed set of manually +/// selected inputs, together with any explicit withdrawal outputs. Building without an attached +/// wallet only succeeds when the request can be satisfied by reusing or amending a prior +/// contribution, by using only manually selected inputs, or by constructing a splice-out that +/// pays fees from the channel balance. /// /// Attach a wallet via [`FundingBuilder::with_coin_selection_source`] or /// [`FundingBuilder::with_coin_selection_source_sync`] when the request may need new wallet -/// inputs. +/// inputs. Manually selected inputs are not supplemented with coin selection. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FundingBuilder(FundingBuilderInner); /// A [`FundingBuilder`] with an attached asynchronous [`CoinSelectionSource`]. /// /// Created by [`FundingBuilder::with_coin_selection_source`]. The attached wallet is only used -/// if the request cannot be satisfied by reusing a prior contribution or by building a pure -/// splice-out directly. +/// if the request cannot be satisfied by reusing a prior contribution, by using only manually +/// selected inputs, or by building a pure splice-out directly. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AsyncFundingBuilder(FundingBuilderInner>); /// A [`FundingBuilder`] with an attached synchronous [`CoinSelectionSourceSync`]. /// /// Created by [`FundingBuilder::with_coin_selection_source_sync`]. The attached wallet is only -/// used if the request cannot be satisfied by reusing a prior contribution or by building a pure -/// splice-out directly. +/// used if the request cannot be satisfied by reusing a prior contribution, by using only +/// manually selected inputs, or by building a pure splice-out directly. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SyncFundingBuilder(FundingBuilderInner>); impl FundingBuilderInner { fn request_matches_prior(&self, prior_contribution: &FundingContribution) -> bool { - self.value_added == prior_contribution.value_added() - && self.outputs == prior_contribution.outputs + let request_matches_prior_inputs = + match (self.funding_inputs.as_ref(), prior_contribution.input_mode) { + ( + Some(FundingInputs::ManuallySelected { inputs }), + Some(FundingInputMode::ManuallySelected), + ) => { + let request_inputs = inputs.iter().map(|input| input.utxo.outpoint); + let prior_inputs = + prior_contribution.inputs.iter().map(|input| input.utxo.outpoint); + request_inputs.eq(prior_inputs) + }, + ( + Some(FundingInputs::CoinSelected { value_added }), + Some(FundingInputMode::CoinSelected), + ) => *value_added == prior_contribution.value_added(), + (None, None) => true, + _ => false, + }; + request_matches_prior_inputs && self.outputs == prior_contribution.outputs } fn build_from_prior_contribution( - &mut self, contribution: PriorContribution, + &self, contribution: PriorContribution, ) -> Result { let PriorContribution { contribution, spliceable_balance } = contribution; + let input_mode = self.funding_inputs.as_ref().map(FundingInputs::mode); if self.request_matches_prior(&contribution) { // Same request, but the feerate may have changed. Adjust the prior contribution @@ -1107,57 +1224,87 @@ impl FundingBuilderInner { adjusted.max_feerate = self.max_feerate; adjusted }) - .map_err(|_| FundingContributionError::MissingCoinSelectionSource); + .map_err(|_| { + if input_mode == Some(FundingInputMode::ManuallySelected) { + FundingContributionError::ManuallySelectedInputsInsufficient + } else { + FundingContributionError::MissingCoinSelectionSource + } + }); } - let funding_inputs = if self.value_added != Amount::ZERO { - FundingInputs::CoinSelected { value_added: self.value_added } - } else { - FundingInputs::None - }; return contribution .amend_without_coin_selection( - funding_inputs, + self.funding_inputs.clone(), &self.outputs, self.feerate, self.max_feerate, spliceable_balance, ) - .ok_or_else(|| FundingContributionError::MissingCoinSelectionSource); + .ok_or_else(|| { + if input_mode == Some(FundingInputMode::ManuallySelected) { + FundingContributionError::ManuallySelectedInputsInsufficient + } else { + FundingContributionError::MissingCoinSelectionSource + } + }); } /// Tries to build the current request without selecting any new wallet inputs. /// /// This first attempts to reuse or amend any prior contribution. If there is no prior - /// contribution, it also supports pure splice-out requests by building a contribution that pays - /// fees from the channel balance. + /// contribution, it also supports manually selected inputs and pure splice-out requests by + /// building a contribution without coin selection. /// /// Returns [`FundingContributionError::MissingCoinSelectionSource`] if the request is - /// otherwise valid but needs wallet inputs. + /// otherwise valid but needs wallet inputs, or + /// [`FundingContributionError::ManuallySelectedInputsInsufficient`] if the manually selected + /// inputs cannot satisfy the request. fn try_build_without_coin_selection( - &mut self, + &self, ) -> Result { - if let Some(contribution) = self.prior_contribution.take() { - return self.build_from_prior_contribution(contribution); + if let Some(contribution) = self.prior_contribution.as_ref() { + return self.build_from_prior_contribution(contribution.clone()); } - if self.value_added == Amount::ZERO { + let value_added = + self.funding_inputs.as_ref().map_or(Amount::ZERO, FundingInputs::value_added); + if value_added == Amount::ZERO { + let inputs = self + .funding_inputs + .as_ref() + .map_or(&[][..], FundingInputs::manually_selected_inputs); + let input_mode = + if inputs.is_empty() { None } else { Some(FundingInputMode::ManuallySelected) }; + + let total_input_value: Amount = + inputs.iter().map(|input| input.utxo.output.value).sum(); let estimated_fee = estimate_transaction_fee( - &[], + inputs, &self.outputs, None, true, self.shared_input.is_some(), self.feerate, ); + if !inputs.is_empty() { + total_input_value + .checked_sub(estimated_fee) + .ok_or(FundingContributionError::ManuallySelectedInputsInsufficient)?; + } + return Ok(FundingContribution { estimated_fee, - inputs: vec![], - outputs: core::mem::take(&mut self.outputs), + inputs: match self.funding_inputs { + Some(FundingInputs::ManuallySelected { ref inputs }) => inputs.clone(), + None | Some(FundingInputs::CoinSelected { .. }) => Vec::new(), + }, + outputs: self.outputs.clone(), change_output: None, feerate: self.feerate, max_feerate: self.max_feerate, is_splice: self.shared_input.is_some(), + input_mode, }); } @@ -1167,6 +1314,8 @@ impl FundingBuilderInner { fn prepare_coin_selection_request( &self, ) -> Result<(Vec, Vec), FundingContributionError> { + let value_added = + self.funding_inputs.as_ref().map_or(Amount::ZERO, FundingInputs::value_added); let dummy_pubkey = PublicKey::from_slice(&[2; 33]).unwrap(); let shared_output = bitcoin::TxOut { value: self @@ -1174,7 +1323,7 @@ impl FundingBuilderInner { .as_ref() .map(|shared_input| shared_input.previous_utxo.value) .unwrap_or(Amount::ZERO) - .checked_add(self.value_added) + .checked_add(value_added) .ok_or(FundingContributionError::InvalidSpliceValue)?, script_pubkey: make_funding_redeemscript(&dummy_pubkey, &dummy_pubkey).to_p2wsh(), }; @@ -1206,7 +1355,9 @@ impl FundingBuilderInner { } } - if self.value_added == Amount::ZERO && self.outputs.is_empty() { + if self.funding_inputs.as_ref().map_or(true, FundingInputs::is_empty) + && self.outputs.is_empty() + { return Err(FundingContributionError::InvalidSpliceValue); } @@ -1214,10 +1365,16 @@ impl FundingBuilderInner { // ensure FundingContribution::net_value() arithmetic cannot overflow. With all // amounts bounded by MAX_MONEY (~2.1e15 sat), the worst-case net_value() // computation is -2 * MAX_MONEY (~-4.2e15), well within i64::MIN (~-9.2e18). - if self.value_added > Amount::MAX_MONEY { + if self.funding_inputs.as_ref().map_or(Amount::ZERO, FundingInputs::value_added) + > Amount::MAX_MONEY + { return Err(FundingContributionError::InvalidSpliceValue); } + validate_inputs( + self.funding_inputs.as_ref().map_or(&[][..], FundingInputs::manually_selected_inputs), + )?; + let mut value_removed = Amount::ZERO; for output in self.outputs.iter() { value_removed = match value_removed.checked_add(output.value) { @@ -1233,19 +1390,29 @@ impl FundingBuilderInner { impl FundingBuilder { fn new(template: FundingTemplate, feerate: FeeRate, max_feerate: FeeRate) -> FundingBuilder { let FundingTemplate { shared_input, min_rbf_feerate, prior_contribution } = template; - let (value_added, outputs) = match prior_contribution.as_ref() { + let (funding_inputs, outputs) = match prior_contribution.as_ref() { Some(prior) => { - let outputs = prior.contribution.outputs.clone(); - (prior.contribution.value_added(), outputs) + let funding_inputs = match prior.contribution.input_mode { + Some(FundingInputMode::ManuallySelected) => { + Some(FundingInputs::ManuallySelected { + inputs: prior.contribution.inputs.clone(), + }) + }, + Some(FundingInputMode::CoinSelected) => Some(FundingInputs::CoinSelected { + value_added: prior.contribution.value_added(), + }), + None => None, + }; + (funding_inputs, prior.contribution.outputs.clone()) }, - None => (Amount::ZERO, Vec::new()), + None => (None, Vec::new()), }; FundingBuilder(FundingBuilderInner { shared_input, min_rbf_feerate, prior_contribution, - value_added, + funding_inputs, outputs, feerate, max_feerate, @@ -1256,7 +1423,8 @@ impl FundingBuilder { /// Attaches an asynchronous [`CoinSelectionSource`] for later use. /// /// The wallet is only consulted if [`AsyncFundingBuilder::build`] cannot satisfy the request by - /// reusing a prior contribution or by constructing a pure splice-out directly. + /// reusing a prior contribution, by using only manually selected inputs, or by constructing a + /// pure splice-out directly. pub fn with_coin_selection_source( self, wallet: W, ) -> AsyncFundingBuilder { @@ -1266,13 +1434,58 @@ impl FundingBuilder { /// Attaches a synchronous [`CoinSelectionSourceSync`] for later use. /// /// The wallet is only consulted if [`SyncFundingBuilder::build`] cannot satisfy the request by - /// reusing a prior contribution or by constructing a pure splice-out directly. + /// reusing a prior contribution, by using only manually selected inputs, or by constructing a + /// pure splice-out directly. pub fn with_coin_selection_source_sync( self, wallet: W, ) -> SyncFundingBuilder { SyncFundingBuilder(self.0.with_state(SyncCoinSelectionSource(wallet))) } + /// Adds a manually selected input to the request. + /// + /// Each input is fully consumed with no change output. When built without additional coin + /// selection, the inputs and explicit outputs are modeled by their net effect on the channel: + /// the contribution may be net-positive or net-negative before fees. + /// + /// Manually selected inputs are a separate request mode and cannot be combined with requesting + /// additional coin-selected value. If the manually selected inputs cannot satisfy the request, + /// [`FundingBuilder::build`] returns + /// [`FundingContributionError::ManuallySelectedInputsInsufficient`] instead of falling back to + /// coin selection. + /// + /// Returns [`FundingContributionError::InvalidSpliceValue`] if the builder already has a + /// coin-selected value request. + pub fn add_input(self, input: FundingTxInput) -> Result { + self.0.add_input_inner(input).map(FundingBuilder) + } + + /// Adds manually selected inputs to the request. + /// + /// Each input is fully consumed with no change output. When built without additional coin + /// selection, the inputs and explicit outputs are modeled by their net effect on the channel: + /// the contribution may be net-positive or net-negative before fees. + /// + /// Manually selected inputs are a separate request mode and cannot be combined with requesting + /// additional coin-selected value. If the manually selected inputs cannot satisfy the request, + /// [`FundingBuilder::build`] returns + /// [`FundingContributionError::ManuallySelectedInputsInsufficient`] instead of falling back to + /// coin selection. + /// + /// Returns [`FundingContributionError::InvalidSpliceValue`] if the builder already has a + /// coin-selected value request. + pub fn add_inputs(self, inputs: Vec) -> Result { + self.0.add_inputs_inner(inputs).map(FundingBuilder) + } + + /// Removes all manually selected inputs whose outpoint matches `outpoint`. + /// + /// Returns [`FundingContributionError::InvalidSpliceValue`] if the builder already has a + /// coin-selected value request. + pub fn remove_input(self, outpoint: &OutPoint) -> Result { + self.0.remove_input_inner(outpoint).map(FundingBuilder) + } + /// Adds a withdrawal output to the request. /// /// `output` is appended to the current set of explicit outputs. If the builder was seeded from @@ -1302,12 +1515,13 @@ impl FundingBuilder { /// Builds a [`FundingContribution`] without coin selection. /// /// This succeeds when the request can be satisfied by reusing or amending a prior - /// contribution, or by building a splice-out contribution that pays fees from the channel - /// balance. + /// contribution, by using only manually selected inputs, or by building a splice-out + /// contribution that pays fees from the channel balance. /// /// Returns [`FundingContributionError::MissingCoinSelectionSource`] if additional wallet - /// inputs are needed. - pub fn build(mut self) -> Result { + /// inputs are needed, or [`FundingContributionError::ManuallySelectedInputsInsufficient`] if + /// the manually selected inputs cannot satisfy the request. + pub fn build(self) -> Result { self.0.build_without_coin_selection() } } @@ -1318,7 +1532,7 @@ impl FundingBuilderInner { shared_input: self.shared_input, min_rbf_feerate: self.min_rbf_feerate, prior_contribution: self.prior_contribution, - value_added: self.value_added, + funding_inputs: self.funding_inputs, outputs: self.outputs, feerate: self.feerate, max_feerate: self.max_feerate, @@ -1326,16 +1540,73 @@ impl FundingBuilderInner { } } - fn add_value_inner(mut self, value: Amount) -> Self { - self.value_added = - Amount::from_sat(self.value_added.to_sat().saturating_add(value.to_sat())); - self + fn add_value_inner(mut self, value: Amount) -> Result { + match &mut self.funding_inputs { + None => self.funding_inputs = Some(FundingInputs::CoinSelected { value_added: value }), + Some(FundingInputs::CoinSelected { value_added }) => { + *value_added = + Amount::from_sat(value_added.to_sat().saturating_add(value.to_sat())); + }, + Some(FundingInputs::ManuallySelected { .. }) => { + return Err(FundingContributionError::InvalidSpliceValue); + }, + } + Ok(self) } - fn remove_value_inner(mut self, value: Amount) -> Self { - self.value_added = - Amount::from_sat(self.value_added.to_sat().saturating_sub(value.to_sat())); - self + fn remove_value_inner(mut self, value: Amount) -> Result { + match &mut self.funding_inputs { + None => {}, + Some(FundingInputs::CoinSelected { value_added }) => { + *value_added = + Amount::from_sat(value_added.to_sat().saturating_sub(value.to_sat())); + }, + Some(FundingInputs::ManuallySelected { .. }) => { + return Err(FundingContributionError::InvalidSpliceValue); + }, + } + Ok(self) + } + + fn add_input_inner(mut self, input: FundingTxInput) -> Result { + match &mut self.funding_inputs { + None => { + self.funding_inputs = Some(FundingInputs::ManuallySelected { inputs: vec![input] }) + }, + Some(FundingInputs::ManuallySelected { inputs }) => inputs.push(input), + Some(FundingInputs::CoinSelected { .. }) => { + return Err(FundingContributionError::InvalidSpliceValue); + }, + } + Ok(self) + } + + fn add_inputs_inner( + mut self, inputs: Vec, + ) -> Result { + match &mut self.funding_inputs { + None => self.funding_inputs = Some(FundingInputs::ManuallySelected { inputs }), + Some(FundingInputs::ManuallySelected { inputs: existing_inputs }) => { + existing_inputs.extend(inputs) + }, + Some(FundingInputs::CoinSelected { .. }) => { + return Err(FundingContributionError::InvalidSpliceValue); + }, + } + Ok(self) + } + + fn remove_input_inner(mut self, outpoint: &OutPoint) -> Result { + match &mut self.funding_inputs { + None => {}, + Some(FundingInputs::ManuallySelected { inputs }) => { + inputs.retain(|input| input.utxo.outpoint != *outpoint); + }, + Some(FundingInputs::CoinSelected { .. }) => { + return Err(FundingContributionError::InvalidSpliceValue); + }, + } + Ok(self) } fn add_output_inner(mut self, output: TxOut) -> Self { @@ -1357,9 +1628,11 @@ impl FundingBuilderInner { /// inputs. /// /// Returns [`FundingContributionError::MissingCoinSelectionSource`] if the request is valid but - /// cannot be satisfied without wallet inputs. + /// cannot be satisfied without wallet inputs, or + /// [`FundingContributionError::ManuallySelectedInputsInsufficient`] if the manually selected + /// inputs cannot satisfy the request. fn build_without_coin_selection( - &mut self, + &self, ) -> Result { self.validate_contribution_parameters()?; self.try_build_without_coin_selection() @@ -1399,8 +1672,11 @@ impl AsyncFundingBuilder { /// prior contribution, this increases that prior contribution's current amount added to the /// channel. If the updated request cannot be satisfied in-place, [`AsyncFundingBuilder::build`] /// may re-run coin selection and return a contribution with a different input set. - pub fn add_value(self, value: Amount) -> Self { - AsyncFundingBuilder(self.0.add_value_inner(value)) + /// + /// Returns [`FundingContributionError::InvalidSpliceValue`] if the builder already has manually + /// selected inputs. + pub fn add_value(self, value: Amount) -> Result { + self.0.add_value_inner(value).map(AsyncFundingBuilder) } /// Decreases the requested amount to add to the channel. @@ -1410,8 +1686,11 @@ impl AsyncFundingBuilder { /// amount added to the channel. If the updated request cannot be satisfied in-place, /// [`AsyncFundingBuilder::build`] may re-run coin selection and return a contribution with a /// different input set. - pub fn remove_value(self, value: Amount) -> Self { - AsyncFundingBuilder(self.0.remove_value_inner(value)) + /// + /// Returns [`FundingContributionError::InvalidSpliceValue`] if the builder already has manually + /// selected inputs. + pub fn remove_value(self, value: Amount) -> Result { + self.0.remove_value_inner(value).map(AsyncFundingBuilder) } } @@ -1419,9 +1698,10 @@ impl AsyncFundingBuilder { /// Builds a [`FundingContribution`], using the attached asynchronous wallet only when needed. /// /// If the request can be satisfied by reusing or amending a prior contribution, or by building - /// a pure splice-out directly, the attached wallet is ignored. + /// a pure splice-out directly, or by using only manually selected inputs, the attached wallet is + /// ignored. pub async fn build(self) -> Result { - let mut inner = self.0; + let inner = self.0; match inner.build_without_coin_selection() { Err(FundingContributionError::MissingCoinSelectionSource) => {}, other => return other, @@ -1462,6 +1742,7 @@ impl AsyncFundingBuilder { feerate: inner.feerate, max_feerate: inner.max_feerate, is_splice, + input_mode: Some(FundingInputMode::CoinSelected), }); } } @@ -1499,8 +1780,11 @@ impl SyncFundingBuilder { /// prior contribution, this increases that prior contribution's current amount added to the /// channel. If the updated request cannot be satisfied in-place, [`SyncFundingBuilder::build`] /// may re-run coin selection and return a contribution with a different input set. - pub fn add_value(self, value: Amount) -> Self { - SyncFundingBuilder(self.0.add_value_inner(value)) + /// + /// Returns [`FundingContributionError::InvalidSpliceValue`] if the builder already has manually + /// selected inputs. + pub fn add_value(self, value: Amount) -> Result { + self.0.add_value_inner(value).map(SyncFundingBuilder) } /// Decreases the requested amount to add to the channel. @@ -1510,8 +1794,11 @@ impl SyncFundingBuilder { /// amount added to the channel. If the updated request cannot be satisfied in-place, /// [`SyncFundingBuilder::build`] may re-run coin selection and return a contribution with a /// different input set. - pub fn remove_value(self, value: Amount) -> Self { - SyncFundingBuilder(self.0.remove_value_inner(value)) + /// + /// Returns [`FundingContributionError::InvalidSpliceValue`] if the builder already has manually + /// selected inputs. + pub fn remove_value(self, value: Amount) -> Result { + self.0.remove_value_inner(value).map(SyncFundingBuilder) } } @@ -1519,9 +1806,10 @@ impl SyncFundingBuilder { /// Builds a [`FundingContribution`], using the attached synchronous wallet only when needed. /// /// If the request can be satisfied by reusing or amending a prior contribution, or by building - /// a pure splice-out directly, the attached wallet is ignored. + /// a pure splice-out directly, or by using only manually selected inputs, the attached wallet is + /// ignored. pub fn build(self) -> Result { - let mut inner = self.0; + let inner = self.0; match inner.build_without_coin_selection() { Err(FundingContributionError::MissingCoinSelectionSource) => {}, other => return other, @@ -1561,6 +1849,7 @@ impl SyncFundingBuilder { feerate: inner.feerate, max_feerate: inner.max_feerate, is_splice, + input_mode: Some(FundingInputMode::CoinSelected), }); } } @@ -1569,7 +1858,8 @@ impl SyncFundingBuilder { mod tests { use super::{ estimate_transaction_fee, FeeRateAdjustmentError, FundingBuilder, FundingContribution, - FundingContributionError, FundingTemplate, FundingTxInput, PriorContribution, + FundingContributionError, FundingInputMode, FundingTemplate, FundingTxInput, + PriorContribution, SyncCoinSelectionSource, SyncFundingBuilder, }; use crate::chain::ClaimId; use crate::util::wallet_utils::{CoinSelection, CoinSelectionSourceSync, Input}; @@ -1747,7 +2037,7 @@ mod tests { let feerate = FeeRate::from_sat_per_kwu(2000); let builder = FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX); - let builder = FundingBuilder(builder.0.add_value_inner(Amount::from_sat(25_000))); + let builder = FundingBuilder(builder.0.add_value_inner(Amount::from_sat(25_000)).unwrap()); assert!(matches!( builder.build(), @@ -1775,6 +2065,7 @@ mod tests { feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let delta = Amount::from_sat(change.value.to_sat() - dust_limit.to_sat() + 1); @@ -1789,9 +2080,10 @@ mod tests { ); let builder = - FundingTemplate::new(None, None, Some(PriorContribution::new(prior, Amount::MAX))) + FundingTemplate::new(None, None, Some(PriorContribution::new(prior, Amount::ZERO))) .with_prior_contribution(feerate, FeeRate::MAX); - let contribution = FundingBuilder(builder.0.add_value_inner(delta)).build().unwrap(); + let contribution = + FundingBuilder(builder.0.add_value_inner(delta).unwrap()).build().unwrap(); assert!(contribution.change_output.is_none()); assert_eq!(contribution.inputs, inputs); @@ -1830,16 +2122,40 @@ mod tests { #[test] fn test_funding_builder_add_and_remove_value_update_request() { let feerate = FeeRate::from_sat_per_kwu(2000); - let builder = + let value_added = Amount::from_sat(15_000); + let input_template = funding_input_sats(1); + let estimated_fee = estimate_transaction_fee( + std::slice::from_ref(&input_template), + &[], + None, + true, + false, + feerate, + ); + let selected_amount = value_added + estimated_fee; + let input = funding_input_sats(selected_amount.to_sat()); + let wallet = MustPayToWallet { + utxo: input.clone(), + change_output: None, + expected_must_pay_to_values: vec![value_added], + }; + + let contribution = FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX) - .with_coin_selection_source_sync(UnreachableWallet) + .with_coin_selection_source_sync(wallet) .add_value(Amount::from_sat(20_000)) + .unwrap() .add_value(Amount::from_sat(5_000)) - .remove_value(Amount::from_sat(10_000)); + .unwrap() + .remove_value(Amount::from_sat(10_000)) + .unwrap() + .build() + .unwrap(); - let (_, must_pay_to) = builder.0.prepare_coin_selection_request().unwrap(); - assert_eq!(must_pay_to.len(), 1); - assert_eq!(must_pay_to[0].value, Amount::from_sat(15_000)); + assert_eq!(contribution.inputs, vec![input]); + assert!(contribution.outputs.is_empty()); + assert!(contribution.change_output.is_none()); + assert_eq!(contribution.value_added(), value_added); } #[test] @@ -1871,6 +2187,7 @@ mod tests { FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX) .with_coin_selection_source_sync(wallet) .add_value(value_added) + .unwrap() .add_output(output.clone()) .build() .unwrap(); @@ -1888,7 +2205,9 @@ mod tests { FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX) .with_coin_selection_source_sync(UnreachableWallet) .add_value(Amount::from_sat(10_000)) + .unwrap() .remove_value(Amount::from_sat(15_000)) + .unwrap() .add_output(output.clone()) .build() .unwrap(); @@ -1899,6 +2218,399 @@ mod tests { assert_eq!(contribution.value_added(), Amount::ZERO); } + #[test] + fn test_funding_builder_builds_manual_input_contribution_without_change() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let input = funding_input_sats(100_000); + let output = funding_output_sats(25_000); + + let contribution = FundingTemplate::new(None, None, None) + .without_prior_contribution(feerate, FeeRate::MAX) + .add_input(input.clone()) + .unwrap() + .add_output(output.clone()) + .build() + .unwrap(); + + let expected_fee = estimate_transaction_fee( + std::slice::from_ref(&input), + std::slice::from_ref(&output), + None, + true, + false, + feerate, + ); + assert_eq!(contribution.inputs, vec![input]); + assert_eq!(contribution.outputs, vec![output.clone()]); + assert!(contribution.change_output.is_none()); + assert_eq!(contribution.input_mode, Some(FundingInputMode::ManuallySelected)); + assert_eq!(contribution.estimated_fee, expected_fee); + assert_eq!( + contribution.value_added(), + Amount::from_sat(100_000) - output.value - expected_fee, + ); + assert_eq!( + contribution.net_value(), + Amount::from_sat(100_000).to_signed().unwrap() + - output.value.to_signed().unwrap() + - expected_fee.to_signed().unwrap(), + ); + } + + #[test] + fn test_funding_builder_add_inputs_builds_manual_input_contribution() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let first_input = funding_input_sats(40_000); + let second_input = funding_input_sats(60_000); + let output = funding_output_sats(25_000); + + let contribution = FundingTemplate::new(None, None, None) + .without_prior_contribution(feerate, FeeRate::MAX) + .add_inputs(vec![first_input.clone(), second_input.clone()]) + .unwrap() + .add_output(output.clone()) + .build() + .unwrap(); + + let expected_fee = estimate_transaction_fee( + &[first_input.clone(), second_input.clone()], + std::slice::from_ref(&output), + None, + true, + false, + feerate, + ); + assert_eq!(contribution.inputs, vec![first_input, second_input]); + assert_eq!(contribution.outputs, vec![output.clone()]); + assert!(contribution.change_output.is_none()); + assert_eq!(contribution.input_mode, Some(FundingInputMode::ManuallySelected)); + assert_eq!(contribution.estimated_fee, expected_fee); + assert_eq!( + contribution.value_added(), + Amount::from_sat(100_000) - output.value - expected_fee, + ); + } + + #[test] + fn test_funding_builder_remove_input_updates_manual_input_request() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let first_input = funding_input_sats(40_000); + let second_input = funding_input_sats(60_000); + let output = funding_output_sats(25_000); + + let contribution = FundingTemplate::new(None, None, None) + .without_prior_contribution(feerate, FeeRate::MAX) + .add_inputs(vec![first_input.clone(), second_input.clone()]) + .unwrap() + .remove_input(&first_input.utxo.outpoint) + .unwrap() + .add_output(output.clone()) + .build() + .unwrap(); + + let expected_fee = estimate_transaction_fee( + std::slice::from_ref(&second_input), + std::slice::from_ref(&output), + None, + true, + false, + feerate, + ); + assert_eq!(contribution.inputs, vec![second_input]); + assert_eq!(contribution.outputs, vec![output.clone()]); + assert_eq!(contribution.input_mode, Some(FundingInputMode::ManuallySelected)); + assert_eq!( + contribution.value_added(), + Amount::from_sat(60_000) - output.value - expected_fee, + ); + } + + #[test] + fn test_splice_in_inputs_builds_manual_input_contribution() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let first_input = funding_input_sats(40_000); + let second_input = funding_input_sats(60_000); + + let contribution = FundingTemplate::new(None, None, None) + .splice_in_inputs( + vec![first_input.clone(), second_input.clone()], + feerate, + FeeRate::MAX, + ) + .unwrap(); + + let expected_fee = estimate_transaction_fee( + &[first_input.clone(), second_input.clone()], + &[], + None, + true, + false, + feerate, + ); + assert_eq!(contribution.inputs, vec![first_input, second_input]); + assert!(contribution.outputs.is_empty()); + assert!(contribution.change_output.is_none()); + assert_eq!(contribution.input_mode, Some(FundingInputMode::ManuallySelected)); + assert_eq!(contribution.value_added(), Amount::from_sat(100_000) - expected_fee); + } + + #[test] + fn test_splice_in_inputs_appends_to_prior_manual_inputs() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let prior_input = funding_input_sats(40_000); + let additional_input = funding_input_sats(60_000); + let prior_fee = estimate_transaction_fee( + std::slice::from_ref(&prior_input), + &[], + None, + true, + false, + feerate, + ); + let prior = FundingContribution { + estimated_fee: prior_fee, + inputs: vec![prior_input.clone()], + outputs: vec![], + change_output: None, + feerate, + max_feerate: FeeRate::MAX, + is_splice: false, + input_mode: Some(FundingInputMode::ManuallySelected), + }; + + let contribution = FundingTemplate::new( + None, + None, + Some(PriorContribution::new(prior, Amount::MAX_MONEY)), + ) + .splice_in_inputs(vec![additional_input.clone()], feerate, FeeRate::MAX) + .unwrap(); + + assert_eq!(contribution.inputs, vec![prior_input, additional_input]); + assert!(contribution.outputs.is_empty()); + assert_eq!(contribution.input_mode, Some(FundingInputMode::ManuallySelected)); + } + + #[test] + fn test_sync_funding_builder_manual_inputs_insufficient_do_not_fallback_to_coin_selection() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let builder = FundingTemplate::new(None, None, None) + .without_prior_contribution(feerate, FeeRate::MAX) + .add_input(funding_input_sats(1)) + .unwrap(); + let builder = + SyncFundingBuilder(builder.0.with_state(SyncCoinSelectionSource(UnreachableWallet))); + + assert!(matches!( + builder.build(), + Err(FundingContributionError::ManuallySelectedInputsInsufficient), + )); + } + + #[test] + fn test_funding_builder_rejects_manual_inputs_with_value_request() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let builder = FundingTemplate::new(None, None, None) + .without_prior_contribution(feerate, FeeRate::MAX) + .add_input(funding_input_sats(100_000)) + .unwrap(); + let result = builder.clone().0.add_value_inner(Amount::from_sat(1_000)); + assert!(matches!(result, Err(FundingContributionError::InvalidSpliceValue),)); + + let builder = + SyncFundingBuilder(builder.0.with_state(SyncCoinSelectionSource(UnreachableWallet))); + let result = builder.remove_value(Amount::from_sat(1_000)); + assert!(matches!(result, Err(FundingContributionError::InvalidSpliceValue),)); + } + + #[test] + fn test_funding_builder_rejects_manual_inputs_on_coin_selected_prior() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let prior_input = funding_input_sats(100_000); + let prior_outpoint = prior_input.utxo.outpoint; + let prior = FundingContribution { + estimated_fee: Amount::from_sat(1_000), + inputs: vec![prior_input], + outputs: vec![], + change_output: Some(funding_output_sats(10_000)), + feerate, + max_feerate: FeeRate::MAX, + is_splice: false, + input_mode: Some(FundingInputMode::CoinSelected), + }; + + let builder = + FundingTemplate::new(None, None, Some(PriorContribution::new(prior, Amount::ZERO))) + .with_prior_contribution(feerate, FeeRate::MAX); + + assert!(matches!( + builder.clone().add_input(funding_input_sats(50_000)), + Err(FundingContributionError::InvalidSpliceValue), + )); + assert!(matches!( + builder.remove_input(&prior_outpoint), + Err(FundingContributionError::InvalidSpliceValue), + )); + } + + #[test] + fn test_funding_builder_validates_manual_input_max_money() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let inputs = vec![funding_input_sats(Amount::MAX_MONEY.to_sat()), funding_input_sats(1)]; + + let builder = FundingTemplate::new(None, None, None) + .without_prior_contribution(feerate, FeeRate::MAX) + .add_inputs(inputs) + .unwrap(); + + assert!(matches!(builder.build(), Err(FundingContributionError::InvalidSpliceValue),)); + } + + #[test] + fn test_build_from_prior_manual_inputs_exact_match_reuses_and_adjusts() { + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(3000); + let input = funding_input_sats(100_000); + let output = funding_output_sats(20_000); + let estimated_fee = estimate_transaction_fee( + std::slice::from_ref(&input), + std::slice::from_ref(&output), + None, + true, + false, + original_feerate, + ); + let prior = FundingContribution { + estimated_fee, + inputs: vec![input.clone()], + outputs: vec![output.clone()], + change_output: None, + feerate: original_feerate, + max_feerate: FeeRate::MAX, + is_splice: false, + input_mode: Some(FundingInputMode::ManuallySelected), + }; + + let contribution = FundingTemplate::new( + None, + None, + Some(PriorContribution::new(prior, Amount::MAX_MONEY)), + ) + .with_prior_contribution(target_feerate, FeeRate::MAX) + .build() + .unwrap(); + + assert_eq!(contribution.inputs, vec![input]); + assert_eq!(contribution.outputs, vec![output]); + assert_eq!(contribution.feerate, target_feerate); + assert_eq!(contribution.input_mode, Some(FundingInputMode::ManuallySelected)); + } + + #[test] + fn test_build_from_prior_manual_inputs_changed_request_insufficient_maps_error() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let input = funding_input_sats(50_000); + let estimated_fee = + estimate_transaction_fee(std::slice::from_ref(&input), &[], None, true, false, feerate); + let prior = FundingContribution { + estimated_fee, + inputs: vec![input], + outputs: vec![], + change_output: None, + feerate, + max_feerate: FeeRate::MAX, + is_splice: false, + input_mode: Some(FundingInputMode::ManuallySelected), + }; + + let result = + FundingTemplate::new(None, None, Some(PriorContribution::new(prior, Amount::ZERO))) + .with_prior_contribution(feerate, FeeRate::MAX) + .add_output(funding_output_sats(60_000)) + .build(); + + assert!(matches!( + result, + Err(FundingContributionError::ManuallySelectedInputsInsufficient), + )); + } + + #[test] + fn test_for_acceptor_at_feerate_manual_inputs_balance_insufficient() { + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(100_000); + let inputs = vec![funding_input_sats(100_000)]; + let outputs = vec![funding_output_sats(80_000)]; + let net_value_without_fee = Amount::from_sat(20_000); + + let estimated_fee = + estimate_transaction_fee(&inputs, &outputs, None, true, true, original_feerate); + let target_fee = + estimate_transaction_fee(&inputs, &outputs, None, false, true, target_feerate); + assert!(target_fee > net_value_without_fee); + + let contribution = FundingContribution { + estimated_fee, + inputs, + outputs, + change_output: None, + feerate: original_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + input_mode: Some(FundingInputMode::ManuallySelected), + }; + + let holder_balance = target_fee + .checked_sub(net_value_without_fee) + .and_then(|shortfall| shortfall.checked_sub(Amount::from_sat(1))) + .unwrap(); + match contribution.for_acceptor_at_feerate(target_feerate, holder_balance) { + Err(FeeRateAdjustmentError::FeeBufferInsufficient { source, available, required }) => { + assert_eq!(source, "channel balance"); + assert_eq!(available, target_fee - Amount::from_sat(1)); + assert_eq!(required, target_fee); + }, + other => panic!("Expected channel-balance shortfall, got {other:?}"), + } + } + + #[test] + fn test_for_acceptor_at_feerate_manual_inputs_balance_sufficient() { + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(100_000); + let inputs = vec![funding_input_sats(100_000)]; + let outputs = vec![funding_output_sats(80_000)]; + let net_value_without_fee = Amount::from_sat(20_000); + + let estimated_fee = + estimate_transaction_fee(&inputs, &outputs, None, true, true, original_feerate); + let target_fee = + estimate_transaction_fee(&inputs, &outputs, None, false, true, target_feerate); + + let contribution = FundingContribution { + estimated_fee, + inputs: inputs.clone(), + outputs: outputs.clone(), + change_output: None, + feerate: original_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + input_mode: Some(FundingInputMode::ManuallySelected), + }; + + let holder_balance = target_fee.checked_sub(net_value_without_fee).unwrap(); + let adjusted = + contribution.for_acceptor_at_feerate(target_feerate, holder_balance).unwrap(); + + assert_eq!(adjusted.inputs, inputs); + assert_eq!(adjusted.outputs, outputs); + assert_eq!(adjusted.estimated_fee, target_fee); + assert_eq!( + adjusted.net_value(), + net_value_without_fee.to_signed().unwrap() - target_fee.to_signed().unwrap(), + ); + } + #[test] fn test_build_funding_contribution_validates_max_money() { let over_max = Amount::MAX_MONEY + Amount::from_sat(1); @@ -1949,6 +2661,7 @@ mod tests { .without_prior_contribution(feerate, feerate) .with_coin_selection_source_sync(UnreachableWallet) .add_value(over_max) + .unwrap() .add_outputs(vec![funding_output_sats(1_000)]) .build(), Err(FundingContributionError::InvalidSpliceValue), @@ -1961,6 +2674,7 @@ mod tests { .without_prior_contribution(feerate, feerate) .with_coin_selection_source_sync(UnreachableWallet) .add_value(Amount::from_sat(1_000)) + .unwrap() .add_outputs(vec![ funding_output_sats(half_over.to_sat()), funding_output_sats(half_over.to_sat()), @@ -2021,6 +2735,7 @@ mod tests { .with_prior_contribution(feerate, feerate) .with_coin_selection_source_sync(wallet) .add_value(Amount::from_sat(10_000)) + .unwrap() .build(), Err(FundingContributionError::PrevTxTooLarge), )); @@ -2049,6 +2764,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let net_value_before = contribution.net_value(); @@ -2086,6 +2802,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); @@ -2126,6 +2843,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let net_value_before = contribution.net_value(); @@ -2161,6 +2879,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); @@ -2186,6 +2905,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let contribution = @@ -2215,6 +2935,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; // Balance of 55,000 sats can't cover outputs (50,000) + target_fee at 50k sat/kwu. @@ -2244,6 +2965,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; // For splice-in with change that stays above dust, the surplus is absorbed by the change @@ -2276,6 +2998,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let net_at_feerate = @@ -2311,6 +3034,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let net_before = contribution.net_value(); @@ -2344,6 +3068,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let result = contribution.net_value_for_acceptor_at_feerate(target_feerate, Amount::MAX); @@ -2371,6 +3096,7 @@ mod tests { feerate: original_feerate, max_feerate, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); @@ -2402,6 +3128,7 @@ mod tests { feerate: original_feerate, max_feerate, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); @@ -2436,6 +3163,7 @@ mod tests { feerate: original_feerate, max_feerate, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); @@ -2478,6 +3206,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); @@ -2510,6 +3239,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); @@ -2548,6 +3278,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); @@ -2584,6 +3315,7 @@ mod tests { feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; // target == min feerate, so FeeRateTooLow check passes. @@ -2611,6 +3343,7 @@ mod tests { feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let result = contribution.for_acceptor_at_feerate(feerate, Amount::MAX); @@ -2635,6 +3368,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; // Balance of 40,000 sats is less than outputs (50,000) + target_fee. @@ -2661,6 +3395,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; // Balance of 100,000 sats is more than outputs (50,000) + target_fee. @@ -2691,6 +3426,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; // Balance of 40,000 sats is less than outputs (50,000) + target_fee. @@ -2720,6 +3456,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let acceptor = @@ -2754,6 +3491,7 @@ mod tests { feerate: prior_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; // max_feerate (2020) < min_rbf_feerate (2025). @@ -2790,6 +3528,7 @@ mod tests { feerate: prior_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let template = FundingTemplate::new( @@ -2823,6 +3562,7 @@ mod tests { feerate: prior_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let template = FundingTemplate::new( @@ -2851,6 +3591,7 @@ mod tests { feerate: prior_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let template = FundingTemplate::new( @@ -2883,6 +3624,7 @@ mod tests { feerate: prior_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let template = FundingTemplate::new( @@ -2949,6 +3691,7 @@ mod tests { feerate: prior_feerate, max_feerate: prior_feerate, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let template = FundingTemplate::new( @@ -2990,6 +3733,7 @@ mod tests { feerate: FeeRate::from_sat_per_kwu(2000), max_feerate: prior_max_feerate, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let template = FundingTemplate::new( diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 6bd5d5224f7..f4843f7551e 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -44,6 +44,7 @@ use bitcoin::hashes::Hash; use bitcoin::secp256k1::ecdsa::Signature; use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use bitcoin::transaction::Version; +use bitcoin::SignedAmount; use bitcoin::{ Amount, FeeRate, OutPoint as BitcoinOutPoint, Psbt, ScriptBuf, Transaction, TxOut, Txid, WPubkeyHash, WScriptHash, @@ -298,6 +299,7 @@ pub fn do_initiate_splice_in_and_out<'a, 'b, 'c, 'd>( .without_prior_contribution(feerate, FeeRate::MAX) .with_coin_selection_source_sync(&wallet) .add_value(value_added) + .unwrap() .add_outputs(outputs) .build() .unwrap(); @@ -4323,6 +4325,7 @@ fn test_funding_contributed_splice_already_pending() { .with_prior_contribution(feerate, FeeRate::MAX) .with_coin_selection_source_sync(&wallet) .add_value(splice_in_amount) + .unwrap() .add_output(first_splice_out.clone()) .build() .unwrap(); @@ -4345,6 +4348,7 @@ fn test_funding_contributed_splice_already_pending() { .without_prior_contribution(feerate, FeeRate::MAX) .with_coin_selection_source_sync(&wallet) .add_value(splice_in_amount) + .unwrap() .add_output(second_splice_out.clone()) .build() .unwrap(); @@ -4495,6 +4499,7 @@ fn do_test_funding_contributed_active_funding_negotiation(state: u8) { .without_prior_contribution(feerate, FeeRate::MAX) .with_coin_selection_source_sync(&wallet) .add_value(splice_in_amount) + .unwrap() .add_outputs(vec![splice_out_output.clone()]) .build() .unwrap(); @@ -5204,6 +5209,7 @@ fn test_splice_rbf_discard_unique_contribution() { .without_prior_contribution(rbf_feerate, FeeRate::MAX) .with_coin_selection_source_sync(&wallet) .add_value(added_value) + .unwrap() .build() .unwrap(); nodes[0] @@ -5775,6 +5781,7 @@ fn test_splice_rbf_stfu_after_splice_locked() { .without_prior_contribution(rbf_feerate, FeeRate::MAX) .with_coin_selection_source_sync(&wallet) .add_value(added_value) + .unwrap() .build() .unwrap(); nodes[0] @@ -6949,6 +6956,7 @@ fn test_splice_rbf_amends_prior_net_positive_contribution_request() { .with_prior_contribution(rbf_feerate, FeeRate::MAX) .with_coin_selection_source_sync(&wallet) .remove_value(half_added_value) + .unwrap() .build() .unwrap(); let (inputs_2, _) = contribution_2.clone().into_contributed_inputs_and_outputs(); @@ -7032,6 +7040,11 @@ fn test_splice_rbf_amends_prior_net_negative_contribution_request() { assert!(initial_inputs.is_empty()); let (splice_tx_0, new_funding_script) = splice_channel(&nodes[0], &nodes[1], channel_id, initial_contribution.clone()); + let manual_input_pair_tx = provide_utxo_reserves(&nodes, 2, Amount::from_sat(20_000)); + let manual_input_single_tx = provide_utxo_reserves(&nodes, 1, Amount::from_sat(10_000)); + let manual_input_0 = ConfirmedUtxo::new_p2wpkh(manual_input_pair_tx.clone(), 0).unwrap(); + let manual_input_1 = ConfirmedUtxo::new_p2wpkh(manual_input_pair_tx, 1).unwrap(); + let manual_input_2 = ConfirmedUtxo::new_p2wpkh(manual_input_single_tx, 0).unwrap(); let run_rbf_round = |contribution: FundingContribution, replaced_txid: Txid| { nodes[0] @@ -7085,21 +7098,72 @@ fn test_splice_rbf_amends_prior_net_negative_contribution_request() { let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); assert_eq!(funding_template.prior_contribution().unwrap().outputs(), contribution_2.outputs()); - let contribution_3 = - funding_template.rbf_prior_contribution_sync(None, FeeRate::MAX, &wallet).unwrap(); + let rbf_feerate = funding_template.min_rbf_feerate().unwrap(); + let contribution_3 = funding_template + .with_prior_contribution(rbf_feerate, FeeRate::MAX) + .add_inputs(vec![manual_input_0.clone(), manual_input_1.clone()]) + .unwrap() + .build() + .unwrap(); let (inputs_3, _) = contribution_3.clone().into_contributed_inputs_and_outputs(); - assert!(inputs_3.is_empty()); + assert_eq!(inputs_3, vec![manual_input_0.utxo.outpoint, manual_input_1.utxo.outpoint],); assert_eq!(contribution_3.outputs(), contribution_2.outputs()); - assert!(contribution_3.net_value() < contribution_2.net_value()); + assert!(contribution_3.net_value() > SignedAmount::ZERO); assert!(contribution_3.change_output().is_none()); - let rbf_tx_final = run_rbf_round(contribution_3, splice_tx_2.compute_txid()); + let splice_tx_3 = run_rbf_round(contribution_3.clone(), splice_tx_2.compute_txid()); + + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + assert_eq!(funding_template.prior_contribution().unwrap().outputs(), contribution_3.outputs()); + let prior_inputs = funding_template + .prior_contribution() + .unwrap() + .clone() + .into_contributed_inputs_and_outputs() + .0; + assert_eq!(prior_inputs, vec![manual_input_0.utxo.outpoint, manual_input_1.utxo.outpoint],); + let rbf_feerate = funding_template.min_rbf_feerate().unwrap(); + let contribution_4 = funding_template + .with_prior_contribution(rbf_feerate, FeeRate::MAX) + .add_input(manual_input_2.clone()) + .unwrap() + .remove_input(&manual_input_0.utxo.outpoint) + .unwrap() + .remove_input(&manual_input_1.utxo.outpoint) + .unwrap() + .build() + .unwrap(); + let (inputs_4, _) = contribution_4.clone().into_contributed_inputs_and_outputs(); + assert_eq!(inputs_4, vec![manual_input_2.utxo.outpoint]); + assert_eq!(contribution_4.outputs(), contribution_3.outputs()); + assert!(contribution_4.net_value() < SignedAmount::ZERO); + assert!(contribution_4.net_value() < contribution_3.net_value()); + assert!(contribution_4.change_output().is_none()); + let splice_tx_4 = run_rbf_round(contribution_4.clone(), splice_tx_3.compute_txid()); + + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + assert_eq!(funding_template.prior_contribution().unwrap().outputs(), contribution_4.outputs()); + let contribution_5 = + funding_template.rbf_prior_contribution_sync(None, FeeRate::MAX, &wallet).unwrap(); + let (inputs_5, _) = contribution_5.clone().into_contributed_inputs_and_outputs(); + assert_eq!(inputs_5, vec![manual_input_2.utxo.outpoint]); + assert_eq!(contribution_5.outputs(), contribution_4.outputs()); + assert!(contribution_5.net_value() < SignedAmount::ZERO); + assert!(contribution_5.net_value() < contribution_4.net_value()); + assert!(contribution_5.change_output().is_none()); + let rbf_tx_final = run_rbf_round(contribution_5, splice_tx_4.compute_txid()); lock_rbf_splice_after_blocks( &nodes[0], &nodes[1], &rbf_tx_final, ANTI_REORG_DELAY - 1, - &[splice_tx_0.compute_txid(), splice_tx_1.compute_txid(), splice_tx_2.compute_txid()], + &[ + splice_tx_0.compute_txid(), + splice_tx_1.compute_txid(), + splice_tx_2.compute_txid(), + splice_tx_3.compute_txid(), + splice_tx_4.compute_txid(), + ], ); } @@ -8088,6 +8152,7 @@ fn test_splice_rbf_rejects_own_low_feerate_after_several_attempts() { .without_prior_contribution(rbf_feerate, FeeRate::MAX) .with_coin_selection_source_sync(&wallet) .add_value(added_value) + .unwrap() .build() .unwrap(); let result = nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution, None); From 740ebb1c633640e3efce92c971e93bacad85bd76 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Tue, 28 Apr 2026 14:25:49 -0700 Subject: [PATCH 411/627] Include spliceable balance in every FundingTemplate There's no reason not to do so, and it allows us to fail earlier when the user's net contribution exceeds their spliceable balance. --- lightning/src/ln/channel.rs | 30 ++- lightning/src/ln/funding.rs | 415 +++++++++++++++-------------- lightning/src/ln/splicing_tests.rs | 88 +++--- 3 files changed, 293 insertions(+), 240 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 8075699c758..1405a5a48c2 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -57,7 +57,7 @@ use crate::ln::channelmanager::{ MAX_LOCAL_BREAKDOWN_TIMEOUT, MIN_CLTV_EXPIRY_DELTA, }; use crate::ln::funding::{ - FeeRateAdjustmentError, FundingContribution, FundingTemplate, FundingTxInput, PriorContribution, + FeeRateAdjustmentError, FundingContribution, FundingTemplate, FundingTxInput, }; use crate::ln::interactivetxs::{ AbortReason, HandleTxCompleteValue, InteractiveTxConstructor, InteractiveTxConstructorArgs, @@ -12482,6 +12482,16 @@ where }); } + let spliceable_balance = self.get_next_splice_out_maximum(&self.funding).map_err(|e| { + APIError::ChannelUnavailable { + err: format!( + "Channel {} cannot be spliced at this time: {}", + self.context.channel_id(), + e + ), + } + })?; + let (min_rbf_feerate, prior_contribution) = if self.is_rbf_compatible().is_err() { // Channel can never RBF (e.g., zero-conf). (None, None) @@ -12514,16 +12524,7 @@ where .as_ref() .and_then(|pending_splice| pending_splice.contributions.last()) { - let spliceable_balance = self - .get_next_splice_out_maximum(&self.funding) - .map_err(|e| APIError::ChannelUnavailable { - err: format!( - "Channel {} cannot be spliced at this time: {}", - self.context.channel_id(), - e - ), - })?; - Some(PriorContribution::new(prior.clone(), spliceable_balance)) + Some(prior.clone()) } else { None } @@ -12545,7 +12546,12 @@ where satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + FUNDING_TRANSACTION_WITNESS_WEIGHT, }; - Ok(FundingTemplate::new(Some(shared_input), min_rbf_feerate, prior_contribution)) + Ok(FundingTemplate::new( + Some(shared_input), + min_rbf_feerate, + prior_contribution, + spliceable_balance, + )) } /// Returns whether this channel can ever RBF, independent of splice state. diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index e31f7657207..b8d0539f27c 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -132,7 +132,8 @@ pub enum FundingContributionError { /// The minimum RBF feerate. min_rbf_feerate: FeeRate, }, - /// The splice value is invalid (zero, empty outputs, or exceeds the maximum money supply). + /// The splice value is invalid (zero, empty outputs, exceeds the maximum money supply, or + /// splices out more than the available channel balance). InvalidSpliceValue, /// An input's `prevtx` is too large to fit in a `tx_add_input` message. PrevTxTooLarge, @@ -163,7 +164,7 @@ impl core::fmt::Display for FundingContributionError { write!(f, "Feerate {} is below minimum RBF feerate {}", feerate, min_rbf_feerate) }, FundingContributionError::InvalidSpliceValue => { - write!(f, "Invalid splice value (zero, empty, or exceeds limit)") + write!(f, "Invalid splice value (zero, empty, exceeds limit, or overdraws balance)") }, FundingContributionError::PrevTxTooLarge => { write!(f, "Input prevtx is too large to fit in a tx_add_input message") @@ -184,39 +185,6 @@ impl core::fmt::Display for FundingContributionError { } } -/// The user's prior contribution from a previous splice negotiation on this channel. -/// -/// When a pending splice exists with negotiated candidates, the prior contribution is -/// available for reuse. It stores the raw contribution together with the holder's balance for -/// deferred feerate adjustment when the contribution is later reused via -/// [`FundingTemplate::with_prior_contribution`] or [`FundingTemplate::rbf_prior_contribution`]. -/// -/// Use [`FundingTemplate::prior_contribution`] to inspect the prior contribution before -/// deciding whether to reuse it or replace it with -/// [`FundingTemplate::without_prior_contribution`]. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(super) struct PriorContribution { - contribution: FundingContribution, - /// The holder's spliceable balance, used for feerate adjustment. - /// - /// This value is captured at [`ChannelManager::splice_channel`] time and may become stale - /// if balances change before the contribution is used. Staleness is acceptable here because - /// this is only used as an optimization to determine if the prior contribution can be - /// reused with adjusted fees — the contribution is re-validated at - /// [`ChannelManager::funding_contributed`] time and again at quiescence time against the - /// current balances. - /// - /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel - /// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed - spliceable_balance: Amount, -} - -impl PriorContribution { - pub(super) fn new(contribution: FundingContribution, spliceable_balance: Amount) -> Self { - Self { contribution, spliceable_balance } - } -} - /// A template for contributing to a channel's splice funding transaction. /// /// This is returned from [`ChannelManager::splice_channel`] when a channel is ready to be @@ -260,17 +228,30 @@ pub struct FundingTemplate { /// pending splice candidates. min_rbf_feerate: Option, - /// The user's prior contribution from a previous splice negotiation, if available. - prior_contribution: Option, + /// The user's prior contribution from a previous splice negotiation on this channel. + prior_contribution: Option, + + /// The portion of the user's balance that can be spliced out. + /// + /// This value is captured at [`ChannelManager::splice_channel`] time and may become stale + /// if balances change before the contribution is used. Staleness is acceptable here because + /// this is only used as an optimization to determine if the prior contribution can be + /// reused with adjusted fees — the contribution is re-validated at + /// [`ChannelManager::funding_contributed`] time and again at quiescence time against the + /// current balances. + /// + /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel + /// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed + spliceable_balance: Amount, } impl FundingTemplate { /// Constructs a [`FundingTemplate`] for a splice using the provided shared input. pub(super) fn new( shared_input: Option, min_rbf_feerate: Option, - prior_contribution: Option, + prior_contribution: Option, spliceable_balance: Amount, ) -> Self { - Self { shared_input, min_rbf_feerate, prior_contribution } + Self { shared_input, min_rbf_feerate, prior_contribution, spliceable_balance } } /// Returns the minimum RBF feerate, if this template is for an RBF attempt. @@ -296,7 +277,7 @@ impl FundingTemplate { /// the acceptor. This can change other parameters too; for example, the amount added to the /// channel may increase if the change output was removed to cover a higher fee. pub fn prior_contribution(&self) -> Option<&FundingContribution> { - self.prior_contribution.as_ref().map(|p| &p.contribution) + self.prior_contribution.as_ref() } /// Creates a [`FundingBuilder`] for constructing a contribution. @@ -1148,7 +1129,8 @@ struct SyncCoinSelectionSource(W); struct FundingBuilderInner { shared_input: Option, min_rbf_feerate: Option, - prior_contribution: Option, + prior_contribution: Option, + spliceable_balance: Amount, funding_inputs: Option, outputs: Vec, feerate: FeeRate, @@ -1210,16 +1192,15 @@ impl FundingBuilderInner { } fn build_from_prior_contribution( - &self, contribution: PriorContribution, + &self, contribution: FundingContribution, ) -> Result { - let PriorContribution { contribution, spliceable_balance } = contribution; let input_mode = self.funding_inputs.as_ref().map(FundingInputs::mode); if self.request_matches_prior(&contribution) { // Same request, but the feerate may have changed. Adjust the prior contribution // to the new feerate if possible. return contribution - .for_initiator_at_feerate(self.feerate, spliceable_balance) + .for_initiator_at_feerate(self.feerate, self.spliceable_balance) .map(|mut adjusted| { adjusted.max_feerate = self.max_feerate; adjusted @@ -1239,7 +1220,7 @@ impl FundingBuilderInner { &self.outputs, self.feerate, self.max_feerate, - spliceable_balance, + self.spliceable_balance, ) .ok_or_else(|| { if input_mode == Some(FundingInputMode::ManuallySelected) { @@ -1277,8 +1258,6 @@ impl FundingBuilderInner { let input_mode = if inputs.is_empty() { None } else { Some(FundingInputMode::ManuallySelected) }; - let total_input_value: Amount = - inputs.iter().map(|input| input.utxo.output.value).sum(); let estimated_fee = estimate_transaction_fee( inputs, &self.outputs, @@ -1287,13 +1266,8 @@ impl FundingBuilderInner { self.shared_input.is_some(), self.feerate, ); - if !inputs.is_empty() { - total_input_value - .checked_sub(estimated_fee) - .ok_or(FundingContributionError::ManuallySelectedInputsInsufficient)?; - } - return Ok(FundingContribution { + let contribution = FundingContribution { estimated_fee, inputs: match self.funding_inputs { Some(FundingInputs::ManuallySelected { ref inputs }) => inputs.clone(), @@ -1305,7 +1279,19 @@ impl FundingBuilderInner { max_feerate: self.max_feerate, is_splice: self.shared_input.is_some(), input_mode, - }); + }; + let net_value = contribution.net_value(); + if net_value.is_negative() { + self.spliceable_balance.checked_sub(net_value.unsigned_abs()).ok_or_else(|| { + if contribution.inputs.is_empty() { + FundingContributionError::InvalidSpliceValue + } else { + FundingContributionError::ManuallySelectedInputsInsufficient + } + })?; + } + + return Ok(contribution); } Err(FundingContributionError::MissingCoinSelectionSource) @@ -1389,21 +1375,26 @@ impl FundingBuilderInner { impl FundingBuilder { fn new(template: FundingTemplate, feerate: FeeRate, max_feerate: FeeRate) -> FundingBuilder { - let FundingTemplate { shared_input, min_rbf_feerate, prior_contribution } = template; + let FundingTemplate { + shared_input, + min_rbf_feerate, + prior_contribution, + spliceable_balance, + } = template; let (funding_inputs, outputs) = match prior_contribution.as_ref() { - Some(prior) => { - let funding_inputs = match prior.contribution.input_mode { + Some(prior_contribution) => { + let funding_inputs = match prior_contribution.input_mode { Some(FundingInputMode::ManuallySelected) => { Some(FundingInputs::ManuallySelected { - inputs: prior.contribution.inputs.clone(), + inputs: prior_contribution.inputs.clone(), }) }, Some(FundingInputMode::CoinSelected) => Some(FundingInputs::CoinSelected { - value_added: prior.contribution.value_added(), + value_added: prior_contribution.value_added(), }), None => None, }; - (funding_inputs, prior.contribution.outputs.clone()) + (funding_inputs, prior_contribution.outputs.clone()) }, None => (None, Vec::new()), }; @@ -1412,6 +1403,7 @@ impl FundingBuilder { shared_input, min_rbf_feerate, prior_contribution, + spliceable_balance, funding_inputs, outputs, feerate, @@ -1532,6 +1524,7 @@ impl FundingBuilderInner { shared_input: self.shared_input, min_rbf_feerate: self.min_rbf_feerate, prior_contribution: self.prior_contribution, + spliceable_balance: self.spliceable_balance, funding_inputs: self.funding_inputs, outputs: self.outputs, feerate: self.feerate, @@ -1859,7 +1852,7 @@ mod tests { use super::{ estimate_transaction_fee, FeeRateAdjustmentError, FundingBuilder, FundingContribution, FundingContributionError, FundingInputMode, FundingTemplate, FundingTxInput, - PriorContribution, SyncCoinSelectionSource, SyncFundingBuilder, + SyncCoinSelectionSource, SyncFundingBuilder, }; use crate::chain::ClaimId; use crate::util::wallet_utils::{CoinSelection, CoinSelectionSourceSync, Input}; @@ -2008,11 +2001,14 @@ mod tests { let feerate = FeeRate::from_sat_per_kwu(2000); let output = funding_output_sats(25_000); - let contribution = - FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX) - .add_output(output.clone()) - .build() - .unwrap(); + let contribution = FundingBuilder::new( + FundingTemplate::new(None, None, None, Amount::MAX_MONEY), + feerate, + FeeRate::MAX, + ) + .add_output(output.clone()) + .build() + .unwrap(); let expected_fee = estimate_transaction_fee( &[], @@ -2032,11 +2028,38 @@ mod tests { ); } + #[test] + fn test_funding_builder_rejects_splice_out_over_balance() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let output = funding_output_sats(25_000); + let expected_fee = estimate_transaction_fee( + &[], + std::slice::from_ref(&output), + None, + true, + false, + feerate, + ); + let exact_balance = output.value + expected_fee; + + let contribution = FundingTemplate::new(None, None, None, exact_balance) + .splice_out(vec![output.clone()], feerate, FeeRate::MAX) + .unwrap(); + assert_eq!(contribution.net_value(), -exact_balance.to_signed().unwrap()); + + let result = FundingTemplate::new(None, None, None, exact_balance - Amount::from_sat(1)) + .splice_out(vec![output], feerate, FeeRate::MAX); + assert!(matches!(result, Err(FundingContributionError::InvalidSpliceValue))); + } + #[test] fn test_funding_builder_requires_wallet_for_splice_in() { let feerate = FeeRate::from_sat_per_kwu(2000); - let builder = - FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX); + let builder = FundingBuilder::new( + FundingTemplate::new(None, None, None, Amount::ZERO), + feerate, + FeeRate::MAX, + ); let builder = FundingBuilder(builder.0.add_value_inner(Amount::from_sat(25_000)).unwrap()); assert!(matches!( @@ -2079,9 +2102,8 @@ mod tests { total_input_value >= target_value_added.checked_add(estimated_fee_no_change).unwrap() ); - let builder = - FundingTemplate::new(None, None, Some(PriorContribution::new(prior, Amount::ZERO))) - .with_prior_contribution(feerate, FeeRate::MAX); + let builder = FundingTemplate::new(None, None, Some(prior), Amount::MAX_MONEY) + .with_prior_contribution(feerate, FeeRate::MAX); let contribution = FundingBuilder(builder.0.add_value_inner(delta).unwrap()).build().unwrap(); @@ -2107,14 +2129,17 @@ mod tests { TxOut { value: Amount::from_sat(12_000), script_pubkey: removed_script.clone() }; let kept_output = TxOut { value: Amount::from_sat(15_000), script_pubkey: kept_script }; - let contribution = - FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX) - .add_output(removed_output_1) - .add_output(kept_output.clone()) - .add_output(removed_output_2) - .remove_outputs(&removed_script) - .build() - .unwrap(); + let contribution = FundingBuilder::new( + FundingTemplate::new(None, None, None, Amount::MAX_MONEY), + feerate, + FeeRate::MAX, + ) + .add_output(removed_output_1) + .add_output(kept_output.clone()) + .add_output(removed_output_2) + .remove_outputs(&removed_script) + .build() + .unwrap(); assert_eq!(contribution.outputs, vec![kept_output]); } @@ -2140,17 +2165,20 @@ mod tests { expected_must_pay_to_values: vec![value_added], }; - let contribution = - FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX) - .with_coin_selection_source_sync(wallet) - .add_value(Amount::from_sat(20_000)) - .unwrap() - .add_value(Amount::from_sat(5_000)) - .unwrap() - .remove_value(Amount::from_sat(10_000)) - .unwrap() - .build() - .unwrap(); + let contribution = FundingBuilder::new( + FundingTemplate::new(None, None, None, Amount::ZERO), + feerate, + FeeRate::MAX, + ) + .with_coin_selection_source_sync(wallet) + .add_value(Amount::from_sat(20_000)) + .unwrap() + .add_value(Amount::from_sat(5_000)) + .unwrap() + .remove_value(Amount::from_sat(10_000)) + .unwrap() + .build() + .unwrap(); assert_eq!(contribution.inputs, vec![input]); assert!(contribution.outputs.is_empty()); @@ -2183,14 +2211,17 @@ mod tests { expected_must_pay_to_values: vec![output.value, value_added], }; - let contribution = - FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX) - .with_coin_selection_source_sync(wallet) - .add_value(value_added) - .unwrap() - .add_output(output.clone()) - .build() - .unwrap(); + let contribution = FundingBuilder::new( + FundingTemplate::new(None, None, None, Amount::MAX_MONEY), + feerate, + FeeRate::MAX, + ) + .with_coin_selection_source_sync(wallet) + .add_value(value_added) + .unwrap() + .add_output(output.clone()) + .build() + .unwrap(); assert_eq!(contribution.value_added(), value_added); assert_eq!(contribution.outputs, vec![output]); @@ -2201,16 +2232,19 @@ mod tests { fn test_funding_builder_remove_value_saturates_at_zero() { let feerate = FeeRate::from_sat_per_kwu(2000); let output = funding_output_sats(8_000); - let contribution = - FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX) - .with_coin_selection_source_sync(UnreachableWallet) - .add_value(Amount::from_sat(10_000)) - .unwrap() - .remove_value(Amount::from_sat(15_000)) - .unwrap() - .add_output(output.clone()) - .build() - .unwrap(); + let contribution = FundingBuilder::new( + FundingTemplate::new(None, None, None, Amount::MAX_MONEY), + feerate, + FeeRate::MAX, + ) + .with_coin_selection_source_sync(UnreachableWallet) + .add_value(Amount::from_sat(10_000)) + .unwrap() + .remove_value(Amount::from_sat(15_000)) + .unwrap() + .add_output(output.clone()) + .build() + .unwrap(); assert!(contribution.inputs.is_empty()); assert_eq!(contribution.outputs, vec![output]); @@ -2224,7 +2258,7 @@ mod tests { let input = funding_input_sats(100_000); let output = funding_output_sats(25_000); - let contribution = FundingTemplate::new(None, None, None) + let contribution = FundingTemplate::new(None, None, None, Amount::ZERO) .without_prior_contribution(feerate, FeeRate::MAX) .add_input(input.clone()) .unwrap() @@ -2264,7 +2298,7 @@ mod tests { let second_input = funding_input_sats(60_000); let output = funding_output_sats(25_000); - let contribution = FundingTemplate::new(None, None, None) + let contribution = FundingTemplate::new(None, None, None, Amount::ZERO) .without_prior_contribution(feerate, FeeRate::MAX) .add_inputs(vec![first_input.clone(), second_input.clone()]) .unwrap() @@ -2298,7 +2332,7 @@ mod tests { let second_input = funding_input_sats(60_000); let output = funding_output_sats(25_000); - let contribution = FundingTemplate::new(None, None, None) + let contribution = FundingTemplate::new(None, None, None, Amount::ZERO) .without_prior_contribution(feerate, FeeRate::MAX) .add_inputs(vec![first_input.clone(), second_input.clone()]) .unwrap() @@ -2331,7 +2365,7 @@ mod tests { let first_input = funding_input_sats(40_000); let second_input = funding_input_sats(60_000); - let contribution = FundingTemplate::new(None, None, None) + let contribution = FundingTemplate::new(None, None, None, Amount::ZERO) .splice_in_inputs( vec![first_input.clone(), second_input.clone()], feerate, @@ -2378,13 +2412,9 @@ mod tests { input_mode: Some(FundingInputMode::ManuallySelected), }; - let contribution = FundingTemplate::new( - None, - None, - Some(PriorContribution::new(prior, Amount::MAX_MONEY)), - ) - .splice_in_inputs(vec![additional_input.clone()], feerate, FeeRate::MAX) - .unwrap(); + let contribution = FundingTemplate::new(None, None, Some(prior), Amount::MAX_MONEY) + .splice_in_inputs(vec![additional_input.clone()], feerate, FeeRate::MAX) + .unwrap(); assert_eq!(contribution.inputs, vec![prior_input, additional_input]); assert!(contribution.outputs.is_empty()); @@ -2394,7 +2424,7 @@ mod tests { #[test] fn test_sync_funding_builder_manual_inputs_insufficient_do_not_fallback_to_coin_selection() { let feerate = FeeRate::from_sat_per_kwu(2000); - let builder = FundingTemplate::new(None, None, None) + let builder = FundingTemplate::new(None, None, None, Amount::ZERO) .without_prior_contribution(feerate, FeeRate::MAX) .add_input(funding_input_sats(1)) .unwrap(); @@ -2410,7 +2440,7 @@ mod tests { #[test] fn test_funding_builder_rejects_manual_inputs_with_value_request() { let feerate = FeeRate::from_sat_per_kwu(2000); - let builder = FundingTemplate::new(None, None, None) + let builder = FundingTemplate::new(None, None, None, Amount::ZERO) .without_prior_contribution(feerate, FeeRate::MAX) .add_input(funding_input_sats(100_000)) .unwrap(); @@ -2439,9 +2469,8 @@ mod tests { input_mode: Some(FundingInputMode::CoinSelected), }; - let builder = - FundingTemplate::new(None, None, Some(PriorContribution::new(prior, Amount::ZERO))) - .with_prior_contribution(feerate, FeeRate::MAX); + let builder = FundingTemplate::new(None, None, Some(prior), Amount::MAX_MONEY) + .with_prior_contribution(feerate, FeeRate::MAX); assert!(matches!( builder.clone().add_input(funding_input_sats(50_000)), @@ -2458,7 +2487,7 @@ mod tests { let feerate = FeeRate::from_sat_per_kwu(2000); let inputs = vec![funding_input_sats(Amount::MAX_MONEY.to_sat()), funding_input_sats(1)]; - let builder = FundingTemplate::new(None, None, None) + let builder = FundingTemplate::new(None, None, None, Amount::ZERO) .without_prior_contribution(feerate, FeeRate::MAX) .add_inputs(inputs) .unwrap(); @@ -2491,14 +2520,10 @@ mod tests { input_mode: Some(FundingInputMode::ManuallySelected), }; - let contribution = FundingTemplate::new( - None, - None, - Some(PriorContribution::new(prior, Amount::MAX_MONEY)), - ) - .with_prior_contribution(target_feerate, FeeRate::MAX) - .build() - .unwrap(); + let contribution = FundingTemplate::new(None, None, Some(prior), Amount::MAX_MONEY) + .with_prior_contribution(target_feerate, FeeRate::MAX) + .build() + .unwrap(); assert_eq!(contribution.inputs, vec![input]); assert_eq!(contribution.outputs, vec![output]); @@ -2523,11 +2548,10 @@ mod tests { input_mode: Some(FundingInputMode::ManuallySelected), }; - let result = - FundingTemplate::new(None, None, Some(PriorContribution::new(prior, Amount::ZERO))) - .with_prior_contribution(feerate, FeeRate::MAX) - .add_output(funding_output_sats(60_000)) - .build(); + let result = FundingTemplate::new(None, None, Some(prior), Amount::ZERO) + .with_prior_contribution(feerate, FeeRate::MAX) + .add_output(funding_output_sats(60_000)) + .build(); assert!(matches!( result, @@ -2618,7 +2642,7 @@ mod tests { // splice_in_sync with value_added > MAX_MONEY { - let template = FundingTemplate::new(None, None, None); + let template = FundingTemplate::new(None, None, None, Amount::ZERO); assert!(matches!( template.splice_in_sync(over_max, feerate, feerate, UnreachableWallet), Err(FundingContributionError::InvalidSpliceValue), @@ -2627,7 +2651,7 @@ mod tests { // splice_out with single output value > MAX_MONEY { - let template = FundingTemplate::new(None, None, None); + let template = FundingTemplate::new(None, None, None, Amount::ZERO); let outputs = vec![funding_output_sats(over_max.to_sat())]; assert!(matches!( template.splice_out(outputs, feerate, feerate), @@ -2637,7 +2661,7 @@ mod tests { // splice_out with multiple outputs summing > MAX_MONEY { - let template = FundingTemplate::new(None, None, None); + let template = FundingTemplate::new(None, None, None, Amount::ZERO); let half_over = Amount::MAX_MONEY / 2 + Amount::from_sat(1); let outputs = vec![ funding_output_sats(half_over.to_sat()), @@ -2657,7 +2681,7 @@ mod tests { // Mixed add/remove request with value_added > MAX_MONEY. assert!(matches!( - FundingTemplate::new(None, None, None) + FundingTemplate::new(None, None, None, Amount::ZERO) .without_prior_contribution(feerate, feerate) .with_coin_selection_source_sync(UnreachableWallet) .add_value(over_max) @@ -2670,7 +2694,7 @@ mod tests { // Mixed add/remove request with outputs summing > MAX_MONEY. let half_over = Amount::MAX_MONEY / 2 + Amount::from_sat(1); assert!(matches!( - FundingTemplate::new(None, None, None) + FundingTemplate::new(None, None, None, Amount::ZERO) .without_prior_contribution(feerate, feerate) .with_coin_selection_source_sync(UnreachableWallet) .add_value(Amount::from_sat(1_000)) @@ -2691,7 +2715,7 @@ mod tests { // min_feerate > max_feerate is rejected { - let template = FundingTemplate::new(None, None, None); + let template = FundingTemplate::new(None, None, None, Amount::ZERO); assert!(matches!( template.splice_in_sync(Amount::from_sat(10_000), high, low, UnreachableWallet), Err(FundingContributionError::FeeRateExceedsMaximum { .. }), @@ -2700,7 +2724,7 @@ mod tests { // min_feerate < min_rbf_feerate is rejected { - let template = FundingTemplate::new(None, Some(high), None); + let template = FundingTemplate::new(None, Some(high), None, Amount::ZERO); assert!(matches!( template.splice_in_sync( Amount::from_sat(10_000), @@ -2731,7 +2755,7 @@ mod tests { change_output: None, }; assert!(matches!( - FundingTemplate::new(None, None, None) + FundingTemplate::new(None, None, None, Amount::ZERO) .with_prior_contribution(feerate, feerate) .with_coin_selection_source_sync(wallet) .add_value(Amount::from_sat(10_000)) @@ -2769,7 +2793,7 @@ mod tests { let net_value_before = contribution.net_value(); let contribution = - contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX).unwrap(); + contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY).unwrap(); // Target fee at target feerate for acceptor (is_initiator=false), including change weight. let expected_target_fee = @@ -2805,7 +2829,7 @@ mod tests { input_mode: Some(FundingInputMode::CoinSelected), }; - let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); + let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY); assert!(matches!(result, Err(FeeRateAdjustmentError::FeeRateTooLow { .. }))); } @@ -2848,7 +2872,7 @@ mod tests { let net_value_before = contribution.net_value(); let contribution = - contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX).unwrap(); + contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY).unwrap(); // Change should be removed; estimated_fee updated to no-change target fee. assert!(contribution.change_output.is_none()); @@ -2882,7 +2906,7 @@ mod tests { input_mode: Some(FundingInputMode::CoinSelected), }; - let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); + let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY); assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. }))); } @@ -2909,7 +2933,7 @@ mod tests { }; let contribution = - contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX).unwrap(); + contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY).unwrap(); // estimated_fee is updated to the target fee; surplus goes back to channel balance. let expected_target_fee = estimate_transaction_fee(&[], &outputs, None, false, true, target_feerate); @@ -2970,8 +2994,9 @@ mod tests { // For splice-in with change that stays above dust, the surplus is absorbed by the change // output so net_value_for_acceptor_at_feerate equals net_value. - let net_at_feerate = - contribution.net_value_for_acceptor_at_feerate(target_feerate, Amount::MAX).unwrap(); + let net_at_feerate = contribution + .net_value_for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY) + .unwrap(); assert_eq!(net_at_feerate, contribution.net_value()); assert_eq!( net_at_feerate, @@ -3001,8 +3026,9 @@ mod tests { input_mode: Some(FundingInputMode::CoinSelected), }; - let net_at_feerate = - contribution.net_value_for_acceptor_at_feerate(target_feerate, Amount::MAX).unwrap(); + let net_at_feerate = contribution + .net_value_for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY) + .unwrap(); // The target fee at target feerate should be less than the initiator's fee estimate. let target_fee = estimate_transaction_fee(&[], &outputs, None, false, true, target_feerate); @@ -3041,7 +3067,7 @@ mod tests { let fee_before = contribution.estimated_fee; let change_before = contribution.change_output.as_ref().unwrap().value; - let _ = contribution.net_value_for_acceptor_at_feerate(target_feerate, Amount::MAX); + let _ = contribution.net_value_for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY); // Nothing should have changed. assert_eq!(contribution.net_value(), net_before); @@ -3071,7 +3097,8 @@ mod tests { input_mode: Some(FundingInputMode::CoinSelected), }; - let result = contribution.net_value_for_acceptor_at_feerate(target_feerate, Amount::MAX); + let result = + contribution.net_value_for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY); assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. }))); } @@ -3099,7 +3126,7 @@ mod tests { input_mode: Some(FundingInputMode::CoinSelected), }; - let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); + let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY); assert!(matches!(result, Err(FeeRateAdjustmentError::FeeRateTooHigh { .. }))); } @@ -3131,7 +3158,7 @@ mod tests { input_mode: Some(FundingInputMode::CoinSelected), }; - let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); + let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY); assert!(result.is_ok()); let adjusted = result.unwrap(); @@ -3166,7 +3193,7 @@ mod tests { input_mode: Some(FundingInputMode::CoinSelected), }; - let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); + let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY); assert!(result.is_ok()); let adjusted = result.unwrap(); @@ -3209,7 +3236,7 @@ mod tests { input_mode: Some(FundingInputMode::CoinSelected), }; - let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); + let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY); assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. }))); } @@ -3242,7 +3269,7 @@ mod tests { input_mode: Some(FundingInputMode::CoinSelected), }; - let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); + let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY); assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. }))); } @@ -3281,7 +3308,7 @@ mod tests { input_mode: Some(FundingInputMode::CoinSelected), }; - let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); + let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY); assert!(result.is_ok()); let adjusted = result.unwrap(); assert!(adjusted.change_output.is_none()); @@ -3321,7 +3348,7 @@ mod tests { // target == min feerate, so FeeRateTooLow check passes. // The surplus (estimated_fee - target_fee) goes to value_added (shared output). let net_value_before = contribution.net_value(); - let result = contribution.for_acceptor_at_feerate(feerate, Amount::MAX); + let result = contribution.for_acceptor_at_feerate(feerate, Amount::MAX_MONEY); assert!(result.is_ok()); let adjusted = result.unwrap(); assert!(adjusted.change_output.is_none()); @@ -3346,7 +3373,7 @@ mod tests { input_mode: Some(FundingInputMode::CoinSelected), }; - let result = contribution.for_acceptor_at_feerate(feerate, Amount::MAX); + let result = contribution.for_acceptor_at_feerate(feerate, Amount::MAX_MONEY); assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferOverflow))); } @@ -3459,9 +3486,12 @@ mod tests { input_mode: Some(FundingInputMode::CoinSelected), }; - let acceptor = - contribution.clone().for_acceptor_at_feerate(target_feerate, Amount::MAX).unwrap(); - let initiator = contribution.for_initiator_at_feerate(target_feerate, Amount::MAX).unwrap(); + let acceptor = contribution + .clone() + .for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY) + .unwrap(); + let initiator = + contribution.for_initiator_at_feerate(target_feerate, Amount::MAX_MONEY).unwrap(); // Initiator pays more in fees (common fields + shared input/output weight). assert!(initiator.estimated_fee > acceptor.estimated_fee); @@ -3495,11 +3525,8 @@ mod tests { }; // max_feerate (2020) < min_rbf_feerate (2025). - let template = FundingTemplate::new( - None, - Some(min_rbf_feerate), - Some(PriorContribution::new(prior, Amount::MAX)), - ); + let template = + FundingTemplate::new(None, Some(min_rbf_feerate), Some(prior), Amount::MAX_MONEY); assert!(matches!( template.rbf_prior_contribution_sync(None, max_feerate, UnreachableWallet), Err(FundingContributionError::FeeRateExceedsMaximum { .. }), @@ -3531,11 +3558,8 @@ mod tests { input_mode: Some(FundingInputMode::CoinSelected), }; - let template = FundingTemplate::new( - None, - Some(min_rbf_feerate), - Some(PriorContribution::new(prior, Amount::MAX)), - ); + let template = + FundingTemplate::new(None, Some(min_rbf_feerate), Some(prior), Amount::MAX_MONEY); let contribution = template.rbf_prior_contribution_sync(None, max_feerate, UnreachableWallet).unwrap(); assert_eq!(contribution.feerate, min_rbf_feerate); @@ -3565,11 +3589,8 @@ mod tests { input_mode: Some(FundingInputMode::CoinSelected), }; - let template = FundingTemplate::new( - None, - Some(min_rbf_feerate), - Some(PriorContribution::new(prior, Amount::MAX)), - ); + let template = + FundingTemplate::new(None, Some(min_rbf_feerate), Some(prior), Amount::MAX_MONEY); let contribution = template .rbf_prior_contribution_sync(Some(override_feerate), max_feerate, UnreachableWallet) .unwrap(); @@ -3594,11 +3615,8 @@ mod tests { input_mode: Some(FundingInputMode::CoinSelected), }; - let template = FundingTemplate::new( - None, - Some(min_rbf_feerate), - Some(PriorContribution::new(prior, Amount::MAX)), - ); + let template = + FundingTemplate::new(None, Some(min_rbf_feerate), Some(prior), Amount::MAX_MONEY); assert!(matches!( template.rbf_prior_contribution_sync( Some(override_feerate), @@ -3627,11 +3645,8 @@ mod tests { input_mode: Some(FundingInputMode::CoinSelected), }; - let template = FundingTemplate::new( - None, - Some(min_rbf_feerate), - Some(PriorContribution::new(prior, Amount::MAX)), - ); + let template = + FundingTemplate::new(None, Some(min_rbf_feerate), Some(prior), Amount::MAX_MONEY); assert!(matches!( template.rbf_prior_contribution_sync( Some(override_feerate), @@ -3697,7 +3712,8 @@ mod tests { let template = FundingTemplate::new( Some(shared_input(100_000)), Some(min_rbf_feerate), - Some(PriorContribution::new(prior, Amount::ZERO)), + Some(prior), + Amount::ZERO, ); let wallet = SingleUtxoWallet { @@ -3739,7 +3755,8 @@ mod tests { let template = FundingTemplate::new( Some(shared_input(100_000)), Some(min_rbf_feerate), - Some(PriorContribution::new(prior, Amount::MAX)), + Some(prior), + Amount::MAX_MONEY, ); let wallet = SingleUtxoWallet { @@ -3765,8 +3782,12 @@ mod tests { let feerate = FeeRate::from_sat_per_kwu(2025); let withdrawal = funding_output_sats(20_000); - let template = - FundingTemplate::new(Some(shared_input(100_000)), Some(min_rbf_feerate), None); + let template = FundingTemplate::new( + Some(shared_input(100_000)), + Some(min_rbf_feerate), + None, + Amount::MAX_MONEY, + ); let contribution = template.splice_out(vec![withdrawal.clone()], feerate, FeeRate::MAX).unwrap(); diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index f4843f7551e..0c578dd9a47 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -24,7 +24,7 @@ use crate::ln::channel::{ }; use crate::ln::channelmanager::{provided_init_features, PaymentId, BREAKDOWN_TIMEOUT}; use crate::ln::functional_test_utils::*; -use crate::ln::funding::{FundingContribution, FundingContributionError}; +use crate::ln::funding::{FundingContribution, FundingContributionError, FundingTemplate}; use crate::ln::msgs::{self, BaseMessageHandler, ChannelMessageHandler, MessageSendEvent}; use crate::ln::outbound_payment::RecipientOnionFields; use crate::ln::types::ChannelId; @@ -256,10 +256,8 @@ pub fn initiate_splice_out<'a, 'b, 'c, 'd>( outputs: Vec, ) -> Result { let node_id_acceptor = acceptor.node.get_our_node_id(); - let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = initiator.node.splice_channel(&channel_id, &node_id_acceptor).unwrap(); - let feerate = funding_template.min_rbf_feerate().unwrap_or(floor_feerate); - let funding_contribution = funding_template.splice_out(outputs, feerate, FeeRate::MAX).unwrap(); + let funding_contribution = + build_splice_out_contribution(initiator, acceptor, channel_id, outputs).unwrap(); match initiator.node.funding_contributed( &channel_id, &node_id_acceptor, @@ -279,6 +277,17 @@ pub fn initiate_splice_out<'a, 'b, 'c, 'd>( } } +pub fn build_splice_out_contribution<'a, 'b, 'c, 'd>( + initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, + outputs: Vec, +) -> Result { + let node_id_acceptor = acceptor.node.get_our_node_id(); + let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = initiator.node.splice_channel(&channel_id, &node_id_acceptor).unwrap(); + let feerate = funding_template.min_rbf_feerate().unwrap_or(floor_feerate); + funding_template.splice_out(outputs, feerate, FeeRate::MAX) +} + pub fn initiate_splice_in_and_out<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, value_added: Amount, outputs: Vec, @@ -4798,15 +4807,10 @@ fn do_test_splice_pending_htlcs(config: UserConfig) { let script_pubkey = initiator.wallet_source.get_change_script().unwrap(); let outputs = vec![TxOut { value: splice_out + Amount::ONE_SAT, script_pubkey }]; - let error = initiate_splice_out(initiator, acceptor, channel_id, outputs).unwrap_err(); - let cannot_accept_contribution = - format!("Channel {} cannot accept funding contribution", channel_id); - assert_eq!(error, APIError::APIMisuseError { err: cannot_accept_contribution }); - let cannot_be_funded = format!( - "Channel {} cannot be funded: Our splice-out value of {} is greater than the maximum {}", - channel_id, splice_out_incl_fees + Amount::ONE_SAT, splice_out_incl_fees, - ); - initiator.logger.assert_log("lightning::ln::channel", cannot_be_funded, 1); + assert!(matches!( + build_splice_out_contribution(initiator, acceptor, channel_id, outputs), + Err(FundingContributionError::InvalidSpliceValue), + )); // 2) Check that splicing out with the additional satoshi removed passes validation on the sender's side. @@ -8594,16 +8598,10 @@ fn do_test_0reserve_splice_holder_validation( mine_transaction(acceptor, &splice_tx); lock_splice_after_blocks(initiator, acceptor, ANTI_REORG_DELAY - 1); } else { - assert!(initiate_splice_out(initiator, acceptor, channel_id, outputs).is_err()); - let splice_out_value = - splice_out_max_value + Amount::from_sat(estimated_fees_sat) + Amount::ONE_SAT; - let splice_out_max_value = splice_out_max_value + Amount::from_sat(estimated_fees_sat); - let cannot_be_funded = format!( - "Channel {channel_id} cannot be funded: Our \ - splice-out value of {splice_out_value} is greater than the maximum \ - {splice_out_max_value}" - ); - initiator.logger.assert_log("lightning::ln::channel", cannot_be_funded, 1); + assert!(matches!( + build_splice_out_contribution(initiator, acceptor, channel_id, outputs), + Err(FundingContributionError::InvalidSpliceValue), + )); } channel_type @@ -8888,6 +8886,39 @@ fn do_test_splice_out_initiator_reserve_breach_zero_fee_commitments( let _ = route_payment(&nodes[0], &[&nodes[1]], node_1_htlc_balance_msat); } + let initiator = &nodes[0]; + let acceptor = &nodes[1]; + let node_id_initiator = initiator.node.get_our_node_id(); + let node_id_acceptor = acceptor.node.get_our_node_id(); + + // We use a stale funding template to get around the enforcement of + // [`FundingTemplate::spliceable_balance`]. + let stale_funding_template = + nodes[0].node.splice_channel(&channel_id, &node_id_acceptor).unwrap(); + let splice_out = |funding_template: FundingTemplate, outputs: Vec| { + let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let feerate = funding_template.min_rbf_feerate().unwrap_or(floor_feerate); + let funding_contribution = + funding_template.splice_out(outputs, feerate, FeeRate::MAX).unwrap(); + match nodes[0].node.funding_contributed( + &channel_id, + &node_id_acceptor, + funding_contribution.clone(), + None, + ) { + Ok(()) => Ok(funding_contribution), + Err(e) => { + expect_splice_failed_events( + &nodes[0], + &channel_id, + funding_contribution, + NegotiationFailureReason::ContributionInvalid, + ); + Err(e) + }, + } + }; + { let per_peer_lock; let mut peer_state_lock; @@ -8924,7 +8955,7 @@ fn do_test_splice_out_initiator_reserve_breach_zero_fee_commitments( value: splice_out_output_amount, script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]; - let contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); + let contribution = splice_out(stale_funding_template, outputs).unwrap(); let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, contribution); mine_transaction(&nodes[0], &splice_tx); @@ -8948,7 +8979,7 @@ fn do_test_splice_out_initiator_reserve_breach_zero_fee_commitments( value, script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]; - let contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs); + let contribution = splice_out(stale_funding_template, outputs); if matches!(validation_case, ValidationCase::FailsAtHolder) { assert_eq!( @@ -8976,11 +9007,6 @@ fn do_test_splice_out_initiator_reserve_breach_zero_fee_commitments( // this point. let v2_channel_reserve = Amount::from_sat(high_dust_limit_satoshis); - let initiator = &nodes[0]; - let acceptor = &nodes[1]; - let node_id_initiator = initiator.node.get_our_node_id(); - let node_id_acceptor = acceptor.node.get_our_node_id(); - let stfu_init = get_event_msg!(initiator, MessageSendEvent::SendStfu, node_id_acceptor); acceptor.node.handle_stfu(node_id_initiator, &stfu_init); let stfu_ack = get_event_msg!(acceptor, MessageSendEvent::SendStfu, node_id_initiator); From dcca939942b96f92d26565151ff2070367efccfd Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Wed, 6 May 2026 12:26:13 -0700 Subject: [PATCH 412/627] Detect duplicate inputs and outputs upon building FundingBuilder While this is already enforced when we get to the interactive negotiation phase, we choose to fail early anyway. --- lightning/src/ln/funding.rs | 57 +++++++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index b8d0539f27c..e1f5d8f59c8 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -132,8 +132,8 @@ pub enum FundingContributionError { /// The minimum RBF feerate. min_rbf_feerate: FeeRate, }, - /// The splice value is invalid (zero, empty outputs, exceeds the maximum money supply, or - /// splices out more than the available channel balance). + /// The splice value is invalid (zero, empty outputs, duplicate inputs or outputs, exceeds the + /// maximum money supply, or splices out more than the available channel balance). InvalidSpliceValue, /// An input's `prevtx` is too large to fit in a `tx_add_input` message. PrevTxTooLarge, @@ -164,7 +164,10 @@ impl core::fmt::Display for FundingContributionError { write!(f, "Feerate {} is below minimum RBF feerate {}", feerate, min_rbf_feerate) }, FundingContributionError::InvalidSpliceValue => { - write!(f, "Invalid splice value (zero, empty, exceeds limit, or overdraws balance)") + write!( + f, + "Invalid splice value (zero, empty, duplicate, exceeds limit, or overdraws balance)" + ) }, FundingContributionError::PrevTxTooLarge => { write!(f, "Input prevtx is too large to fit in a tx_add_input message") @@ -514,7 +517,14 @@ fn estimate_transaction_fee( fn validate_inputs(inputs: &[FundingTxInput]) -> Result<(), FundingContributionError> { let mut total_value = Amount::ZERO; - for input in inputs { + for (idx, input) in inputs.iter().enumerate() { + if inputs[..idx] + .iter() + .any(|existing_input| existing_input.utxo.outpoint == input.utxo.outpoint) + { + return Err(FundingContributionError::InvalidSpliceValue); + } + use crate::util::ser::Writeable; const MESSAGE_TEMPLATE: msgs::TxAddInput = msgs::TxAddInput { channel_id: ChannelId([0; 32]), @@ -1362,7 +1372,14 @@ impl FundingBuilderInner { )?; let mut value_removed = Amount::ZERO; - for output in self.outputs.iter() { + for (idx, output) in self.outputs.iter().enumerate() { + if self.outputs[..idx] + .iter() + .any(|existing_output| existing_output.script_pubkey == output.script_pubkey) + { + return Err(FundingContributionError::InvalidSpliceValue); + } + value_removed = match value_removed.checked_add(output.value) { Some(sum) if sum <= Amount::MAX_MONEY => sum, _ => return Err(FundingContributionError::InvalidSpliceValue), @@ -2325,6 +2342,36 @@ mod tests { ); } + #[test] + fn test_funding_builder_rejects_duplicate_inputs() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let input = funding_input_sats(100_000); + + let result = FundingTemplate::new(None, None, None, Amount::ZERO) + .without_prior_contribution(feerate, FeeRate::MAX) + .add_inputs(vec![input.clone(), input]) + .unwrap() + .build(); + + assert!(matches!(result, Err(FundingContributionError::InvalidSpliceValue),)); + } + + #[test] + fn test_funding_builder_rejects_duplicate_outputs() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let first_output = funding_output_sats(25_000); + let second_output = funding_output_sats(30_000); + assert_ne!(first_output, second_output); + assert_eq!(first_output.script_pubkey, second_output.script_pubkey); + + let result = FundingTemplate::new(None, None, None, Amount::MAX_MONEY) + .without_prior_contribution(feerate, FeeRate::MAX) + .add_outputs(vec![first_output, second_output]) + .build(); + + assert!(matches!(result, Err(FundingContributionError::InvalidSpliceValue),)); + } + #[test] fn test_funding_builder_remove_input_updates_manual_input_request() { let feerate = FeeRate::from_sat_per_kwu(2000); From 2631b91088830b80eb6afdf842852f1f42856626 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 13 May 2026 01:09:08 +0000 Subject: [PATCH 413/627] Remove all tests covering 0-reserve legacy channels --- lightning/src/ln/htlc_reserve_unit_tests.rs | 236 +------------------- lightning/src/ln/splicing_tests.rs | 52 +---- 2 files changed, 14 insertions(+), 274 deletions(-) diff --git a/lightning/src/ln/htlc_reserve_unit_tests.rs b/lightning/src/ln/htlc_reserve_unit_tests.rs index 68581ef580a..86d98b78826 100644 --- a/lightning/src/ln/htlc_reserve_unit_tests.rs +++ b/lightning/src/ln/htlc_reserve_unit_tests.rs @@ -39,9 +39,7 @@ fn do_test_counterparty_no_reserve(send_from_initiator: bool) { // in normal testing, we test it explicitly here. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - let legacy_cfg = test_legacy_channel_config(); - let node_chanmgrs = - create_node_chanmgrs(2, &node_cfgs, &[Some(legacy_cfg.clone()), Some(legacy_cfg)]); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let node_a_id = nodes[0].node.get_our_node_id(); @@ -52,13 +50,14 @@ fn do_test_counterparty_no_reserve(send_from_initiator: bool) { // Have node0 initiate a channel to node1 with aforementioned parameters let mut push_amt = 100_000_000; let feerate_per_kw = 253; - let channel_type_features = ChannelTypeFeatures::only_static_remote_key(); + let channel_type_features = ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies(); push_amt -= feerate_per_kw as u64 * (commitment_tx_base_weight(&channel_type_features) + 4 * COMMITMENT_TX_WEIGHT_PER_HTLC) / 1000 * 1000; push_amt -= get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config, false) .unwrap() * 1000; + push_amt -= 2 * 330_000; let push = if send_from_initiator { 0 } else { push_amt }; let temp_channel_id = @@ -109,10 +108,8 @@ fn do_test_counterparty_no_reserve(send_from_initiator: bool) { &nodes[0], &[&nodes[1]], 100_000_000 - // Note that for outbound channels we have to consider the commitment tx fee and the - // "fee spike buffer", which is currently a multiple of the total commitment tx fee as - // well as an additional HTLC. - - FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE * commit_tx_fee_msat(feerate_per_kw, 2, &channel_type_features), + - commit_tx_fee_msat(feerate_per_kw, 2, &channel_type_features) + - 2 * 330_000, ); } else { send_payment(&nodes[1], &[&nodes[0]], push_amt); @@ -2358,12 +2355,6 @@ pub fn do_test_dust_limit_fee_accounting(can_afford: bool) { fn test_create_channel_to_trusted_peer_0reserve() { let mut config = test_default_channel_config(); - // Legacy channels - config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; - config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; - let channel_type = do_test_create_channel_to_trusted_peer_0reserve(config.clone()); - assert_eq!(channel_type, ChannelTypeFeatures::only_static_remote_key()); - // Anchor channels config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; @@ -2416,14 +2407,8 @@ fn do_test_create_channel_to_trusted_peer_0reserve(mut config: UserConfig) -> Ch } 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, + feerate_per_kw, 2, // We reserve space for two HTLCs, the next outbound non-dust HTLC, and the fee spike buffer HTLC &channel_type, ); @@ -2446,12 +2431,6 @@ fn do_test_create_channel_to_trusted_peer_0reserve(mut config: UserConfig) -> Ch fn test_accept_inbound_channel_from_trusted_peer_0reserve() { let mut config = test_default_channel_config(); - // Legacy channels - config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; - config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; - let channel_type = do_test_accept_inbound_channel_from_trusted_peer_0reserve(config.clone()); - assert_eq!(channel_type, ChannelTypeFeatures::only_static_remote_key()); - // Anchor channels config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; @@ -2539,14 +2518,8 @@ fn do_test_accept_inbound_channel_from_trusted_peer_0reserve( } 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, + feerate_per_kw, 2, // We reserve space for two HTLCs, the next outbound non-dust HTLC, and the fee spike buffer HTLC &channel_type, ); @@ -2565,20 +2538,8 @@ fn do_test_accept_inbound_channel_from_trusted_peer_0reserve( channel_type } -enum LegacyChannelsNoOutputs { - PaymentSucceeds, - FailsReceiverUpdateAddHTLC, - FailsReceiverCanAcceptHTLCA, - FailsReceiverCanAcceptHTLCB, -} - #[xtest(feature = "_externalize_tests")] fn test_0reserve_no_outputs() { - do_test_0reserve_no_outputs_legacy(LegacyChannelsNoOutputs::PaymentSucceeds); - do_test_0reserve_no_outputs_legacy(LegacyChannelsNoOutputs::FailsReceiverCanAcceptHTLCA); - do_test_0reserve_no_outputs_legacy(LegacyChannelsNoOutputs::FailsReceiverCanAcceptHTLCB); - do_test_0reserve_no_outputs_legacy(LegacyChannelsNoOutputs::FailsReceiverUpdateAddHTLC); - do_test_0reserve_no_outputs_keyed_anchors(true); do_test_0reserve_no_outputs_keyed_anchors(false); @@ -2678,189 +2639,6 @@ pub(crate) fn setup_0reserve_no_outputs_channels<'a, 'b, 'c, 'd>( (channel_id, tx) } -fn do_test_0reserve_no_outputs_legacy(no_outputs_case: LegacyChannelsNoOutputs) { - let mut config = test_default_channel_config(); - config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; - config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; - - let chanmon_cfgs = create_chanmon_cfgs(2); - let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = - 100; - - let channel_type = ChannelTypeFeatures::only_static_remote_key(); - - let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); - let nodes = create_network(2, &node_cfgs, &node_chanmgrs); - - let node_a_id = nodes[0].node.get_our_node_id(); - let _node_b_id = nodes[1].node.get_our_node_id(); - - let feerate_per_kw = 253; - let spike_multiple = FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32; - let dust_limit_satoshis: u64 = 546; - let channel_value_sat = 1000; - - let (channel_id, _funding_tx) = - setup_0reserve_no_outputs_channels(&nodes, channel_value_sat, dust_limit_satoshis); - assert_eq!(nodes[0].node.list_channels()[0].channel_type.as_ref().unwrap(), &channel_type); - - // Sending the biggest dust HTLC possible trims our balance output! - let (timeout_tx_fee_sat, success_tx_fee_sat) = - second_stage_tx_fees_sat(&channel_type, spike_multiple * feerate_per_kw); - let max_dust_htlc_sat = dust_limit_satoshis + success_tx_fee_sat - 1; - assert!( - channel_value_sat - .saturating_sub(commit_tx_fee_sat(feerate_per_kw, 0, &channel_type)) - .saturating_sub(max_dust_htlc_sat) - < dust_limit_satoshis - ); - - // We can't afford the fee for an additional non-dust HTLC + the fee spike HTLC, so we can only send - // dust HTLCs... - let min_local_nondust_htlc_sat = dust_limit_satoshis + timeout_tx_fee_sat; - assert!( - channel_value_sat - commit_tx_fee_sat(spike_multiple * feerate_per_kw, 2, &channel_type) - < min_local_nondust_htlc_sat - ); - - // We cannot trim our own balance output, otherwise we'd have no outputs on the commitment. We must - // also reserve enough fees to pay for an incoming non-dust HTLC, aka the fee spike buffer HTLC. - let min_value_sat = core::cmp::max( - commit_tx_fee_sat(spike_multiple * feerate_per_kw, 0, &channel_type) + dust_limit_satoshis, - commit_tx_fee_sat(spike_multiple * feerate_per_kw, 1, &channel_type), - ); - // At this point the tighter requirement is "must have an output" - assert!( - commit_tx_fee_sat(spike_multiple * feerate_per_kw, 0, &channel_type) + dust_limit_satoshis - > commit_tx_fee_sat(spike_multiple * feerate_per_kw, 1, &channel_type) - ); - // But say at 9sat/vb with default dust limit, - // the tighter requirement is actually "must have funds for an inbound HTLC" ! - assert!( - commit_tx_fee_sat(9 * 250, 0, &channel_type) + 354 - < commit_tx_fee_sat(9 * 250, 1, &channel_type) - ); - 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); - - let (sender_amount_msat, receiver_amount_msat) = match no_outputs_case { - LegacyChannelsNoOutputs::PaymentSucceeds => (sender_amount_msat, sender_amount_msat), - LegacyChannelsNoOutputs::FailsReceiverCanAcceptHTLCA => { - // A dust HTLC with 1msat added to it will break counterparty `can_accept_incoming_htlc` - // validation, as this dust HTLC would push the holder's balance output below the - // dust limit at the spike multiple feerate. - (sender_amount_msat, sender_amount_msat + 1) - }, - LegacyChannelsNoOutputs::FailsReceiverCanAcceptHTLCB => { - // In `validate_update_add_htlc`, we check that there is still some output present on - // the commitment given the *current* set of HTLCs, and the *current* feerate. So this - // HTLC will pass at `validate_update_add_htlc`, but will fail in - // `can_accept_incoming_htlc` due to failed fee spike buffer checks. - let receiver_amount_msat = (channel_value_sat - - commit_tx_fee_sat(feerate_per_kw, 0, &channel_type) - - dust_limit_satoshis) - * 1000; - (sender_amount_msat, receiver_amount_msat) - }, - LegacyChannelsNoOutputs::FailsReceiverUpdateAddHTLC => { - // Same value as above, just add 1msat, and this fails at `validate_update_add_htlc` - let receiver_amount_msat = (channel_value_sat - - commit_tx_fee_sat(feerate_per_kw, 0, &channel_type) - - dust_limit_satoshis) - * 1000; - (sender_amount_msat, receiver_amount_msat + 1) - }, - }; - - if let LegacyChannelsNoOutputs::PaymentSucceeds = no_outputs_case { - send_payment(&nodes[0], &[&nodes[1]], sender_amount_msat); - // Node 1 the fundee has 0-reserve too, so whatever they receive, they can send right back! - // Node 0 should *always* have the funds to cover the fee of a single non-dust HTLC from node 1. - assert_eq!( - nodes[1].node.list_channels()[0].next_outbound_htlc_limit_msat, - sender_amount_msat - ); - send_payment(&nodes[1], &[&nodes[0]], sender_amount_msat); - } else { - let (route, payment_hash, _, payment_secret) = - get_route_and_payment_hash!(nodes[0], nodes[1], sender_amount_msat); - let secp_ctx = Secp256k1::new(); - let session_priv = SecretKey::from_slice(&[42; 32]).unwrap(); - let cur_height = nodes[0].node.best_block.read().unwrap().height + 1; - let onion_keys = - onion_utils::construct_onion_keys(&secp_ctx, &route.paths[0], &session_priv); - let recipient_onion_fields = - RecipientOnionFields::secret_only(payment_secret, sender_amount_msat); - let (onion_payloads, htlc_msat, htlc_cltv) = onion_utils::test_build_onion_payloads( - &route.paths[0], - &recipient_onion_fields, - cur_height, - &None, - None, - None, - ) - .unwrap(); - assert_eq!(htlc_msat, sender_amount_msat); - let onion_packet = - onion_utils::construct_onion_packet(onion_payloads, onion_keys, [0; 32], &payment_hash) - .unwrap(); - let msg = msgs::UpdateAddHTLC { - channel_id, - htlc_id: 0, - amount_msat: receiver_amount_msat, - payment_hash, - cltv_expiry: htlc_cltv, - onion_routing_packet: onion_packet, - skimmed_fee_msat: None, - blinding_point: None, - hold_htlc: None, - accountable: None, - }; - - nodes[1].node.handle_update_add_htlc(node_a_id, &msg); - - if let LegacyChannelsNoOutputs::FailsReceiverUpdateAddHTLC = no_outputs_case { - nodes[1].logger.assert_log_contains( - "lightning::ln::channelmanager", - "Remote HTLC add would overdraw remaining funds", - 3, - ); - assert_eq!(nodes[1].node.list_channels().len(), 0); - let err_msg = check_closed_broadcast(&nodes[1], 1, true).pop().unwrap(); - assert_eq!(err_msg.data, "Remote HTLC add would overdraw remaining funds"); - let reason = ClosureReason::ProcessingError { - err: "Remote HTLC add would overdraw remaining funds".to_string(), - }; - check_added_monitors(&nodes[1], 1); - check_closed_event(&nodes[1], 1, reason, &[node_a_id], channel_value_sat); - - return; - } - - let htlcs_in_commitment = vec![HTLCOutputInCommitment { - offered: false, - amount_msat: receiver_amount_msat, - cltv_expiry: htlc_cltv, - payment_hash, - transaction_output_index: Some(1), - }]; - - manually_trigger_update_fail_htlc( - &nodes, - channel_id, - channel_value_sat * 1000, - dust_limit_satoshis, - payment_hash, - htlcs_in_commitment, - false, - ); - } -} - fn manually_trigger_update_fail_htlc<'a, 'b, 'c, 'd>( nodes: &'a Vec>, channel_id: ChannelId, value_to_self_msat: u64, dust_limit_satoshis: u64, payment_hash: PaymentHash, diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 6bd5d5224f7..e6ad2d249b9 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -8311,20 +8311,6 @@ fn test_no_disconnect_after_quiescence_on_reconnect() { #[test] fn test_0reserve_splice() { let mut config = test_default_channel_config(); - config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; - config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; - let a = do_test_0reserve_splice_holder_validation(false, false, false, config.clone()); - let _b = do_test_0reserve_splice_holder_validation(true, false, false, config.clone()); - let _c = do_test_0reserve_splice_holder_validation(false, true, false, config.clone()); - let _d = do_test_0reserve_splice_holder_validation(true, true, false, config.clone()); - - let _e = do_test_0reserve_splice_holder_validation(false, false, true, config.clone()); - let _f = do_test_0reserve_splice_holder_validation(true, false, true, config.clone()); - let _g = do_test_0reserve_splice_holder_validation(false, true, true, config.clone()); - let _h = do_test_0reserve_splice_holder_validation(true, true, true, config.clone()); - - assert_eq!(a, ChannelTypeFeatures::only_static_remote_key()); - config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; let a = do_test_0reserve_splice_holder_validation(false, false, false, config.clone()); @@ -8354,20 +8340,6 @@ fn test_0reserve_splice() { assert_eq!(a, ChannelTypeFeatures::anchors_zero_fee_commitments()); let mut config = test_default_channel_config(); - config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; - config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; - let a = do_test_0reserve_splice_counterparty_validation(false, false, false, config.clone()); - let _b = do_test_0reserve_splice_counterparty_validation(true, false, false, config.clone()); - let _c = do_test_0reserve_splice_counterparty_validation(false, true, false, config.clone()); - let _d = do_test_0reserve_splice_counterparty_validation(true, true, false, config.clone()); - - let _e = do_test_0reserve_splice_counterparty_validation(false, false, true, config.clone()); - let _f = do_test_0reserve_splice_counterparty_validation(true, false, true, config.clone()); - let _g = do_test_0reserve_splice_counterparty_validation(false, true, true, config.clone()); - let _h = do_test_0reserve_splice_counterparty_validation(true, true, true, config.clone()); - - assert_eq!(a, ChannelTypeFeatures::only_static_remote_key()); - config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; let a = do_test_0reserve_splice_counterparty_validation(false, false, false, config.clone()); @@ -8426,11 +8398,6 @@ fn do_test_0reserve_splice_holder_validation( let feerate = if channel_type == ChannelTypeFeatures::anchors_zero_fee_commitments() { 0 } else { 253 }; - let spiked_feerate = if channel_type == ChannelTypeFeatures::only_static_remote_key() { - feerate * FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 - } else { - feerate - }; let anchors_sat = if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() { ANCHOR_OUTPUT_VALUE_SATOSHI * 2 @@ -8442,7 +8409,7 @@ fn do_test_0reserve_splice_holder_validation( send_payment(&nodes[0], &[&nodes[1]], channel_value_sat / 2 * 1_000); channel_value_sat / 2 } else if !node_0_is_initiator { - let tx_fee_msat = chan_utils::commit_tx_fee_sat(spiked_feerate, 2, &channel_type) * 1000; + let tx_fee_msat = chan_utils::commit_tx_fee_sat(feerate, 2, &channel_type) * 1000; let node_0_details = &nodes[0].node.list_channels()[0]; let outbound_capacity_msat = node_0_details.outbound_capacity_msat; let available_capacity_msat = node_0_details.next_outbound_htlc_limit_msat; @@ -8479,12 +8446,12 @@ fn do_test_0reserve_splice_holder_validation( // The estimated fees to splice out a single output at 253sat/kw let estimated_fees_sat = 183; let mut splice_out_max_value = if counterparty_has_output && node_0_is_initiator { - let commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(spiked_feerate, 1, &channel_type); + let commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(feerate, 1, &channel_type); Amount::from_sat( initiator_value_to_self_sat - commit_tx_fee_sat - anchors_sat - estimated_fees_sat, ) } else if !counterparty_has_output && node_0_is_initiator { - let commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(spiked_feerate, 0, &channel_type); + let commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(feerate, 0, &channel_type); Amount::from_sat( initiator_value_to_self_sat - commit_tx_fee_sat @@ -8573,11 +8540,6 @@ fn do_test_0reserve_splice_counterparty_validation( let feerate = if channel_type == ChannelTypeFeatures::anchors_zero_fee_commitments() { 0 } else { 253 }; - let spiked_feerate = if channel_type == ChannelTypeFeatures::only_static_remote_key() { - feerate * FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 - } else { - feerate - }; let anchors_sat = if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() { ANCHOR_OUTPUT_VALUE_SATOSHI * 2 @@ -8589,7 +8551,7 @@ fn do_test_0reserve_splice_counterparty_validation( send_payment(&nodes[0], &[&nodes[1]], channel_value_sat / 2 * 1_000); channel_value_sat / 2 } else if !node_0_is_initiator { - let tx_fee_msat = chan_utils::commit_tx_fee_sat(spiked_feerate, 2, &channel_type) * 1000; + let tx_fee_msat = chan_utils::commit_tx_fee_sat(feerate, 2, &channel_type) * 1000; let node_0_details = &nodes[0].node.list_channels()[0]; let outbound_capacity_msat = node_0_details.outbound_capacity_msat; let available_capacity_msat = node_0_details.next_outbound_htlc_limit_msat; @@ -8601,7 +8563,7 @@ fn do_test_0reserve_splice_counterparty_validation( let node_0_to_local_output_msat = channel_value_sat * 1000 - available_capacity_msat - anchors_sat * 1000 - - chan_utils::commit_tx_fee_sat(spiked_feerate, 0, &channel_type) * 1000; + - chan_utils::commit_tx_fee_sat(feerate, 0, &channel_type) * 1000; assert!(node_0_to_local_output_msat / 1000 < dust_limit_satoshis); let commit_tx = &get_local_commitment_txn!(nodes[0], channel_id)[0]; assert_eq!( @@ -8624,10 +8586,10 @@ fn do_test_0reserve_splice_counterparty_validation( }; let mut splice_out_value_incl_fees = if counterparty_has_output && node_0_is_initiator { - let commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(spiked_feerate, 1, &channel_type); + let commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(feerate, 1, &channel_type); Amount::from_sat(initiator_value_to_self_sat - commit_tx_fee_sat - anchors_sat) } else if !counterparty_has_output && node_0_is_initiator { - let commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(spiked_feerate, 0, &channel_type); + let commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(feerate, 0, &channel_type); Amount::from_sat( initiator_value_to_self_sat - commit_tx_fee_sat - anchors_sat - dust_limit_satoshis, ) From 7ccc6b79559467bcee1bedb73a1583e9dcc4965f Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 13 May 2026 00:36:07 +0000 Subject: [PATCH 414/627] Disallow holders from selecting 0-reserve in legacy channels We still allow counterparties to set 0-reserve legacy channels, as in prior releases. --- fuzz/src/chanmon_consistency.rs | 15 ++++++++------- lightning/src/ln/channel.rs | 31 ++++++++++++++++++++++++++++++ lightning/src/ln/channelmanager.rs | 4 ++++ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 4a182c33beb..4ff0e4a4a03 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -628,7 +628,7 @@ fn assert_action_timeout_awaiting_response(action: &msgs::ErrorAction) { ); } -#[derive(Copy, Clone)] +#[derive(Clone, Copy, PartialEq)] enum ChanType { Legacy, KeyedAnchors, @@ -2082,19 +2082,20 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { connect_peers(&nodes[0], &nodes[1]); connect_peers(&nodes[1], &nodes[2]); + let set_0reserve = chan_type != ChanType::Legacy; // Create 3 channels between A-B and 3 channels between B-C (6 total). // // Use distinct version numbers for each funding transaction so each test // channel gets its own txid and funding outpoint. // A-B: channel 2 A and B have 0-reserve (trusted open + trusted accept), - // channel 3 A has 0-reserve (trusted accept). + // channel 3 A has 0-reserve (trusted accept), if channels are non-legacy. make_channel(&nodes[0], &nodes[1], 1, false, false, &mut chain_state); - make_channel(&nodes[0], &nodes[1], 2, true, true, &mut chain_state); - make_channel(&nodes[0], &nodes[1], 3, false, true, &mut chain_state); + make_channel(&nodes[0], &nodes[1], 2, set_0reserve, set_0reserve, &mut chain_state); + make_channel(&nodes[0], &nodes[1], 3, false, set_0reserve, &mut chain_state); // B-C: channel 4 B has 0-reserve (via trusted accept), - // channel 5 C has 0-reserve (via trusted open). - make_channel(&nodes[1], &nodes[2], 4, false, true, &mut chain_state); - make_channel(&nodes[1], &nodes[2], 5, true, false, &mut chain_state); + // channel 5 C has 0-reserve (via trusted open), if channels are non-legacy. + make_channel(&nodes[1], &nodes[2], 4, false, set_0reserve, &mut chain_state); + make_channel(&nodes[1], &nodes[2], 5, set_0reserve, false, &mut chain_state); make_channel(&nodes[1], &nodes[2], 6, false, false, &mut chain_state); // Wipe the transactions-broadcasted set to make sure we don't broadcast diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 863256f3493..063b5303639 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -3832,6 +3832,14 @@ impl ChannelContext { "Funding must be smaller than the total bitcoin supply. It was {channel_value_satoshis}" ))); } + if !channel_type.supports_anchors_zero_fee_htlc_tx() + && !channel_type.supports_anchor_zero_fee_commitments() + && holder_selected_channel_reserve_satoshis == 0 + { + return Err(ChannelError::close( + "0-reserve is not allowed on legacy channels".to_owned(), + )); + } if msg_channel_reserve_satoshis > channel_value_satoshis { return Err(ChannelError::close(format!( "Bogus channel_reserve_satoshis ({msg_channel_reserve_satoshis}). Must be no greater than channel_value_satoshis: {channel_value_satoshis}" @@ -4323,6 +4331,14 @@ impl ChannelContext { } let channel_type = get_initial_channel_type(&config, their_features); + if !channel_type.supports_anchors_zero_fee_htlc_tx() + && !channel_type.supports_anchor_zero_fee_commitments() + && holder_selected_channel_reserve_satoshis == 0 + { + return Err(APIError::APIMisuseError { + err: "0-reserve is not allowed on legacy channels".to_owned(), + }); + } debug_assert!(!channel_type.supports_any_optional_bits()); debug_assert!(!channel_type .requires_unknown_bits_from(&channelmanager::provided_channel_type_features(&config))); @@ -4869,6 +4885,14 @@ impl ChannelContext { } let channel_type = funding.get_channel_type(); + if !channel_type.supports_anchors_zero_fee_htlc_tx() + && !channel_type.supports_anchor_zero_fee_commitments() + && funding.holder_selected_channel_reserve_satoshis == 0 + { + return Err(ChannelError::close( + "0-reserve is not allowed on legacy channels".to_owned(), + )); + } if common_fields.max_accepted_htlcs > max_htlcs(channel_type) { return Err(ChannelError::close(format!( "max_accepted_htlcs was {}. It must not be larger than {}", @@ -6576,6 +6600,13 @@ impl ChannelContext { } let next_channel_type = get_initial_channel_type(user_config, &eligible_features); + if !next_channel_type.supports_anchors_zero_fee_htlc_tx() + && !next_channel_type.supports_anchor_zero_fee_commitments() + && funding.holder_selected_channel_reserve_satoshis == 0 + { + // 0-reserve is not allowed on legacy channels + return Err(()); + } self.feerate_per_kw = selected_commitment_sat_per_1000_weight(&fee_estimator, &next_channel_type); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 19fd2f96797..55fe83b2bc3 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -3617,6 +3617,8 @@ pub enum TrustedChannelFeatures { /// with a revoked commitment transaction *for free*. /// /// Note that there is no guarantee that the counterparty accepts such a channel themselves. + /// + /// The zero-reserve feature is not allowed on legacy / anchorless channels. ZeroReserve, /// Sets the combination of [`TrustedChannelFeatures::ZeroConf`] and [`TrustedChannelFeatures::ZeroReserve`] ZeroConfZeroReserve, @@ -3873,6 +3875,8 @@ impl< /// transaction *for free*. /// /// Note that there is no guarantee that the counterparty accepts such a channel. + /// + /// The zero-reserve feature is not allowed on legacy / anchorless channels. pub fn create_channel_to_trusted_peer_0reserve( &self, their_network_key: PublicKey, channel_value_satoshis: u64, push_msat: u64, user_channel_id: u128, temporary_channel_id: Option, From c199697cf2b8684a7f9430b217263c4f0f826130 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 15 May 2026 05:56:22 +0000 Subject: [PATCH 415/627] Add test coverage for the ban on 0-reserve legacy channels --- lightning/src/ln/channel_open_tests.rs | 190 ++++++++++++++++++++++++- 1 file changed, 189 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/channel_open_tests.rs b/lightning/src/ln/channel_open_tests.rs index 50ef0721e07..2c048c9906c 100644 --- a/lightning/src/ln/channel_open_tests.rs +++ b/lightning/src/ln/channel_open_tests.rs @@ -24,7 +24,8 @@ use crate::ln::channelmanager::{ MAX_UNFUNDED_CHANS_PER_PEER, }; use crate::ln::msgs::{ - AcceptChannel, BaseMessageHandler, ChannelMessageHandler, ErrorAction, MessageSendEvent, + AcceptChannel, BaseMessageHandler, ChannelMessageHandler, ErrorAction, ErrorMessage, + MessageSendEvent, }; use crate::ln::types::ChannelId; use crate::ln::{functional_test_utils::*, msgs}; @@ -48,6 +49,7 @@ use bitcoin::{Amount, Sequence, Transaction, TxIn, TxOut, Witness}; use lightning_macros::xtest; use lightning_types::features::ChannelTypeFeatures; +use types::string::UntrustedString; #[test] fn test_outbound_chans_unlimited() { @@ -2496,3 +2498,189 @@ fn test_fund_pending_channel() { }; check_closed_event(&nodes[0], 1, reason, &[node_b_id], 100_000); } + +#[xtest(feature = "_externalize_tests")] +fn test_holder_selected_0reserve_on_legacy_channel_is_not_allowed() { + { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut channel_config = test_default_channel_config(); + assert!(channel_config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx); + let node_chanmgrs = create_node_chanmgrs( + 2, + &node_cfgs, + &[Some(channel_config.clone()), Some(channel_config.clone())], + ); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let _node_a_id = nodes[0].node.get_our_node_id(); + let node_b_id = nodes[1].node.get_our_node_id(); + + let mut legacy_channel_config = test_default_channel_config(); + legacy_channel_config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + legacy_channel_config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = + false; + + // User tries to open a legacy 0-reserve channel with a config override, we fail + assert_eq!( + nodes[0] + .node + .create_channel_to_trusted_peer_0reserve( + node_b_id, + 100_000, + 0, + 42, + None, + Some(legacy_channel_config) + ) + .unwrap_err(), + APIError::APIMisuseError { + err: "0-reserve is not allowed on legacy channels".to_owned() + } + ); + } + { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut channel_config = test_default_channel_config(); + channel_config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + channel_config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + let node_chanmgrs = create_node_chanmgrs( + 2, + &node_cfgs, + &[Some(channel_config.clone()), Some(channel_config.clone())], + ); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let _node_a_id = nodes[0].node.get_our_node_id(); + let node_b_id = nodes[1].node.get_our_node_id(); + + // User tries to open a legacy 0-reserve channel from the default config, we fail + assert_eq!( + nodes[0] + .node + .create_channel_to_trusted_peer_0reserve(node_b_id, 100_000, 0, 42, None, None) + .unwrap_err(), + APIError::APIMisuseError { + err: "0-reserve is not allowed on legacy channels".to_owned() + } + ); + } + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut channel_config = test_default_channel_config(); + channel_config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + channel_config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + let node_chanmgrs = create_node_chanmgrs( + 2, + &node_cfgs, + &[Some(channel_config.clone()), Some(channel_config.clone())], + ); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_a_id = nodes[0].node.get_our_node_id(); + let node_b_id = nodes[1].node.get_our_node_id(); + + nodes[0].node.create_channel(node_b_id, 100_000, 0, 42, None, None).unwrap(); + let mut open_channel_msg_0reserve = + get_event_msg!(nodes[0], MessageSendEvent::SendOpenChannel, node_b_id); + open_channel_msg_0reserve.channel_reserve_satoshis = 0; + assert_eq!( + open_channel_msg_0reserve.common_fields.channel_type, + Some(ChannelTypeFeatures::only_static_remote_key()) + ); + + // User accepts a legacy channel, and sets 0-reserve for the counterparty, we fail + nodes[1].node.handle_open_channel(node_a_id, &open_channel_msg_0reserve); + let events = nodes[1].node.get_and_clear_pending_events(); + match events[0] { + Event::OpenChannelRequest { temporary_channel_id, .. } => { + let error = nodes[1] + .node + .accept_inbound_channel_from_trusted_peer( + &temporary_channel_id, + &node_a_id, + 42, + TrustedChannelFeatures::ZeroReserve, + None, + ) + .unwrap_err(); + assert_eq!( + error, + APIError::ChannelUnavailable { + err: "0-reserve is not allowed on legacy channels".to_owned() + } + ); + }, + _ => panic!("Unexpected event"), + } + let err_msg = get_err_msg(&nodes[1], &node_a_id); + assert_eq!( + err_msg, + ErrorMessage { + channel_id: open_channel_msg_0reserve.common_fields.temporary_channel_id, + data: "0-reserve is not allowed on legacy channels".to_string() + } + ); + + // But legacy channels where only the counterparty sets 0-reserve are ok! + // Here node 1 accepts 0-reserve from node 0, and node 1 sets some non-zero reserve... + handle_and_accept_open_channel(&nodes[1], node_a_id, &open_channel_msg_0reserve); + let mut accept_channel_msg = + get_event_msg!(nodes[1], MessageSendEvent::SendAcceptChannel, node_a_id); + // Override the reserve selected by node 1, make sure node 0 accepts too + accept_channel_msg.channel_reserve_satoshis = 0; + + nodes[0].node.handle_accept_channel(node_b_id, &accept_channel_msg); + + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + assert!( + matches!(events[0], Event::FundingGenerationReady { channel_value_satoshis: 100_000, user_channel_id: 42, counterparty_node_id, .. } if counterparty_node_id == node_b_id) + ); +} + +#[xtest(feature = "_externalize_tests")] +fn test_error_if_0reserve_negotiates_down_to_legacy() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let channel_config = test_default_channel_config(); + assert!(channel_config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx); + let node_chanmgrs = create_node_chanmgrs( + 2, + &node_cfgs, + &[Some(channel_config.clone()), Some(channel_config.clone())], + ); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_b_id = nodes[1].node.get_our_node_id(); + + nodes[0] + .node + .create_channel_to_trusted_peer_0reserve(node_b_id, 100_000, 0, 42, None, None) + .unwrap(); + let open_channel_msg = get_event_msg!(nodes[0], MessageSendEvent::SendOpenChannel, node_b_id); + assert_eq!( + open_channel_msg.common_fields.channel_type, + Some(ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies()) + ); + assert_eq!(open_channel_msg.channel_reserve_satoshis, 0); + + let reason = "Don't like your channel".to_owned(); + nodes[0].node.handle_error( + node_b_id, + &ErrorMessage { + channel_id: open_channel_msg.common_fields.temporary_channel_id, + data: reason.clone(), + }, + ); + + let reason = ClosureReason::CounterpartyForceClosed { peer_msg: UntrustedString(reason) }; + let expected_closing = ExpectedCloseEvent::from_id_reason( + open_channel_msg.common_fields.temporary_channel_id, + false, + reason, + ); + check_closed_events(&nodes[0], &[expected_closing]); +} From 257033fe1eb57db6f6ecaf3893dc4965677089c0 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 13 May 2026 04:36:10 +0000 Subject: [PATCH 416/627] Don't check for no-outputs under fee spikes in `get_available_balances` We only assume fee spikes in legacy channels, and we do not allow `holder_selected_channel_reserve_satoshis` to be set to zero in such channels. It is nonetheless still possible to reach the no-outputs case in a fee spike with solely the counterparty selected reserve set to zero, so we still guard against this case in `get_next_commitment_stats`. We don't guard against no-outputs under fee spikes `get_available_balances`; in the worst case, the receiver of the HTLC we just sent fails it back. --- lightning/src/sign/tx_builder.rs | 95 +++++++++++++------------------- 1 file changed, 39 insertions(+), 56 deletions(-) diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index 8300d17b7f7..6859d4dcf62 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -285,6 +285,15 @@ fn get_next_commitment_stats( // For zero-reserve channels, we check two things independently: // 1) Given the current set of HTLCs and feerate, does the commitment have at least one output ? + // + // We only assume fee spikes in legacy channels, and we do not allow + // `holder_selected_channel_reserve_satoshis` to be set to zero in such channels. It is + // nonetheless still possible to reach the no-outputs case in a fee spike with solely the + // counterparty selected reserve set to zero, so we still guard against this case here. + // + // We don't guard against no-outputs under fee spikes further below in + // `get_available_balances`; in the worst case, the receiver of the HTLC we just sent fails + // it back. if !has_output( is_outbound_from_holder, holder_balance_before_fee_msat, @@ -349,9 +358,9 @@ fn get_next_commitment_stats( // 3) s < (100h + 100 - 100d - c) / 99 fn get_next_splice_out_maximum_sat( is_outbound_from_holder: bool, channel_value_satoshis: u64, local_balance_before_fee_msat: u64, - remote_balance_before_fee_msat: u64, spiked_feerate: u32, - spiked_feerate_nondust_htlc_count: usize, post_splice_delta_above_reserve_sat: u64, - channel_constraints: &ChannelConstraints, channel_type: &ChannelTypeFeatures, + remote_balance_before_fee_msat: u64, feerate_per_kw: u32, nondust_htlc_count: usize, + post_splice_delta_above_reserve_sat: u64, channel_constraints: &ChannelConstraints, + channel_type: &ChannelTypeFeatures, ) -> u64 { let local_balance_before_fee_sat = local_balance_before_fee_msat / 1000; let mut next_splice_out_maximum_sat = if channel_constraints @@ -423,23 +432,23 @@ fn get_next_splice_out_maximum_sat( // // If the current `next_splice_out_maximum_sat` would produce a local commitment with no // outputs, bump this maximum such that, after the splice, the holder's balance covers at - // least `dust_limit_satoshis` and, if they are the funder, `current_spiked_tx_fee_sat`. - // We don't include an additional non-dust inbound HTLC in the `current_spiked_tx_fee_sat`, + // least `dust_limit_satoshis` and, if they are the funder, `current_tx_fee_sat`. + // We don't include an additional non-dust inbound HTLC in the `current_tx_fee_sat`, // because we don't mind if the holder dips below their dust limit to cover the fee for that // inbound non-dust HTLC. if !has_output( is_outbound_from_holder, local_balance_before_fee_msat.saturating_sub(next_splice_out_maximum_sat * 1000), remote_balance_before_fee_msat, - spiked_feerate, - spiked_feerate_nondust_htlc_count, + feerate_per_kw, + nondust_htlc_count, channel_constraints.holder_dust_limit_satoshis, channel_type, ) { let dust_limit_satoshis = channel_constraints.holder_dust_limit_satoshis; - let current_spiked_tx_fee_sat = commit_tx_fee_sat(spiked_feerate, 0, channel_type); + let current_tx_fee_sat = commit_tx_fee_sat(feerate_per_kw, 0, channel_type); let min_balance_sat = if is_outbound_from_holder { - dust_limit_satoshis.saturating_add(current_spiked_tx_fee_sat) + dust_limit_satoshis.saturating_add(current_tx_fee_sat) } else { dust_limit_satoshis }; @@ -476,13 +485,12 @@ fn get_available_balances( if channel_type.supports_anchor_zero_fee_commitments() { 0 } else { 1 }; // Note that the feerate is 0 in zero-fee commitment channels, so this statement is a noop - let spiked_feerate = feerate_per_kw.saturating_mul( - if is_outbound_from_holder && !channel_type.supports_anchors_zero_fee_htlc_tx() { + let spiked_feerate = + feerate_per_kw.saturating_mul(if !channel_type.supports_anchors_zero_fee_htlc_tx() { crate::ln::channel::FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 } else { 1 - }, - ); + }); let local_nondust_htlc_count = pending_htlcs .iter() @@ -495,17 +503,6 @@ fn get_available_balances( ) }) .count(); - let local_spiked_nondust_htlc_count = pending_htlcs - .iter() - .filter(|htlc| { - !htlc.is_dust( - true, - spiked_feerate, - channel_constraints.holder_dust_limit_satoshis, - channel_type, - ) - }) - .count(); // Note here we use the htlc count at the current feerate together with the spiked feerate; // this makes sure that the holder can afford any fee bump between 1x to 2x from the current @@ -571,9 +568,9 @@ fn get_available_balances( channel_value_satoshis, local_balance_before_fee_msat, remote_balance_before_fee_msat, - spiked_feerate, - // The number of non-dust HTLCs on the local commitment at the spiked feerate - local_spiked_nondust_htlc_count, + feerate_per_kw, + // The number of non-dust HTLCs on the local commitment at the current feerate + local_nondust_htlc_count, // The post-splice minimum balance of the holder if is_outbound_from_holder { local_min_commit_tx_fee_sat } else { 0 }, &channel_constraints, @@ -704,19 +701,7 @@ fn get_available_balances( } // Now adjust our min and max size HTLC to make sure both the local and the remote commitments still have - // at least one output at the spiked feerate. - - let remote_spiked_nondust_htlc_count = pending_htlcs - .iter() - .filter(|htlc| { - !htlc.is_dust( - false, - spiked_feerate, - channel_constraints.counterparty_dust_limit_satoshis, - channel_type, - ) - }) - .count(); + // at least one output at the current feerate. let (next_outbound_htlc_minimum_msat, available_capacity_msat) = adjust_boundaries_if_max_dust_htlc_produces_no_output( @@ -724,8 +709,8 @@ fn get_available_balances( is_outbound_from_holder, local_balance_before_fee_msat, remote_balance_before_fee_msat, - spiked_feerate, - local_spiked_nondust_htlc_count, + feerate_per_kw, + local_nondust_htlc_count, channel_constraints.holder_dust_limit_satoshis, channel_type, next_outbound_htlc_minimum_msat, @@ -738,8 +723,8 @@ fn get_available_balances( is_outbound_from_holder, local_balance_before_fee_msat, remote_balance_before_fee_msat, - spiked_feerate, - remote_spiked_nondust_htlc_count, + feerate_per_kw, + remote_nondust_htlc_count, channel_constraints.counterparty_dust_limit_satoshis, channel_type, next_outbound_htlc_minimum_msat, @@ -760,14 +745,13 @@ fn get_available_balances( fn adjust_boundaries_if_max_dust_htlc_produces_no_output( local: bool, is_outbound_from_holder: bool, holder_balance_before_fee_msat: u64, - counterparty_balance_before_fee_msat: u64, spiked_feerate: u32, - spiked_feerate_nondust_htlc_count: usize, dust_limit_satoshis: u64, - channel_type: &ChannelTypeFeatures, next_outbound_htlc_minimum_msat: u64, - available_capacity_msat: u64, + counterparty_balance_before_fee_msat: u64, feerate_per_kw: u32, nondust_htlc_count: usize, + dust_limit_satoshis: u64, channel_type: &ChannelTypeFeatures, + next_outbound_htlc_minimum_msat: u64, available_capacity_msat: u64, ) -> (u64, u64) { // First, determine the biggest dust HTLC we could send let (htlc_success_tx_fee_sat, htlc_timeout_tx_fee_sat) = - second_stage_tx_fees_sat(channel_type, spiked_feerate); + second_stage_tx_fees_sat(channel_type, feerate_per_kw); let min_nondust_htlc_sat = dust_limit_satoshis + if local { htlc_timeout_tx_fee_sat } else { htlc_success_tx_fee_sat }; let max_dust_htlc_msat = (min_nondust_htlc_sat.saturating_mul(1000)).saturating_sub(1); @@ -778,8 +762,8 @@ fn adjust_boundaries_if_max_dust_htlc_produces_no_output( is_outbound_from_holder, holder_balance_before_fee_msat.saturating_sub(max_dust_htlc_msat), counterparty_balance_before_fee_msat, - spiked_feerate, - spiked_feerate_nondust_htlc_count, + feerate_per_kw, + nondust_htlc_count, dust_limit_satoshis, channel_type, ) { @@ -796,16 +780,15 @@ fn adjust_boundaries_if_max_dust_htlc_produces_no_output( // Note that this will be a dust HTLC. } else { // 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); + let current_tx_fee_sat = commit_tx_fee_sat(feerate_per_kw, 0, channel_type); + let spike_buffer_tx_fee_sat = commit_tx_fee_sat(feerate_per_kw, 1, channel_type); // In case we are the funder, we must cover the greater of - // 1) The dust_limit_satoshis plus the fee of the existing commitment at the spiked feerate. + // 1) The dust_limit_satoshis plus the fee of the existing commitment at the current 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 = if is_outbound_from_holder { - cmp::max(dust_limit_satoshis + current_spiked_tx_fee_sat, spike_buffer_tx_fee_sat) - * 1000 + cmp::max(dust_limit_satoshis + current_tx_fee_sat, spike_buffer_tx_fee_sat) * 1000 // In case we are the fundee, we can send dust HTLCs as long as our own balance output // remains above the dust limit. } else { From 44ca076c34d9286512513b5df275144077a1358d Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 19 May 2026 15:22:37 -0500 Subject: [PATCH 417/627] Remove FundingTxInput type alias in favor of ConfirmedUtxo ConfirmedUtxo already documents its use as a funding input for v2 channel establishment and splicing, so the alias added a layer of indirection without conveying additional information. Co-Authored-By: Claude --- lightning/src/ln/channel.rs | 12 +++--- lightning/src/ln/functional_test_utils.rs | 8 ++-- lightning/src/ln/funding.rs | 48 +++++++++++------------ lightning/src/ln/interactivetxs.rs | 40 +++++++++---------- 4 files changed, 50 insertions(+), 58 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 38cb095009c..2ba2f2ccf7c 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -56,9 +56,7 @@ use crate::ln::channelmanager::{ PendingHTLCStatus, RAACommitmentOrder, SentHTLCId, TrustedChannelFeatures, BREAKDOWN_TIMEOUT, MAX_LOCAL_BREAKDOWN_TIMEOUT, MIN_CLTV_EXPIRY_DELTA, }; -use crate::ln::funding::{ - FeeRateAdjustmentError, FundingContribution, FundingTemplate, FundingTxInput, -}; +use crate::ln::funding::{FeeRateAdjustmentError, FundingContribution, FundingTemplate}; use crate::ln::interactivetxs::{ AbortReason, HandleTxCompleteValue, InteractiveTxConstructor, InteractiveTxConstructorArgs, InteractiveTxMessageSend, InteractiveTxSigningSession, SharedOwnedInput, SharedOwnedOutput, @@ -87,7 +85,7 @@ use crate::util::errors::APIError; use crate::util::logger::{Level as LoggerLevel, Logger, Record, WithContext}; use crate::util::scid_utils::{block_from_scid, scid_from_parts}; use crate::util::ser::{Readable, ReadableArgs, RequiredWrapper, Writeable, Writer}; -use crate::util::wallet_utils::Input; +use crate::util::wallet_utils::{ConfirmedUtxo, Input}; use crate::{impl_readable_for_vec, impl_writeable_for_vec}; use alloc::collections::{btree_map, BTreeMap}; @@ -3100,7 +3098,7 @@ impl FundingNegotiation { funding: FundingScope, context: &ChannelContext, entropy_source: &ES, holder_node_id: &PublicKey, our_funding_contribution: SignedAmount, prev_funding_input: SharedOwnedInput, locktime: u32, feerate_sat_per_1000_weight: u32, - our_funding_inputs: Vec, our_funding_outputs: Vec, + our_funding_inputs: Vec, our_funding_outputs: Vec, ) -> FundingNegotiation { let funding_negotiation_context = FundingNegotiationContext { is_initiator: false, @@ -6905,7 +6903,7 @@ pub(super) struct FundingNegotiationContext { pub shared_funding_input: Option, /// The funding inputs we will be contributing to the channel. #[allow(dead_code)] // TODO(dual_funding): Remove once contribution to V2 channels is enabled. - pub our_funding_inputs: Vec, + pub our_funding_inputs: Vec, /// The funding outputs we will be contributing to the channel. #[allow(dead_code)] // TODO(dual_funding): Remove once contribution to V2 channels is enabled. pub our_funding_outputs: Vec, @@ -15402,7 +15400,7 @@ impl PendingV2Channel { pub fn new_outbound( fee_estimator: &LowerBoundedFeeEstimator, entropy_source: &ES, signer_provider: &SP, counterparty_node_id: PublicKey, their_features: &InitFeatures, funding_satoshis: u64, - funding_inputs: Vec, user_id: u128, config: &UserConfig, + funding_inputs: Vec, user_id: u128, config: &UserConfig, current_chain_height: u32, outbound_scid_alias: u64, funding_confirmation_target: ConfirmationTarget, logger: L, trusted_channel_features: Option, ) -> Result { diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 3dd3018964a..bbb184d2e48 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -29,7 +29,7 @@ use crate::ln::channelmanager::{ AChannelManager, ChainParameters, ChannelManager, ChannelManagerReadArgs, PaymentId, RAACommitmentOrder, TrustedChannelFeatures, MIN_CLTV_EXPIRY_DELTA, }; -use crate::ln::funding::{FundingContribution, FundingTxInput}; +use crate::ln::funding::FundingContribution; use crate::ln::msgs::{self, OpenChannel}; use crate::ln::msgs::{ BaseMessageHandler, ChannelMessageHandler, MessageSendEvent, RoutingMessageHandler, @@ -55,7 +55,7 @@ use crate::util::test_channel_signer::SignerOp; use crate::util::test_channel_signer::TestChannelSigner; use crate::util::test_utils::{self, TestLogger}; use crate::util::test_utils::{TestChainMonitor, TestKeysInterface, TestScorer}; -use crate::util::wallet_utils::{WalletSourceSync, WalletSync}; +use crate::util::wallet_utils::{ConfirmedUtxo, WalletSourceSync, WalletSync}; use bitcoin::amount::Amount; use bitcoin::block::{Block, Header, Version as BlockVersion}; @@ -1512,7 +1512,7 @@ fn internal_create_funding_transaction<'a, 'b, 'c>( /// Return the inputs (with prev tx), and the total witness weight for these inputs pub fn create_dual_funding_utxos_with_prev_txs( node: &Node<'_, '_, '_>, utxo_values_in_satoshis: &[u64], -) -> Vec { +) -> Vec { // Ensure we have unique transactions per node by using the locktime. let tx = Transaction { version: TxVersion::TWO, @@ -1536,7 +1536,7 @@ pub fn create_dual_funding_utxos_with_prev_txs( .iter() .enumerate() .map(|(index, _)| index as u32) - .map(|vout| FundingTxInput::new_p2wpkh(tx.clone(), vout).unwrap()) + .map(|vout| ConfirmedUtxo::new_p2wpkh(tx.clone(), vout).unwrap()) .collect() } diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 68055a9027b..fd9fc298285 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -24,7 +24,7 @@ use crate::ln::LN_MAX_MSG_LEN; use crate::prelude::*; use crate::util::native_async::MaybeSend; use crate::util::wallet_utils::{ - CoinSelection, CoinSelectionSource, CoinSelectionSourceSync, Input, + CoinSelection, CoinSelectionSource, CoinSelectionSourceSync, ConfirmedUtxo, Input, }; /// Error returned when a [`FundingContribution`] cannot be adjusted to a target feerate. @@ -370,7 +370,7 @@ impl FundingTemplate { /// when that is set. `max_feerate` is the highest feerate we are willing to tolerate if we end /// up as the acceptor, and must be at least `min_feerate`. pub fn splice_in_inputs( - self, inputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, + self, inputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, ) -> Result { self.with_prior_contribution(min_feerate, max_feerate).add_inputs(inputs)?.build() } @@ -466,8 +466,8 @@ impl FundingTemplate { } fn estimate_transaction_fee( - inputs: &[FundingTxInput], outputs: &[TxOut], change_output: Option<&TxOut>, - is_initiator: bool, is_splice: bool, feerate: FeeRate, + inputs: &[ConfirmedUtxo], outputs: &[TxOut], change_output: Option<&TxOut>, is_initiator: bool, + is_splice: bool, feerate: FeeRate, ) -> Amount { let input_weight: u64 = inputs .iter() @@ -515,7 +515,7 @@ fn estimate_transaction_fee( Weight::from_wu(weight) * feerate } -fn validate_inputs(inputs: &[FundingTxInput]) -> Result<(), FundingContributionError> { +fn validate_inputs(inputs: &[ConfirmedUtxo]) -> Result<(), FundingContributionError> { let mut total_value = Amount::ZERO; for (idx, input) in inputs.iter().enumerate() { if inputs[..idx] @@ -559,7 +559,7 @@ enum FundingInputs { /// Replaces the contribution's inputs with the provided set and fully consumes them without a /// change output. The amount added to the channel is recomputed from the input total minus fees, /// while explicit withdrawal outputs still reduce the splice's net value. - ManuallySelected { inputs: Vec }, + ManuallySelected { inputs: Vec }, } impl FundingInputs { @@ -584,7 +584,7 @@ impl FundingInputs { } } - fn manually_selected_inputs(&self) -> &[FundingTxInput] { + fn manually_selected_inputs(&self) -> &[ConfirmedUtxo] { match self { FundingInputs::ManuallySelected { inputs } => inputs, FundingInputs::CoinSelected { .. } => &[], @@ -613,7 +613,7 @@ pub struct FundingContribution { /// /// For coin-selected contributions, excess value is returned via [`Self::change_output`]. For /// manually selected inputs, the full input value is consumed and no change output is created. - inputs: Vec, + inputs: Vec, /// The outputs to include in the funding transaction. /// @@ -691,7 +691,7 @@ impl FundingContribution { } /// Returns the inputs included in this contribution. - pub fn inputs(&self) -> &[FundingTxInput] { + pub fn inputs(&self) -> &[ConfirmedUtxo] { &self.inputs } @@ -827,7 +827,7 @@ impl FundingContribution { Some(new_contribution_at_target_feerate) } - pub(super) fn into_tx_parts(self) -> (Vec, Vec) { + pub(super) fn into_tx_parts(self) -> (Vec, Vec) { let FundingContribution { inputs, mut outputs, change_output, .. } = self; if let Some(change_output) = change_output { @@ -1131,10 +1131,6 @@ impl FundingContribution { } } -/// An input to contribute to a channel's funding transaction either when using the v2 channel -/// establishment protocol or when splicing. -pub type FundingTxInput = crate::util::wallet_utils::ConfirmedUtxo; - #[derive(Debug, Clone, PartialEq, Eq)] struct NoCoinSelectionSource; #[derive(Debug, Clone, PartialEq, Eq)] @@ -1472,7 +1468,7 @@ impl FundingBuilder { /// /// Returns [`FundingContributionError::InvalidSpliceValue`] if the builder already has a /// coin-selected value request. - pub fn add_input(self, input: FundingTxInput) -> Result { + pub fn add_input(self, input: ConfirmedUtxo) -> Result { self.0.add_input_inner(input).map(FundingBuilder) } @@ -1490,7 +1486,7 @@ impl FundingBuilder { /// /// Returns [`FundingContributionError::InvalidSpliceValue`] if the builder already has a /// coin-selected value request. - pub fn add_inputs(self, inputs: Vec) -> Result { + pub fn add_inputs(self, inputs: Vec) -> Result { self.0.add_inputs_inner(inputs).map(FundingBuilder) } @@ -1585,7 +1581,7 @@ impl FundingBuilderInner { Ok(self) } - fn add_input_inner(mut self, input: FundingTxInput) -> Result { + fn add_input_inner(mut self, input: ConfirmedUtxo) -> Result { match &mut self.funding_inputs { None => { self.funding_inputs = Some(FundingInputs::ManuallySelected { inputs: vec![input] }) @@ -1599,7 +1595,7 @@ impl FundingBuilderInner { } fn add_inputs_inner( - mut self, inputs: Vec, + mut self, inputs: Vec, ) -> Result { match &mut self.funding_inputs { None => self.funding_inputs = Some(FundingInputs::ManuallySelected { inputs }), @@ -1875,11 +1871,11 @@ impl SyncFundingBuilder { mod tests { use super::{ estimate_transaction_fee, FeeRateAdjustmentError, FundingBuilder, FundingContribution, - FundingContributionError, FundingInputMode, FundingTemplate, FundingTxInput, - SyncCoinSelectionSource, SyncFundingBuilder, + FundingContributionError, FundingInputMode, FundingTemplate, SyncCoinSelectionSource, + SyncFundingBuilder, }; use crate::chain::ClaimId; - use crate::util::wallet_utils::{CoinSelection, CoinSelectionSourceSync, Input}; + use crate::util::wallet_utils::{CoinSelection, CoinSelectionSourceSync, ConfirmedUtxo, Input}; use bitcoin::hashes::Hash; use bitcoin::transaction::{Transaction, TxOut, Version}; use bitcoin::{Amount, FeeRate, Psbt, ScriptBuf, SignedAmount, WPubkeyHash, WScriptHash}; @@ -1960,7 +1956,7 @@ mod tests { } #[rustfmt::skip] - fn funding_input_sats(input_value_sats: u64) -> FundingTxInput { + fn funding_input_sats(input_value_sats: u64) -> ConfirmedUtxo { let prevout = TxOut { value: Amount::from_sat(input_value_sats), script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()), @@ -1970,7 +1966,7 @@ mod tests { version: Version::TWO, lock_time: bitcoin::absolute::LockTime::ZERO, }; - FundingTxInput::new_p2wpkh(prevtx, 0).unwrap() + ConfirmedUtxo::new_p2wpkh(prevtx, 0).unwrap() } fn funding_output_sats(output_value_sats: u64) -> TxOut { @@ -1995,7 +1991,7 @@ mod tests { } struct MustPayToWallet { - utxo: FundingTxInput, + utxo: ConfirmedUtxo, change_output: Option, expected_must_pay_to_values: Vec, } @@ -2805,7 +2801,7 @@ mod tests { assert!(prevtx.serialized_length() > crate::ln::LN_MAX_MSG_LEN); let wallet = SingleUtxoWallet { - utxo: FundingTxInput::new_p2wpkh(prevtx, 0).unwrap(), + utxo: ConfirmedUtxo::new_p2wpkh(prevtx, 0).unwrap(), change_output: None, }; assert!(matches!( @@ -3713,7 +3709,7 @@ mod tests { /// A mock wallet that returns a single UTXO for coin selection. struct SingleUtxoWallet { - utxo: FundingTxInput, + utxo: ConfirmedUtxo, change_output: Option, } diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index faa352f1f07..0deb119890d 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -33,11 +33,11 @@ use crate::ln::chan_utils::{ SEGWIT_MARKER_FLAG_WEIGHT, }; use crate::ln::channel::TOTAL_BITCOIN_SUPPLY_SATOSHIS; -use crate::ln::funding::FundingTxInput; use crate::ln::msgs; use crate::ln::msgs::{MessageSendEvent, SerialId, TxSignatures}; use crate::ln::types::ChannelId; use crate::sign::{EntropySource, P2TR_KEY_PATH_WITNESS_WEIGHT, P2WPKH_WITNESS_WEIGHT}; +use crate::util::wallet_utils::ConfirmedUtxo; use core::fmt::Display; @@ -2034,7 +2034,7 @@ pub(super) struct InteractiveTxConstructorArgs<'a, ES: EntropySource> { pub channel_id: ChannelId, pub feerate_sat_per_kw: u32, pub funding_tx_locktime: AbsoluteLockTime, - pub inputs_to_contribute: Vec, + pub inputs_to_contribute: Vec, pub shared_funding_input: Option, pub shared_funding_output: SharedOwnedOutput, pub outputs_to_contribute: Vec, @@ -2071,7 +2071,7 @@ impl InteractiveTxConstructor { let mut inputs_to_contribute: Vec<(SerialId, InputOwned)> = inputs_to_contribute .into_iter() - .map(|FundingTxInput { utxo, prevtx: prev_tx }| { + .map(|ConfirmedUtxo { utxo, prevtx: prev_tx }| { let serial_id = generate_holder_serial_id(entropy_source, is_initiator); let txin = TxIn { previous_output: utxo.outpoint, @@ -2322,7 +2322,6 @@ impl InteractiveTxConstructor { mod tests { use crate::chain::chaininterface::{fee_for_weight, FEERATE_FLOOR_SATS_PER_KW}; use crate::ln::channel::TOTAL_BITCOIN_SUPPLY_SATOSHIS; - use crate::ln::funding::FundingTxInput; use crate::ln::interactivetxs::{ generate_holder_serial_id, AbortReason, HandleTxCompleteValue, InteractiveTxConstructor, InteractiveTxConstructorArgs, InteractiveTxMessageSend, SharedOwnedInput, @@ -2332,6 +2331,7 @@ mod tests { use crate::ln::types::ChannelId; use crate::sign::EntropySource; use crate::util::atomic_counter::AtomicCounter; + use crate::util::wallet_utils::ConfirmedUtxo; use bitcoin::absolute::LockTime as AbsoluteLockTime; use bitcoin::amount::Amount; use bitcoin::hashes::Hash; @@ -2395,12 +2395,12 @@ mod tests { struct TestSession { description: &'static str, - inputs_a: Vec, + inputs_a: Vec, a_shared_input: Option<(OutPoint, TxOut, u64)>, /// The funding output, with the value contributed shared_output_a: (TxOut, u64), outputs_a: Vec, - inputs_b: Vec, + inputs_b: Vec, b_shared_input: Option<(OutPoint, TxOut, u64)>, /// The funding output, with the value contributed shared_output_b: (TxOut, u64), @@ -2642,22 +2642,20 @@ mod tests { } } - fn generate_inputs(outputs: &[TestOutput]) -> Vec { + fn generate_inputs(outputs: &[TestOutput]) -> Vec { let tx = generate_tx(outputs); outputs .iter() .enumerate() .map(|(idx, output)| match output { - TestOutput::P2WPKH(_) => { - FundingTxInput::new_p2wpkh(tx.clone(), idx as u32).unwrap() - }, + TestOutput::P2WPKH(_) => ConfirmedUtxo::new_p2wpkh(tx.clone(), idx as u32).unwrap(), TestOutput::P2WSH(_) => { - FundingTxInput::new_p2wsh(tx.clone(), idx as u32, Weight::from_wu(42)).unwrap() + ConfirmedUtxo::new_p2wsh(tx.clone(), idx as u32, Weight::from_wu(42)).unwrap() }, TestOutput::P2TR(_) => { - FundingTxInput::new_p2tr_key_spend(tx.clone(), idx as u32).unwrap() + ConfirmedUtxo::new_p2tr_key_spend(tx.clone(), idx as u32).unwrap() }, - TestOutput::P2PKH(_) => FundingTxInput::new_p2pkh(tx.clone(), idx as u32).unwrap(), + TestOutput::P2PKH(_) => ConfirmedUtxo::new_p2pkh(tx.clone(), idx as u32).unwrap(), }) .collect() } @@ -2705,12 +2703,12 @@ mod tests { (generate_txout(&TestOutput::P2WSH(value)), local_value) } - fn generate_fixed_number_of_inputs(count: u16) -> Vec { + fn generate_fixed_number_of_inputs(count: u16) -> Vec { // Generate transactions with a total `count` number of outputs such that no transaction has a // serialized length greater than u16::MAX. let max_outputs_per_prevtx = 1_500; let mut remaining = count; - let mut inputs: Vec = Vec::with_capacity(count as usize); + let mut inputs: Vec = Vec::with_capacity(count as usize); while remaining > 0 { let tx_output_count = remaining.min(max_outputs_per_prevtx); @@ -2721,10 +2719,10 @@ mod tests { // Use unique locktime for each tx so outpoints are different across transactions let tx = generate_tx_with_locktime(&outputs, (1337 + remaining).into()); - let mut temp: Vec = outputs + let mut temp: Vec = outputs .iter() .enumerate() - .map(|(idx, _)| FundingTxInput::new_p2wpkh(tx.clone(), idx as u32).unwrap()) + .map(|(idx, _)| ConfirmedUtxo::new_p2wpkh(tx.clone(), idx as u32).unwrap()) .collect(); inputs.append(&mut temp); @@ -2935,7 +2933,7 @@ mod tests { }); let tx = generate_tx(&[TestOutput::P2WPKH(1_000_000)]); - let mut invalid_sequence_input = FundingTxInput::new_p2wpkh(tx.clone(), 0).unwrap(); + let mut invalid_sequence_input = ConfirmedUtxo::new_p2wpkh(tx.clone(), 0).unwrap(); invalid_sequence_input.set_sequence(Default::default()); do_test_interactive_tx_constructor(TestSession { description: "Invalid input sequence from initiator", @@ -2949,7 +2947,7 @@ mod tests { outputs_b: vec![], expect_error: Some((AbortReason::IncorrectInputSequenceValue, ErrorCulprit::NodeA)), }); - let duplicate_input = FundingTxInput::new_p2wpkh(tx.clone(), 0).unwrap(); + let duplicate_input = ConfirmedUtxo::new_p2wpkh(tx.clone(), 0).unwrap(); do_test_interactive_tx_constructor(TestSession { description: "Duplicate prevout from initiator", inputs_a: vec![duplicate_input.clone(), duplicate_input], @@ -2963,7 +2961,7 @@ mod tests { expect_error: Some((AbortReason::PrevTxOutInvalid, ErrorCulprit::NodeB)), }); // Non-initiator uses same prevout as initiator. - let duplicate_input = FundingTxInput::new_p2wpkh(tx.clone(), 0).unwrap(); + let duplicate_input = ConfirmedUtxo::new_p2wpkh(tx.clone(), 0).unwrap(); do_test_interactive_tx_constructor(TestSession { description: "Non-initiator uses same prevout as initiator", inputs_a: vec![duplicate_input.clone()], @@ -2976,7 +2974,7 @@ mod tests { outputs_b: vec![], expect_error: Some((AbortReason::PrevTxOutInvalid, ErrorCulprit::NodeA)), }); - let duplicate_input = FundingTxInput::new_p2wpkh(tx.clone(), 0).unwrap(); + let duplicate_input = ConfirmedUtxo::new_p2wpkh(tx.clone(), 0).unwrap(); do_test_interactive_tx_constructor(TestSession { description: "Non-initiator uses same prevout as initiator", inputs_a: vec![duplicate_input.clone()], From 04808cb6571d74ade69df989e02b8d2cfb0f0379 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Wed, 20 May 2026 10:11:19 +0200 Subject: [PATCH 418/627] Count symlinked fuzz corpus files The fuzz CI job links cloned corpus dirs into hfuzz_workspace, but plain find does not descend through those symlinked directories. Count with find -L so the iteration budget reflects the real corpus size, and fail loudly if a linked corpus resolves to zero files. --- fuzz/ci-fuzz.sh | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/fuzz/ci-fuzz.sh b/fuzz/ci-fuzz.sh index 3fc206bd0ee..a9cb9b21e57 100755 --- a/fuzz/ci-fuzz.sh +++ b/fuzz/ci-fuzz.sh @@ -41,6 +41,23 @@ check_crash() { fi } +corpus_count() { + local CORPUS_DIR=$1 + # CI links cloned corpus directories into hfuzz_workspace. + find -L "$CORPUS_DIR" -type f 2>/dev/null | wc -l +} + +check_linked_corpus() { + local CORPUS_DIR=$1 + local FILE=$2 + local CORPUS_COUNT=$3 + + if [ -L "$CORPUS_DIR" ] && [ "$CORPUS_COUNT" -eq 0 ]; then + echo "Linked corpus for $FILE has no visible input files: $CORPUS_DIR" + exit 1 + fi +} + run_targets() { local CRATE_DIR=$1 local TARGET_RUSTFLAGS=$2 @@ -55,7 +72,8 @@ run_targets() { FILENAME=$(basename "$TARGET") FILE="${FILENAME%.*}" CORPUS_DIR="$HFUZZ_WORKSPACE/$FILE/input" - CORPUS_COUNT=$(find "$CORPUS_DIR" -type f 2>/dev/null | wc -l) + CORPUS_COUNT=$(corpus_count "$CORPUS_DIR") + check_linked_corpus "$CORPUS_DIR" "$FILE" "$CORPUS_COUNT" # Run 8x the corpus size plus a baseline, ensuring full corpus replay # with room for new mutations. The 10-minute hard cap (--run_time 600) # prevents slow-per-iteration targets from running too long. @@ -69,7 +87,7 @@ run_targets() { cargo --color always hfuzz run "$FILE" FUZZ_END=$(date +%s) FUZZ_TIME=$((FUZZ_END - FUZZ_START)) - FUZZ_CORPUS_COUNT=$(find "$CORPUS_DIR" -type f 2>/dev/null | wc -l) + FUZZ_CORPUS_COUNT=$(corpus_count "$CORPUS_DIR") check_crash "$HFUZZ_WORKSPACE" "$FILE" if [ "$GITHUB_REF" = "refs/heads/main" ] || [ "$FUZZ_MINIMIZE" = "true" ]; then HFUZZ_RUN_ARGS="-M -q -n8 -t 3" @@ -78,7 +96,7 @@ run_targets() { cargo --color always hfuzz run "$FILE" MIN_END=$(date +%s) MIN_TIME=$((MIN_END - MIN_START)) - MIN_CORPUS_COUNT=$(find "$CORPUS_DIR" -type f 2>/dev/null | wc -l) + MIN_CORPUS_COUNT=$(corpus_count "$CORPUS_DIR") check_crash "$HFUZZ_WORKSPACE" "$FILE" SUMMARY="${SUMMARY}${FILE}|${ITERATIONS}|${CORPUS_COUNT}|${FUZZ_CORPUS_COUNT}|${FUZZ_TIME}|${MIN_CORPUS_COUNT}|${MIN_TIME}\n" else From cab1673c17f09bedb4f94bed9c11c58b7309863e Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Mon, 11 May 2026 14:02:44 +0200 Subject: [PATCH 419/627] fuzz: model chanmon persistence in harness Replace the chanmon consistency harness' Watch wrapper with a Persist implementation backed by HarnessPersister. Monitor writes now flow through the real ChainMonitor persistence hooks. Track restart candidates separately from monitor completion callbacks. A monitor can stop being a valid reload candidate once a newer baseline is durable, while its callback may still be needed to unblock the live ChainMonitor. On reload, choose the durable baseline, first pending snapshot, or last pending snapshot. Startup monitor registration completes immediately before the configured persistence style is restored. --- fuzz/src/chanmon_consistency.rs | 567 ++++++++++++++++++++------------ 1 file changed, 348 insertions(+), 219 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 4ff0e4a4a03..6e38866999c 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -41,8 +41,7 @@ use lightning::chain; use lightning::chain::chaininterface::{ BroadcasterInterface, ConfirmationTarget, FeeEstimator, TransactionType, }; -use lightning::chain::channelmonitor::{ChannelMonitor, MonitorEvent}; -use lightning::chain::transaction::OutPoint; +use lightning::chain::channelmonitor::ChannelMonitor; use lightning::chain::{ chainmonitor, channelmonitor, BlockLocator, ChannelMonitorUpdateStatus, Confirm, Watch, }; @@ -87,7 +86,6 @@ use lightning::util::wallet_utils::{WalletSourceSync, WalletSync}; use lightning_invoice::RawBolt11Invoice; use crate::utils::test_logger::{self, Output}; -use crate::utils::test_persister::TestPersister; use bitcoin::secp256k1::ecdh::SharedSecret; use bitcoin::secp256k1::ecdsa::{RecoverableSignature, Signature}; @@ -293,144 +291,302 @@ impl Writer for VecWriter { } } -/// The LDK API requires that any time we tell it we're done persisting a `ChannelMonitor[Update]` -/// we never pass it in as the "latest" `ChannelMonitor` on startup. However, we can pass -/// out-of-date monitors as long as we never told LDK we finished persisting them, which we do by -/// storing both old `ChannelMonitor`s and ones that are "being persisted" here. +fn serialize_monitor(monitor: &ChannelMonitor) -> Vec { + let mut ser = VecWriter(Vec::new()); + monitor.write(&mut ser).unwrap(); + ser.0 +} + +/// LDK requires the `ChannelMonitor` loaded on startup to be at least as current as the +/// `ChannelManager` state, except for monitor updates that `ChannelManager` still records as +/// in-flight and can replay. This harness tracks the monitor blobs that remain valid restart +/// candidates under that rule. /// -/// Note that such "being persisted" `ChannelMonitor`s are stored in `ChannelManager` and will -/// simply be replayed on startup. +/// Separately, we track every `InProgress` persistence operation that still needs a +/// `channel_monitor_updated` call. A newer persisted monitor can make an older monitor invalid for +/// restart while the older update still needs to be completed to unblock the live `ChainMonitor`. +/// +/// Off-chain monitor updates that are still "being persisted" are stored in `ChannelManager` and +/// will be replayed on startup. Full-monitor snapshots from chain sync or archive paths that return +/// `InProgress` are only restart candidates; losing one on restart does not require a +/// `channel_monitor_updated` callback. struct LatestMonitorState { /// The latest monitor id which we told LDK we've persisted. /// - /// Note that there may still be earlier pending monitor updates in [`Self::pending_monitors`] - /// which we haven't yet completed. We're allowed to reload with those as well, at least until - /// they're completed. + /// Note that earlier updates may still need a `channel_monitor_updated` callback via + /// [`Self::pending_monitor_completions`]. persisted_monitor_id: u64, /// The latest serialized `ChannelMonitor` that we told LDK we persisted. persisted_monitor: Vec, - /// A set of (monitor id, serialized `ChannelMonitor`)s which we're currently "persisting", - /// from LDK's perspective. + /// An ordered list of (monitor id, serialized `ChannelMonitor`)s which remain safe to use as + /// stale monitors on reload. pending_monitors: Vec<(u64, Vec)>, + /// An ordered list of (monitor id, serialized `ChannelMonitor`)s which still need a + /// `channel_monitor_updated` callback. + pending_monitor_completions: Vec<(u64, Vec)>, } +impl LatestMonitorState { + fn insert_pending_entry( + pending: &mut Vec<(u64, Vec)>, monitor_id: u64, serialized_monitor: Vec, + ) { + // Monitor update ids must arrive in order. Assert at insertion time so duplicates or + // out-of-order updates fail close to the write that caused them instead of being sorted + // into place. + assert!( + pending.last().map_or(true, |(last_id, _)| *last_id < monitor_id), + "pending monitor updates should arrive in order" + ); + pending.push((monitor_id, serialized_monitor)); + } -struct TestChainMonitor { - pub logger: Arc, - pub keys: Arc, - pub persister: Arc, - pub chain_monitor: Arc< - chainmonitor::ChainMonitor< - TestChannelSigner, - Arc, - Arc, - Arc, - Arc, - Arc, - Arc, - >, - >, - pub latest_monitors: Mutex>, -} -impl TestChainMonitor { - pub fn new( - broadcaster: Arc, logger: Arc, feeest: Arc, - persister: Arc, keys: Arc, - ) -> Self { - Self { - chain_monitor: Arc::new(chainmonitor::ChainMonitor::new( - None, - broadcaster, - logger.clone(), - feeest, - Arc::clone(&persister), - Arc::clone(&keys), - keys.get_peer_storage_key(), - false, - )), - logger, - keys, - persister, - latest_monitors: Mutex::new(new_hash_map()), + fn insert_pending_monitor_candidate(&mut self, monitor_id: u64, serialized_monitor: Vec) { + // Full-monitor persists from chain sync or archive paths use the monitor's current + // latest_update_id rather than a fresh ChannelMonitorUpdate id. Keep duplicate ids so + // reload can choose between multiple same-id full snapshots that were in flight together. + if let Some((last_id, _)) = self.pending_monitors.last() { + assert!(*last_id <= monitor_id, "pending monitor updates should arrive in order"); } + self.pending_monitors.push((monitor_id, serialized_monitor)); } -} -impl chain::Watch for TestChainMonitor { - fn watch_channel( - &self, channel_id: ChannelId, monitor: channelmonitor::ChannelMonitor, - ) -> Result { - let mut ser = VecWriter(Vec::new()); - monitor.write(&mut ser).unwrap(); - let monitor_id = monitor.get_latest_update_id(); - let res = self.chain_monitor.watch_channel(channel_id, monitor); - let state = match res { - Ok(chain::ChannelMonitorUpdateStatus::Completed) => LatestMonitorState { - persisted_monitor_id: monitor_id, - persisted_monitor: ser.0, - pending_monitors: Vec::new(), - }, - Ok(chain::ChannelMonitorUpdateStatus::InProgress) => LatestMonitorState { - persisted_monitor_id: monitor_id, - persisted_monitor: Vec::new(), - pending_monitors: vec![(monitor_id, ser.0)], - }, - Ok(chain::ChannelMonitorUpdateStatus::UnrecoverableError) => panic!(), - Err(()) => panic!(), - }; - if self.latest_monitors.lock().unwrap().insert(channel_id, state).is_some() { - panic!("Already had monitor pre-watch_channel"); + + fn mark_persisted(&mut self, monitor_id: u64, serialized_monitor: Vec) { + // Once a monitor is durable, use it as the restart baseline and stop tracking candidates + // at or behind that update id. Completion obligations are tracked separately and are + // deliberately not pruned here. + self.pending_monitors.retain(|(id, _)| *id > monitor_id); + if monitor_id >= self.persisted_monitor_id { + self.persisted_monitor_id = monitor_id; + self.persisted_monitor = serialized_monitor; } - res } - fn update_channel( - &self, channel_id: ChannelId, update: &channelmonitor::ChannelMonitorUpdate, - ) -> chain::ChannelMonitorUpdateStatus { - let mut map_lock = self.latest_monitors.lock().unwrap(); - let map_entry = map_lock.get_mut(&channel_id).expect("Didn't have monitor on update call"); - let latest_monitor_data = map_entry - .pending_monitors - .last() - .as_ref() - .map(|(_, data)| data) - .unwrap_or(&map_entry.persisted_monitor); - let deserialized_monitor = - <(BlockLocator, channelmonitor::ChannelMonitor)>::read( - &mut &latest_monitor_data[..], - (&*self.keys, &*self.keys), - ) - .unwrap() - .1; - deserialized_monitor - .update_monitor( - update, - &&TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }, - &&FuzzEstimator { ret_val: atomic::AtomicU32::new(253) }, - &self.logger, - ) - .unwrap(); - let mut ser = VecWriter(Vec::new()); - deserialized_monitor.write(&mut ser).unwrap(); - let res = self.chain_monitor.update_channel(channel_id, update); - match res { - chain::ChannelMonitorUpdateStatus::Completed => { - map_entry.persisted_monitor_id = update.update_id; - map_entry.persisted_monitor = ser.0; + fn insert_pending( + &mut self, monitor_id: u64, serialized_monitor: Vec, needs_completion: bool, + ) { + if needs_completion { + // persist_new_channel and update_persisted_channel(Some(_)) require a later + // channel_monitor_updated callback if persistence returns InProgress. + Self::insert_pending_entry( + &mut self.pending_monitors, + monitor_id, + serialized_monitor.clone(), + ); + Self::insert_pending_entry( + &mut self.pending_monitor_completions, + monitor_id, + serialized_monitor, + ); + } else { + // This harness treats update_persisted_channel(None, ...) as the chain-sync/archive + // case: the full monitor may be used on restart, but ChainMonitor does not wait for a + // channel_monitor_updated callback. + self.insert_pending_monitor_candidate(monitor_id, serialized_monitor); + } + } + + fn mark_completed_update_persisted(&mut self, monitor_id: u64, serialized_monitor: Vec) { + // The selector/drain path should already have removed this entry before + // finish_monitor_update calls channel_monitor_updated. This check catches accidental + // double-completion or pruning of the wrong list. + assert!( + self.pending_monitor_completions.iter().all(|(id, _)| *id != monitor_id), + "completed monitor update should already be removed from the completion queue" + ); + self.mark_persisted(monitor_id, serialized_monitor); + } + + fn drain_pending_completions(&mut self) -> Vec<(u64, Vec)> { + std::mem::take(&mut self.pending_monitor_completions) + } + + fn take_pending_completion( + &mut self, selector: MonitorUpdateSelector, + ) -> Option<(u64, Vec)> { + // The fuzzer chooses which outstanding callback to deliver. These choices apply to + // completion obligations, not to the set of monitors that may be used on restart. + match selector { + MonitorUpdateSelector::First => { + if self.pending_monitor_completions.is_empty() { + None + } else { + Some(self.pending_monitor_completions.remove(0)) + } }, - chain::ChannelMonitorUpdateStatus::InProgress => { - map_entry.pending_monitors.push((update.update_id, ser.0)); + MonitorUpdateSelector::Second => { + if self.pending_monitor_completions.len() > 1 { + Some(self.pending_monitor_completions.remove(1)) + } else { + None + } }, - chain::ChannelMonitorUpdateStatus::UnrecoverableError => panic!(), + MonitorUpdateSelector::Last => self.pending_monitor_completions.pop(), } - res } - fn release_pending_monitor_events( - &self, - ) -> Vec<(OutPoint, ChannelId, Vec, PublicKey)> { - return self.chain_monitor.release_pending_monitor_events(); + fn select_monitor_for_reload(&mut self, selector: MonitorReloadSelector) { + // A restart can load the last monitor we told LDK was persisted, or a monitor snapshot + // whose write was started before the simulated crash. + let old_mon = (self.persisted_monitor_id, std::mem::take(&mut self.persisted_monitor)); + let (monitor_id, serialized_monitor) = match selector { + MonitorReloadSelector::Persisted => old_mon, + MonitorReloadSelector::FirstPending => { + if self.pending_monitors.is_empty() { + old_mon + } else { + self.pending_monitors.remove(0) + } + }, + MonitorReloadSelector::LastPending => self.pending_monitors.pop().unwrap_or(old_mon), + }; + self.persisted_monitor_id = monitor_id; + self.persisted_monitor = serialized_monitor; + // After restart, stop tracking pre-restart in-flight writes. ChannelManager will replay + // off-chain monitor updates that still matter; full-monitor snapshots may simply be absent. + self.pending_monitors.clear(); + self.pending_monitor_completions.clear(); + } +} + +struct HarnessPersister { + pub update_ret: Mutex, + pub latest_monitors: Mutex>, +} +impl HarnessPersister { + fn track_monitor_update( + &self, channel_id: ChannelId, monitor_id: u64, serialized_monitor: Vec, + status: chain::ChannelMonitorUpdateStatus, needs_completion: bool, + ) { + let mut latest_monitors = self.latest_monitors.lock().unwrap(); + if let Some(state) = latest_monitors.get_mut(&channel_id) { + match status { + chain::ChannelMonitorUpdateStatus::Completed => { + // A completed write advances the restart baseline. Once LDK can rely on that + // monitor state being durable, the harness stops offering candidates at or + // behind that update id. + state.mark_persisted(monitor_id, serialized_monitor); + }, + chain::ChannelMonitorUpdateStatus::InProgress => { + // InProgress always creates a restart candidate, but only some calls also need + // an explicit channel_monitor_updated completion. + state.insert_pending(monitor_id, serialized_monitor, needs_completion); + }, + chain::ChannelMonitorUpdateStatus::UnrecoverableError => {}, + } + } else { + let state = match status { + chain::ChannelMonitorUpdateStatus::Completed => LatestMonitorState { + persisted_monitor_id: monitor_id, + persisted_monitor: serialized_monitor, + pending_monitors: Vec::new(), + pending_monitor_completions: Vec::new(), + }, + chain::ChannelMonitorUpdateStatus::InProgress => { + // The first persist for a channel is persist_new_channel, which always needs a + // completion callback when it returns InProgress. A full-monitor update without + // existing state would mean the harness missed the channel's initial monitor. + assert!(needs_completion, "missing monitor state for full monitor update"); + LatestMonitorState { + persisted_monitor_id: monitor_id, + persisted_monitor: Vec::new(), + pending_monitors: vec![(monitor_id, serialized_monitor.clone())], + pending_monitor_completions: vec![(monitor_id, serialized_monitor)], + } + }, + chain::ChannelMonitorUpdateStatus::UnrecoverableError => return, + }; + assert!( + latest_monitors.insert(channel_id, state).is_none(), + "Already had monitor state pre-persist" + ); + } + } + + fn mark_update_completed( + &self, channel_id: ChannelId, monitor_id: u64, serialized_monitor: Vec, + ) { + let mut latest_monitors = self.latest_monitors.lock().unwrap(); + let state = latest_monitors + .get_mut(&channel_id) + .expect("missing monitor state for completed update"); + // Once we tell LDK update N is completed, use the completed monitor as the restart + // baseline and drop restart candidates at or behind N. + state.mark_completed_update_persisted(monitor_id, serialized_monitor); + } + + fn drain_pending_updates(&self, channel_id: &ChannelId) -> Vec<(u64, Vec)> { + self.latest_monitors + .lock() + .unwrap() + .get_mut(channel_id) + .map_or_else(Vec::new, |state| state.drain_pending_completions()) + } + + fn drain_all_pending_updates(&self) -> Vec<(ChannelId, u64, Vec)> { + let mut completed_updates = Vec::new(); + for (channel_id, state) in self.latest_monitors.lock().unwrap().iter_mut() { + for (monitor_id, data) in state.drain_pending_completions() { + completed_updates.push((*channel_id, monitor_id, data)); + } + } + completed_updates + } + + fn take_pending_update( + &self, channel_id: &ChannelId, selector: MonitorUpdateSelector, + ) -> Option<(u64, Vec)> { + self.latest_monitors + .lock() + .unwrap() + .get_mut(channel_id) + .and_then(|state| state.take_pending_completion(selector)) + } +} +impl chainmonitor::Persist for HarnessPersister { + fn persist_new_channel( + &self, _monitor_name: lightning::util::persist::MonitorName, + data: &channelmonitor::ChannelMonitor, + ) -> chain::ChannelMonitorUpdateStatus { + let status = self.update_ret.lock().unwrap().clone(); + let monitor_id = data.get_latest_update_id(); + let serialized_monitor = serialize_monitor(data); + self.track_monitor_update(data.channel_id(), monitor_id, serialized_monitor, status, true); + status + } + + fn update_persisted_channel( + &self, _monitor_name: lightning::util::persist::MonitorName, + update: Option<&channelmonitor::ChannelMonitorUpdate>, + data: &channelmonitor::ChannelMonitor, + ) -> chain::ChannelMonitorUpdateStatus { + let status = self.update_ret.lock().unwrap().clone(); + let monitor_id = update.map_or_else(|| data.get_latest_update_id(), |upd| upd.update_id); + let serialized_monitor = serialize_monitor(data); + self.track_monitor_update( + data.channel_id(), + monitor_id, + serialized_monitor, + status, + // `None` normally comes from chain-sync or archive writes, which need no completion + // callback. `update_channel_internal` can also use `None` after `update_monitor` + // fails, but this harness does not model that error-recovery path. + update.is_some(), + ); + status } + + fn archive_persisted_channel(&self, _monitor_name: lightning::util::persist::MonitorName) {} } +type TestChainMonitor = chainmonitor::ChainMonitor< + TestChannelSigner, + Arc, + Arc, + Arc, + Arc, + Arc, + Arc, +>; + struct KeyProvider { node_secret: SecretKey, rand_bytes_id: atomic::AtomicU32, @@ -654,6 +810,7 @@ struct HarnessNode<'a> { node_id: u8, node: ChanMan<'a>, monitor: Arc, + persister: Arc, keys_manager: Arc, logger: Arc, broadcaster: Arc, @@ -674,26 +831,33 @@ impl<'a> std::ops::Deref for HarnessNode<'a> { } impl<'a> HarnessNode<'a> { - fn build_loggers( + fn build_logger( node_id: u8, out: &Out, - ) -> (Arc, Arc) { - let raw_logger = Arc::new(test_logger::TestLogger::new(node_id.to_string(), out.clone())); - let logger_for_monitor: Arc = raw_logger.clone(); - let logger: Arc = raw_logger; - (logger_for_monitor, logger) + ) -> Arc { + Arc::new(test_logger::TestLogger::new(node_id.to_string(), out.clone())) + } + + fn build_persister(persistence_style: ChannelMonitorUpdateStatus) -> Arc { + Arc::new(HarnessPersister { + update_ret: Mutex::new(persistence_style), + latest_monitors: Mutex::new(new_hash_map()), + }) } fn build_chain_monitor( broadcaster: &Arc, fee_estimator: &Arc, - keys_manager: &Arc, logger_for_monitor: Arc, - persistence_style: ChannelMonitorUpdateStatus, + keys_manager: &Arc, logger: Arc, + persister: &Arc, ) -> Arc { - Arc::new(TestChainMonitor::new( + Arc::new(chainmonitor::ChainMonitor::new( + None, Arc::clone(broadcaster), - logger_for_monitor, + logger, Arc::clone(fee_estimator), - Arc::new(TestPersister { update_ret: Mutex::new(persistence_style) }), + Arc::clone(persister), Arc::clone(keys_manager), + keys_manager.get_peer_storage_key(), + false, )) } @@ -702,7 +866,7 @@ impl<'a> HarnessNode<'a> { broadcaster: Arc, persistence_style: ChannelMonitorUpdateStatus, out: &Out, router: &'a FuzzRouter, chan_type: ChanType, ) -> Self { - let (logger_for_monitor, logger) = Self::build_loggers(node_id, out); + let logger = Self::build_logger(node_id, out); let node_secret = SecretKey::from_slice(&[ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, node_id, @@ -713,12 +877,13 @@ impl<'a> HarnessNode<'a> { rand_bytes_id: atomic::AtomicU32::new(0), enforcement_states: Mutex::new(new_hash_map()), }); + let persister = Self::build_persister(persistence_style); let monitor = Self::build_chain_monitor( &broadcaster, &fee_estimator, &keys_manager, - logger_for_monitor, - persistence_style, + Arc::clone(&logger), + &persister, ); let network = Network::Bitcoin; let best_block_timestamp = genesis_block(network).header.time; @@ -741,6 +906,7 @@ impl<'a> HarnessNode<'a> { node_id, node, monitor, + persister, keys_manager, logger, broadcaster, @@ -754,67 +920,31 @@ impl<'a> HarnessNode<'a> { } fn set_persistence_style(&mut self, style: ChannelMonitorUpdateStatus) { + // Store the style for the next reload. The active persister is intentionally not changed + // in place. self.persistence_style = style; } + fn finish_monitor_update(&self, chan_id: ChannelId, monitor_id: u64, data: Vec) { + self.monitor.channel_monitor_updated(chan_id, monitor_id).unwrap(); + self.persister.mark_update_completed(chan_id, monitor_id, data); + } + fn complete_all_monitor_updates(&self, chan_id: &ChannelId) { - if let Some(state) = self.monitor.latest_monitors.lock().unwrap().get_mut(chan_id) { - assert!( - state.pending_monitors.windows(2).all(|pair| pair[0].0 < pair[1].0), - "updates should be sorted by id" - ); - for (id, data) in state.pending_monitors.drain(..) { - self.monitor.chain_monitor.channel_monitor_updated(*chan_id, id).unwrap(); - if id > state.persisted_monitor_id { - state.persisted_monitor_id = id; - state.persisted_monitor = data; - } - } + for (monitor_id, data) in self.persister.drain_pending_updates(chan_id) { + self.finish_monitor_update(*chan_id, monitor_id, data); } } fn complete_all_pending_monitor_updates(&self) { - for (channel_id, state) in self.monitor.latest_monitors.lock().unwrap().iter_mut() { - for (id, data) in state.pending_monitors.drain(..) { - self.monitor.chain_monitor.channel_monitor_updated(*channel_id, id).unwrap(); - if id >= state.persisted_monitor_id { - state.persisted_monitor_id = id; - state.persisted_monitor = data; - } - } + for (channel_id, monitor_id, data) in self.persister.drain_all_pending_updates() { + self.finish_monitor_update(channel_id, monitor_id, data); } } fn complete_monitor_update(&self, chan_id: &ChannelId, selector: MonitorUpdateSelector) { - if let Some(state) = self.monitor.latest_monitors.lock().unwrap().get_mut(chan_id) { - assert!( - state.pending_monitors.windows(2).all(|pair| pair[0].0 < pair[1].0), - "updates should be sorted by id" - ); - let update = match selector { - MonitorUpdateSelector::First => { - if state.pending_monitors.is_empty() { - None - } else { - Some(state.pending_monitors.remove(0)) - } - }, - MonitorUpdateSelector::Second => { - if state.pending_monitors.len() > 1 { - Some(state.pending_monitors.remove(1)) - } else { - None - } - }, - MonitorUpdateSelector::Last => state.pending_monitors.pop(), - }; - if let Some((id, data)) = update { - self.monitor.chain_monitor.channel_monitor_updated(*chan_id, id).unwrap(); - if id > state.persisted_monitor_id { - state.persisted_monitor_id = id; - state.persisted_monitor = data; - } - } + if let Some((monitor_id, data)) = self.persister.take_pending_update(chan_id, selector) { + self.finish_monitor_update(*chan_id, monitor_id, data); } } @@ -942,50 +1072,39 @@ impl<'a> HarnessNode<'a> { fn reload( &mut self, use_old_mons: u8, out: &Out, router: &'a FuzzRouter, chan_type: ChanType, ) { - let (logger_for_monitor, logger) = Self::build_loggers(self.node_id, out); + let logger = Self::build_logger(self.node_id, out); + // Re-registering monitors during reload reflects data that was already selected from + // simulated storage, so these startup watch_channel calls should complete immediately. + let persister = Self::build_persister(ChannelMonitorUpdateStatus::Completed); let chain_monitor = Self::build_chain_monitor( &self.broadcaster, &self.fee_estimator, &self.keys_manager, - logger_for_monitor, - ChannelMonitorUpdateStatus::Completed, + Arc::clone(&logger), + &persister, ); let mut monitors = new_hash_map(); let mut use_old_mons = use_old_mons; { - let mut old_monitors = self.monitor.latest_monitors.lock().unwrap(); + let mut old_monitors = self.persister.latest_monitors.lock().unwrap(); for (channel_id, mut prev_state) in old_monitors.drain() { - let (mon_id, serialized_mon) = if use_old_mons % 3 == 0 { - // Reload with the oldest `ChannelMonitor` (the one that we already told - // `ChannelManager` we finished persisting). - (prev_state.persisted_monitor_id, prev_state.persisted_monitor) - } else if use_old_mons % 3 == 1 { - // Reload with the second-oldest `ChannelMonitor`. - let old_mon = (prev_state.persisted_monitor_id, prev_state.persisted_monitor); - prev_state.pending_monitors.drain(..).next().unwrap_or(old_mon) - } else { - // Reload with the newest `ChannelMonitor`. - let old_mon = (prev_state.persisted_monitor_id, prev_state.persisted_monitor); - prev_state.pending_monitors.pop().unwrap_or(old_mon) + let selector = match use_old_mons % 3 { + 0 => MonitorReloadSelector::Persisted, + 1 => MonitorReloadSelector::FirstPending, + _ => MonitorReloadSelector::LastPending, }; - // Use a different value of `use_old_mons` if we have another monitor - // (only for node B) by shifting `use_old_mons` one in base-3. + prev_state.select_monitor_for_reload(selector); + // Use a different trit for each monitor so one restart byte can vary the stale + // monitor depth across multiple monitors for the node. use_old_mons /= 3; let mon = <(BlockLocator, ChannelMonitor)>::read( - &mut &serialized_mon[..], + &mut &prev_state.persisted_monitor[..], (&*self.keys_manager, &*self.keys_manager), ) .expect("Failed to read monitor"); monitors.insert(channel_id, mon.1); - // Update the latest `ChannelMonitor` state to match what we just told LDK. - prev_state.persisted_monitor = serialized_mon; - prev_state.persisted_monitor_id = mon_id; - // Wipe any `ChannelMonitor`s which we never told LDK we finished persisting, - // considering them discarded. LDK should replay these for us as they're stored in - // the `ChannelManager`. - prev_state.pending_monitors.clear(); - chain_monitor.latest_monitors.lock().unwrap().insert(channel_id, prev_state); + persister.latest_monitors.lock().unwrap().insert(channel_id, prev_state); } } let mut monitor_refs = new_hash_map(); @@ -1011,17 +1130,27 @@ impl<'a> HarnessNode<'a> { .expect("Failed to read manager"); for (channel_id, mon) in monitors.drain() { assert_eq!( - chain_monitor.chain_monitor.watch_channel(channel_id, mon), + chain_monitor.watch_channel(channel_id, mon), Ok(ChannelMonitorUpdateStatus::Completed) ); } - *chain_monitor.persister.update_ret.lock().unwrap() = self.persistence_style; + // Future monitor writes should follow the node's configured persistence style; only the + // startup watch_channel registration above is forced to Completed. + *persister.update_ret.lock().unwrap() = self.persistence_style; self.node = manager.1; self.monitor = chain_monitor; + self.persister = persister; self.logger = logger; } } +#[derive(Copy, Clone)] +enum MonitorReloadSelector { + Persisted, + FirstPending, + LastPending, +} + #[derive(Copy, Clone)] enum MonitorUpdateSelector { First, @@ -1921,7 +2050,7 @@ fn make_channel( } }; dest.handle_funding_created(source.get_our_node_id(), &funding_created); - // Complete any pending monitor updates for dest after watch_channel. + // Complete any pending monitor persistence callbacks for dest after watch_channel. dest.complete_all_pending_monitor_updates(); let (funding_signed, channel_id) = { @@ -1942,7 +2071,7 @@ fn make_channel( } source.handle_funding_signed(dest.get_our_node_id(), &funding_signed); - // Complete any pending monitor updates for source after watch_channel. + // Complete any pending monitor persistence callbacks for source after watch_channel. source.complete_all_pending_monitor_updates(); let events = source.get_and_clear_pending_events(); @@ -2621,7 +2750,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { "It may take may iterations to settle the state, but it should not take forever" ); } - // Next, make sure no monitor updates are pending. + // Next, make sure no monitor completion callbacks are pending. self.ab_link.complete_all_monitor_updates(&self.nodes); self.bc_link.complete_all_monitor_updates(&self.nodes); // Then, make sure any current forwards make their way to their destination. @@ -3020,18 +3149,18 @@ pub fn do_test(data: &[u8], out: Out) { }, 0xb0 | 0xb1 | 0xb2 => { - // Restart node A, picking among the in-flight `ChannelMonitor`s to use based on - // the value of `v` we're matching. + // Restart node A, picking among persisted and in-flight `ChannelMonitor` + // candidates based on the value of `v` we're matching. harness.restart_node(0, v, &router); }, 0xb3..=0xbb => { - // Restart node B, picking among the in-flight `ChannelMonitor`s to use based on - // the value of `v` we're matching. + // Restart node B, picking among persisted and in-flight `ChannelMonitor` + // candidates based on the value of `v` we're matching. harness.restart_node(1, v, &router); }, 0xbc | 0xbd | 0xbe => { - // Restart node C, picking among the in-flight `ChannelMonitor`s to use based on - // the value of `v` we're matching. + // Restart node C, picking among persisted and in-flight `ChannelMonitor` + // candidates based on the value of `v` we're matching. harness.restart_node(2, v, &router); }, From bc3286d0114e4d9c01f36305eafa196fd7f61eaa Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Wed, 13 May 2026 13:52:16 +0200 Subject: [PATCH 420/627] fuzz: keep settling after progress-only passes Treat HTLC-forward processing and monitor completion as real progress in the chanmon harness. This keeps the settle loop running after passes that only unblock follow-up work instead of stopping before the next event or message batch. --- fuzz/src/chanmon_consistency.rs | 39 +++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 6e38866999c..eb89321c06d 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -930,10 +930,13 @@ impl<'a> HarnessNode<'a> { self.persister.mark_update_completed(chan_id, monitor_id, data); } - fn complete_all_monitor_updates(&self, chan_id: &ChannelId) { - for (monitor_id, data) in self.persister.drain_pending_updates(chan_id) { + fn complete_all_monitor_updates(&self, chan_id: &ChannelId) -> bool { + let completed_updates = self.persister.drain_pending_updates(chan_id); + let completed_any = !completed_updates.is_empty(); + for (monitor_id, data) in completed_updates { self.finish_monitor_update(*chan_id, monitor_id, data); } + completed_any } fn complete_all_pending_monitor_updates(&self) { @@ -966,9 +969,12 @@ impl<'a> HarnessNode<'a> { } } - fn refresh_serialized_manager(&mut self) { + fn refresh_serialized_manager(&mut self) -> bool { if self.node.get_and_clear_needs_persistence() { self.serialized_manager = self.node.encode(); + true + } else { + false } } @@ -1362,11 +1368,13 @@ impl PeerLink { || (self.node_a == node_b && self.node_b == node_a) } - fn complete_all_monitor_updates(&self, nodes: &[HarnessNode<'_>; 3]) { + fn complete_all_monitor_updates(&self, nodes: &[HarnessNode<'_>; 3]) -> bool { + let mut completed_updates = false; for id in &self.channel_ids { - nodes[self.node_a].complete_all_monitor_updates(id); - nodes[self.node_b].complete_all_monitor_updates(id); + completed_updates |= nodes[self.node_a].complete_all_monitor_updates(id); + completed_updates |= nodes[self.node_b].complete_all_monitor_updates(id); } + completed_updates } fn complete_monitor_updates_for_node( @@ -2143,7 +2151,6 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { ChannelMonitorUpdateStatus::Completed }, ]; - let wallet_a = TestWalletSource::new(SecretKey::from_slice(&[1; 32]).unwrap()); let wallet_b = TestWalletSource::new(SecretKey::from_slice(&[2; 32]).unwrap()); let wallet_c = TestWalletSource::new(SecretKey::from_slice(&[3; 32]).unwrap()); @@ -2672,7 +2679,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { // claim/fail handling per event batch. let mut claim_set = new_hash_map(); let mut events = nodes[node_idx].get_and_clear_pending_events(); - let had_events = !events.is_empty(); + let mut had_events = !events.is_empty(); for event in events.drain(..) { match event { events::Event::PaymentClaimable { payment_hash, .. } => { @@ -2728,6 +2735,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { } while nodes[node_idx].needs_pending_htlc_processing() { nodes[node_idx].process_pending_htlc_forwards(); + had_events = true; } had_events } @@ -2750,9 +2758,10 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { "It may take may iterations to settle the state, but it should not take forever" ); } + let mut made_progress = self.refresh_serialized_managers(); // Next, make sure no monitor completion callbacks are pending. - self.ab_link.complete_all_monitor_updates(&self.nodes); - self.bc_link.complete_all_monitor_updates(&self.nodes); + made_progress |= self.ab_link.complete_all_monitor_updates(&self.nodes); + made_progress |= self.bc_link.complete_all_monitor_updates(&self.nodes); // Then, make sure any current forwards make their way to their destination. if self.process_msg_events(0, false, ProcessMessages::AllMessages) { last_pass_no_updates = false; @@ -2779,6 +2788,10 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { last_pass_no_updates = false; continue; } + if made_progress { + last_pass_no_updates = false; + continue; + } if last_pass_no_updates { // In some cases, we may generate a message to send in // `process_msg_events`, but block sending until @@ -2894,10 +2907,12 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { self.nodes[2].record_last_htlc_clear_fee(); } - fn refresh_serialized_managers(&mut self) { + fn refresh_serialized_managers(&mut self) -> bool { + let mut made_progress = false; for node in &mut self.nodes { - node.refresh_serialized_manager(); + made_progress |= node.refresh_serialized_manager(); } + made_progress } } From 9938884b5f7b469ab70ebcfab1e986fc7df441c3 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Wed, 13 May 2026 13:53:01 +0200 Subject: [PATCH 421/627] fuzz: reload monitors with the configured status Build the replacement persister with the configured monitor update status during reload. This keeps non-deferred restart behavior aligned with the active persistence-style matrix. --- fuzz/src/chanmon_consistency.rs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index eb89321c06d..85fee5dc465 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1079,9 +1079,7 @@ impl<'a> HarnessNode<'a> { &mut self, use_old_mons: u8, out: &Out, router: &'a FuzzRouter, chan_type: ChanType, ) { let logger = Self::build_logger(self.node_id, out); - // Re-registering monitors during reload reflects data that was already selected from - // simulated storage, so these startup watch_channel calls should complete immediately. - let persister = Self::build_persister(ChannelMonitorUpdateStatus::Completed); + let persister = Self::build_persister(self.persistence_style); let chain_monitor = Self::build_chain_monitor( &self.broadcaster, &self.fee_estimator, @@ -1135,14 +1133,8 @@ impl<'a> HarnessNode<'a> { let manager = <(BlockLocator, ChanMan)>::read(&mut &self.serialized_manager[..], read_args) .expect("Failed to read manager"); for (channel_id, mon) in monitors.drain() { - assert_eq!( - chain_monitor.watch_channel(channel_id, mon), - Ok(ChannelMonitorUpdateStatus::Completed) - ); + assert_eq!(chain_monitor.watch_channel(channel_id, mon), Ok(self.persistence_style)); } - // Future monitor writes should follow the node's configured persistence style; only the - // startup watch_channel registration above is forced to Completed. - *persister.update_ret.lock().unwrap() = self.persistence_style; self.node = manager.1; self.monitor = chain_monitor; self.persister = persister; From 58d3a7401aba56e8d86e7272a06e8fb72ad66361 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Wed, 13 May 2026 13:56:10 +0200 Subject: [PATCH 422/627] fuzz: add deferred chanmon checkpoints Track deferred monitor writes in the harness and checkpoint the ChannelManager state before flushing them to the persister. This extends setup, reload, and settle paths to model deferred ChainMonitor persistence ordering. --- fuzz/src/chanmon_consistency.rs | 88 +++++++++++++++++++++++++-------- 1 file changed, 67 insertions(+), 21 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 85fee5dc465..ce148157df9 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -817,6 +817,7 @@ struct HarnessNode<'a> { fee_estimator: Arc, wallet: TestWalletSource, persistence_style: ChannelMonitorUpdateStatus, + deferred: bool, serialized_manager: Vec, height: u32, last_htlc_clear_fee: u32, @@ -847,7 +848,7 @@ impl<'a> HarnessNode<'a> { fn build_chain_monitor( broadcaster: &Arc, fee_estimator: &Arc, keys_manager: &Arc, logger: Arc, - persister: &Arc, + persister: &Arc, deferred: bool, ) -> Arc { Arc::new(chainmonitor::ChainMonitor::new( None, @@ -857,14 +858,14 @@ impl<'a> HarnessNode<'a> { Arc::clone(persister), Arc::clone(keys_manager), keys_manager.get_peer_storage_key(), - false, + deferred, )) } fn new( node_id: u8, wallet: TestWalletSource, fee_estimator: Arc, broadcaster: Arc, persistence_style: ChannelMonitorUpdateStatus, - out: &Out, router: &'a FuzzRouter, chan_type: ChanType, + deferred: bool, out: &Out, router: &'a FuzzRouter, chan_type: ChanType, ) -> Self { let logger = Self::build_logger(node_id, out); let node_secret = SecretKey::from_slice(&[ @@ -884,6 +885,7 @@ impl<'a> HarnessNode<'a> { &keys_manager, Arc::clone(&logger), &persister, + deferred, ); let network = Network::Bitcoin; let best_block_timestamp = genesis_block(network).header.time; @@ -913,6 +915,7 @@ impl<'a> HarnessNode<'a> { fee_estimator, wallet, persistence_style, + deferred, serialized_manager: Vec::new(), height: 0, last_htlc_clear_fee: 253, @@ -969,15 +972,33 @@ impl<'a> HarnessNode<'a> { } } - fn refresh_serialized_manager(&mut self) -> bool { + fn checkpoint_manager_persistence(&mut self) -> bool { if self.node.get_and_clear_needs_persistence() { + let pending_monitor_writes = self.monitor.pending_operation_count(); self.serialized_manager = self.node.encode(); + if self.deferred { + self.monitor.flush(pending_monitor_writes, &self.logger); + } else { + assert_eq!(pending_monitor_writes, 0); + } true } else { + assert_eq!(self.monitor.pending_operation_count(), 0); false } } + fn force_checkpoint_manager_persistence(&mut self) { + let pending_monitor_writes = self.monitor.pending_operation_count(); + self.serialized_manager = self.node.encode(); + self.node.get_and_clear_needs_persistence(); + if self.deferred { + self.monitor.flush(pending_monitor_writes, &self.logger); + } else { + assert_eq!(pending_monitor_writes, 0); + } + } + fn bump_fee_estimate(&mut self, chan_type: ChanType) { let mut max_feerate = self.last_htlc_clear_fee; if matches!(chan_type, ChanType::Legacy) { @@ -1086,6 +1107,7 @@ impl<'a> HarnessNode<'a> { &self.keys_manager, Arc::clone(&logger), &persister, + self.deferred, ); let mut monitors = new_hash_map(); @@ -1132,13 +1154,22 @@ impl<'a> HarnessNode<'a> { let manager = <(BlockLocator, ChanMan)>::read(&mut &self.serialized_manager[..], read_args) .expect("Failed to read manager"); + let expected_status = if self.deferred { + ChannelMonitorUpdateStatus::InProgress + } else { + self.persistence_style + }; for (channel_id, mon) in monitors.drain() { - assert_eq!(chain_monitor.watch_channel(channel_id, mon), Ok(self.persistence_style)); + assert_eq!(chain_monitor.watch_channel(channel_id, mon), Ok(expected_status)); } self.node = manager.1; self.monitor = chain_monitor; self.persister = persister; self.logger = logger; + // In deferred mode, the startup watch_channel registrations above queue monitor operations + // even if the reloaded ChannelManager does not need persistence. Always checkpoint here so + // those registrations can be flushed against the manager snapshot they belong to. + self.force_checkpoint_manager_persistence(); } } @@ -1937,9 +1968,12 @@ fn connect_peers(source: &ChanMan<'_>, dest: &ChanMan<'_>) { } fn make_channel( - source: &HarnessNode<'_>, dest: &HarnessNode<'_>, chan_id: i32, trusted_open: bool, - trusted_accept: bool, chain_state: &mut ChainState, + nodes: &mut [HarnessNode<'_>; 3], source_idx: usize, dest_idx: usize, chan_id: i32, + trusted_open: bool, trusted_accept: bool, chain_state: &mut ChainState, ) { + assert!(source_idx < dest_idx); + let (left, right) = nodes.split_at_mut(dest_idx); + let (source, dest) = (&mut left[source_idx], &mut right[0]); if trusted_open { source .create_channel_to_trusted_peer_0reserve( @@ -2050,7 +2084,8 @@ fn make_channel( } }; dest.handle_funding_created(source.get_our_node_id(), &funding_created); - // Complete any pending monitor persistence callbacks for dest after watch_channel. + dest.checkpoint_manager_persistence(); + // Complete any monitor persistence callbacks made available for dest after watch_channel. dest.complete_all_pending_monitor_updates(); let (funding_signed, channel_id) = { @@ -2071,7 +2106,8 @@ fn make_channel( } source.handle_funding_signed(dest.get_our_node_id(), &funding_signed); - // Complete any pending monitor persistence callbacks for source after watch_channel. + source.checkpoint_manager_persistence(); + // Complete any monitor persistence callbacks made available for source after watch_channel. source.complete_all_pending_monitor_updates(); let events = source.get_and_clear_pending_events(); @@ -2143,6 +2179,12 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { ChannelMonitorUpdateStatus::Completed }, ]; + let deferred = [ + config_byte & 0b0010_0000 != 0, + config_byte & 0b0100_0000 != 0, + config_byte & 0b1000_0000 != 0, + ]; + let wallet_a = TestWalletSource::new(SecretKey::from_slice(&[1; 32]).unwrap()); let wallet_b = TestWalletSource::new(SecretKey::from_slice(&[2; 32]).unwrap()); let wallet_c = TestWalletSource::new(SecretKey::from_slice(&[3; 32]).unwrap()); @@ -2179,6 +2221,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { Arc::clone(&fee_est_a), Arc::clone(&broadcast_a), persistence_styles[0], + deferred[0], &out, router, chan_type, @@ -2189,6 +2232,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { Arc::clone(&fee_est_b), Arc::clone(&broadcast_b), persistence_styles[1], + deferred[1], &out, router, chan_type, @@ -2199,6 +2243,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { Arc::clone(&fee_est_c), Arc::clone(&broadcast_c), persistence_styles[2], + deferred[2], &out, router, chan_type, @@ -2217,14 +2262,14 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { // channel gets its own txid and funding outpoint. // A-B: channel 2 A and B have 0-reserve (trusted open + trusted accept), // channel 3 A has 0-reserve (trusted accept), if channels are non-legacy. - make_channel(&nodes[0], &nodes[1], 1, false, false, &mut chain_state); - make_channel(&nodes[0], &nodes[1], 2, set_0reserve, set_0reserve, &mut chain_state); - make_channel(&nodes[0], &nodes[1], 3, false, set_0reserve, &mut chain_state); + make_channel(&mut nodes, 0, 1, 1, false, false, &mut chain_state); + make_channel(&mut nodes, 0, 1, 2, set_0reserve, set_0reserve, &mut chain_state); + make_channel(&mut nodes, 0, 1, 3, false, set_0reserve, &mut chain_state); // B-C: channel 4 B has 0-reserve (via trusted accept), // channel 5 C has 0-reserve (via trusted open), if channels are non-legacy. - make_channel(&nodes[1], &nodes[2], 4, false, set_0reserve, &mut chain_state); - make_channel(&nodes[1], &nodes[2], 5, set_0reserve, false, &mut chain_state); - make_channel(&nodes[1], &nodes[2], 6, false, false, &mut chain_state); + make_channel(&mut nodes, 1, 2, 4, false, set_0reserve, &mut chain_state); + make_channel(&mut nodes, 1, 2, 5, set_0reserve, false, &mut chain_state); + make_channel(&mut nodes, 1, 2, 6, false, false, &mut chain_state); // Wipe the transactions-broadcasted set to make sure we don't broadcast // any transactions during normal operation after setup. @@ -2251,7 +2296,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { }; for node in &mut nodes { - node.serialized_manager = node.encode(); + node.force_checkpoint_manager_persistence(); } Self { @@ -2750,7 +2795,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { "It may take may iterations to settle the state, but it should not take forever" ); } - let mut made_progress = self.refresh_serialized_managers(); + let mut made_progress = self.checkpoint_manager_persistences(); // Next, make sure no monitor completion callbacks are pending. made_progress |= self.ab_link.complete_all_monitor_updates(&self.nodes); made_progress |= self.bc_link.complete_all_monitor_updates(&self.nodes); @@ -2899,10 +2944,10 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { self.nodes[2].record_last_htlc_clear_fee(); } - fn refresh_serialized_managers(&mut self) -> bool { + fn checkpoint_manager_persistences(&mut self) -> bool { let mut made_progress = false; for node in &mut self.nodes { - made_progress |= node.refresh_serialized_manager(); + made_progress |= node.checkpoint_manager_persistence(); } made_progress } @@ -2911,9 +2956,10 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { #[inline] pub fn do_test(data: &[u8], out: Out) { let router = FuzzRouter {}; - // Read initial monitor styles and channel type from fuzz input byte 0: + // Read initial monitor styles, channel type, and deferred write mode from fuzz input byte 0: // bits 0-2: monitor styles (1 bit per node) // bits 3-4: channel type (0=Legacy, 1=KeyedAnchors, 2=ZeroFeeCommitments) + // bits 5-7: deferred monitor write mode (1 bit per node) let config_byte = if !data.is_empty() { data[0] } else { 0 }; let mut harness = Harness::new(config_byte, out, &router); let mut read_pos = 1; // First byte was consumed for initial config. @@ -3325,7 +3371,7 @@ pub fn do_test(data: &[u8], out: Out) { _ => break 'fuzz_loop, } - harness.refresh_serialized_managers(); + harness.checkpoint_manager_persistences(); } harness.finish(); } From beffe75a323f00e7c2eeff02497ee40cfc1002e5 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 20 May 2026 10:24:41 +0200 Subject: [PATCH 423/627] Handle overflowing route-hint fee aggregates Crafted route hints can overflow aggregate downstream proportional fees when the payer disables the routing fee cap. Treat such paths as unusable so route finding fails cleanly instead of panicking. Co-Authored-By: HAL 9000 Signed-off-by: Elias Rohrer --- lightning/src/routing/router.rs | 94 +++++++++++++++++++++++++++++++-- 1 file changed, 90 insertions(+), 4 deletions(-) diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 4ac89874e2e..be33fa45e21 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -2421,10 +2421,12 @@ impl<'a> PaymentPath<'a> { /// contribution this path can make to the final value of the payment. /// May be slightly lower than the actual max due to rounding errors when aggregating fees /// along the path. + /// Returns an error with the index of a later hop to discard if the following hops' aggregate + /// fees overflow. #[rustfmt::skip] fn max_final_value_msat( &self, used_liquidities: &HashMap, channel_saturation_pow_half: u8 - ) -> (usize, u64) { + ) -> Result<(usize, u64), usize> { let mut max_path_contribution = (0, u64::MAX); for (idx, (hop, _)) in self.hops.iter().enumerate() { let hop_effective_capacity_msat = hop.candidate.effective_capacity(); @@ -2440,7 +2442,8 @@ impl<'a> PaymentPath<'a> { // Aggregate the fees of the hops that come after this one, and use those fees to compute the // maximum amount that this hop can contribute to the final value received by the payee. let (next_hops_aggregated_base, next_hops_aggregated_prop) = - crate::blinded_path::payment::compute_aggregated_base_prop_fee(next_hops_feerates_iter).unwrap(); + crate::blinded_path::payment::compute_aggregated_base_prop_fee(next_hops_feerates_iter) + .map_err(|_| idx + 1)?; // floor(((hop_max_msat - agg_base) * 1_000_000) / (1_000_000 + agg_prop)) let hop_max_final_value_contribution = (hop_max_msat as u128) @@ -2457,7 +2460,19 @@ impl<'a> PaymentPath<'a> { } else { debug_assert!(false); } } - max_path_contribution + Ok(max_path_contribution) + } +} + +fn mark_candidate_liquidity_exhausted( + used_liquidities: &mut HashMap, candidate: &CandidateRouteHop, +) { + let exhausted = u64::max_value(); + if let Some(scid) = candidate.short_channel_id() { + *used_liquidities.entry(CandidateHopId::Clear((scid, false))).or_default() = exhausted; + *used_liquidities.entry(CandidateHopId::Clear((scid, true))).or_default() = exhausted; + } else { + *used_liquidities.entry(candidate.id()).or_default() = exhausted; } } @@ -3637,7 +3652,17 @@ pub(crate) fn get_route( // underpaid htlc_minimum_msat with fees. debug_assert_eq!(payment_path.get_value_msat(), value_contribution_msat); let (lowest_value_contrib_hop, max_path_contribution_msat) = - payment_path.max_final_value_msat(&used_liquidities, channel_saturation_pow_half); + match payment_path.max_final_value_msat(&used_liquidities, channel_saturation_pow_half) { + Ok(contribution) => contribution, + Err(candidate_idx_to_skip) => { + let candidate = &payment_path.hops[candidate_idx_to_skip].0.candidate; + log_trace!(logger, + "Ignoring path because aggregate fees including hop {} overflow.", + LoggedCandidateHop(candidate)); + mark_candidate_liquidity_exhausted(&mut used_liquidities, candidate); + continue 'paths_collection; + } + }; let desired_value_contribution = cmp::min(max_path_contribution_msat, final_value_msat); value_contribution_msat = payment_path.update_value_and_recompute_fees(desired_value_contribution); @@ -9332,6 +9357,67 @@ mod tests { assert_eq!(route.paths[0].hops[0].short_channel_id, 44); } + #[test] + fn aggregated_prop_fee_overflow_fails_route() { + // If the fee cap is disabled, we may consider invoice hints with very large + // proportional fees. Aggregating those fees can overflow, in which case we should fail + // routing cleanly rather than panic. + let secp_ctx = Secp256k1::new(); + let logger = Arc::new(ln_test_utils::TestLogger::new()); + let network_graph = Arc::new(NetworkGraph::new(Network::Testnet, Arc::clone(&logger))); + let scorer = ln_test_utils::TestScorer::new(); + let random_seed_bytes = [42; 32]; + let config = UserConfig::default(); + + let (_, our_node_id, _, nodes) = get_nodes(&secp_ctx); + let route_hint = RouteHint(vec![ + RouteHintHop { + src_node_id: nodes[0], + short_channel_id: 100, + fees: RoutingFees { base_msat: 0, proportional_millionths: u32::MAX }, + cltv_expiry_delta: 10, + htlc_minimum_msat: None, + htlc_maximum_msat: None, + }, + RouteHintHop { + src_node_id: nodes[1], + short_channel_id: 101, + fees: RoutingFees { base_msat: 0, proportional_millionths: u32::MAX }, + cltv_expiry_delta: 10, + htlc_minimum_msat: None, + htlc_maximum_msat: None, + }, + ]); + + let payment_params = PaymentParameters::from_node_id(nodes[2], 42) + .with_route_hints(vec![route_hint]) + .unwrap() + .with_bolt11_features(channelmanager::provided_bolt11_invoice_features(&config)) + .unwrap(); + let first_hops = [get_channel_details( + Some(1), + nodes[0], + channelmanager::provided_init_features(&config), + 100_000_000, + )]; + let route_params = RouteParameters { + payment_params, + final_value_msat: 1, + max_total_routing_fee_msat: None, + }; + let route = get_route( + &our_node_id, + &route_params, + &network_graph.read_only(), + Some(&first_hops.iter().collect::>()), + Arc::clone(&logger), + &scorer, + &Default::default(), + &random_seed_bytes, + ); + assert!(route.is_err()); + } + #[test] fn prefers_paths_by_cost_amt_ratio() { // Previously, we preferred paths during MPP selection based on their absolute cost, rather From dbb1c967b2bd93a4058437d992c6272cb838322f Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Tue, 12 May 2026 16:30:06 -0700 Subject: [PATCH 424/627] Avoid splice checks when responding to stfu Only gate local quiescence initiation on splice RBF eligibility. If the counterparty initiated quiescence first, respond with non-initiator stfu once pending channel updates are clear. --- lightning/src/ln/channel.rs | 44 +++++++++++++++------------- lightning/src/ln/splicing_tests.rs | 47 ++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 20 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index edcaacfedc6..a5f8c6a3719 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -14636,7 +14636,24 @@ where return None; } - if let Some(action) = self.quiescent_action.as_ref() { + if self.context.is_waiting_on_peer_pending_channel_update() + || self.context.is_monitor_or_signer_pending_channel_update() + { + log_given_level!( + logger, + logger_level, + "Waiting for state machine pending changes to complete before sending stfu" + ); + return None; + } + + let initiator = if self.context.channel_state.is_remote_stfu_sent() { + // Since we may have also attempted to initiate quiescence but the counterparty + // initiated first, we'll retry after we're no longer quiescent. + self.context.channel_state.clear_remote_stfu_sent(); + self.context.channel_state.set_quiescent(); + false + } else if let Some(action) = self.quiescent_action.as_ref() { #[allow(irrefutable_let_patterns)] if let QuiescentAction::Splice { contribution, .. } = action { if self.pending_splice.is_some() { @@ -14663,29 +14680,16 @@ where } } } - } - if self.context.is_waiting_on_peer_pending_channel_update() - || self.context.is_monitor_or_signer_pending_channel_update() - { - log_given_level!( - logger, - logger_level, - "Waiting for state machine pending changes to complete before sending stfu" - ); - return None; - } - - let initiator = if self.context.channel_state.is_remote_stfu_sent() { - // Since we may have also attempted to initiate quiescence but the counterparty - // initiated first, we'll retry after we're no longer quiescent. - self.context.channel_state.clear_remote_stfu_sent(); - self.context.channel_state.set_quiescent(); - false - } else { log_debug!(logger, "Sending stfu as quiescence initiator"); self.context.channel_state.set_local_stfu_sent(); true + } else { + debug_assert!( + false, + "Either we have a pending quiescent action or need to respond to the counterparty" + ); + false }; Some(msgs::Stfu { channel_id: self.context.channel_id, initiator }) diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 35c72509d0b..75fc1b94354 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -7841,6 +7841,53 @@ fn test_funding_contributed_rbf_adjustment_exceeds_max_feerate() { assert_eq!(splice_init.funding_feerate_per_kw, FEERATE_FLOOR_SATS_PER_KW); } +#[test] +fn test_peer_initiated_stfu_skips_local_rbf_feerate_check() { + // Test that a local low-fee splice RBF attempt does not prevent us from responding to a + // counterparty-initiated quiescence attempt. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 4, added_value * 2); + + let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let node_0_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let node_0_contribution = + node_0_template.splice_in_sync(added_value, floor_feerate, floor_feerate, &wallet).unwrap(); + + // Node 1 creates a pending splice before node 0 submits its contribution. Node 0's + // contribution cannot be adjusted up to the pending splice's minimum RBF feerate, so it must + // not send its own stfu yet. + let node_1_contribution = do_initiate_splice_in(&nodes[1], &nodes[0], channel_id, added_value); + let (_first_splice_tx, _) = + splice_channel(&nodes[1], &nodes[0], channel_id, node_1_contribution); + nodes[0].node.funding_contributed(&channel_id, &node_id_1, node_0_contribution, None).unwrap(); + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + + // Node 1 can still initiate quiescence for its own RBF attempt. Node 0 should reply as the + // non-initiator instead of applying its local splice RBF feerate check to the response. + let min_rbf_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25); + let _node_1_rbf_contribution = + do_initiate_rbf_splice_in(&nodes[1], &nodes[0], channel_id, min_rbf_feerate); + let stfu_init = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + assert!(stfu_init.initiator); + + nodes[0].node.handle_stfu(node_id_1, &stfu_init); + let stfu_response = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + assert!(!stfu_response.initiator); +} + #[test] fn test_funding_contributed_rbf_adjustment_insufficient_budget() { // Test that when the change output can't absorb the fee increase needed for the minimum RBF feerate From 7165827ed43ccc1ed2b58ca35f4e28d25a2dba71 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Tue, 12 May 2026 15:13:58 -0700 Subject: [PATCH 425/627] Send missing splice_locked when confirmation precedes reestablishment In most cases, we end up sending our `splice_locked` either implicitly during reestablishment via `ChannelReestablish::my_current_funding_locked`, or explicitly after reestablishment. However, we did not consider that it's possible for the node to be notified of the splice confirmation after connecting to their peer but prior to reestablishing their channel. In such cases, we need to explicitly send the `splice_locked` since it wasn't included in `my_current_funding_locked`, but only after the channel has been reestablished. Found by the chanmon_consistency fuzz target. --- lightning/src/ln/channel.rs | 31 +++++++++- lightning/src/ln/channelmanager.rs | 13 +++-- lightning/src/ln/splicing_tests.rs | 91 ++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 5 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index edcaacfedc6..2341128c74d 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -1265,6 +1265,7 @@ pub(super) struct ReestablishResponses { pub shutdown_msg: Option, pub tx_signatures: Option, pub tx_abort: Option, + pub splice_locked: Option, pub inferred_splice_locked: Option, } @@ -3503,6 +3504,12 @@ pub(super) struct ChannelContext { /// See-also pub workaround_lnd_bug_4006: Option, + /// The `my_current_funding_locked` txid included in our `channel_reestablish` for the current + /// reconnect, if any. We track this as we cannot tell what was included after we've already + /// sent it, as it's possible it was unconfirmed at the time we sent it, but confirmed shortly + /// after. + funding_locked_txid_sent_in_reestablish: Option, + /// An option set when we wish to track how many ticks have elapsed while waiting for a response /// from our counterparty after entering specific states. If the peer has yet to respond after /// reaching `DISCONNECT_PEER_AWAITING_RESPONSE_TICKS`, a reconnection should be attempted to @@ -4225,6 +4232,7 @@ impl ChannelContext { announcement_sigs: None, workaround_lnd_bug_4006: None, + funding_locked_txid_sent_in_reestablish: None, sent_message_awaiting_response: None, latest_inbound_scid_alias: None, @@ -4536,6 +4544,7 @@ impl ChannelContext { announcement_sigs: None, workaround_lnd_bug_4006: None, + funding_locked_txid_sent_in_reestablish: None, sent_message_awaiting_response: None, latest_inbound_scid_alias: None, @@ -10512,6 +10521,8 @@ where // remaining cases either succeed or ErrorMessage-fail). self.context.channel_state.clear_peer_disconnected(); self.mark_response_received(); + let funding_locked_txid_sent_in_reestablish = + self.context.funding_locked_txid_sent_in_reestablish.take(); let shutdown_msg = self.get_outbound_shutdown(); @@ -10663,6 +10674,7 @@ where shutdown_msg, announcement_sigs, tx_signatures, tx_abort: None, + splice_locked: None, inferred_splice_locked: None, }); } @@ -10676,6 +10688,7 @@ where shutdown_msg, announcement_sigs, tx_signatures, tx_abort, + splice_locked: None, inferred_splice_locked: None, }); } @@ -10745,6 +10758,15 @@ where splice_txid, }) }); + let splice_locked = self.pending_splice.as_ref().and_then(|pending_splice| { + pending_splice + .sent_funding_txid + .filter(|splice_txid| Some(*splice_txid) != funding_locked_txid_sent_in_reestablish) + .map(|splice_txid| msgs::SpliceLocked { + channel_id: self.context.channel_id, + splice_txid, + }) + }); if msg.next_local_commitment_number == next_counterparty_commitment_number { if required_revoke.is_some() || self.context.signer_pending_revoke_and_ack { @@ -10763,6 +10785,7 @@ where commitment_order: self.context.resend_order.clone(), tx_signatures, tx_abort, + splice_locked, inferred_splice_locked, }) } else if msg.next_local_commitment_number == next_counterparty_commitment_number - 1 { @@ -10788,6 +10811,7 @@ where commitment_order: self.context.resend_order.clone(), tx_signatures: None, tx_abort, + splice_locked, inferred_splice_locked, }) } else { @@ -10815,6 +10839,7 @@ where commitment_order: self.context.resend_order.clone(), tx_signatures: None, tx_abort, + splice_locked, inferred_splice_locked, }) } @@ -12492,6 +12517,9 @@ where log_info!(logger, "Sending a data_loss_protect with no previous remote per_commitment_secret for channel {}", &self.context.channel_id()); [0;32] }; + let my_current_funding_locked = self.maybe_get_my_current_funding_locked(); + self.context.funding_locked_txid_sent_in_reestablish = + my_current_funding_locked.as_ref().map(|funding_locked| funding_locked.txid); msgs::ChannelReestablish { channel_id: self.context.channel_id(), // The protocol has two different commitment number concepts - the "commitment @@ -12515,7 +12543,7 @@ where your_last_per_commitment_secret: remote_last_secret, my_current_per_commitment_point: dummy_pubkey, next_funding: self.maybe_get_next_funding(), - my_current_funding_locked: self.maybe_get_my_current_funding_locked(), + my_current_funding_locked, } } @@ -17196,6 +17224,7 @@ impl<'a, 'b, 'c, ES: EntropySource, SP: SignerProvider> announcement_sigs, workaround_lnd_bug_4006: None, + funding_locked_txid_sent_in_reestablish: None, sent_message_awaiting_response: None, latest_inbound_scid_alias, diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 0ff2f19b830..2126cafaadf 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -13285,10 +13285,15 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } } let need_lnd_workaround = chan.context.workaround_lnd_bug_4006.take(); - let funding_tx_signed = responses.tx_signatures.map(|tx_signatures| FundingTxSigned { - tx_signatures: Some(tx_signatures), - ..Default::default() - }); + let funding_tx_signed = if responses.tx_signatures.is_some() || responses.splice_locked.is_some() { + Some(FundingTxSigned { + tx_signatures: responses.tx_signatures, + splice_locked: responses.splice_locked, + ..Default::default() + }) + } else { + None + }; let (htlc_forwards, decode_update_add_htlcs) = self.handle_channel_resumption( &mut peer_state.pending_msg_events, chan, responses.raa, responses.commitment_update, responses.commitment_order, Vec::new(), Vec::new(), None, responses.channel_ready, responses.announcement_sigs, diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 35c72509d0b..ca45a39c8cb 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -751,7 +751,21 @@ pub fn lock_splice<'a, 'b, 'c, 'd>( .get_monitor(splice_locked_for_node_b.channel_id) .map(|monitor| monitor.get_funding_txo().txid) .unwrap(); + complete_splice_locked_exchange( + node_a, + node_b, + splice_locked_for_node_b, + is_0conf, + expected_discard_txids, + prev_funding_txid, + ) +} +fn complete_splice_locked_exchange<'a, 'b, 'c, 'd>( + node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>, + splice_locked_for_node_b: &msgs::SpliceLocked, is_0conf: bool, expected_discard_txids: &[Txid], + prev_funding_txid: Txid, +) -> SpliceLockedResult { let node_id_a = node_a.node.get_our_node_id(); let node_id_b = node_b.node.get_our_node_id(); @@ -2585,6 +2599,83 @@ fn do_test_splice_reestablish(reload: bool, async_monitor_update: bool) { .remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script); } +#[test] +fn test_splice_locked_waits_for_channel_reestablish() { + // If a splice confirms after `peer_connected` but before `channel_reestablish` is handled, the + // peer state is connected while the channel still has its disconnected bit set. We must not send + // `splice_locked` until the channel is reestablished, but should send it immediately after. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + let prev_funding_txid = get_monitor!(nodes[0], channel_id).get_funding_txo().txid; + + send_payment(&nodes[0], &[&nodes[1]], 1_000_000); + + let outputs = vec![ + TxOut { + value: Amount::from_sat(initial_channel_value_sat / 4), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }, + TxOut { + value: Amount::from_sat(initial_channel_value_sat / 4), + script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), + }, + ]; + let funding_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); + let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + + connect_nodes(&nodes[0], &nodes[1]); + let reestablish_0 = + get_event_msg!(nodes[0], MessageSendEvent::SendChannelReestablish, node_id_1); + let reestablish_1 = + get_event_msg!(nodes[1], MessageSendEvent::SendChannelReestablish, node_id_0); + + confirm_transaction(&nodes[0], &splice_tx); + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + + nodes[1].node.handle_channel_reestablish(node_id_0, &reestablish_0); + let _ = get_event_msg!(nodes[1], MessageSendEvent::SendChannelUpdate, node_id_0); + nodes[0].node.handle_channel_reestablish(node_id_1, &reestablish_1); + let mut msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + let splice_locked_0 = + if let MessageSendEvent::SendSpliceLocked { node_id, msg } = msg_events.remove(0) { + assert_eq!(node_id, node_id_1); + msg + } else { + panic!(); + }; + if let MessageSendEvent::SendChannelUpdate { node_id, .. } = msg_events.remove(0) { + assert_eq!(node_id, node_id_1); + } else { + panic!(); + } + + confirm_transaction(&nodes[1], &splice_tx); + complete_splice_locked_exchange( + &nodes[0], + &nodes[1], + &splice_locked_0, + false, + &[], + prev_funding_txid, + ); + + send_payment(&nodes[0], &[&nodes[1]], 1_000_000); +} + #[test] fn test_splice_confirms_on_both_sides_while_disconnected() { // Regression test: when a splice transaction confirms on both sides while peers are From 5e14a3fc98457974dd3cb841de2ecf3b1226c57e Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Tue, 19 May 2026 15:05:30 -0700 Subject: [PATCH 426/627] Handle inferred splice_locked on reestablish first prior to updates Upon channel reestablishment, we free our holding cells to send any pending updates to our peer. If we happened to implicitly lock a pending splice during reestablishment, we want to make sure any updates we send after the fact are considering the new channel state (post-splice), even if the update was queued while the splice was still pending. Therefore, we must always handle the inferred `splice_locked` first. Found by the `chanmon_consistency` fuzz target. --- lightning/src/ln/channelmanager.rs | 288 +++++++++++++++++------------ lightning/src/ln/splicing_tests.rs | 85 +++++++++ 2 files changed, 257 insertions(+), 116 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 2126cafaadf..4f90b00d727 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -12368,43 +12368,13 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ match peer_state.channel_by_id.entry(msg.channel_id) { hash_map::Entry::Occupied(mut chan_entry) => { if let Some(chan) = chan_entry.get_mut().as_funded_mut() { - let logger = WithChannelContext::from(&self.logger, &chan.context, None); - let res = chan.channel_ready( - &msg, - &self.node_signer, - self.chain_hash, - &self.config.read().unwrap(), - &self.best_block.read().unwrap(), - &&logger + let res = self.internal_channel_ready_with_funded_channel( + counterparty_node_id, + msg, + chan, + &mut peer_state.pending_msg_events, ); - let announcement_sigs_opt = - try_channel_entry!(self, peer_state, res, chan_entry); - if let Some(announcement_sigs) = announcement_sigs_opt { - log_trace!(logger, "Sending announcement_signatures"); - peer_state.pending_msg_events.push(MessageSendEvent::SendAnnouncementSignatures { - node_id: counterparty_node_id.clone(), - msg: announcement_sigs, - }); - } else if chan.context.is_usable() { - // If we're sending an announcement_signatures, we'll send the (public) - // channel_update after sending a channel_announcement when we receive our - // counterparty's announcement_signatures. Thus, we only bother to send a - // channel_update here if the channel is not public, i.e. we're not sending an - // announcement_signatures. - log_trace!(logger, "Sending private initial channel_update for our counterparty"); - if let Ok((msg, _, _)) = self.get_channel_update_for_unicast(chan) { - peer_state.pending_msg_events.push(MessageSendEvent::SendChannelUpdate { - node_id: counterparty_node_id.clone(), - msg, - }); - } - } - - { - let mut pending_events = self.pending_events.lock().unwrap(); - emit_initial_channel_ready_event!(pending_events, chan); - } - + try_channel_entry!(self, peer_state, res, chan_entry); Ok(()) } else { try_channel_entry!(self, peer_state, Err(ChannelError::close( @@ -12417,6 +12387,49 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } } + #[rustfmt::skip] + fn internal_channel_ready_with_funded_channel( + &self, counterparty_node_id: &PublicKey, msg: &msgs::ChannelReady, + chan: &mut FundedChannel, pending_msg_events: &mut Vec, + ) -> Result<(), ChannelError> { + let logger = WithChannelContext::from(&self.logger, &chan.context, None); + let announcement_sigs_opt = chan.channel_ready( + &msg, + &self.node_signer, + self.chain_hash, + &self.config.read().unwrap(), + &self.best_block.read().unwrap(), + &&logger + )?; + if let Some(announcement_sigs) = announcement_sigs_opt { + log_trace!(logger, "Sending announcement_signatures"); + pending_msg_events.push(MessageSendEvent::SendAnnouncementSignatures { + node_id: counterparty_node_id.clone(), + msg: announcement_sigs, + }); + } else if chan.context.is_usable() { + // If we're sending an announcement_signatures, we'll send the (public) + // channel_update after sending a channel_announcement when we receive our + // counterparty's announcement_signatures. Thus, we only bother to send a + // channel_update here if the channel is not public, i.e. we're not sending an + // announcement_signatures. + log_trace!(logger, "Sending private initial channel_update for our counterparty"); + if let Ok((msg, _, _)) = self.get_channel_update_for_unicast(chan) { + pending_msg_events.push(MessageSendEvent::SendChannelUpdate { + node_id: counterparty_node_id.clone(), + msg, + }); + } + } + + { + let mut pending_events = self.pending_events.lock().unwrap(); + emit_initial_channel_ready_event!(pending_events, chan); + } + + Ok(()) + } + fn internal_shutdown( &self, counterparty_node_id: &PublicKey, msg: &msgs::Shutdown, ) -> Result<(), MsgHandleErrInternal> { @@ -13240,7 +13253,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ #[rustfmt::skip] fn internal_channel_reestablish(&self, counterparty_node_id: &PublicKey, msg: &msgs::ChannelReestablish) -> Result<(), MsgHandleErrInternal> { - let (inferred_splice_locked, need_lnd_workaround, holding_cell_res) = { + let (post_splice_locked_update, holding_cell_res) = { let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { @@ -13249,7 +13262,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let logger = WithContext::from(&self.logger, Some(*counterparty_node_id), Some(msg.channel_id), None); let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; - match peer_state.channel_by_id.entry(msg.channel_id) { + let post_splice_locked_update = match peer_state.channel_by_id.entry(msg.channel_id) { hash_map::Entry::Occupied(mut chan_entry) => { if let Some(chan) = chan_entry.get_mut().as_funded_mut() { // Currently, we expect all holding cell update_adds to be dropped on peer @@ -13285,6 +13298,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } } let need_lnd_workaround = chan.context.workaround_lnd_bug_4006.take(); + let inferred_splice_locked = responses.inferred_splice_locked; let funding_tx_signed = if responses.tx_signatures.is_some() || responses.splice_locked.is_some() { Some(FundingTxSigned { tx_signatures: responses.tx_signatures, @@ -13305,8 +13319,33 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ peer_state.pending_msg_events.push(upd); } - let holding_cell_res = self.check_free_peer_holding_cells(peer_state); - (responses.inferred_splice_locked, need_lnd_workaround, holding_cell_res) + if let Some(channel_ready_msg) = need_lnd_workaround { + let res = self.internal_channel_ready_with_funded_channel( + counterparty_node_id, + &channel_ready_msg, + chan, + &mut peer_state.pending_msg_events, + ); + try_channel_entry!(self, peer_state, res, chan_entry); + } + + // A reestablish may infer a missed `splice_locked`; apply it before freeing + // holding cells so we don't generate commitment updates against stale splice + // state. + if let Some(splice_locked) = inferred_splice_locked { + let result = self.internal_splice_locked_with_funded_channel( + counterparty_node_id, + &splice_locked, + chan, + &mut peer_state.in_flight_monitor_updates, + &mut peer_state.monitor_update_blocked_actions, + &mut peer_state.pending_msg_events, + peer_state.is_connected, + ); + try_channel_entry!(self, peer_state, result, chan_entry) + } else { + None + } } else { return try_channel_entry!(self, peer_state, Err(ChannelError::close( "Got a channel_reestablish message for an unfunded channel!".into())), chan_entry); @@ -13344,18 +13383,16 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ return Err(MsgHandleErrInternal::no_such_channel_for_peer(counterparty_node_id, msg.channel_id) ) } - } - }; - - self.handle_holding_cell_free_result(holding_cell_res); + }; - if let Some(channel_ready_msg) = need_lnd_workaround { - self.internal_channel_ready(counterparty_node_id, &channel_ready_msg)?; - } + let holding_cell_res = self.check_free_peer_holding_cells(peer_state); + (post_splice_locked_update, holding_cell_res) + }; - if let Some(splice_locked) = inferred_splice_locked { - self.internal_splice_locked(counterparty_node_id, &splice_locked)?; + if let Some(data) = post_splice_locked_update { + self.handle_post_monitor_update_chan_resume(data); } + self.handle_holding_cell_free_result(holding_cell_res); Ok(()) } @@ -13585,9 +13622,8 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; - // Look for the channel - match peer_state.channel_by_id.entry(msg.channel_id) { + let post_update_data = match peer_state.channel_by_id.entry(msg.channel_id) { hash_map::Entry::Vacant(_) => { return Err(MsgHandleErrInternal::no_such_channel_for_peer( counterparty_node_id, @@ -13596,73 +13632,16 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }, hash_map::Entry::Occupied(mut chan_entry) => { if let Some(chan) = chan_entry.get_mut().as_funded_mut() { - let logger = WithChannelContext::from(&self.logger, &chan.context, None); - let result = chan.splice_locked( + let result = self.internal_splice_locked_with_funded_channel( + counterparty_node_id, msg, - &self.node_signer, - self.chain_hash, - &self.config.read().unwrap(), - self.best_block.read().unwrap().height, - &&logger, + chan, + &mut peer_state.in_flight_monitor_updates, + &mut peer_state.monitor_update_blocked_actions, + &mut peer_state.pending_msg_events, + peer_state.is_connected, ); - let splice_promotion = try_channel_entry!(self, peer_state, result, chan_entry); - if let Some(splice_promotion) = splice_promotion { - { - let mut short_to_chan_info = self.short_to_chan_info.write().unwrap(); - insert_short_channel_id!(short_to_chan_info, chan); - } - - { - let mut pending_events = self.pending_events.lock().unwrap(); - pending_events.push_back(( - events::Event::ChannelReady { - channel_id: chan.context.channel_id(), - user_channel_id: chan.context.get_user_id(), - counterparty_node_id: chan.context.get_counterparty_node_id(), - funding_txo: Some( - splice_promotion.funding_txo.into_bitcoin_outpoint(), - ), - channel_type: chan.funding.get_channel_type().clone(), - }, - None, - )); - splice_promotion.discarded_funding.into_iter().for_each( - |funding_info| { - let event = Event::DiscardFunding { - channel_id: chan.context.channel_id(), - funding_info, - }; - pending_events.push_back((event, None)); - }, - ); - } - - if let Some(announcement_sigs) = splice_promotion.announcement_sigs { - log_trace!(logger, "Sending announcement_signatures",); - peer_state.pending_msg_events.push( - MessageSendEvent::SendAnnouncementSignatures { - node_id: counterparty_node_id.clone(), - msg: announcement_sigs, - }, - ); - } - - if let Some(monitor_update) = splice_promotion.monitor_update { - if let Some(data) = self.handle_new_monitor_update( - &mut peer_state.in_flight_monitor_updates, - &mut peer_state.monitor_update_blocked_actions, - &mut peer_state.pending_msg_events, - peer_state.is_connected, - chan, - splice_promotion.funding_txo, - monitor_update, - ) { - mem::drop(peer_state_lock); - mem::drop(per_peer_state); - self.handle_post_monitor_update_chan_resume(data); - } - } - } + try_channel_entry!(self, peer_state, result, chan_entry) } else { return Err(MsgHandleErrInternal::send_err_msg_no_close( "Channel is not funded, cannot splice".to_owned(), @@ -13671,10 +13650,87 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } }, }; + mem::drop(peer_state_lock); + mem::drop(per_peer_state); + + if let Some(data) = post_update_data { + self.handle_post_monitor_update_chan_resume(data); + } Ok(()) } + fn internal_splice_locked_with_funded_channel( + &self, counterparty_node_id: &PublicKey, msg: &msgs::SpliceLocked, + chan: &mut FundedChannel, + in_flight_monitor_updates: &mut BTreeMap)>, + monitor_update_blocked_actions: &mut BTreeMap< + ChannelId, + Vec, + >, + pending_msg_events: &mut Vec, is_connected: bool, + ) -> Result, ChannelError> { + let logger = WithChannelContext::from(&self.logger, &chan.context, None); + let splice_promotion = chan.splice_locked( + msg, + &self.node_signer, + self.chain_hash, + &self.config.read().unwrap(), + self.best_block.read().unwrap().height, + &&logger, + )?; + let mut post_update_data = None; + if let Some(splice_promotion) = splice_promotion { + { + let mut short_to_chan_info = self.short_to_chan_info.write().unwrap(); + insert_short_channel_id!(short_to_chan_info, chan); + } + + { + let mut pending_events = self.pending_events.lock().unwrap(); + pending_events.push_back(( + events::Event::ChannelReady { + channel_id: chan.context.channel_id(), + user_channel_id: chan.context.get_user_id(), + counterparty_node_id: chan.context.get_counterparty_node_id(), + funding_txo: Some(splice_promotion.funding_txo.into_bitcoin_outpoint()), + channel_type: chan.funding.get_channel_type().clone(), + }, + None, + )); + splice_promotion.discarded_funding.into_iter().for_each(|funding_info| { + let event = Event::DiscardFunding { + channel_id: chan.context.channel_id(), + funding_info, + }; + pending_events.push_back((event, None)); + }); + } + + if let Some(announcement_sigs) = splice_promotion.announcement_sigs { + log_trace!(logger, "Sending announcement_signatures",); + pending_msg_events.push(MessageSendEvent::SendAnnouncementSignatures { + node_id: counterparty_node_id.clone(), + msg: announcement_sigs, + }); + } + + if let Some(monitor_update) = splice_promotion.monitor_update { + post_update_data = self.handle_new_monitor_update( + in_flight_monitor_updates, + monitor_update_blocked_actions, + pending_msg_events, + is_connected, + chan, + splice_promotion.funding_txo, + monitor_update, + ); + } + } + + Ok(post_update_data) + } + /// Process pending events from the [`chain::Watch`], returning whether any events were processed. fn process_pending_monitor_events(&self) -> bool { debug_assert!(self.total_consistency_lock.try_write().is_err()); // Caller holds read lock diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index ca45a39c8cb..6e6af600faf 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -2794,6 +2794,91 @@ fn test_splice_confirms_on_both_sides_while_disconnected() { .remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script); } +#[test] +fn test_holding_cell_claim_freed_after_inferred_splice_locked() { + // If `channel_reestablish` infers a missed `splice_locked`, it must promote the splice before + // freeing holding-cell updates. If the promotion monitor update is asynchronous, holding-cell + // updates must remain held until that monitor update completes. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + let prev_funding_outpoint = get_monitor!(nodes[0], channel_id).get_funding_txo(); + let prev_funding_script = get_monitor!(nodes[0], channel_id).get_funding_script(); + let prev_scid = nodes[0].node.list_channels()[0].short_channel_id; + + let (payment_preimage, payment_hash, ..) = route_payment(&nodes[0], &[&nodes[1]], 1_000_000); + + let outputs = vec![ + TxOut { + value: Amount::from_sat(initial_channel_value_sat / 4), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }, + TxOut { + value: Amount::from_sat(initial_channel_value_sat / 4), + script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), + }, + ]; + let funding_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); + let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + + nodes[1].node.claim_funds(payment_preimage); + check_added_monitors(&nodes[1], 1); + expect_payment_claimed!(nodes[1], payment_hash, 1_000_000); + + confirm_transaction(&nodes[0], &splice_tx); + confirm_transaction(&nodes[1], &splice_tx); + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + + chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress); + + let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); + reconnect_args.expect_renegotiated_funding_locked_monitor_update = (true, true); + reconnect_args.send_announcement_sigs = (true, true); + reconnect_nodes(reconnect_args); + + expect_channel_ready_event(&nodes[0], &node_id_1); + expect_channel_ready_event(&nodes[1], &node_id_0); + assert_ne!(prev_scid, nodes[0].node.list_channels()[0].short_channel_id); + + nodes[1].chain_monitor.complete_sole_pending_chan_update(&channel_id); + chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); + + let mut commitment_update = get_htlc_update_msgs(&nodes[1], &node_id_0); + check_added_monitors(&nodes[1], 1); + nodes[0] + .node + .handle_update_fulfill_htlc(node_id_1, commitment_update.update_fulfill_htlcs.remove(0)); + do_commitment_signed_dance( + &nodes[0], + &nodes[1], + &commitment_update.commitment_signed, + false, + false, + ); + + expect_payment_sent!(nodes[0], payment_preimage); + + nodes[0] + .chain_source + .remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script.clone()); + nodes[1] + .chain_source + .remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script); +} + #[test] fn test_stale_announcement_signatures_ignored_after_splice_lock() { // Regression test: a peer may transmit `announcement_signatures` signed over a pre-splice From b33526ef5660c064d40728b4d0cb66ca20ca0acc Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Thu, 21 May 2026 08:39:41 +0200 Subject: [PATCH 427/627] Add explicit chanmon manager persistence commands Add chanmon_consistency commands to persist each node's ChannelManager state explicitly. This lets the fuzz target exercise delayed manager persistence instead of checkpointing it after every command. --- fuzz/src/chanmon_consistency.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index ce148157df9..95874c340cd 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -2860,6 +2860,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { } fn restart_node(&mut self, node_idx: usize, v: u8, router: &'a FuzzRouter) { + self.nodes[node_idx].checkpoint_manager_persistence(); match node_idx { 0 => { self.ab_link.disconnect_for_reload(0, &self.nodes, &mut self.queues); @@ -3116,6 +3117,16 @@ pub fn do_test(data: &[u8], out: Out) { 0x88 => harness.nodes[2].bump_fee_estimate(harness.chan_type), 0x89 => harness.nodes[2].reset_fee_estimate(), + 0x90 => { + harness.nodes[0].checkpoint_manager_persistence(); + }, + 0x91 => { + harness.nodes[1].checkpoint_manager_persistence(); + }, + 0x92 => { + harness.nodes[2].checkpoint_manager_persistence(); + }, + 0xa0 => { if !cfg!(splicing) { break 'fuzz_loop; @@ -3370,8 +3381,6 @@ pub fn do_test(data: &[u8], out: Out) { }, _ => break 'fuzz_loop, } - - harness.checkpoint_manager_persistences(); } harness.finish(); } From ac7e4e8d890b0b46af953b34853172c53821929d Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 11 May 2026 20:33:38 +0000 Subject: [PATCH 428/627] Enforce that node_ids are sorted in channel_announcements We already enforced that nodes can't have a chanel with themselves, but the spec was updated to require strict ordering at https://github.com/lightning/bolts/pull/1333 so we enforce this as well. Test fixes by claude. --- fuzz/src/full_stack.rs | 10 +++--- lightning-rapid-gossip-sync/src/processing.rs | 4 +-- lightning/src/routing/gossip.rs | 32 ++++++++++++----- lightning/src/routing/scoring.rs | 31 +++++++++++++--- lightning/src/routing/test_utils.rs | 36 +++++++++++++++---- 5 files changed, 85 insertions(+), 28 deletions(-) diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index e79bef7c5ec..5cff40043ba 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -1693,11 +1693,11 @@ fn gossip_exchange_seed() -> Vec { // inbound read from peer id 0 of len 255 ext_from_hex("0300ff", &mut test); // First part of channel_announcement (type 256) - ext_from_hex("0100 00000000000000000000000000000000000000000000000000000000000000b20303030303030303030303030303030303030303030303030303030303030303 00000000000000000000000000000000000000000000000000000000000000b20202020202020202020202020202020202020202020202020202020202020202 00000000000000000000000000000000000000000000000000000000000000b20303030303030303030303030303030303030303030303030303030303030303 00000000000000000000000000000000000000000000000000000000000000b20202020202020202020202020202020202020202020202020202020202", &mut test); + ext_from_hex("0100 00000000000000000000000000000000000000000000000000000000000000b20202020202020202020202020202020202020202020202020202020202020202 00000000000000000000000000000000000000000000000000000000000000b20303030303030303030303030303030303030303030303030303030303030303 00000000000000000000000000000000000000000000000000000000000000b20202020202020202020202020202020202020202020202020202020202020202 00000000000000000000000000000000000000000000000000000000000000b20303030303030303030303030303030303030303030303030303030303", &mut test); // inbound read from peer id 0 of len 193 ext_from_hex("0300c1", &mut test); // Last part of channel_announcement and mac - ext_from_hex("020202 00006fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000000000000000002a030303030303030303030303030303030303030303030303030303030303030303020202020202020202020202020202020202020202020202020202020202020202030303030303030303030303030303030303030303030303030303030303030303020202020202020202020202020202020202020202020202020202020202020202 03000000000000000000000000000000", &mut test); + ext_from_hex("030303 00006fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000000000000000002a020202020202020202020202020202020202020202020202020202020202020202030303030303030303030303030303030303030303030303030303030303030303020202020202020202020202020202020202020202020202020202020202020202030303030303030303030303030303030303030303030303030303030303030303 03000000000000000000000000000000", &mut test); // inbound read from peer id 0 of len 18 ext_from_hex("030012", &mut test); @@ -1706,7 +1706,7 @@ fn gossip_exchange_seed() -> Vec { // inbound read from peer id 0 of len 154 ext_from_hex("03009a", &mut test); // channel_update (type 258) and mac - ext_from_hex("0102 00000000000000000000000000000000000000000000000000000000000000a60303030303030303030303030303030303030303030303030303030303030303 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000 000000000000002a0000002c01000028000000000000000000000000000000000000000005f5e100 03000000000000000000000000000000", &mut test); + ext_from_hex("0102 00000000000000000000000000000000000000000000000000000000000000a60202020202020202020202020202020202020202020202020202020202020202 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000 000000000000002a0000002c01000028000000000000000000000000000000000000000005f5e100 03000000000000000000000000000000", &mut test); // inbound read from peer id 0 of len 18 ext_from_hex("030012", &mut test); @@ -2024,8 +2024,8 @@ mod tests { super::do_test(&test, &(Arc::clone(&logger) as Arc)); let log_entries = logger.lines.lock().unwrap(); - assert_eq!(log_entries.get(&("lightning::ln::peer_handler".to_string(), "Sending message to all peers except Some(PublicKey(0000000000000000000000000000000000000000000000000000000000000002ff00000000000000000000000000000000000000000000000000000000000002)) or the announced channel's counterparties: ChannelAnnouncement { node_signature_1: 3026020200b202200303030303030303030303030303030303030303030303030303030303030303, node_signature_2: 3026020200b202200202020202020202020202020202020202020202020202020202020202020202, bitcoin_signature_1: 3026020200b202200303030303030303030303030303030303030303030303030303030303030303, bitcoin_signature_2: 3026020200b202200202020202020202020202020202020202020202020202020202020202020202, contents: UnsignedChannelAnnouncement { features: [], chain_hash: 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000, short_channel_id: 42, node_id_1: NodeId(030303030303030303030303030303030303030303030303030303030303030303), node_id_2: NodeId(020202020202020202020202020202020202020202020202020202020202020202), bitcoin_key_1: NodeId(030303030303030303030303030303030303030303030303030303030303030303), bitcoin_key_2: NodeId(020202020202020202020202020202020202020202020202020202020202020202), excess_data: [] } }".to_string())), Some(&1)); - assert_eq!(log_entries.get(&("lightning::ln::peer_handler".to_string(), "Sending message to all peers except Some(PublicKey(0000000000000000000000000000000000000000000000000000000000000002ff00000000000000000000000000000000000000000000000000000000000002)): ChannelUpdate { signature: 3026020200a602200303030303030303030303030303030303030303030303030303030303030303, contents: UnsignedChannelUpdate { chain_hash: 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000, short_channel_id: 42, timestamp: 44, message_flags: 1, channel_flags: 0, cltv_expiry_delta: 40, htlc_minimum_msat: 0, htlc_maximum_msat: 100000000, fee_base_msat: 0, fee_proportional_millionths: 0, excess_data: [] } }".to_string())), Some(&1)); + assert_eq!(log_entries.get(&("lightning::ln::peer_handler".to_string(), "Sending message to all peers except Some(PublicKey(0000000000000000000000000000000000000000000000000000000000000002ff00000000000000000000000000000000000000000000000000000000000002)) or the announced channel's counterparties: ChannelAnnouncement { node_signature_1: 3026020200b202200202020202020202020202020202020202020202020202020202020202020202, node_signature_2: 3026020200b202200303030303030303030303030303030303030303030303030303030303030303, bitcoin_signature_1: 3026020200b202200202020202020202020202020202020202020202020202020202020202020202, bitcoin_signature_2: 3026020200b202200303030303030303030303030303030303030303030303030303030303030303, contents: UnsignedChannelAnnouncement { features: [], chain_hash: 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000, short_channel_id: 42, node_id_1: NodeId(020202020202020202020202020202020202020202020202020202020202020202), node_id_2: NodeId(030303030303030303030303030303030303030303030303030303030303030303), bitcoin_key_1: NodeId(020202020202020202020202020202020202020202020202020202020202020202), bitcoin_key_2: NodeId(030303030303030303030303030303030303030303030303030303030303030303), excess_data: [] } }".to_string())), Some(&1)); + assert_eq!(log_entries.get(&("lightning::ln::peer_handler".to_string(), "Sending message to all peers except Some(PublicKey(0000000000000000000000000000000000000000000000000000000000000002ff00000000000000000000000000000000000000000000000000000000000002)): ChannelUpdate { signature: 3026020200a602200202020202020202020202020202020202020202020202020202020202020202, contents: UnsignedChannelUpdate { chain_hash: 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000, short_channel_id: 42, timestamp: 44, message_flags: 1, channel_flags: 0, cltv_expiry_delta: 40, htlc_minimum_msat: 0, htlc_maximum_msat: 100000000, fee_base_msat: 0, fee_proportional_millionths: 0, excess_data: [] } }".to_string())), Some(&1)); assert_eq!(log_entries.get(&("lightning::ln::peer_handler".to_string(), "Sending message to all peers except Some(PublicKey(0000000000000000000000000000000000000000000000000000000000000002ff00000000000000000000000000000000000000000000000000000000000002)) or the announced node: NodeAnnouncement { signature: 302502012802200303030303030303030303030303030303030303030303030303030303030303, contents: UnsignedNodeAnnouncement { features: [], timestamp: 43, node_id: NodeId(030303030303030303030303030303030303030303030303030303030303030303), rgb: [0, 0, 0], alias: NodeAlias([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), addresses: [], excess_address_data: [], excess_data: [] } }".to_string())), Some(&1)); } diff --git a/lightning-rapid-gossip-sync/src/processing.rs b/lightning-rapid-gossip-sync/src/processing.rs index 9d3287969f2..cce3dc29a59 100644 --- a/lightning-rapid-gossip-sync/src/processing.rs +++ b/lightning-rapid-gossip-sync/src/processing.rs @@ -549,7 +549,7 @@ mod tests { 108, 101, 46, 99, 111, 109, 1, 187, 19, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 5, 57, 13, 3, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 0, 2, 23, 48, 62, 77, 75, 108, 209, 54, 16, 50, 202, 155, 210, 174, 185, 217, 0, 170, 77, 69, 217, 234, 216, 10, 201, - 66, 51, 116, 196, 81, 167, 37, 77, 7, 102, 0, 0, 2, 25, 48, 0, 0, 0, 1, 0, 0, 1, 0, 1, + 66, 51, 116, 196, 81, 167, 37, 77, 7, 102, 0, 0, 2, 25, 48, 0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, ]; @@ -669,7 +669,7 @@ mod tests { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 1, 0, 0, 1, 0, 255, 128, 0, 0, 0, 0, 0, 0, 1, 0, 147, 42, 23, 23, 23, 23, 23, + 0, 0, 0, 1, 0, 0, 1, 1, 255, 128, 0, 0, 0, 0, 0, 0, 0, 0, 147, 42, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, diff --git a/lightning/src/routing/gossip.rs b/lightning/src/routing/gossip.rs index adeb67a9e6c..3dda7194142 100644 --- a/lightning/src/routing/gossip.rs +++ b/lightning/src/routing/gossip.rs @@ -2014,9 +2014,9 @@ impl NetworkGraph { &self, short_channel_id: u64, capacity_sats: Option, timestamp: u64, features: ChannelFeatures, node_id_1: NodeId, node_id_2: NodeId, ) -> Result<(), LightningError> { - if node_id_1 == node_id_2 { + if node_id_1 >= node_id_2 { return Err(LightningError { - err: "Channel announcement node had a channel with itself".to_owned(), + err: "node_ids in channel_announcements must be sorted".to_owned(), action: ErrorAction::IgnoreError, }); }; @@ -2123,6 +2123,13 @@ impl NetworkGraph { ) -> Result<(), LightningError> { let channels = self.channels.read().unwrap(); + if msg.node_id_1 >= msg.node_id_2 { + return Err(LightningError { + err: "node_ids in channel_announcements must be sorted".to_owned(), + action: ErrorAction::IgnoreError, + }); + } + if let Some(chan) = channels.get(&msg.short_channel_id) { if chan.capacity_sats.is_some() { // If we'd previously looked up the channel on-chain and checked the script @@ -2831,8 +2838,15 @@ pub(crate) mod tests { pub(crate) fn get_signed_channel_announcement( f: F, node_1_key: &SecretKey, node_2_key: &SecretKey, secp_ctx: &Secp256k1, ) -> ChannelAnnouncement { - let node_id_1 = PublicKey::from_secret_key(&secp_ctx, node_1_key); - let node_id_2 = PublicKey::from_secret_key(&secp_ctx, node_2_key); + let mut node_id_1 = NodeId::from_pubkey(&PublicKey::from_secret_key(&secp_ctx, node_1_key)); + let mut node_id_2 = NodeId::from_pubkey(&PublicKey::from_secret_key(&secp_ctx, node_2_key)); + let mut signer_1 = node_1_key; + let mut signer_2 = node_2_key; + if node_id_1 > node_id_2 { + core::mem::swap(&mut node_id_1, &mut node_id_2); + core::mem::swap(&mut signer_1, &mut signer_2); + } + let node_1_btckey = &SecretKey::from_slice(&[40; 32]).unwrap(); let node_2_btckey = &SecretKey::from_slice(&[39; 32]).unwrap(); @@ -2840,8 +2854,8 @@ pub(crate) mod tests { features: channelmanager::provided_channel_features(&UserConfig::default()), chain_hash: ChainHash::using_genesis_block(Network::Testnet), short_channel_id: 0, - node_id_1: NodeId::from_pubkey(&node_id_1), - node_id_2: NodeId::from_pubkey(&node_id_2), + node_id_1, + node_id_2, bitcoin_key_1: NodeId::from_pubkey(&PublicKey::from_secret_key( &secp_ctx, node_1_btckey, @@ -2855,8 +2869,8 @@ pub(crate) mod tests { f(&mut unsigned_announcement); let msghash = hash_to_message!(&Sha256dHash::hash(&unsigned_announcement.encode()[..])[..]); ChannelAnnouncement { - node_signature_1: secp_ctx.sign_ecdsa(&msghash, node_1_key), - node_signature_2: secp_ctx.sign_ecdsa(&msghash, node_2_key), + node_signature_1: secp_ctx.sign_ecdsa(&msghash, signer_1), + node_signature_2: secp_ctx.sign_ecdsa(&msghash, signer_2), bitcoin_signature_1: secp_ctx.sign_ecdsa(&msghash, node_1_btckey), bitcoin_signature_2: secp_ctx.sign_ecdsa(&msghash, node_2_btckey), contents: unsigned_announcement, @@ -3126,7 +3140,7 @@ pub(crate) mod tests { .handle_channel_announcement(Some(node_1_pubkey), &channel_to_itself_announcement) { Ok(_) => panic!(), - Err(e) => assert_eq!(e.err, "Channel announcement node had a channel with itself"), + Err(e) => assert_eq!(e.err, "node_ids in channel_announcements must be sorted"), }; // Test that channel announcements with the wrong chain hash are ignored (network graph is testnet, diff --git a/lightning/src/routing/scoring.rs b/lightning/src/routing/scoring.rs index 1592bc0ccb2..f921d9e21d7 100644 --- a/lightning/src/routing/scoring.rs +++ b/lightning/src/routing/scoring.rs @@ -2705,20 +2705,28 @@ mod tests { let node_1_secret = &SecretKey::from_slice(&[39; 32]).unwrap(); let node_2_secret = &SecretKey::from_slice(&[40; 32]).unwrap(); let secp_ctx = Secp256k1::new(); + let mut node_id_1 = NodeId::from_pubkey(&PublicKey::from_secret_key(&secp_ctx, &node_1_key)); + let mut node_id_2 = NodeId::from_pubkey(&PublicKey::from_secret_key(&secp_ctx, &node_2_key)); + let mut node_signer_1 = &node_1_key; + let mut node_signer_2 = &node_2_key; + if node_id_1 > node_id_2 { + core::mem::swap(&mut node_id_1, &mut node_id_2); + core::mem::swap(&mut node_signer_1, &mut node_signer_2); + } let unsigned_announcement = UnsignedChannelAnnouncement { features: channelmanager::provided_channel_features(&UserConfig::default()), chain_hash: genesis_hash, short_channel_id, - node_id_1: NodeId::from_pubkey(&PublicKey::from_secret_key(&secp_ctx, &node_1_key)), - node_id_2: NodeId::from_pubkey(&PublicKey::from_secret_key(&secp_ctx, &node_2_key)), + node_id_1, + node_id_2, bitcoin_key_1: NodeId::from_pubkey(&PublicKey::from_secret_key(&secp_ctx, &node_1_secret)), bitcoin_key_2: NodeId::from_pubkey(&PublicKey::from_secret_key(&secp_ctx, &node_2_secret)), excess_data: Vec::new(), }; let msghash = hash_to_message!(&Sha256dHash::hash(&unsigned_announcement.encode()[..])[..]); let signed_announcement = ChannelAnnouncement { - node_signature_1: secp_ctx.sign_ecdsa(&msghash, &node_1_key), - node_signature_2: secp_ctx.sign_ecdsa(&msghash, &node_2_key), + node_signature_1: secp_ctx.sign_ecdsa(&msghash, node_signer_1), + node_signature_2: secp_ctx.sign_ecdsa(&msghash, node_signer_2), bitcoin_signature_1: secp_ctx.sign_ecdsa(&msghash, &node_1_secret), bitcoin_signature_2: secp_ctx.sign_ecdsa(&msghash, &node_2_secret), contents: unsigned_announcement, @@ -2732,10 +2740,23 @@ mod tests { fn update_channel( network_graph: &mut NetworkGraph<&TestLogger>, short_channel_id: u64, node_key: SecretKey, - channel_flags: u8, htlc_maximum_msat: u64, timestamp: u32, + mut channel_flags: u8, htlc_maximum_msat: u64, timestamp: u32, ) { let genesis_hash = ChainHash::using_genesis_block(Network::Testnet); let secp_ctx = Secp256k1::new(); + let node_id = NodeId::from_pubkey(&PublicKey::from_secret_key(&secp_ctx, &node_key)); + // `add_channel` may have swapped the node order to satisfy the spec's sorted node_ids + // requirement, so override `channel_flags` bit 0 to match the actual node position. + { + let read_only = network_graph.read_only(); + if let Some(channel) = read_only.channel(short_channel_id) { + if channel.node_one == node_id { + channel_flags &= !1; + } else { + channel_flags |= 1; + } + } + } let unsigned_update = UnsignedChannelUpdate { chain_hash: genesis_hash, short_channel_id, diff --git a/lightning/src/routing/test_utils.rs b/lightning/src/routing/test_utils.rs index a433fa30c5b..daaf65367c0 100644 --- a/lightning/src/routing/test_utils.rs +++ b/lightning/src/routing/test_utils.rs @@ -36,8 +36,14 @@ pub(crate) fn channel_announcement( node_1_privkey: &SecretKey, node_2_privkey: &SecretKey, features: ChannelFeatures, short_channel_id: u64, secp_ctx: &Secp256k1, ) -> ChannelAnnouncement { - let node_id_1 = NodeId::from_pubkey(&PublicKey::from_secret_key(&secp_ctx, node_1_privkey)); - let node_id_2 = NodeId::from_pubkey(&PublicKey::from_secret_key(&secp_ctx, node_2_privkey)); + let mut node_id_1 = NodeId::from_pubkey(&PublicKey::from_secret_key(&secp_ctx, node_1_privkey)); + let mut node_id_2 = NodeId::from_pubkey(&PublicKey::from_secret_key(&secp_ctx, node_2_privkey)); + let mut signer_1 = node_1_privkey; + let mut signer_2 = node_2_privkey; + if node_id_1 > node_id_2 { + core::mem::swap(&mut node_id_1, &mut node_id_2); + core::mem::swap(&mut signer_1, &mut signer_2); + } let unsigned_announcement = UnsignedChannelAnnouncement { features, @@ -52,10 +58,10 @@ pub(crate) fn channel_announcement( let msghash = hash_to_message!(&Sha256dHash::hash(&unsigned_announcement.encode()[..])[..]); ChannelAnnouncement { - node_signature_1: secp_ctx.sign_ecdsa(&msghash, node_1_privkey), - node_signature_2: secp_ctx.sign_ecdsa(&msghash, node_2_privkey), - bitcoin_signature_1: secp_ctx.sign_ecdsa(&msghash, node_1_privkey), - bitcoin_signature_2: secp_ctx.sign_ecdsa(&msghash, node_2_privkey), + node_signature_1: secp_ctx.sign_ecdsa(&msghash, signer_1), + node_signature_2: secp_ctx.sign_ecdsa(&msghash, signer_2), + bitcoin_signature_1: secp_ctx.sign_ecdsa(&msghash, signer_1), + bitcoin_signature_2: secp_ctx.sign_ecdsa(&msghash, signer_2), contents: unsigned_announcement.clone(), } } @@ -119,9 +125,25 @@ pub(crate) fn add_or_update_node( pub(crate) fn update_channel( gossip_sync: &P2PGossipSync>>, Arc, Arc>, - secp_ctx: &Secp256k1, node_privkey: &SecretKey, update: UnsignedChannelUpdate + secp_ctx: &Secp256k1, node_privkey: &SecretKey, mut update: UnsignedChannelUpdate ) { let node_pubkey = PublicKey::from_secret_key(&secp_ctx, node_privkey); + let node_id = NodeId::from_pubkey(&node_pubkey); + + // `channel_announcement` may have swapped the node order to satisfy the spec's sorted node_ids + // requirement, so override `channel_flags` bit 0 to match the actual node position recorded in + // the network graph. + { + let network_graph = gossip_sync.network_graph().read_only(); + if let Some(channel) = network_graph.channel(update.short_channel_id) { + if channel.node_one == node_id { + update.channel_flags &= !1; + } else { + update.channel_flags |= 1; + } + } + } + let msghash = hash_to_message!(&Sha256dHash::hash(&update.encode()[..])[..]); let valid_channel_update = ChannelUpdate { signature: secp_ctx.sign_ecdsa(&msghash, node_privkey), From 89e9b75b7deeb89911ce5538b854b6bbea779a06 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 13 May 2026 21:41:18 +0000 Subject: [PATCH 429/627] Validate `next_splice_out_maximum_sat` on both commitments Wilmer's fuzzing runs caught a case where an advertised splice out maximum hit the debug assertions in `get_next_splice_out_maximum`. These debug assertions ensure that any adverstised splice out maximum passes the validation of splice contributions. The core issue is that we only read the local commitment when calculating the splice out maximum, but our splice validation requires that any splice out maximum is covered by the minimum of the holder's balances on the local and the remote commitments. Therefore, if a HTLC is dust on the local commitment, but non-dust on the remote commitment, and the holder is the funder of the channel, we advertise a splice out maximum that is not covered by the holder's balance on the remote commitment, and fails our validation of splice contributions. We now read both commitments when calculating the next splice out maximum, which fixes this issue. --- lightning/src/ln/channel.rs | 3 + lightning/src/ln/splicing_tests.rs | 172 +++++++++++++++++++++++++++++ lightning/src/sign/tx_builder.rs | 73 +++++++----- 3 files changed, 220 insertions(+), 28 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 50c395df658..bb721c74d1f 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2554,6 +2554,9 @@ pub(super) struct FundingScope { value_to_self_msat: u64, // Excluding all pending_htlcs, fees, and anchor outputs /// minimum channel reserve for self to maintain - set by them. + #[cfg(any(test, feature = "_externalize_tests"))] + pub(super) counterparty_selected_channel_reserve_satoshis: Option, + #[cfg(not(any(test, feature = "_externalize_tests")))] counterparty_selected_channel_reserve_satoshis: Option, #[cfg(any(test, feature = "_externalize_tests"))] diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 35c72509d0b..5e02c42b19b 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -9304,3 +9304,175 @@ fn do_test_splice_out_initiator_reserve_breach_zero_fee_commitments( acceptor.logger.assert_log("lightning::ln::channelmanager", cannot_splice_out, 1); } } + +#[test] +fn test_splice_out_maximum_on_both_commitments_dust_on_fundee_commitment() { + use crate::ln::htlc_reserve_unit_tests::setup_0reserve_no_outputs_channels; + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut config = test_default_channel_config(); + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + const CHANNEL_VALUE_SAT: u64 = 100_000; + const FEERATE: u32 = 253; + const TOTAL_ANCHORS_SAT: u64 = 2 * 330; + const NODE_0_DUST_LIMIT_SAT: u64 = 354; + const NODE_1_DUST_LIMIT_SAT: u64 = 10_000; + + let (channel_id, _transaction) = + setup_0reserve_no_outputs_channels(&nodes, CHANNEL_VALUE_SAT, NODE_0_DUST_LIMIT_SAT); + + { + let per_peer_state_lock; + let mut peer_state_lock; + let chan = + get_channel_ref!(nodes[0], nodes[1], per_peer_state_lock, peer_state_lock, channel_id); + chan.context_mut().counterparty_dust_limit_satoshis = NODE_1_DUST_LIMIT_SAT; + assert_eq!(chan.context().holder_dust_limit_satoshis, NODE_0_DUST_LIMIT_SAT); + assert_eq!(chan.funding().holder_selected_channel_reserve_satoshis, 0); + assert_eq!(chan.funding().counterparty_selected_channel_reserve_satoshis, Some(0)); + } + + { + let per_peer_state_lock; + let mut peer_state_lock; + let chan = + get_channel_ref!(nodes[1], nodes[0], per_peer_state_lock, peer_state_lock, channel_id); + chan.context_mut().holder_dust_limit_satoshis = NODE_1_DUST_LIMIT_SAT; + assert_eq!(chan.context().counterparty_dust_limit_satoshis, NODE_0_DUST_LIMIT_SAT); + assert_eq!(chan.funding().holder_selected_channel_reserve_satoshis, 0); + assert_eq!(chan.funding().counterparty_selected_channel_reserve_satoshis, Some(0)); + } + + let details = &nodes[0].node.list_channels()[0]; + let channel_type = details.channel_type.clone().unwrap(); + assert_eq!(channel_type, ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies()); + + // This HTLC is only present on node 0's commitment + const SNEAKY_HTLC_SAT: u64 = 5_000; + + let (_, payment_hash, ..) = route_payment(&nodes[0], &[&nodes[1]], SNEAKY_HTLC_SAT * 1000); + + let node_0_details = &nodes[0].node.list_channels()[0]; + let reserved_fee_sat = chan_utils::commit_tx_fee_sat(FEERATE, 0, &channel_type); + let expected_next_splice_out_maximum_sat = CHANNEL_VALUE_SAT + - SNEAKY_HTLC_SAT + - TOTAL_ANCHORS_SAT + - reserved_fee_sat + - NODE_1_DUST_LIMIT_SAT; + assert_eq!(node_0_details.next_splice_out_maximum_sat, expected_next_splice_out_maximum_sat); + let node_1_details = &nodes[1].node.list_channels()[0]; + assert_eq!(node_1_details.next_splice_out_maximum_sat, 0); + + fail_payment(&nodes[0], &[&nodes[1]], payment_hash); + + let details = &nodes[0].node.list_channels()[0]; + let reserved_fee_sat = chan_utils::commit_tx_fee_sat(FEERATE, 2, &channel_type); + let expected_available_capacity_sat = CHANNEL_VALUE_SAT - TOTAL_ANCHORS_SAT - reserved_fee_sat; + assert_eq!(details.next_outbound_htlc_limit_msat, expected_available_capacity_sat * 1000); + let node_0_payment_sat = expected_available_capacity_sat; + send_payment(&nodes[0], &[&nodes[1]], node_0_payment_sat * 1000); + + // Make sure the local output is now gone from node 1's commitment + assert!(TOTAL_ANCHORS_SAT + reserved_fee_sat < NODE_1_DUST_LIMIT_SAT); + + let details = &nodes[1].node.list_channels()[0]; + let expected_next_splice_out_maximum_sat = node_0_payment_sat - NODE_1_DUST_LIMIT_SAT; + assert_eq!(details.next_splice_out_maximum_sat, expected_next_splice_out_maximum_sat); + + let details = &nodes[0].node.list_channels()[0]; + let reserved_fee_sat = chan_utils::commit_tx_fee_sat(FEERATE, 1, &channel_type); + let expected_next_splice_out_maximum_sat = + CHANNEL_VALUE_SAT - node_0_payment_sat - TOTAL_ANCHORS_SAT - reserved_fee_sat; + assert_eq!(details.next_splice_out_maximum_sat, expected_next_splice_out_maximum_sat); +} + +#[test] +fn test_splice_out_maximum_on_both_commitments_dust_on_funder_commitment() { + use crate::ln::htlc_reserve_unit_tests::setup_0reserve_no_outputs_channels; + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut config = test_default_channel_config(); + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + const CHANNEL_VALUE_SAT: u64 = 100_000; + const FEERATE: u32 = 253; + const TOTAL_ANCHORS_SAT: u64 = 2 * 330; + const NODE_0_DUST_LIMIT_SAT: u64 = 10_000; + const NODE_1_DUST_LIMIT_SAT: u64 = 354; + + let (channel_id, _transaction) = + setup_0reserve_no_outputs_channels(&nodes, CHANNEL_VALUE_SAT, NODE_1_DUST_LIMIT_SAT); + + { + let per_peer_state_lock; + let mut peer_state_lock; + let chan = + get_channel_ref!(nodes[0], nodes[1], per_peer_state_lock, peer_state_lock, channel_id); + chan.context_mut().holder_dust_limit_satoshis = NODE_0_DUST_LIMIT_SAT; + assert_eq!(chan.context().counterparty_dust_limit_satoshis, NODE_1_DUST_LIMIT_SAT); + assert_eq!(chan.funding().holder_selected_channel_reserve_satoshis, 0); + assert_eq!(chan.funding().counterparty_selected_channel_reserve_satoshis, Some(0)); + } + + { + let per_peer_state_lock; + let mut peer_state_lock; + let chan = + get_channel_ref!(nodes[1], nodes[0], per_peer_state_lock, peer_state_lock, channel_id); + chan.context_mut().counterparty_dust_limit_satoshis = NODE_0_DUST_LIMIT_SAT; + assert_eq!(chan.context().holder_dust_limit_satoshis, NODE_1_DUST_LIMIT_SAT); + assert_eq!(chan.funding().holder_selected_channel_reserve_satoshis, 0); + assert_eq!(chan.funding().counterparty_selected_channel_reserve_satoshis, Some(0)); + } + + let details = &nodes[0].node.list_channels()[0]; + let channel_type = details.channel_type.clone().unwrap(); + assert_eq!(channel_type, ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies()); + + // This HTLC is only present on node 1's commitment + const SNEAKY_HTLC_SAT: u64 = 5_000; + + let (_, payment_hash, ..) = route_payment(&nodes[0], &[&nodes[1]], SNEAKY_HTLC_SAT * 1000); + + let node_0_details = &nodes[0].node.list_channels()[0]; + let reserved_fee_sat = chan_utils::commit_tx_fee_sat(FEERATE, 0, &channel_type); + let expected_next_splice_out_maximum_sat = CHANNEL_VALUE_SAT + - SNEAKY_HTLC_SAT + - TOTAL_ANCHORS_SAT + - reserved_fee_sat + - NODE_0_DUST_LIMIT_SAT; + assert_eq!(node_0_details.next_splice_out_maximum_sat, expected_next_splice_out_maximum_sat); + let node_1_details = &nodes[1].node.list_channels()[0]; + assert_eq!(node_1_details.next_splice_out_maximum_sat, 0); + + fail_payment(&nodes[0], &[&nodes[1]], payment_hash); + + let details = &nodes[0].node.list_channels()[0]; + let reserved_fee_sat = chan_utils::commit_tx_fee_sat(FEERATE, 2, &channel_type); + let expected_available_capacity_sat = CHANNEL_VALUE_SAT - TOTAL_ANCHORS_SAT - reserved_fee_sat; + assert_eq!(details.next_outbound_htlc_limit_msat, expected_available_capacity_sat * 1000); + let node_0_payment_sat = expected_available_capacity_sat; + send_payment(&nodes[0], &[&nodes[1]], node_0_payment_sat * 1000); + + // Make sure the local output is now gone from node 0's commitment + assert!(TOTAL_ANCHORS_SAT + reserved_fee_sat < NODE_0_DUST_LIMIT_SAT); + + let details = &nodes[1].node.list_channels()[0]; + let expected_next_splice_out_maximum_sat = node_0_payment_sat - NODE_0_DUST_LIMIT_SAT; + assert_eq!(details.next_splice_out_maximum_sat, expected_next_splice_out_maximum_sat); + + let details = &nodes[0].node.list_channels()[0]; + let reserved_fee_sat = chan_utils::commit_tx_fee_sat(FEERATE, 1, &channel_type); + let expected_next_splice_out_maximum_sat = + CHANNEL_VALUE_SAT - node_0_payment_sat - TOTAL_ANCHORS_SAT - reserved_fee_sat; + assert_eq!(details.next_splice_out_maximum_sat, expected_next_splice_out_maximum_sat); +} diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index 6859d4dcf62..3a67eec1389 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -358,10 +358,18 @@ fn get_next_commitment_stats( // 3) s < (100h + 100 - 100d - c) / 99 fn get_next_splice_out_maximum_sat( is_outbound_from_holder: bool, channel_value_satoshis: u64, local_balance_before_fee_msat: u64, - remote_balance_before_fee_msat: u64, feerate_per_kw: u32, nondust_htlc_count: usize, - post_splice_delta_above_reserve_sat: u64, channel_constraints: &ChannelConstraints, - channel_type: &ChannelTypeFeatures, + remote_balance_before_fee_msat: u64, local_nondust_htlc_count: usize, + remote_nondust_htlc_count: usize, feerate_per_kw: u32, spiked_feerate: u32, + channel_constraints: &ChannelConstraints, channel_type: &ChannelTypeFeatures, ) -> u64 { + let post_splice_delta_above_reserve_sat = if is_outbound_from_holder { + let nondust_htlc_count = cmp::max(local_nondust_htlc_count, remote_nondust_htlc_count); + let commit_tx_fee_sat = + commit_tx_fee_sat(spiked_feerate, nondust_htlc_count + 1, channel_type); + commit_tx_fee_sat + } else { + 0 + }; let local_balance_before_fee_sat = local_balance_before_fee_msat / 1000; let mut next_splice_out_maximum_sat = if channel_constraints .counterparty_selected_channel_reserve_satoshis @@ -424,37 +432,47 @@ fn get_next_splice_out_maximum_sat( } max_splice_out_sat } else { - // In a zero-reserve channel, the holder is free to withdraw up to its `post_splice_delta_above_reserve_sat` + // In a zero-reserve channel, the holder is free to withdraw up to its `post_splice_delta_above_reserve_sat`. local_balance_before_fee_sat.saturating_sub(post_splice_delta_above_reserve_sat) }; - // We only bother to check the local commitment here, the counterparty will check its own commitment. - // // If the current `next_splice_out_maximum_sat` would produce a local commitment with no // outputs, bump this maximum such that, after the splice, the holder's balance covers at // least `dust_limit_satoshis` and, if they are the funder, `current_tx_fee_sat`. // We don't include an additional non-dust inbound HTLC in the `current_tx_fee_sat`, // because we don't mind if the holder dips below their dust limit to cover the fee for that // inbound non-dust HTLC. - if !has_output( - is_outbound_from_holder, - local_balance_before_fee_msat.saturating_sub(next_splice_out_maximum_sat * 1000), - remote_balance_before_fee_msat, - feerate_per_kw, - nondust_htlc_count, + // + // We use the regular feerate instead of the spiked feerate here as zero-reserve is not + // allowed on legacy channels. + let current_tx_fee_sat = commit_tx_fee_sat(feerate_per_kw, 0, channel_type); + let mut trim_splice_out_max_if_no_outputs = |nondust_htlc_count, dust_limit_satoshis| { + if !has_output( + is_outbound_from_holder, + local_balance_before_fee_msat.saturating_sub(next_splice_out_maximum_sat * 1000), + remote_balance_before_fee_msat, + feerate_per_kw, + nondust_htlc_count, + dust_limit_satoshis, + channel_type, + ) { + let min_balance_sat = if is_outbound_from_holder { + dust_limit_satoshis.saturating_add(current_tx_fee_sat) + } else { + dust_limit_satoshis + }; + next_splice_out_maximum_sat = + (local_balance_before_fee_msat / 1000).saturating_sub(min_balance_sat); + } + }; + trim_splice_out_max_if_no_outputs( + local_nondust_htlc_count, channel_constraints.holder_dust_limit_satoshis, - channel_type, - ) { - let dust_limit_satoshis = channel_constraints.holder_dust_limit_satoshis; - let current_tx_fee_sat = commit_tx_fee_sat(feerate_per_kw, 0, channel_type); - let min_balance_sat = if is_outbound_from_holder { - dust_limit_satoshis.saturating_add(current_tx_fee_sat) - } else { - dust_limit_satoshis - }; - next_splice_out_maximum_sat = - (local_balance_before_fee_msat / 1000).saturating_sub(min_balance_sat); - } + ); + trim_splice_out_max_if_no_outputs( + remote_nondust_htlc_count, + channel_constraints.counterparty_dust_limit_satoshis, + ); if channel_value_satoshis < next_splice_out_maximum_sat + MIN_CHANNEL_VALUE_SATOSHIS { next_splice_out_maximum_sat = @@ -568,11 +586,10 @@ fn get_available_balances( channel_value_satoshis, local_balance_before_fee_msat, remote_balance_before_fee_msat, - feerate_per_kw, - // The number of non-dust HTLCs on the local commitment at the current feerate local_nondust_htlc_count, - // The post-splice minimum balance of the holder - if is_outbound_from_holder { local_min_commit_tx_fee_sat } else { 0 }, + remote_nondust_htlc_count, + feerate_per_kw, + spiked_feerate, &channel_constraints, channel_type, ); From 69d2f08cfb189df16eaf7d8c78bd976163c3c500 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 13 May 2026 21:57:07 +0000 Subject: [PATCH 430/627] Break `get_available_balances` into small helper functions Most diffs here are code moves. --- lightning/src/sign/tx_builder.rs | 368 ++++++++++++++++++------------- 1 file changed, 220 insertions(+), 148 deletions(-) diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index 3a67eec1389..746f6d32b2e 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -482,6 +482,169 @@ fn get_next_splice_out_maximum_sat( next_splice_out_maximum_sat } +fn adjust_capacity_for_holder_reserved_fee(mut available_capacity_msat: u64, + local_nondust_htlc_count: usize, feerate_per_kw: u32, spiked_feerate: u32, + channel_constraints: &ChannelConstraints, channel_type: &ChannelTypeFeatures, +) -> u64 { + let (_real_htlc_success_tx_fee_sat, real_htlc_timeout_tx_fee_sat) = + second_stage_tx_fees_sat(channel_type, feerate_per_kw); + let fee_spike_buffer_htlc = 1; + // Note here we use the htlc count at the current feerate together with the spiked feerate; + // this makes sure that the holder can afford any fee bump between 1x to 2x from the current + // feerate. + let local_max_commit_tx_fee_sat = commit_tx_fee_sat( + spiked_feerate, + local_nondust_htlc_count + fee_spike_buffer_htlc + 1, + channel_type, + ); + let local_min_commit_tx_fee_sat = commit_tx_fee_sat( + spiked_feerate, + local_nondust_htlc_count + fee_spike_buffer_htlc, + channel_type, + ); + // We should mind channel commit tx fee when computing how much of the available capacity + // can be used in the next htlc. Mirrors the logic in send_htlc. + // + // The fee depends on whether the amount we will be sending is above dust or not, + // and the answer will in turn change the amount itself — making it a circular + // dependency. + // This complicates the computation around dust-values, up to the one-htlc-value. + + let real_dust_limit_timeout_sat = + real_htlc_timeout_tx_fee_sat + channel_constraints.holder_dust_limit_satoshis; + let max_reserved_commit_tx_fee_msat = local_max_commit_tx_fee_sat * 1000; + let min_reserved_commit_tx_fee_msat = local_min_commit_tx_fee_sat * 1000; + + // We will first subtract the fee as if we were above-dust. Then, if the resulting + // value ends up being below dust, we have this fee available again. In that case, + // match the value to right-below-dust. + let capacity_minus_max_commitment_fee_msat = + available_capacity_msat.saturating_sub(max_reserved_commit_tx_fee_msat); + if capacity_minus_max_commitment_fee_msat < real_dust_limit_timeout_sat * 1000 { + let capacity_minus_min_commitment_fee_msat = + available_capacity_msat.saturating_sub(min_reserved_commit_tx_fee_msat); + available_capacity_msat = cmp::min( + real_dust_limit_timeout_sat * 1000 - 1, + capacity_minus_min_commitment_fee_msat, + ); + } else { + available_capacity_msat = capacity_minus_max_commitment_fee_msat; + } + available_capacity_msat +} + +fn adjust_capacity_for_counterparty_reserved_fee(mut available_capacity_msat: u64, + remote_balance_before_fee_msat: u64, remote_nondust_htlc_count: usize, feerate_per_kw: u32, + channel_constraints: &ChannelConstraints, channel_type: &ChannelTypeFeatures +) -> u64 { + let (real_htlc_success_tx_fee_sat, _real_htlc_timeout_tx_fee_sat) = + second_stage_tx_fees_sat(channel_type, feerate_per_kw); + let remote_commit_tx_fee_sat = + commit_tx_fee_sat(feerate_per_kw, remote_nondust_htlc_count + 1, channel_type); + // If the channel is inbound (i.e. counterparty pays the fee), we need to make sure + // sending a new HTLC won't reduce their balance below our reserve threshold. + let real_dust_limit_success_sat = + real_htlc_success_tx_fee_sat + channel_constraints.counterparty_dust_limit_satoshis; + let max_reserved_commit_tx_fee_msat = remote_commit_tx_fee_sat * 1000; + + let holder_selected_chan_reserve_msat = + channel_constraints.holder_selected_channel_reserve_satoshis * 1000; + if remote_balance_before_fee_msat + < max_reserved_commit_tx_fee_msat + holder_selected_chan_reserve_msat + { + // If another HTLC's fee would reduce the remote's balance below the reserve limit + // we've selected for them, we can only send dust HTLCs. + available_capacity_msat = + cmp::min(available_capacity_msat, real_dust_limit_success_sat * 1000 - 1); + } + available_capacity_msat +} + +fn adjust_min_max_htlc_for_dust_exposure( + pending_htlcs: &[HTLCAmountDirection], feerate_per_kw: u32, + dust_exposure_limiting_feerate: Option, max_dust_htlc_exposure_msat: u64, + channel_constraints: &ChannelConstraints, channel_type: &ChannelTypeFeatures, + mut available_capacity_msat: u64, +) -> (u64, u64, u64) { + let mut next_outbound_htlc_minimum_msat = channel_constraints.counterparty_htlc_minimum_msat; + + let (local_dust_exposure_msat, _) = get_dust_exposure_stats( + true, + pending_htlcs, + feerate_per_kw, + dust_exposure_limiting_feerate, + channel_constraints.holder_dust_limit_satoshis, + channel_type, + ); + let (remote_dust_exposure_msat, extra_htlc_remote_dust_exposure_msat) = get_dust_exposure_stats( + false, + pending_htlcs, + feerate_per_kw, + dust_exposure_limiting_feerate, + channel_constraints.counterparty_dust_limit_satoshis, + channel_type, + ); + + // If we get close to our maximum dust exposure, we end up in a situation where we can send + // between zero and the remaining dust exposure limit remaining OR above the dust limit. + // Because we cannot express this as a simple min/max, we prefer to tell the user they can + // send above the dust limit (as the router can always overpay to meet the dust limit). + let mut remaining_msat_below_dust_exposure_limit = None; + let mut dust_exposure_dust_limit_msat = 0; + + let dust_buffer_feerate = get_dust_buffer_feerate(feerate_per_kw); + let (buffer_htlc_success_tx_fee_sat, buffer_htlc_timeout_tx_fee_sat) = + second_stage_tx_fees_sat(channel_type, dust_buffer_feerate); + let buffer_dust_limit_success_sat = + buffer_htlc_success_tx_fee_sat + channel_constraints.counterparty_dust_limit_satoshis; + let buffer_dust_limit_timeout_sat = + buffer_htlc_timeout_tx_fee_sat + channel_constraints.holder_dust_limit_satoshis; + + if let Some(extra_htlc_remote_dust_exposure) = extra_htlc_remote_dust_exposure_msat { + if extra_htlc_remote_dust_exposure > max_dust_htlc_exposure_msat { + // If adding an extra HTLC would put us over the dust limit in total fees, we cannot + // send any non-dust HTLCs. + available_capacity_msat = + cmp::min(available_capacity_msat, buffer_dust_limit_success_sat * 1000); + } + } + + if remote_dust_exposure_msat.saturating_add(buffer_dust_limit_success_sat * 1000) + > max_dust_htlc_exposure_msat.saturating_add(1) + { + // Note that we don't use the `counterparty_tx_dust_exposure` (with + // `htlc_dust_exposure_msat`) here as it only applies to non-dust HTLCs. + remaining_msat_below_dust_exposure_limit = + Some(max_dust_htlc_exposure_msat.saturating_sub(remote_dust_exposure_msat)); + dust_exposure_dust_limit_msat = + cmp::max(dust_exposure_dust_limit_msat, buffer_dust_limit_success_sat * 1000); + } + + if local_dust_exposure_msat as i64 + buffer_dust_limit_timeout_sat as i64 * 1000 - 1 + > max_dust_htlc_exposure_msat.try_into().unwrap_or(i64::max_value()) + { + remaining_msat_below_dust_exposure_limit = Some(cmp::min( + remaining_msat_below_dust_exposure_limit.unwrap_or(u64::max_value()), + max_dust_htlc_exposure_msat.saturating_sub(local_dust_exposure_msat), + )); + dust_exposure_dust_limit_msat = + cmp::max(dust_exposure_dust_limit_msat, buffer_dust_limit_timeout_sat * 1000); + } + + if let Some(remaining_limit_msat) = remaining_msat_below_dust_exposure_limit { + if available_capacity_msat < dust_exposure_dust_limit_msat { + available_capacity_msat = cmp::min(available_capacity_msat, remaining_limit_msat); + } else { + next_outbound_htlc_minimum_msat = + cmp::max(next_outbound_htlc_minimum_msat, dust_exposure_dust_limit_msat); + } + } + + let dust_exposure_msat = cmp::max(local_dust_exposure_msat, remote_dust_exposure_msat); + + (next_outbound_htlc_minimum_msat, available_capacity_msat, dust_exposure_msat) +} + fn get_available_balances( is_outbound_from_holder: bool, channel_value_satoshis: u64, value_to_holder_msat: u64, pending_htlcs: &[HTLCAmountDirection], feerate_per_kw: u32, @@ -499,9 +662,6 @@ fn get_available_balances( // commitment, we have not ack'ed these removals yet, so we expect the counterparty to count them when // validating our own HTLC add. These HTLCs would also revert to `Committed` upon a disconnection. - let fee_spike_buffer_htlc = - if channel_type.supports_anchor_zero_fee_commitments() { 0 } else { 1 }; - // Note that the feerate is 0 in zero-fee commitment channels, so this statement is a noop let spiked_feerate = feerate_per_kw.saturating_mul(if !channel_type.supports_anchors_zero_fee_htlc_tx() { @@ -522,27 +682,6 @@ fn get_available_balances( }) .count(); - // Note here we use the htlc count at the current feerate together with the spiked feerate; - // this makes sure that the holder can afford any fee bump between 1x to 2x from the current - // feerate. - let local_max_commit_tx_fee_sat = commit_tx_fee_sat( - spiked_feerate, - local_nondust_htlc_count + fee_spike_buffer_htlc + 1, - channel_type, - ); - let local_min_commit_tx_fee_sat = commit_tx_fee_sat( - spiked_feerate, - local_nondust_htlc_count + fee_spike_buffer_htlc, - channel_type, - ); - let (local_dust_exposure_msat, _) = get_dust_exposure_stats( - true, - pending_htlcs, - feerate_per_kw, - dust_exposure_limiting_feerate, - channel_constraints.holder_dust_limit_satoshis, - channel_type, - ); let remote_nondust_htlc_count = pending_htlcs .iter() .filter(|htlc| { @@ -554,16 +693,6 @@ fn get_available_balances( ) }) .count(); - let remote_commit_tx_fee_sat = - commit_tx_fee_sat(feerate_per_kw, remote_nondust_htlc_count + 1, channel_type); - let (remote_dust_exposure_msat, extra_htlc_remote_dust_exposure_msat) = get_dust_exposure_stats( - false, - pending_htlcs, - feerate_per_kw, - dust_exposure_limiting_feerate, - channel_constraints.counterparty_dust_limit_satoshis, - channel_type, - ); let outbound_htlcs_value_msat: u64 = pending_htlcs.iter().filter_map(|htlc| htlc.outbound.then_some(htlc.amount_msat)).sum(); @@ -598,117 +727,39 @@ fn get_available_balances( .saturating_sub(channel_constraints.counterparty_selected_channel_reserve_satoshis * 1000); let mut available_capacity_msat = outbound_capacity_msat; - let (real_htlc_success_tx_fee_sat, real_htlc_timeout_tx_fee_sat) = - second_stage_tx_fees_sat(channel_type, feerate_per_kw); if is_outbound_from_holder { - // We should mind channel commit tx fee when computing how much of the available capacity - // can be used in the next htlc. Mirrors the logic in send_htlc. - // - // The fee depends on whether the amount we will be sending is above dust or not, - // and the answer will in turn change the amount itself — making it a circular - // dependency. - // This complicates the computation around dust-values, up to the one-htlc-value. - - let real_dust_limit_timeout_sat = - real_htlc_timeout_tx_fee_sat + channel_constraints.holder_dust_limit_satoshis; - let max_reserved_commit_tx_fee_msat = local_max_commit_tx_fee_sat * 1000; - let min_reserved_commit_tx_fee_msat = local_min_commit_tx_fee_sat * 1000; - - // We will first subtract the fee as if we were above-dust. Then, if the resulting - // value ends up being below dust, we have this fee available again. In that case, - // match the value to right-below-dust. - let capacity_minus_max_commitment_fee_msat = - available_capacity_msat.saturating_sub(max_reserved_commit_tx_fee_msat); - if capacity_minus_max_commitment_fee_msat < real_dust_limit_timeout_sat * 1000 { - let capacity_minus_min_commitment_fee_msat = - available_capacity_msat.saturating_sub(min_reserved_commit_tx_fee_msat); - available_capacity_msat = cmp::min( - real_dust_limit_timeout_sat * 1000 - 1, - capacity_minus_min_commitment_fee_msat, - ); - } else { - available_capacity_msat = capacity_minus_max_commitment_fee_msat; - } + available_capacity_msat = adjust_capacity_for_holder_reserved_fee( + available_capacity_msat, local_nondust_htlc_count, feerate_per_kw, + spiked_feerate, &channel_constraints, channel_type + ); } else { - // If the channel is inbound (i.e. counterparty pays the fee), we need to make sure - // sending a new HTLC won't reduce their balance below our reserve threshold. - let real_dust_limit_success_sat = - real_htlc_success_tx_fee_sat + channel_constraints.counterparty_dust_limit_satoshis; - let max_reserved_commit_tx_fee_msat = remote_commit_tx_fee_sat * 1000; - - let holder_selected_chan_reserve_msat = - channel_constraints.holder_selected_channel_reserve_satoshis * 1000; - if remote_balance_before_fee_msat - < max_reserved_commit_tx_fee_msat + holder_selected_chan_reserve_msat - { - // If another HTLC's fee would reduce the remote's balance below the reserve limit - // we've selected for them, we can only send dust HTLCs. - available_capacity_msat = - cmp::min(available_capacity_msat, real_dust_limit_success_sat * 1000 - 1); - } - } - - let mut next_outbound_htlc_minimum_msat = channel_constraints.counterparty_htlc_minimum_msat; - - // If we get close to our maximum dust exposure, we end up in a situation where we can send - // between zero and the remaining dust exposure limit remaining OR above the dust limit. - // Because we cannot express this as a simple min/max, we prefer to tell the user they can - // send above the dust limit (as the router can always overpay to meet the dust limit). - let mut remaining_msat_below_dust_exposure_limit = None; - let mut dust_exposure_dust_limit_msat = 0; - - let dust_buffer_feerate = get_dust_buffer_feerate(feerate_per_kw); - let (buffer_htlc_success_tx_fee_sat, buffer_htlc_timeout_tx_fee_sat) = - second_stage_tx_fees_sat(channel_type, dust_buffer_feerate); - let buffer_dust_limit_success_sat = - buffer_htlc_success_tx_fee_sat + channel_constraints.counterparty_dust_limit_satoshis; - let buffer_dust_limit_timeout_sat = - buffer_htlc_timeout_tx_fee_sat + channel_constraints.holder_dust_limit_satoshis; - - if let Some(extra_htlc_remote_dust_exposure) = extra_htlc_remote_dust_exposure_msat { - if extra_htlc_remote_dust_exposure > max_dust_htlc_exposure_msat { - // If adding an extra HTLC would put us over the dust limit in total fees, we cannot - // send any non-dust HTLCs. - available_capacity_msat = - cmp::min(available_capacity_msat, buffer_dust_limit_success_sat * 1000); - } - } - - if remote_dust_exposure_msat.saturating_add(buffer_dust_limit_success_sat * 1000) - > max_dust_htlc_exposure_msat.saturating_add(1) - { - // Note that we don't use the `counterparty_tx_dust_exposure` (with - // `htlc_dust_exposure_msat`) here as it only applies to non-dust HTLCs. - remaining_msat_below_dust_exposure_limit = - Some(max_dust_htlc_exposure_msat.saturating_sub(remote_dust_exposure_msat)); - dust_exposure_dust_limit_msat = - cmp::max(dust_exposure_dust_limit_msat, buffer_dust_limit_success_sat * 1000); - } - - if local_dust_exposure_msat as i64 + buffer_dust_limit_timeout_sat as i64 * 1000 - 1 - > max_dust_htlc_exposure_msat.try_into().unwrap_or(i64::max_value()) - { - remaining_msat_below_dust_exposure_limit = Some(cmp::min( - remaining_msat_below_dust_exposure_limit.unwrap_or(u64::max_value()), - max_dust_htlc_exposure_msat.saturating_sub(local_dust_exposure_msat), - )); - dust_exposure_dust_limit_msat = - cmp::max(dust_exposure_dust_limit_msat, buffer_dust_limit_timeout_sat * 1000); + available_capacity_msat = adjust_capacity_for_counterparty_reserved_fee( + available_capacity_msat, + remote_balance_before_fee_msat, + remote_nondust_htlc_count, + feerate_per_kw, + &channel_constraints, + channel_type + ) } - if let Some(remaining_limit_msat) = remaining_msat_below_dust_exposure_limit { - if available_capacity_msat < dust_exposure_dust_limit_msat { - available_capacity_msat = cmp::min(available_capacity_msat, remaining_limit_msat); - } else { - next_outbound_htlc_minimum_msat = - cmp::max(next_outbound_htlc_minimum_msat, dust_exposure_dust_limit_msat); - } - } + let (next_outbound_htlc_minimum_msat, mut available_capacity_msat, dust_exposure_msat) = + adjust_min_max_htlc_for_dust_exposure( + pending_htlcs, + feerate_per_kw, + dust_exposure_limiting_feerate, + max_dust_htlc_exposure_msat, + &channel_constraints, + channel_type, + available_capacity_msat, + ); available_capacity_msat = cmp::min( available_capacity_msat, - channel_constraints.counterparty_max_htlc_value_in_flight_msat - outbound_htlcs_value_msat, + channel_constraints + .counterparty_max_htlc_value_in_flight_msat + .saturating_sub(outbound_htlcs_value_msat), ); if pending_htlcs.iter().filter(|htlc| htlc.outbound).count() + 1 @@ -719,7 +770,38 @@ fn get_available_balances( // Now adjust our min and max size HTLC to make sure both the local and the remote commitments still have // at least one output at the current feerate. + let (next_outbound_htlc_minimum_msat, available_capacity_msat) = + adjust_min_max_htlc_if_max_dust_htlc_produces_no_output( + is_outbound_from_holder, + local_balance_before_fee_msat, + remote_balance_before_fee_msat, + local_nondust_htlc_count, + remote_nondust_htlc_count, + feerate_per_kw, + &channel_constraints, + channel_type, + next_outbound_htlc_minimum_msat, + available_capacity_msat, + ); + + crate::ln::channel::AvailableBalances { + inbound_capacity_msat: remote_balance_before_fee_msat + .saturating_sub(channel_constraints.holder_selected_channel_reserve_satoshis * 1000), + outbound_capacity_msat, + next_outbound_htlc_limit_msat: available_capacity_msat, + next_outbound_htlc_minimum_msat, + dust_exposure_msat, + next_splice_out_maximum_sat, + } +} +fn adjust_min_max_htlc_if_max_dust_htlc_produces_no_output( + is_outbound_from_holder: bool, local_balance_before_fee_msat: u64, + remote_balance_before_fee_msat: u64, local_nondust_htlc_count: usize, + remote_nondust_htlc_count: usize, feerate_per_kw: u32, + channel_constraints: &ChannelConstraints, channel_type: &ChannelTypeFeatures, + next_outbound_htlc_minimum_msat: u64, available_capacity_msat: u64, +) -> (u64, u64) { let (next_outbound_htlc_minimum_msat, available_capacity_msat) = adjust_boundaries_if_max_dust_htlc_produces_no_output( true, @@ -747,17 +829,7 @@ fn get_available_balances( next_outbound_htlc_minimum_msat, available_capacity_msat, ); - let dust_exposure_msat = cmp::max(local_dust_exposure_msat, remote_dust_exposure_msat); - - crate::ln::channel::AvailableBalances { - inbound_capacity_msat: remote_balance_before_fee_msat - .saturating_sub(channel_constraints.holder_selected_channel_reserve_satoshis * 1000), - outbound_capacity_msat, - next_outbound_htlc_limit_msat: available_capacity_msat, - next_outbound_htlc_minimum_msat, - dust_exposure_msat, - next_splice_out_maximum_sat, - } + (next_outbound_htlc_minimum_msat, available_capacity_msat) } fn adjust_boundaries_if_max_dust_htlc_produces_no_output( From 9fe1a362295c680d512fb324fc12f629a73bb176 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Sat, 16 May 2026 02:52:27 +0000 Subject: [PATCH 431/627] Validate reserved fees on both commitments The local and remote commitments may have different dust limits, which can cause each commitment to have a different transaction fee. Therefore when we reserve commitment transaction fees in `get_available_balances`, we must ensure that we read the maximum of the transaction fees on the local and the remote commitments. Otherwise, we may have not reserved enough fees to ensure that our next proposed channel state update is onside. --- lightning/src/ln/htlc_reserve_unit_tests.rs | 269 +++++++++++++++++++- lightning/src/sign/tx_builder.rs | 167 ++++++------ 2 files changed, 355 insertions(+), 81 deletions(-) diff --git a/lightning/src/ln/htlc_reserve_unit_tests.rs b/lightning/src/ln/htlc_reserve_unit_tests.rs index 86d98b78826..290ae18f6fc 100644 --- a/lightning/src/ln/htlc_reserve_unit_tests.rs +++ b/lightning/src/ln/htlc_reserve_unit_tests.rs @@ -1053,10 +1053,10 @@ pub fn test_chan_reserve_dust_inbound_htlcs_outbound_chan() { * 1000; create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100000, push_amt); - let (htlc_success_tx_fee_sat, _) = + let (_htlc_success_tx_fee_sat, htlc_timeout_tx_fee_sat) = second_stage_tx_fees_sat(&channel_type_features, feerate_per_kw); let dust_amt = crate::ln::channel::MIN_CHAN_DUST_LIMIT_SATOSHIS * 1000 - + htlc_success_tx_fee_sat * 1000 + + htlc_timeout_tx_fee_sat * 1000 - 1; // In the previous code, routing this dust payment would cause nodes[0] to perceive a channel // reserve violation even though it's a dust HTLC and therefore shouldn't count towards the @@ -3528,3 +3528,268 @@ fn test_fail_cannot_afford_dust_htlcs_at_spike_multiple_if_nondust_at_base_feera true, ); } + +#[xtest(feature = "_externalize_tests")] +fn test_available_balances_both_commitments_dust_on_funder_commitment() { + let mut config = test_default_channel_config(); + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; + + let channel_type = ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies(); + + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + const FEERATE: u32 = 253; + const TOTAL_ANCHORS_MSAT: u64 = 2 * 330_000; + const NODE_0_DUST_LIMIT_MSAT: u64 = 10_000 * 1000; + const NODE_1_DUST_LIMIT_MSAT: u64 = 354 * 1000; + const CHANNEL_VALUE_MSAT: u64 = 50_000 * 1000; + const NODE_0_VALUE_TO_SELF_MSAT: u64 = 25_000 * 1000; + const NODE_1_VALUE_TO_SELF_MSAT: u64 = 25_000 * 1000; + const NODE_0_SELECTED_CHANNEL_RESERVE_MSAT: u64 = 1_000 * 1_000; + const NODE_1_SELECTED_CHANNEL_RESERVE_MSAT: u64 = 10_000 * 1_000; + + let channel_id = create_announced_chan_between_nodes_with_value( + &nodes, + 0, + 1, + CHANNEL_VALUE_MSAT / 1000, + NODE_1_VALUE_TO_SELF_MSAT, + ) + .2; + assert_eq!(nodes[0].node.list_channels()[0].channel_type.as_ref().unwrap(), &channel_type); + + { + let per_peer_state_lock; + let mut peer_state_lock; + let chan = + get_channel_ref!(nodes[0], nodes[1], per_peer_state_lock, peer_state_lock, channel_id); + chan.context_mut().holder_dust_limit_satoshis = NODE_0_DUST_LIMIT_MSAT / 1000; + chan.funding_mut().counterparty_selected_channel_reserve_satoshis = + Some(NODE_1_SELECTED_CHANNEL_RESERVE_MSAT / 1000); + assert_eq!(chan.context().counterparty_dust_limit_satoshis, NODE_1_DUST_LIMIT_MSAT / 1000); + assert_eq!( + chan.funding().holder_selected_channel_reserve_satoshis, + NODE_0_SELECTED_CHANNEL_RESERVE_MSAT / 1000 + ); + } + + { + let per_peer_state_lock; + let mut peer_state_lock; + let chan = + get_channel_ref!(nodes[1], nodes[0], per_peer_state_lock, peer_state_lock, channel_id); + chan.context_mut().counterparty_dust_limit_satoshis = NODE_0_DUST_LIMIT_MSAT / 1000; + chan.funding_mut().holder_selected_channel_reserve_satoshis = + NODE_1_SELECTED_CHANNEL_RESERVE_MSAT / 1000; + assert_eq!(chan.context().holder_dust_limit_satoshis, NODE_1_DUST_LIMIT_MSAT / 1000); + assert_eq!( + chan.funding().counterparty_selected_channel_reserve_satoshis, + Some(NODE_0_SELECTED_CHANNEL_RESERVE_MSAT / 1000) + ); + } + + // This HTLC is only present on node 1's commitment + const SNEAKY_HTLC_MSAT: u64 = 5_000_000; + + route_payment(&nodes[1], &[&nodes[0]], SNEAKY_HTLC_MSAT); + + let node_1_details = &nodes[1].node.list_channels()[0]; + let expected_outbound_capacity_msat = + NODE_1_VALUE_TO_SELF_MSAT - SNEAKY_HTLC_MSAT - NODE_0_SELECTED_CHANNEL_RESERVE_MSAT; + assert_eq!(node_1_details.outbound_capacity_msat, expected_outbound_capacity_msat); + let expected_available_capacity_msat = expected_outbound_capacity_msat; + assert_eq!(node_1_details.next_outbound_htlc_limit_msat, expected_available_capacity_msat); + let expected_splice_out_max = + NODE_1_VALUE_TO_SELF_MSAT / 1000 - SNEAKY_HTLC_MSAT / 1000 - NODE_1_DUST_LIMIT_MSAT / 1000; + assert_eq!(node_1_details.next_splice_out_maximum_sat, expected_splice_out_max); + + let node_0_details = &nodes[0].node.list_channels()[0]; + let expected_outbound_capacity_msat = + NODE_0_VALUE_TO_SELF_MSAT - NODE_1_SELECTED_CHANNEL_RESERVE_MSAT - TOTAL_ANCHORS_MSAT; + assert_eq!(node_0_details.outbound_capacity_msat, expected_outbound_capacity_msat); + let expected_available_capacity_msat = + expected_outbound_capacity_msat - commit_tx_fee_sat(FEERATE, 3, &channel_type) * 1000; + assert_eq!(node_0_details.next_outbound_htlc_limit_msat, expected_available_capacity_msat); + let expected_splice_out_max = NODE_0_VALUE_TO_SELF_MSAT / 1000 + - TOTAL_ANCHORS_MSAT / 1000 + - commit_tx_fee_sat(FEERATE, 2, &channel_type) + - NODE_0_DUST_LIMIT_MSAT / 1000; + assert_eq!(node_0_details.next_splice_out_maximum_sat, expected_splice_out_max); + + let node_0_payment_msat = expected_available_capacity_msat; + send_payment(&nodes[0], &[&nodes[1]], node_0_payment_msat); + + route_payment(&nodes[1], &[&nodes[0]], SNEAKY_HTLC_MSAT); + route_payment(&nodes[1], &[&nodes[0]], SNEAKY_HTLC_MSAT); + + let node_0_details = &nodes[0].node.list_channels()[0]; + let expected_outbound_capacity_msat = NODE_0_VALUE_TO_SELF_MSAT + - node_0_payment_msat + - NODE_1_SELECTED_CHANNEL_RESERVE_MSAT + - TOTAL_ANCHORS_MSAT; + assert_eq!(node_0_details.outbound_capacity_msat, expected_outbound_capacity_msat); + assert_eq!( + node_0_details.outbound_capacity_msat, + commit_tx_fee_sat(FEERATE, 3, &channel_type) * 1000 + ); + assert_eq!(node_0_details.next_outbound_htlc_limit_msat, 0); + assert_eq!(node_0_details.next_splice_out_maximum_sat, 0); + + let node_1_details = &nodes[1].node.list_channels()[0]; + let expected_outbound_capacity_msat = NODE_1_VALUE_TO_SELF_MSAT + node_0_payment_msat + - 3 * SNEAKY_HTLC_MSAT + - NODE_0_SELECTED_CHANNEL_RESERVE_MSAT; + assert_eq!(node_1_details.outbound_capacity_msat, expected_outbound_capacity_msat); + let (_htlc_success_tx_fee_sat, htlc_timeout_tx_fee_sat) = + second_stage_tx_fees_sat(&channel_type, FEERATE); + let expected_available_capacity_msat = + (NODE_1_DUST_LIMIT_MSAT / 1000 + htlc_timeout_tx_fee_sat) * 1000 - 1; + assert_eq!(node_1_details.next_outbound_htlc_limit_msat, expected_available_capacity_msat); + let expected_splice_out_max = NODE_1_VALUE_TO_SELF_MSAT / 1000 + node_0_payment_msat / 1000 + - 3 * SNEAKY_HTLC_MSAT / 1000 + - NODE_1_DUST_LIMIT_MSAT / 1000; + assert_eq!(node_1_details.next_splice_out_maximum_sat, expected_splice_out_max); +} + +#[xtest(feature = "_externalize_tests")] +fn test_available_balances_both_commitments_dust_on_fundee_commitment() { + let mut config = test_default_channel_config(); + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; + + let channel_type = ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies(); + + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + const FEERATE: u32 = 253; + const TOTAL_ANCHORS_MSAT: u64 = 2 * 330_000; + const NODE_0_DUST_LIMIT_MSAT: u64 = 354 * 1000; + const NODE_1_DUST_LIMIT_MSAT: u64 = 10_000 * 1000; + const CHANNEL_VALUE_MSAT: u64 = 50_000 * 1000; + const NODE_0_VALUE_TO_SELF_MSAT: u64 = 25_000 * 1000; + const NODE_1_VALUE_TO_SELF_MSAT: u64 = 25_000 * 1000; + const NODE_0_SELECTED_CHANNEL_RESERVE_MSAT: u64 = 10_000 * 1_000; + const NODE_1_SELECTED_CHANNEL_RESERVE_MSAT: u64 = 1_000 * 1_000; + + let channel_id = create_announced_chan_between_nodes_with_value( + &nodes, + 0, + 1, + CHANNEL_VALUE_MSAT / 1000, + NODE_1_VALUE_TO_SELF_MSAT, + ) + .2; + assert_eq!(nodes[0].node.list_channels()[0].channel_type.as_ref().unwrap(), &channel_type); + + { + let per_peer_state_lock; + let mut peer_state_lock; + let chan = + get_channel_ref!(nodes[0], nodes[1], per_peer_state_lock, peer_state_lock, channel_id); + chan.context_mut().counterparty_dust_limit_satoshis = NODE_1_DUST_LIMIT_MSAT / 1000; + chan.funding_mut().holder_selected_channel_reserve_satoshis = + NODE_0_SELECTED_CHANNEL_RESERVE_MSAT / 1000; + assert_eq!(chan.context().holder_dust_limit_satoshis, NODE_0_DUST_LIMIT_MSAT / 1000); + assert_eq!( + chan.funding().counterparty_selected_channel_reserve_satoshis, + Some(NODE_1_SELECTED_CHANNEL_RESERVE_MSAT / 1000) + ); + } + + { + let per_peer_state_lock; + let mut peer_state_lock; + let chan = + get_channel_ref!(nodes[1], nodes[0], per_peer_state_lock, peer_state_lock, channel_id); + chan.context_mut().holder_dust_limit_satoshis = NODE_1_DUST_LIMIT_MSAT / 1000; + chan.funding_mut().counterparty_selected_channel_reserve_satoshis = + Some(NODE_0_SELECTED_CHANNEL_RESERVE_MSAT / 1000); + assert_eq!(chan.context().counterparty_dust_limit_satoshis, NODE_0_DUST_LIMIT_MSAT / 1000); + assert_eq!( + chan.funding().holder_selected_channel_reserve_satoshis, + NODE_1_SELECTED_CHANNEL_RESERVE_MSAT / 1000 + ); + } + + // This HTLC is only present on node 0's commitment + const SNEAKY_HTLC_MSAT: u64 = 5_000_000; + + route_payment(&nodes[1], &[&nodes[0]], SNEAKY_HTLC_MSAT); + + let node_1_details = &nodes[1].node.list_channels()[0]; + let expected_outbound_capacity_msat = + NODE_1_VALUE_TO_SELF_MSAT - SNEAKY_HTLC_MSAT - NODE_0_SELECTED_CHANNEL_RESERVE_MSAT; + assert_eq!(node_1_details.outbound_capacity_msat, expected_outbound_capacity_msat); + let expected_available_capacity_msat = expected_outbound_capacity_msat; + assert_eq!(node_1_details.next_outbound_htlc_limit_msat, expected_available_capacity_msat); + let expected_splice_out_max = + NODE_1_VALUE_TO_SELF_MSAT / 1000 - SNEAKY_HTLC_MSAT / 1000 - NODE_1_DUST_LIMIT_MSAT / 1000; + assert_eq!(node_1_details.next_splice_out_maximum_sat, expected_splice_out_max); + + let node_0_details = &nodes[0].node.list_channels()[0]; + + let expected_outbound_capacity_msat = + NODE_0_VALUE_TO_SELF_MSAT - NODE_1_SELECTED_CHANNEL_RESERVE_MSAT - TOTAL_ANCHORS_MSAT; + assert_eq!(node_0_details.outbound_capacity_msat, expected_outbound_capacity_msat); + + let expected_splice_out_max = NODE_0_VALUE_TO_SELF_MSAT / 1000 + - TOTAL_ANCHORS_MSAT / 1000 + - commit_tx_fee_sat(FEERATE, 2, &channel_type) + - NODE_0_DUST_LIMIT_MSAT / 1000; + assert_eq!(node_0_details.next_splice_out_maximum_sat, expected_splice_out_max); + + let expected_available_capacity_msat = + expected_outbound_capacity_msat - commit_tx_fee_sat(FEERATE, 3, &channel_type) * 1000; + assert_eq!(node_0_details.next_outbound_htlc_limit_msat, expected_available_capacity_msat); + + let node_0_payment_msat = expected_available_capacity_msat; + send_payment(&nodes[0], &[&nodes[1]], node_0_payment_msat); + + route_payment(&nodes[1], &[&nodes[0]], SNEAKY_HTLC_MSAT); + route_payment(&nodes[1], &[&nodes[0]], SNEAKY_HTLC_MSAT); + + let node_0_details = &nodes[0].node.list_channels()[0]; + let expected_outbound_capacity_msat = NODE_0_VALUE_TO_SELF_MSAT + - node_0_payment_msat + - NODE_1_SELECTED_CHANNEL_RESERVE_MSAT + - TOTAL_ANCHORS_MSAT; + assert_eq!(node_0_details.outbound_capacity_msat, expected_outbound_capacity_msat); + assert_eq!( + node_0_details.outbound_capacity_msat, + commit_tx_fee_sat(FEERATE, 3, &channel_type) * 1000 + ); + assert_eq!(node_0_details.next_outbound_htlc_limit_msat, 0); + + let local_balance_before_fee_sat = + NODE_0_VALUE_TO_SELF_MSAT / 1000 - node_0_payment_msat / 1000 - TOTAL_ANCHORS_MSAT / 1000; + let post_splice_delta_above_reserve = commit_tx_fee_sat(FEERATE, 4, &channel_type); + let divident_sat = local_balance_before_fee_sat * 100 + 100 + - (post_splice_delta_above_reserve * 100) + - CHANNEL_VALUE_MSAT / 1000; + let expected_splice_out_max = (divident_sat - 1) / 99; + assert_eq!(node_0_details.next_splice_out_maximum_sat, expected_splice_out_max); + + let node_1_details = &nodes[1].node.list_channels()[0]; + let expected_outbound_capacity_msat = NODE_1_VALUE_TO_SELF_MSAT + node_0_payment_msat + - 3 * SNEAKY_HTLC_MSAT + - NODE_0_SELECTED_CHANNEL_RESERVE_MSAT; + assert_eq!(node_1_details.outbound_capacity_msat, expected_outbound_capacity_msat); + let (htlc_success_tx_fee_sat, _htlc_timeout_tx_fee_sat) = + second_stage_tx_fees_sat(&channel_type, FEERATE); + let expected_available_capacity_msat = + (NODE_0_DUST_LIMIT_MSAT / 1000 + htlc_success_tx_fee_sat) * 1000 - 1; + assert_eq!(node_1_details.next_outbound_htlc_limit_msat, expected_available_capacity_msat); + let expected_splice_out_max = NODE_1_VALUE_TO_SELF_MSAT / 1000 + node_0_payment_msat / 1000 + - 3 * SNEAKY_HTLC_MSAT / 1000 + - NODE_1_DUST_LIMIT_MSAT / 1000; + assert_eq!(node_1_details.next_splice_out_maximum_sat, expected_splice_out_max); +} diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index 746f6d32b2e..8f699fc85aa 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -482,82 +482,87 @@ fn get_next_splice_out_maximum_sat( next_splice_out_maximum_sat } -fn adjust_capacity_for_holder_reserved_fee(mut available_capacity_msat: u64, - local_nondust_htlc_count: usize, feerate_per_kw: u32, spiked_feerate: u32, - channel_constraints: &ChannelConstraints, channel_type: &ChannelTypeFeatures, +fn adjust_capacity_for_holder_reserved_fee( + outbound_capacity_msat: u64, local_nondust_htlc_count: usize, remote_nondust_htlc_count: usize, + feerate_per_kw: u32, spiked_feerate: u32, channel_constraints: &ChannelConstraints, + channel_type: &ChannelTypeFeatures, ) -> u64 { - let (_real_htlc_success_tx_fee_sat, real_htlc_timeout_tx_fee_sat) = + let read_available_capacity = |nondust_htlc_count, htlc_dust_limit_sat| { + // Note here we use the htlc count at the current feerate together with the spiked feerate; + // this makes sure that the holder can afford any fee bump between 1x to 2x from the current + // feerate. + let max_commit_tx_fee_sat = + commit_tx_fee_sat(spiked_feerate, nondust_htlc_count + 2, channel_type); + let min_commit_tx_fee_sat = + commit_tx_fee_sat(spiked_feerate, nondust_htlc_count + 1, channel_type); + + // We should mind channel commit tx fee when computing how much of the available capacity + // can be used in the next htlc. Mirrors the logic in send_htlc. + // + // The fee depends on whether the amount we will be sending is above dust or not, + // and the answer will in turn change the amount itself — making it a circular + // dependency. + // This complicates the computation around dust-values, up to the one-htlc-value. + + // We will first subtract the fee as if we were above-dust. Then, if the resulting + // value ends up being below dust, we have this fee available again. In that case, + // match the value to right-below-dust. + let capacity_minus_max_commitment_fee_msat = + outbound_capacity_msat.saturating_sub(max_commit_tx_fee_sat * 1000); + if capacity_minus_max_commitment_fee_msat < htlc_dust_limit_sat * 1000 { + let capacity_minus_min_commitment_fee_msat = + outbound_capacity_msat.saturating_sub(min_commit_tx_fee_sat * 1000); + cmp::min(htlc_dust_limit_sat * 1000 - 1, capacity_minus_min_commitment_fee_msat) + } else { + capacity_minus_max_commitment_fee_msat + } + }; + + let (real_htlc_success_tx_fee_sat, real_htlc_timeout_tx_fee_sat) = second_stage_tx_fees_sat(channel_type, feerate_per_kw); - let fee_spike_buffer_htlc = 1; - // Note here we use the htlc count at the current feerate together with the spiked feerate; - // this makes sure that the holder can afford any fee bump between 1x to 2x from the current - // feerate. - let local_max_commit_tx_fee_sat = commit_tx_fee_sat( - spiked_feerate, - local_nondust_htlc_count + fee_spike_buffer_htlc + 1, - channel_type, + let available_capacity_on_local_commitment = read_available_capacity( + local_nondust_htlc_count, + channel_constraints.holder_dust_limit_satoshis + real_htlc_timeout_tx_fee_sat, ); - let local_min_commit_tx_fee_sat = commit_tx_fee_sat( - spiked_feerate, - local_nondust_htlc_count + fee_spike_buffer_htlc, - channel_type, + let available_capacity_on_remote_commitment = read_available_capacity( + remote_nondust_htlc_count, + channel_constraints.counterparty_dust_limit_satoshis + real_htlc_success_tx_fee_sat, ); - // We should mind channel commit tx fee when computing how much of the available capacity - // can be used in the next htlc. Mirrors the logic in send_htlc. - // - // The fee depends on whether the amount we will be sending is above dust or not, - // and the answer will in turn change the amount itself — making it a circular - // dependency. - // This complicates the computation around dust-values, up to the one-htlc-value. - - let real_dust_limit_timeout_sat = - real_htlc_timeout_tx_fee_sat + channel_constraints.holder_dust_limit_satoshis; - let max_reserved_commit_tx_fee_msat = local_max_commit_tx_fee_sat * 1000; - let min_reserved_commit_tx_fee_msat = local_min_commit_tx_fee_sat * 1000; - - // We will first subtract the fee as if we were above-dust. Then, if the resulting - // value ends up being below dust, we have this fee available again. In that case, - // match the value to right-below-dust. - let capacity_minus_max_commitment_fee_msat = - available_capacity_msat.saturating_sub(max_reserved_commit_tx_fee_msat); - if capacity_minus_max_commitment_fee_msat < real_dust_limit_timeout_sat * 1000 { - let capacity_minus_min_commitment_fee_msat = - available_capacity_msat.saturating_sub(min_reserved_commit_tx_fee_msat); - available_capacity_msat = cmp::min( - real_dust_limit_timeout_sat * 1000 - 1, - capacity_minus_min_commitment_fee_msat, - ); - } else { - available_capacity_msat = capacity_minus_max_commitment_fee_msat; - } - available_capacity_msat + cmp::min(available_capacity_on_local_commitment, available_capacity_on_remote_commitment) } -fn adjust_capacity_for_counterparty_reserved_fee(mut available_capacity_msat: u64, - remote_balance_before_fee_msat: u64, remote_nondust_htlc_count: usize, feerate_per_kw: u32, - channel_constraints: &ChannelConstraints, channel_type: &ChannelTypeFeatures +fn adjust_capacity_for_counterparty_reserved_fee( + outbound_capacity_msat: u64, remote_balance_before_fee_msat: u64, + local_nondust_htlc_count: usize, remote_nondust_htlc_count: usize, feerate_per_kw: u32, + channel_constraints: &ChannelConstraints, channel_type: &ChannelTypeFeatures, ) -> u64 { - let (real_htlc_success_tx_fee_sat, _real_htlc_timeout_tx_fee_sat) = + let read_available_capacity = |nondust_htlc_count, htlc_dust_limit_sat| { + let commit_tx_fee_sat = + commit_tx_fee_sat(feerate_per_kw, nondust_htlc_count + 1, channel_type); + // If the channel is inbound (i.e. counterparty pays the fee), we need to make sure + // sending a new HTLC won't reduce their balance below our reserve threshold. + if remote_balance_before_fee_msat + < commit_tx_fee_sat * 1000 + + channel_constraints.holder_selected_channel_reserve_satoshis * 1000 + { + // If another HTLC's fee would reduce the remote's balance below the reserve limit + // we've selected for them, we can only send dust HTLCs. + cmp::min(outbound_capacity_msat, htlc_dust_limit_sat * 1000 - 1) + } else { + outbound_capacity_msat + } + }; + let (real_htlc_success_tx_fee_sat, real_htlc_timeout_tx_fee_sat) = second_stage_tx_fees_sat(channel_type, feerate_per_kw); - let remote_commit_tx_fee_sat = - commit_tx_fee_sat(feerate_per_kw, remote_nondust_htlc_count + 1, channel_type); - // If the channel is inbound (i.e. counterparty pays the fee), we need to make sure - // sending a new HTLC won't reduce their balance below our reserve threshold. - let real_dust_limit_success_sat = - real_htlc_success_tx_fee_sat + channel_constraints.counterparty_dust_limit_satoshis; - let max_reserved_commit_tx_fee_msat = remote_commit_tx_fee_sat * 1000; - - let holder_selected_chan_reserve_msat = - channel_constraints.holder_selected_channel_reserve_satoshis * 1000; - if remote_balance_before_fee_msat - < max_reserved_commit_tx_fee_msat + holder_selected_chan_reserve_msat - { - // If another HTLC's fee would reduce the remote's balance below the reserve limit - // we've selected for them, we can only send dust HTLCs. - available_capacity_msat = - cmp::min(available_capacity_msat, real_dust_limit_success_sat * 1000 - 1); - } - available_capacity_msat + let available_capacity_on_local_commitment = read_available_capacity( + local_nondust_htlc_count, + channel_constraints.holder_dust_limit_satoshis + real_htlc_timeout_tx_fee_sat, + ); + let available_capacity_on_remote_commitment = read_available_capacity( + remote_nondust_htlc_count, + channel_constraints.counterparty_dust_limit_satoshis + real_htlc_success_tx_fee_sat, + ); + cmp::min(available_capacity_on_local_commitment, available_capacity_on_remote_commitment) } fn adjust_min_max_htlc_for_dust_exposure( @@ -726,23 +731,27 @@ fn get_available_balances( let outbound_capacity_msat = local_balance_before_fee_msat .saturating_sub(channel_constraints.counterparty_selected_channel_reserve_satoshis * 1000); - let mut available_capacity_msat = outbound_capacity_msat; - - if is_outbound_from_holder { - available_capacity_msat = adjust_capacity_for_holder_reserved_fee( - available_capacity_msat, local_nondust_htlc_count, feerate_per_kw, - spiked_feerate, &channel_constraints, channel_type - ); + let available_capacity_msat = if is_outbound_from_holder { + adjust_capacity_for_holder_reserved_fee( + outbound_capacity_msat, + local_nondust_htlc_count, + remote_nondust_htlc_count, + feerate_per_kw, + spiked_feerate, + &channel_constraints, + channel_type, + ) } else { - available_capacity_msat = adjust_capacity_for_counterparty_reserved_fee( - available_capacity_msat, + adjust_capacity_for_counterparty_reserved_fee( + outbound_capacity_msat, remote_balance_before_fee_msat, + local_nondust_htlc_count, remote_nondust_htlc_count, feerate_per_kw, &channel_constraints, - channel_type + channel_type, ) - } + }; let (next_outbound_htlc_minimum_msat, mut available_capacity_msat, dust_exposure_msat) = adjust_min_max_htlc_for_dust_exposure( From fad75054302aacfb5080a618e88a84e4175d042e Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Tue, 12 May 2026 15:26:32 -0700 Subject: [PATCH 432/627] Gate interactive commitment_signed on user approval during reestablish Interactive funding transactions must be approved by the user via `ChannelManager::funding_transaction_signed` prior to exchanging signatures for it. This ensures the user is able to cancel up until the very last point throughout the handshake. When this was done in 83b2d3e, we forgot the cover the reestablish cases, which we do here. --- lightning/src/ln/channel.rs | 46 ++++++++++--------- lightning/src/ln/splicing_tests.rs | 71 ++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 22 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 2341128c74d..1588da4a813 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -10581,30 +10581,32 @@ where self.context.expecting_peer_commitment_signed = true; } - // - if it has not received `tx_signatures` for that funding transaction: - // - if the `commitment_signed` bit is set in `retransmit_flags`: - if !session.has_received_tx_signatures() - && next_funding.should_retransmit(msgs::NextFundingFlag::CommitmentSigned) - { - // - MUST retransmit its `commitment_signed` for that funding transaction. - retransmit_funding_commit_sig = Some(next_funding.txid); - } + if !session.has_holder_witnesses() { + log_debug!(logger, "Waiting for funding transaction signatures to be provided"); + } else { + // - if it has not received `tx_signatures` for that funding transaction: + // - if the `commitment_signed` bit is set in `retransmit_flags`: + if !session.has_received_tx_signatures() + && next_funding.should_retransmit(msgs::NextFundingFlag::CommitmentSigned) + { + // - MUST retransmit its `commitment_signed` for that funding transaction. + retransmit_funding_commit_sig = Some(next_funding.txid); + } - // - if it has already received `commitment_signed` and it should sign first - // - MUST send its `tx_signatures` for that funding transaction. - // - // - if it has already received `tx_signatures` for that funding transaction: - // - MUST send its `tx_signatures` for that funding transaction. - if let Some(holder_tx_signatures) = session.holder_tx_signatures() { - if self.is_awaiting_monitor_update() { - log_debug!(logger, "Waiting for monitor update before providing funding transaction signatures"); - } else if self.context.signer_pending_funding { - log_debug!(logger, "Waiting for signer to provide counterparty commitment_signed before releasing funding transaction signatures"); - } else { - tx_signatures = Some(holder_tx_signatures); + // - if it has already received `commitment_signed` and it should sign first + // - MUST send its `tx_signatures` for that funding transaction. + // + // - if it has already received `tx_signatures` for that funding transaction: + // - MUST send its `tx_signatures` for that funding transaction. + if let Some(holder_tx_signatures) = session.holder_tx_signatures() { + if self.is_awaiting_monitor_update() { + log_debug!(logger, "Waiting for monitor update before providing funding transaction signatures"); + } else if self.context.signer_pending_funding { + log_debug!(logger, "Waiting for signer to provide counterparty commitment_signed before releasing funding transaction signatures"); + } else { + tx_signatures = Some(holder_tx_signatures); + } } - } else if !session.has_holder_witnesses() { - log_debug!(logger, "Waiting for funding transaction signatures to be provided"); } } else { // We'll just send a `tx_abort` here if we don't have a signing session for this channel diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 6e6af600faf..a8821e7c1c3 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -2676,6 +2676,77 @@ fn test_splice_locked_waits_for_channel_reestablish() { send_payment(&nodes[0], &[&nodes[1]], 1_000_000); } +#[test] +fn test_splice_reestablish_waits_for_holder_tx_signatures_before_commitment_signed() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let outputs = vec![TxOut { + value: Amount::from_sat(initial_channel_value_sat / 4), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]; + let initiator_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); + negotiate_splice_tx(&nodes[0], &nodes[1], channel_id, initiator_contribution); + + let signing_event = get_event!(nodes[0], Event::FundingTransactionReadyForSigning); + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + + // Drop the acceptor's initial `commitment_signed`. On reconnection, node 0's + // `channel_reestablish` should request it again, while node 1's `channel_reestablish` should + // not make node 0 retransmit a `commitment_signed` before holder transaction signatures are + // available. + let _ = get_htlc_update_msgs(&nodes[1], &node_id_0); + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + + let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); + reconnect_args.send_announcement_sigs = (true, true); + reconnect_args.send_interactive_tx_commit_sig = (true, false); + reconnect_nodes(reconnect_args); + + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + + let unsigned_transaction = if let Event::FundingTransactionReadyForSigning { + unsigned_transaction, + .. + } = signing_event + { + unsigned_transaction + } else { + panic!("Expected FundingTransactionReadyForSigning event"); + }; + let tx = nodes[0].wallet_source.sign_tx(unsigned_transaction).unwrap(); + nodes[0].node.funding_transaction_signed(&channel_id, &node_id_1, tx).unwrap(); + check_added_monitors(&nodes[0], 1); + + let initiator_commit_sig = get_htlc_update_msgs(&nodes[0], &node_id_1); + nodes[1] + .node + .handle_commitment_signed_batch_test(node_id_0, &initiator_commit_sig.commitment_signed); + check_added_monitors(&nodes[1], 1); + + let acceptor_tx_signatures = + get_event_msg!(nodes[1], MessageSendEvent::SendTxSignatures, node_id_0); + nodes[0].node.handle_tx_signatures(node_id_1, &acceptor_tx_signatures); + let initiator_tx_signatures = + get_event_msg!(nodes[0], MessageSendEvent::SendTxSignatures, node_id_1); + nodes[1].node.handle_tx_signatures(node_id_0, &initiator_tx_signatures); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); +} + #[test] fn test_splice_confirms_on_both_sides_while_disconnected() { // Regression test: when a splice transaction confirms on both sides while peers are From c30d6103f586b9a38135c6f595a596c1f8bf358f Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Wed, 25 Feb 2026 14:17:09 +0100 Subject: [PATCH 433/627] Return NotifyOption from process_pending_monitor_events Refactor process_pending_monitor_events to return a NotifyOption instead of a bool, allowing callers to distinguish between DoPersist, SkipPersistHandleEvents, and SkipPersistNoEvents. Both call sites in process_events_body and get_and_clear_pending_msg_events are updated accordingly. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channelmanager.rs | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 1174ccf42c8..6d8dbe631be 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -3512,8 +3512,12 @@ macro_rules! process_events_body { // TODO: This behavior should be documented. It's unintuitive that we query // ChannelMonitors when clearing other events. - if $self.process_pending_monitor_events() { - result = NotifyOption::DoPersist; + match $self.process_pending_monitor_events() { + NotifyOption::DoPersist => result = NotifyOption::DoPersist, + NotifyOption::SkipPersistHandleEvents + if result == NotifyOption::SkipPersistNoEvents => + result = NotifyOption::SkipPersistHandleEvents, + _ => {}, } } @@ -13732,13 +13736,16 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ Ok(post_update_data) } - /// Process pending events from the [`chain::Watch`], returning whether any events were processed. - fn process_pending_monitor_events(&self) -> bool { + /// Process pending events from the [`chain::Watch`], returning the appropriate + /// [`NotifyOption`] for persistence and event handling. + fn process_pending_monitor_events(&self) -> NotifyOption { debug_assert!(self.total_consistency_lock.try_write().is_err()); // Caller holds read lock let mut failed_channels: Vec<(Result, _)> = Vec::new(); let mut pending_monitor_events = self.chain_monitor.release_pending_monitor_events(); - let has_pending_monitor_events = !pending_monitor_events.is_empty(); + if pending_monitor_events.is_empty() { + return NotifyOption::SkipPersistNoEvents; + } for (funding_outpoint, channel_id, mut monitor_events, counterparty_node_id) in pending_monitor_events.drain(..) { @@ -13862,7 +13869,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let _ = self.handle_error(err, counterparty_node_id); } - has_pending_monitor_events + NotifyOption::DoPersist } fn handle_holding_cell_free_result(&self, result: FreeHoldingCellsResult) { @@ -16001,8 +16008,6 @@ impl< fn get_and_clear_pending_msg_events(&self) -> Vec { let events = RefCell::new(Vec::new()); PersistenceNotifierGuard::optionally_notify(self, || { - let mut result = NotifyOption::SkipPersistNoEvents; - // This method is quite performance-sensitive. Not only is it called very often, but it // *is* the critical path between generating a message for a peer and giving it to the // `PeerManager` to send. Thus, we should avoid adding any more logic here than we @@ -16011,9 +16016,7 @@ impl< // TODO: This behavior should be documented. It's unintuitive that we query // ChannelMonitors when clearing other events. - if self.process_pending_monitor_events() { - result = NotifyOption::DoPersist; - } + let mut result = self.process_pending_monitor_events(); if self.maybe_generate_initial_closing_signed() { result = NotifyOption::DoPersist; From 52074601d0ba7f7a98c81a8572519ff772474c55 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Wed, 15 Apr 2026 13:43:12 +0200 Subject: [PATCH 434/627] Skip ChannelManager persistence for message-only monitor completions When process_pending_monitor_events processes only Completed events and the resulting work is limited to message-only monitor completion handling, ChannelManager persistence can be skipped. Completion handling now reports whether it actually mutated ChannelManager state, and process_pending_monitor_events uses that to decide between SkipPersistHandleEvents and DoPersist. --- lightning/src/ln/channel.rs | 32 +++++++--- lightning/src/ln/channelmanager.rs | 99 ++++++++++++++++++++---------- 2 files changed, 93 insertions(+), 38 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 2341128c74d..8b0e9708861 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -1236,6 +1236,9 @@ pub(super) struct MonitorRestoreUpdates { /// (the outbound edge), along with their outbound amounts. Useful to store in the inbound HTLC /// to ensure it gets resolved. pub committed_outbound_htlc_sources: Vec<(HTLCPreviousHopData, u64)>, + /// Whether the restoration changed serialized channel state that needs ChannelManager + /// persistence. + pub requires_channel_manager_persistence: bool, } /// The return value of `signer_maybe_unblocked` @@ -9860,6 +9863,9 @@ where assert!(self.context.channel_state.is_monitor_update_in_progress()); self.context.channel_state.clear_monitor_update_in_progress(); assert_eq!(self.blocked_monitor_updates_pending(), 0); + // Some cases below may not strictly require ChannelManager persistence, but we err on + // the conservative side to avoid missing state changes. + let mut requires_channel_manager_persistence = false; // We want to clear that the monitor update for our `tx_signatures` has completed, but // we may still need to hold back the message until it's ready to be sent. @@ -9887,6 +9893,7 @@ where splice_negotiated: None, splice_locked: None, }); + requires_channel_manager_persistence = true; if let Some(funding_tx) = signing_session.signed_tx() { self.on_tx_signatures_exchange( funding_tx_signed.as_mut().unwrap(), @@ -9911,7 +9918,8 @@ where { // Broadcast only if not yet confirmed if self.funding.get_funding_tx_confirmation_height().is_none() { - funding_broadcastable = Some(funding_transaction.clone()) + funding_broadcastable = Some(funding_transaction.clone()); + requires_channel_manager_persistence = true; } } } @@ -9937,20 +9945,27 @@ where assert!(!self.funding.is_outbound() || self.context.minimum_depth == Some(0), "Funding transaction broadcast by the local client before it should have - LDK didn't do it!"); self.context.monitor_pending_channel_ready = false; - self.get_channel_ready(logger) + let channel_ready = self.get_channel_ready(logger); + requires_channel_manager_persistence |= channel_ready.is_some(); + channel_ready } else { None }; let announcement_sigs = self.get_announcement_sigs(node_signer, chain_hash, user_config, best_block_height, logger); + requires_channel_manager_persistence |= announcement_sigs.is_some(); let mut accepted_htlcs = Vec::new(); mem::swap(&mut accepted_htlcs, &mut self.context.monitor_pending_forwards); + requires_channel_manager_persistence |= !accepted_htlcs.is_empty(); let mut failed_htlcs = Vec::new(); mem::swap(&mut failed_htlcs, &mut self.context.monitor_pending_failures); + requires_channel_manager_persistence |= !failed_htlcs.is_empty(); let mut finalized_claimed_htlcs = Vec::new(); mem::swap(&mut finalized_claimed_htlcs, &mut self.context.monitor_pending_finalized_fulfills); + requires_channel_manager_persistence |= !finalized_claimed_htlcs.is_empty(); let mut pending_update_adds = Vec::new(); mem::swap(&mut pending_update_adds, &mut self.context.monitor_pending_update_adds); - let committed_outbound_htlc_sources = self.context.pending_outbound_htlcs.iter().filter_map(|htlc| { + requires_channel_manager_persistence |= !pending_update_adds.is_empty(); + let committed_outbound_htlc_sources: Vec<(HTLCPreviousHopData, u64)> = self.context.pending_outbound_htlcs.iter().filter_map(|htlc| { if let &OutboundHTLCState::LocalAnnounced(_) = &htlc.state { if let HTLCSource::PreviousHopData(prev_hop_data) = &htlc.source { return Some((prev_hop_data.clone(), htlc.amount_msat)) @@ -9958,6 +9973,7 @@ where } None }).collect(); + requires_channel_manager_persistence |= !committed_outbound_htlc_sources.is_empty(); if self.context.channel_state.is_peer_disconnected() { self.context.monitor_pending_revoke_and_ack = false; @@ -9965,8 +9981,9 @@ where return MonitorRestoreUpdates { raa: None, commitment_update: None, commitment_order: RAACommitmentOrder::RevokeAndACKFirst, accepted_htlcs, failed_htlcs, finalized_claimed_htlcs, pending_update_adds, - funding_broadcastable, channel_ready, announcement_sigs, funding_tx_signed, - channel_ready_order, committed_outbound_htlc_sources + funding_broadcastable, channel_ready, channel_ready_order, announcement_sigs, + funding_tx_signed, committed_outbound_htlc_sources, + requires_channel_manager_persistence, }; } @@ -9996,8 +10013,9 @@ where match commitment_order { RAACommitmentOrder::CommitmentFirst => "commitment", RAACommitmentOrder::RevokeAndACKFirst => "RAA"}); MonitorRestoreUpdates { raa, commitment_update, commitment_order, accepted_htlcs, failed_htlcs, finalized_claimed_htlcs, - pending_update_adds, funding_broadcastable, channel_ready, announcement_sigs, funding_tx_signed, - channel_ready_order, committed_outbound_htlc_sources + pending_update_adds, funding_broadcastable, channel_ready, channel_ready_order, + announcement_sigs, funding_tx_signed, committed_outbound_htlc_sources, + requires_channel_manager_persistence, } } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 6d8dbe631be..cf3e4a2d45d 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -1592,6 +1592,7 @@ enum PostMonitorUpdateChanResume { Blocked { update_actions: Vec }, /// Channel was fully unblocked and has been resumed. Contains remaining data to process. Unblocked { + needs_persist: bool, channel_id: ChannelId, counterparty_node_id: PublicKey, funding_txo: OutPoint, @@ -4233,7 +4234,7 @@ impl< ) { mem::drop(peer_state_lock); mem::drop(per_peer_state); - self.handle_post_monitor_update_chan_resume(data); + let _ = self.handle_post_monitor_update_chan_resume(data); } } } else { @@ -4362,7 +4363,7 @@ impl< ) { mem::drop(peer_state_lock); mem::drop(per_peer_state); - self.handle_post_monitor_update_chan_resume(data); + let _ = self.handle_post_monitor_update_chan_resume(data); } return; } else { @@ -4437,7 +4438,7 @@ impl< // TODO: If we do the `in_flight_monitor_updates.is_empty()` check in // `convert_channel_err` we can skip the locks here. if shutdown_res.channel_funding_txo.is_some() { - self.channel_monitor_updated( + let _ = self.channel_monitor_updated( &shutdown_res.channel_id, None, &shutdown_res.counterparty_node_id, @@ -5556,7 +5557,7 @@ impl< if let Some(data) = completion_data { mem::drop(peer_state_lock); mem::drop(per_peer_state); - self.handle_post_monitor_update_chan_resume(data); + let _ = self.handle_post_monitor_update_chan_resume(data); } if !update_completed { // Note that MonitorUpdateInProgress here indicates (per function @@ -7076,7 +7077,7 @@ impl< if let Some(monitor_update_result) = monitor_update_result { match monitor_update_result { Ok(post_update_data) => { - self.handle_post_monitor_update_chan_resume(post_update_data); + let _ = self.handle_post_monitor_update_chan_resume(post_update_data); }, Err(_) => { let _ = self.handle_error(monitor_update_result, *counterparty_node_id); @@ -8787,7 +8788,7 @@ impl< // already been persisted to the monitor and can be applied to our internal // state such that the channel resumes operation if no new updates have been // made since. - self.channel_monitor_updated( + let _ = self.channel_monitor_updated( &channel_id, Some(highest_update_id_completed), &counterparty_node_id, @@ -9910,7 +9911,7 @@ impl< ) { mem::drop(peer_state_lock); mem::drop(per_peer_state); - self.handle_post_monitor_update_chan_resume(data); + let _ = self.handle_post_monitor_update_chan_resume(data); } }, UpdateFulfillCommitFetch::DuplicateClaim {} => { @@ -10753,7 +10754,11 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ /// /// If the channel has no more blocked monitor updates, this resumes normal operation by /// calling [`Self::handle_channel_resumption`] and returns the remaining work to process - /// after locks are released. If blocked updates remain, only the update actions are returned. + /// after locks are released. If blocked updates remain, only the update actions are returned + /// and the caller should persist if any are present. + /// + /// This method also determines whether the prepared work mutates `ChannelManager` state in a + /// way that should be persisted before returning control to the caller. /// /// Note: This method takes individual fields from [`PeerState`] rather than the whole struct /// to avoid borrow checker issues when the channel is borrowed from `peer_state.channel_by_id`. @@ -10815,6 +10820,12 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ None }; + let unbroadcasted_batch_funding_txid = + chan.context.unbroadcasted_batch_funding_txid(&chan.funding); + let mut needs_persist = updates.requires_channel_manager_persistence + || !update_actions.is_empty() + || unbroadcasted_batch_funding_txid.is_some(); + let (htlc_forwards, decode_update_add_htlcs) = self.handle_channel_resumption( pending_msg_events, chan, @@ -10830,6 +10841,8 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ None, updates.channel_ready_order, ); + needs_persist |= !htlc_forwards.is_empty(); + if let Some(upd) = channel_update { pending_msg_events.push(upd); } @@ -10838,10 +10851,8 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ self.push_decode_update_add_htlcs(update_adds); } - let unbroadcasted_batch_funding_txid = - chan.context.unbroadcasted_batch_funding_txid(&chan.funding); - PostMonitorUpdateChanResume::Unblocked { + needs_persist, channel_id: chan_id, counterparty_node_id, funding_txo: chan.funding_outpoint(), @@ -10931,7 +10942,13 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ /// Processes the [`PostMonitorUpdateChanResume`] returned by /// [`Self::try_resume_channel_post_monitor_update`], handling update actions and any /// remaining work that requires locks to be released (e.g., forwarding HTLCs, failing HTLCs). - fn handle_post_monitor_update_chan_resume(&self, data: PostMonitorUpdateChanResume) { + /// + /// Returns whether the completed work mutated `ChannelManager` state in a way that should be + /// persisted before returning control to the caller. In other words, this method executes the + /// prepared post-monitor-update work and reports whether the caller should treat monitor + /// completion as requiring `ChannelManager` persistence. + #[must_use = "callers must either persist when true or explicitly discard the result"] + fn handle_post_monitor_update_chan_resume(&self, data: PostMonitorUpdateChanResume) -> bool { debug_assert_ne!(self.per_peer_state.held_by_thread(), LockHeldState::HeldByThread); #[cfg(debug_assertions)] for (_, peer) in self.per_peer_state.read().unwrap().iter() { @@ -10940,9 +10957,12 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ match data { PostMonitorUpdateChanResume::Blocked { update_actions } => { + let needs_persist = !update_actions.is_empty(); self.handle_monitor_update_completion_actions(update_actions); + needs_persist }, PostMonitorUpdateChanResume::Unblocked { + needs_persist, channel_id, counterparty_node_id, funding_txo, @@ -10966,6 +10986,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ failed_htlcs, committed_outbound_htlc_sources, ); + needs_persist }, } } @@ -11163,13 +11184,14 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } #[rustfmt::skip] - fn channel_monitor_updated(&self, channel_id: &ChannelId, highest_applied_update_id: Option, counterparty_node_id: &PublicKey) { + #[must_use = "callers must either persist when true or explicitly discard the result"] + fn channel_monitor_updated(&self, channel_id: &ChannelId, highest_applied_update_id: Option, counterparty_node_id: &PublicKey) -> bool { debug_assert!(self.total_consistency_lock.try_write().is_err()); // Caller holds read lock let per_peer_state = self.per_peer_state.read().unwrap(); let mut peer_state_lock; let peer_state_mutex_opt = per_peer_state.get(counterparty_node_id); - if peer_state_mutex_opt.is_none() { return } + if peer_state_mutex_opt.is_none() { return false; } peer_state_lock = peer_state_mutex_opt.unwrap().lock().unwrap(); let peer_state = &mut *peer_state_lock; @@ -11201,7 +11223,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } else { 0 }; if remaining_in_flight != 0 { - return; + return false; } if let Some(chan) = peer_state.channel_by_id @@ -11222,10 +11244,12 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ mem::drop(peer_state_lock); mem::drop(per_peer_state); - self.handle_post_monitor_update_chan_resume(completion_data); + let needs_persist = self.handle_post_monitor_update_chan_resume(completion_data); self.handle_holding_cell_free_result(holding_cell_res); + needs_persist } else { log_trace!(logger, "Channel is open but not awaiting update"); + false } } else { let update_actions = peer_state.monitor_update_blocked_actions @@ -11233,7 +11257,12 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ log_trace!(logger, "Channel is closed, applying {} post-update actions", update_actions.len()); mem::drop(peer_state_lock); mem::drop(per_peer_state); - self.handle_monitor_update_completion_actions(update_actions); + if !update_actions.is_empty() { + self.handle_monitor_update_completion_actions(update_actions); + true + } else { + false + } } } @@ -11794,7 +11823,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ ) { mem::drop(peer_state_lock); mem::drop(per_peer_state); - self.handle_post_monitor_update_chan_resume(data); + let _ = self.handle_post_monitor_update_chan_resume(data); } } else { unreachable!("This must be a funded channel as we just inserted it."); @@ -11964,7 +11993,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ ) { mem::drop(peer_state_lock); mem::drop(per_peer_state); - self.handle_post_monitor_update_chan_resume(data); + let _ = self.handle_post_monitor_update_chan_resume(data); } Ok(()) }, @@ -12522,7 +12551,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ ) { mem::drop(peer_state_lock); mem::drop(per_peer_state); - self.handle_post_monitor_update_chan_resume(data); + let _ = self.handle_post_monitor_update_chan_resume(data); } } }, @@ -12858,7 +12887,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ ) { mem::drop(peer_state_lock); mem::drop(per_peer_state); - self.handle_post_monitor_update_chan_resume(data); + let _ = self.handle_post_monitor_update_chan_resume(data); } } else { let logger = @@ -12881,7 +12910,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ ) { mem::drop(peer_state_lock); mem::drop(per_peer_state); - self.handle_post_monitor_update_chan_resume(data); + let _ = self.handle_post_monitor_update_chan_resume(data); } } } @@ -12924,7 +12953,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ ) { mem::drop(peer_state_lock); mem::drop(per_peer_state); - self.handle_post_monitor_update_chan_resume(data); + let _ = self.handle_post_monitor_update_chan_resume(data); } } } @@ -13043,7 +13072,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ ) { mem::drop(peer_state_lock); mem::drop(per_peer_state); - self.handle_post_monitor_update_chan_resume(data); + let _ = self.handle_post_monitor_update_chan_resume(data); } } (htlcs_to_fail, static_invoices) @@ -13395,7 +13424,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }; if let Some(data) = post_splice_locked_update { - self.handle_post_monitor_update_chan_resume(data); + let _ = self.handle_post_monitor_update_chan_resume(data); } self.handle_holding_cell_free_result(holding_cell_res); @@ -13659,7 +13688,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ mem::drop(per_peer_state); if let Some(data) = post_update_data { - self.handle_post_monitor_update_chan_resume(data); + let _ = self.handle_post_monitor_update_chan_resume(data); } Ok(()) @@ -13746,12 +13775,14 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ if pending_monitor_events.is_empty() { return NotifyOption::SkipPersistNoEvents; } + let mut needs_persist = false; for (funding_outpoint, channel_id, mut monitor_events, counterparty_node_id) in pending_monitor_events.drain(..) { for monitor_event in monitor_events.drain(..) { match monitor_event { MonitorEvent::HTLCEvent(htlc_update) => { + needs_persist = true; let logger = WithContext::from( &self.logger, Some(counterparty_node_id), @@ -13802,6 +13833,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }, MonitorEvent::HolderForceClosed(_) | MonitorEvent::HolderForceClosedWithInfo { .. } => { + needs_persist = true; let per_peer_state = self.per_peer_state.read().unwrap(); if let Some(peer_state_mutex) = per_peer_state.get(&counterparty_node_id) { let mut peer_state_lock = peer_state_mutex.lock().unwrap(); @@ -13834,6 +13866,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } }, MonitorEvent::CommitmentTxConfirmed(_) => { + needs_persist = true; let per_peer_state = self.per_peer_state.read().unwrap(); if let Some(peer_state_mutex) = per_peer_state.get(&counterparty_node_id) { let mut peer_state_lock = peer_state_mutex.lock().unwrap(); @@ -13855,7 +13888,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } }, MonitorEvent::Completed { channel_id, monitor_update_id, .. } => { - self.channel_monitor_updated( + needs_persist |= self.channel_monitor_updated( &channel_id, Some(monitor_update_id), &counterparty_node_id, @@ -13869,7 +13902,11 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let _ = self.handle_error(err, counterparty_node_id); } - NotifyOption::DoPersist + if needs_persist { + NotifyOption::DoPersist + } else { + NotifyOption::SkipPersistHandleEvents + } } fn handle_holding_cell_free_result(&self, result: FreeHoldingCellsResult) { @@ -13879,7 +13916,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ ); for (chan_id, cp_node_id, post_update_data, failed_htlcs) in result { if let Some(data) = post_update_data { - self.handle_post_monitor_update_chan_resume(data); + let _ = self.handle_post_monitor_update_chan_resume(data); } self.fail_holding_cell_htlcs(failed_htlcs, chan_id, &cp_node_id); @@ -15568,7 +15605,7 @@ impl< mem::drop(per_peer_state); if let Some(data) = post_update_data { - self.handle_post_monitor_update_chan_resume(data); + let _ = self.handle_post_monitor_update_chan_resume(data); } self.handle_holding_cell_free_result(holding_cell_res); @@ -16510,7 +16547,7 @@ impl< } for (counterparty_node_id, channel_id) in to_process_monitor_update_actions { - self.channel_monitor_updated(&channel_id, None, &counterparty_node_id); + let _ = self.channel_monitor_updated(&channel_id, None, &counterparty_node_id); } if let Some(height) = height_opt { From bbeba3e2afdd94437acf6046dab8d1974b39255a Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Fri, 22 May 2026 10:46:32 +0200 Subject: [PATCH 435/627] Refactor chanmon payment tracker helpers Move node-local payment tracking mutations onto NodePayments. Pending and resolved payment state are updated the same way. The owner of that state now owns the helper methods. --- fuzz/src/chanmon_consistency.rs | 85 +++++++++++++++++---------------- 1 file changed, 43 insertions(+), 42 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 95874c340cd..39b166b8d29 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1485,6 +1485,42 @@ impl NodePayments { fn new() -> Self { Self { pending: Vec::new(), resolved: new_hash_map() } } + + fn add_pending(&mut self, payment_id: PaymentId) { + self.pending.push(payment_id); + } + + fn mark_sent(&mut self, sent_id: PaymentId, payment_hash: PaymentHash) { + let idx_opt = self.pending.iter().position(|id| *id == sent_id); + if let Some(idx) = idx_opt { + self.pending.remove(idx); + self.resolved.insert(sent_id, Some(payment_hash)); + } else { + assert!(self.resolved.contains_key(&sent_id)); + } + } + + fn mark_resolved_without_hash(&mut self, payment_id: PaymentId) { + let idx_opt = self.pending.iter().position(|id| *id == payment_id); + if let Some(idx) = idx_opt { + self.pending.remove(idx); + self.resolved.insert(payment_id, None); + } else if !self.resolved.contains_key(&payment_id) { + // Some resolutions can arrive immediately, before the send helper records + // the payment as pending. Track them so later duplicate events are accepted. + self.resolved.insert(payment_id, None); + } + } + + fn mark_successful_probe(&mut self, payment_id: PaymentId) { + let idx_opt = self.pending.iter().position(|id| *id == payment_id); + if let Some(idx) = idx_opt { + self.pending.remove(idx); + self.resolved.insert(payment_id, None); + } else { + assert!(self.resolved.contains_key(&payment_id)); + } + } } struct PaymentTracker { @@ -1590,7 +1626,7 @@ impl PaymentTracker { }, }; if succeeded { - self.nodes[source_idx].pending.push(id); + self.nodes[source_idx].add_pending(id); } succeeded } @@ -1667,7 +1703,7 @@ impl PaymentTracker { }, }; if succeeded { - self.nodes[source_idx].pending.push(id); + self.nodes[source_idx].add_pending(id); } } @@ -1736,7 +1772,7 @@ impl PaymentTracker { Ok(()) => Self::check_payment_send_events(source, id), }; if succeeded { - self.nodes[source_idx].pending.push(id); + self.nodes[source_idx].add_pending(id); } } @@ -1836,7 +1872,7 @@ impl PaymentTracker { Ok(()) => Self::check_payment_send_events(source, id), }; if succeeded { - self.nodes[source_idx].pending.push(id); + self.nodes[source_idx].add_pending(id); } } @@ -1853,41 +1889,6 @@ impl PaymentTracker { } } - fn mark_sent(&mut self, node_idx: usize, sent_id: PaymentId, payment_hash: PaymentHash) { - let node = &mut self.nodes[node_idx]; - let idx_opt = node.pending.iter().position(|id| *id == sent_id); - if let Some(idx) = idx_opt { - node.pending.remove(idx); - node.resolved.insert(sent_id, Some(payment_hash)); - } else { - assert!(node.resolved.contains_key(&sent_id)); - } - } - - fn mark_resolved_without_hash(&mut self, node_idx: usize, payment_id: PaymentId) { - let node = &mut self.nodes[node_idx]; - let idx_opt = node.pending.iter().position(|id| *id == payment_id); - if let Some(idx) = idx_opt { - node.pending.remove(idx); - node.resolved.insert(payment_id, None); - } else if !node.resolved.contains_key(&payment_id) { - // Some resolutions can arrive immediately, before the send helper records - // the payment as pending. Track them so later duplicate events are accepted. - node.resolved.insert(payment_id, None); - } - } - - fn mark_successful_probe(&mut self, node_idx: usize, payment_id: PaymentId) { - let node = &mut self.nodes[node_idx]; - let idx_opt = node.pending.iter().position(|id| *id == payment_id); - if let Some(idx) = idx_opt { - node.pending.remove(idx); - node.resolved.insert(payment_id, None); - } else { - assert!(node.resolved.contains_key(&payment_id)); - } - } - fn assert_all_resolved(&self) { for (idx, node) in self.nodes.iter().enumerate() { assert!( @@ -2725,17 +2726,17 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { } }, events::Event::PaymentSent { payment_id, payment_hash, .. } => { - payments.mark_sent(node_idx, payment_id.unwrap(), payment_hash); + payments.nodes[node_idx].mark_sent(payment_id.unwrap(), payment_hash); }, // Even though we don't explicitly send probes, because probes are detected based on // hashing the payment hash+preimage, it is rather trivial for the fuzzer to build // payments that accidentally end up looking like probes. events::Event::ProbeSuccessful { payment_id, .. } => { - payments.mark_successful_probe(node_idx, payment_id); + payments.nodes[node_idx].mark_successful_probe(payment_id); }, events::Event::PaymentFailed { payment_id, .. } | events::Event::ProbeFailed { payment_id, .. } => { - payments.mark_resolved_without_hash(node_idx, payment_id); + payments.nodes[node_idx].mark_resolved_without_hash(payment_id); }, events::Event::PaymentClaimed { .. } => {}, events::Event::PaymentPathSuccessful { .. } => {}, From c18fab55a83bde8ac038f4e29c12c845da54ef01 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Fri, 22 May 2026 11:05:28 +0200 Subject: [PATCH 436/627] Track chanmon payment persistence generations Stamp pending payments with the first manager generation. On deferred reload, drop payments born after the loaded snapshot. This keeps tracker state aligned with explicit persistence. --- fuzz/src/chanmon_consistency.rs | 88 ++++++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 13 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 39b166b8d29..7f03ceaa50c 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -819,6 +819,7 @@ struct HarnessNode<'a> { persistence_style: ChannelMonitorUpdateStatus, deferred: bool, serialized_manager: Vec, + serialized_manager_generation: u64, height: u32, last_htlc_clear_fee: u32, } @@ -917,6 +918,7 @@ impl<'a> HarnessNode<'a> { persistence_style, deferred, serialized_manager: Vec::new(), + serialized_manager_generation: 0, height: 0, last_htlc_clear_fee: 253, } @@ -976,6 +978,7 @@ impl<'a> HarnessNode<'a> { if self.node.get_and_clear_needs_persistence() { let pending_monitor_writes = self.monitor.pending_operation_count(); self.serialized_manager = self.node.encode(); + self.serialized_manager_generation += 1; if self.deferred { self.monitor.flush(pending_monitor_writes, &self.logger); } else { @@ -991,6 +994,7 @@ impl<'a> HarnessNode<'a> { fn force_checkpoint_manager_persistence(&mut self) { let pending_monitor_writes = self.monitor.pending_operation_count(); self.serialized_manager = self.node.encode(); + self.serialized_manager_generation += 1; self.node.get_and_clear_needs_persistence(); if self.deferred { self.monitor.flush(pending_monitor_writes, &self.logger); @@ -999,6 +1003,10 @@ impl<'a> HarnessNode<'a> { } } + fn next_manager_persistence_generation(&self) -> u64 { + self.serialized_manager_generation + 1 + } + fn bump_fee_estimate(&mut self, chan_type: ChanType) { let mut max_feerate = self.last_htlc_clear_fee; if matches!(chan_type, ChanType::Legacy) { @@ -1098,7 +1106,8 @@ impl<'a> HarnessNode<'a> { fn reload( &mut self, use_old_mons: u8, out: &Out, router: &'a FuzzRouter, chan_type: ChanType, - ) { + ) -> u64 { + let loaded_manager_generation = self.serialized_manager_generation; let logger = Self::build_logger(self.node_id, out); let persister = Self::build_persister(self.persistence_style); let chain_monitor = Self::build_chain_monitor( @@ -1170,6 +1179,7 @@ impl<'a> HarnessNode<'a> { // even if the reloaded ChannelManager does not need persistence. Always checkpoint here so // those registrations can be flushed against the manager snapshot they belong to. self.force_checkpoint_manager_persistence(); + loaded_manager_generation } } @@ -1476,8 +1486,14 @@ impl PeerLink { } } +struct PendingPayment { + payment_id: PaymentId, + payment_hash: PaymentHash, + first_persisted_manager_generation: u64, +} + struct NodePayments { - pending: Vec, + pending: Vec, resolved: HashMap>, } @@ -1486,12 +1502,19 @@ impl NodePayments { Self { pending: Vec::new(), resolved: new_hash_map() } } - fn add_pending(&mut self, payment_id: PaymentId) { - self.pending.push(payment_id); + fn add_pending( + &mut self, payment_id: PaymentId, payment_hash: PaymentHash, + first_persisted_manager_generation: u64, + ) { + self.pending.push(PendingPayment { + payment_id, + payment_hash, + first_persisted_manager_generation, + }); } fn mark_sent(&mut self, sent_id: PaymentId, payment_hash: PaymentHash) { - let idx_opt = self.pending.iter().position(|id| *id == sent_id); + let idx_opt = self.pending.iter().position(|pending| pending.payment_id == sent_id); if let Some(idx) = idx_opt { self.pending.remove(idx); self.resolved.insert(sent_id, Some(payment_hash)); @@ -1501,7 +1524,7 @@ impl NodePayments { } fn mark_resolved_without_hash(&mut self, payment_id: PaymentId) { - let idx_opt = self.pending.iter().position(|id| *id == payment_id); + let idx_opt = self.pending.iter().position(|pending| pending.payment_id == payment_id); if let Some(idx) = idx_opt { self.pending.remove(idx); self.resolved.insert(payment_id, None); @@ -1513,7 +1536,7 @@ impl NodePayments { } fn mark_successful_probe(&mut self, payment_id: PaymentId) { - let idx_opt = self.pending.iter().position(|id| *id == payment_id); + let idx_opt = self.pending.iter().position(|pending| pending.payment_id == payment_id); if let Some(idx) = idx_opt { self.pending.remove(idx); self.resolved.insert(payment_id, None); @@ -1521,6 +1544,21 @@ impl NodePayments { assert!(self.resolved.contains_key(&payment_id)); } } + + fn sync_pending_with_manager_generation( + &mut self, loaded_manager_generation: u64, + ) -> Vec { + let mut rolled_back_payment_hashes = Vec::new(); + let pending = mem::take(&mut self.pending); + for pending_payment in pending { + if pending_payment.first_persisted_manager_generation > loaded_manager_generation { + rolled_back_payment_hashes.push(pending_payment.payment_hash); + } else { + self.pending.push(pending_payment); + } + } + rolled_back_payment_hashes + } } struct PaymentTracker { @@ -1626,7 +1664,11 @@ impl PaymentTracker { }, }; if succeeded { - self.nodes[source_idx].add_pending(id); + self.nodes[source_idx].add_pending( + id, + hash, + source.next_manager_persistence_generation(), + ); } succeeded } @@ -1703,7 +1745,11 @@ impl PaymentTracker { }, }; if succeeded { - self.nodes[source_idx].add_pending(id); + self.nodes[source_idx].add_pending( + id, + hash, + source.next_manager_persistence_generation(), + ); } } @@ -1772,7 +1818,11 @@ impl PaymentTracker { Ok(()) => Self::check_payment_send_events(source, id), }; if succeeded { - self.nodes[source_idx].add_pending(id); + self.nodes[source_idx].add_pending( + id, + hash, + source.next_manager_persistence_generation(), + ); } } @@ -1872,7 +1922,11 @@ impl PaymentTracker { Ok(()) => Self::check_payment_send_events(source, id), }; if succeeded { - self.nodes[source_idx].add_pending(id); + self.nodes[source_idx].add_pending( + id, + hash, + source.next_manager_persistence_generation(), + ); } } @@ -2861,7 +2915,9 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { } fn restart_node(&mut self, node_idx: usize, v: u8, router: &'a FuzzRouter) { - self.nodes[node_idx].checkpoint_manager_persistence(); + if !self.nodes[node_idx].deferred { + self.nodes[node_idx].checkpoint_manager_persistence(); + } match node_idx { 0 => { self.ab_link.disconnect_for_reload(0, &self.nodes, &mut self.queues); @@ -2875,7 +2931,13 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { }, _ => panic!("invalid node index"), } - self.nodes[node_idx].reload(v, &self.out, router, self.chan_type); + let loaded_manager_generation = + self.nodes[node_idx].reload(v, &self.out, router, self.chan_type); + let rolled_back_payment_hashes = self.payments.nodes[node_idx] + .sync_pending_with_manager_generation(loaded_manager_generation); + for payment_hash in rolled_back_payment_hashes { + self.payments.claimed_payment_hashes.remove(&payment_hash); + } } fn settle_all(&mut self) { From 3855252239749fb4f6c81ae76b04b8123edff108 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Wed, 20 May 2026 20:04:06 +0000 Subject: [PATCH 437/627] Encrypt `payment_metadata` when we build the payment secret In 657ac8f58e51af74c610375cb65cdad6f7a18c6b we started committing to the `payment_metadata` in the `payment_secret`. We'd largely assumed that downstream code could simply encrypt the `payment_metadata` itself before passing it to `lightning` and decrypt before reading it from `lightning`. However, this presents a challenge - we'd very much love for that downstream code to avoid adding any extra bytes to its `payment_metadata` if at all possible, but it doesn't have a great way to get a decent IV without simply shoving it in the encrypted `payment_metadata`. Instead, here, we encrypt and decrypt the `payment_metadata` internally in `lightning`. This allows us to reuse the IV that is used for `lightning`-generated `payment_hash`es as the IV for the encrypted `payment_metadata` as well. Sadly, we don't have any similar IV for user-provided `payment_hash`es. In that case, we simply accept the limitations and document that users must avoid encrypting multiple `payment_metadata`s for payments with the same `payment_hash`. This avoids padding the size of the `payment_metadata` and should generally not be a material concern - `payment_hash` reuse should generally not exist anyway, and if it does it should only be in cases where its "the same payment" being retried after failure, at which point `payment_metadata` should hopefully be the same. --- fuzz/src/chanmon_consistency.rs | 2 +- .../tests/lsps2_integration_tests.rs | 2 +- lightning/src/crypto/utils.rs | 15 +- lightning/src/ln/bolt11_payment_tests.rs | 16 +- lightning/src/ln/channelmanager.rs | 58 +++-- lightning/src/ln/functional_test_utils.rs | 2 +- lightning/src/ln/functional_tests.rs | 29 +-- lightning/src/ln/inbound_payment.rs | 112 +++++++-- lightning/src/ln/invoice_utils.rs | 21 +- .../src/ln/max_payment_path_len_tests.rs | 53 ++--- lightning/src/ln/payment_tests.rs | 218 ++++++++++++++++-- 11 files changed, 403 insertions(+), 125 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 4ff0e4a4a03..72134abdad8 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1369,7 +1369,7 @@ impl PaymentTracker { let mut payment_preimage = PaymentPreimage([0; 32]); payment_preimage.0[0..8].copy_from_slice(&self.payment_ctr.to_be_bytes()); let hash = PaymentHash(Sha256::hash(&payment_preimage.0).to_byte_array()); - let secret = dest + let (secret, _no_metadata) = dest .create_inbound_payment_for_hash(hash, None, 3600, None, None) .expect("create_inbound_payment_for_hash failed"); assert!(self.payment_preimages.insert(hash, payment_preimage).is_none()); diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 92e6b33ebb6..d361215822c 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -120,7 +120,7 @@ fn create_jit_invoice( ) -> Result { // LSPS2 requires min_final_cltv_expiry_delta to be at least 2 more than usual. let min_final_cltv_expiry_delta = MIN_FINAL_CLTV_EXPIRY_DELTA + 2; - let (payment_hash, payment_secret) = node + let (payment_hash, payment_secret, _) = node .node .create_inbound_payment(None, expiry_secs, Some(min_final_cltv_expiry_delta), None) .map_err(|e| { diff --git a/lightning/src/crypto/utils.rs b/lightning/src/crypto/utils.rs index 88911b0baf8..749f7d423c0 100644 --- a/lightning/src/crypto/utils.rs +++ b/lightning/src/crypto/utils.rs @@ -22,7 +22,7 @@ macro_rules! hkdf_extract_expand { let (k1, k2, _) = hkdf_extract_expand!($salt, $ikm); (k1, k2) }}; - ($salt: expr, $ikm: expr, 7) => {{ + ($salt: expr, $ikm: expr, 8) => {{ let (k1, k2, prk) = hkdf_extract_expand!($salt, $ikm); let mut hmac = HmacEngine::::new(&prk[..]); @@ -50,7 +50,12 @@ macro_rules! hkdf_extract_expand { hmac.input(&[7; 1]); let k7 = Hmac::from_engine(hmac).to_byte_array(); - (k1, k2, k3, k4, k5, k6, k7) + let mut hmac = HmacEngine::::new(&prk[..]); + hmac.input(&k7); + hmac.input(&[8; 1]); + let k8 = Hmac::from_engine(hmac).to_byte_array(); + + (k1, k2, k3, k4, k5, k6, k7, k8) }}; } @@ -58,10 +63,10 @@ pub fn hkdf_extract_expand_twice(salt: &[u8], ikm: &[u8]) -> ([u8; 32], [u8; 32] hkdf_extract_expand!(salt, ikm, 2) } -pub fn hkdf_extract_expand_7x( +pub fn hkdf_extract_expand_8x( salt: &[u8], ikm: &[u8], -) -> ([u8; 32], [u8; 32], [u8; 32], [u8; 32], [u8; 32], [u8; 32], [u8; 32]) { - hkdf_extract_expand!(salt, ikm, 7) +) -> ([u8; 32], [u8; 32], [u8; 32], [u8; 32], [u8; 32], [u8; 32], [u8; 32], [u8; 32]) { + hkdf_extract_expand!(salt, ikm, 8) } #[inline] diff --git a/lightning/src/ln/bolt11_payment_tests.rs b/lightning/src/ln/bolt11_payment_tests.rs index 733e26d0f1b..3e0ebbbefc2 100644 --- a/lightning/src/ln/bolt11_payment_tests.rs +++ b/lightning/src/ln/bolt11_payment_tests.rs @@ -30,8 +30,10 @@ fn payment_metadata_end_to_end_for_invoice_with_amount() { let payment_metadata = vec![42, 43, 44, 45, 46, 47, 48, 49, 42]; - let (payment_hash, payment_secret) = - nodes[1].node.create_inbound_payment(None, 7200, None, Some(&payment_metadata)).unwrap(); + let (payment_hash, payment_secret, encrypted_metadata) = nodes[1] + .node + .create_inbound_payment(None, 7200, None, Some(payment_metadata.clone())) + .unwrap(); let timestamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(); let invoice = InvoiceBuilder::new(Currency::Bitcoin) @@ -41,7 +43,7 @@ fn payment_metadata_end_to_end_for_invoice_with_amount() { .duration_since_epoch(timestamp) .min_final_cltv_expiry_delta(144) .amount_milli_satoshis(50_000) - .payment_metadata(payment_metadata.clone()) + .payment_metadata(encrypted_metadata.unwrap()) .build_raw() .unwrap(); let sig = nodes[1].keys_manager.backing.sign_invoice(&invoice, Recipient::Node).unwrap(); @@ -97,8 +99,10 @@ fn payment_metadata_end_to_end_for_invoice_with_no_amount() { let payment_metadata = vec![42, 43, 44, 45, 46, 47, 48, 49, 42]; - let (payment_hash, payment_secret) = - nodes[1].node.create_inbound_payment(None, 7200, None, Some(&payment_metadata)).unwrap(); + let (payment_hash, payment_secret, encrypted_metadata) = nodes[1] + .node + .create_inbound_payment(None, 7200, None, Some(payment_metadata.clone())) + .unwrap(); let timestamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(); let invoice = InvoiceBuilder::new(Currency::Bitcoin) @@ -107,7 +111,7 @@ fn payment_metadata_end_to_end_for_invoice_with_no_amount() { .payment_secret(payment_secret) .duration_since_epoch(timestamp) .min_final_cltv_expiry_delta(144) - .payment_metadata(payment_metadata.clone()) + .payment_metadata(encrypted_metadata.unwrap()) .build_raw() .unwrap(); let sig = nodes[1].keys_manager.backing.sign_invoice(&invoice, Recipient::Node).unwrap(); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 25c86b3f0cb..2adb0a1ca59 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8462,7 +8462,7 @@ impl< payment_data, payment_context, phantom_shared_secret, - onion_fields, + mut onion_fields, has_recipient_created_payment_secret, invoice_request_opt, trampoline_shared_secret, @@ -8603,7 +8603,7 @@ impl< let verify_res = inbound_payment::verify( payment_hash, &payment_data, - onion_fields.payment_metadata.as_deref(), + onion_fields.payment_metadata.as_mut(), self.highest_seen_timestamp.load(Ordering::Acquire) as u64, &self.inbound_payment_key, &self.logger, @@ -14372,24 +14372,24 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } } - let (payment_hash, payment_secret) = match payment_hash { + let (payment_hash, payment_secret, payment_metadata) = match payment_hash { Some(payment_hash) => { - let payment_secret = self + let (payment_secret, payment_metadata) = self .create_inbound_payment_for_hash( payment_hash, amount_msats, invoice_expiry_delta_secs.unwrap_or(DEFAULT_EXPIRY_TIME as u32), min_final_cltv_expiry_delta, - payment_metadata.as_deref(), + payment_metadata, ) .map_err(|()| SignOrCreationError::CreationError(CreationError::InvalidAmount))?; - (payment_hash, payment_secret) + (payment_hash, payment_secret, payment_metadata) }, None => { self .create_inbound_payment( amount_msats, invoice_expiry_delta_secs.unwrap_or(DEFAULT_EXPIRY_TIME as u32), min_final_cltv_expiry_delta, - payment_metadata.as_deref(), + payment_metadata, ) .map_err(|()| SignOrCreationError::CreationError(CreationError::InvalidAmount))? }, @@ -14516,8 +14516,7 @@ pub struct Bolt11InvoiceParameters { /// onion by the sender, available as [`RecipientOnionFields::payment_metadata`] via /// [`Event::PaymentClaimable::onion_fields`]. /// - /// Note that because it is exposed to the sender in the invoice you should consider encrypting - /// it. It is committed to, however, so cannot be modified by the sender. + /// The metadata itself is encrypted and HMAC'd before being stored in the BOLT 11 invoice. pub payment_metadata: Option>, } @@ -15023,6 +15022,7 @@ impl< |amount_msats, relative_expiry| { self.create_inbound_payment(Some(amount_msats), relative_expiry, None, None) .map_err(|()| Bolt12SemanticError::InvalidAmount) + .map(|(preimage, secret, _no_metadata)| (preimage, secret)) }, None, )?; @@ -15033,8 +15033,8 @@ impl< Ok(invoice) } - /// Gets a payment secret and payment hash for use in an invoice given to a third party wishing - /// to pay us. + /// Gets a payment secret, payment hash, and encrypts the `payment_metadata` for use in an + /// invoice given to a third party wishing to pay us. /// /// This differs from [`create_inbound_payment_for_hash`] only in that it generates the /// [`PaymentHash`] and [`PaymentPreimage`] for you. @@ -15065,8 +15065,8 @@ impl< /// [`create_inbound_payment_for_hash`]: Self::create_inbound_payment_for_hash pub fn create_inbound_payment( &self, min_value_msat: Option, invoice_expiry_delta_secs: u32, - min_final_cltv_expiry_delta: Option, payment_metadata: Option<&[u8]>, - ) -> Result<(PaymentHash, PaymentSecret), ()> { + min_final_cltv_expiry_delta: Option, payment_metadata: Option>, + ) -> Result<(PaymentHash, PaymentSecret, Option>), ()> { inbound_payment::create( &self.inbound_payment_key, min_value_msat, @@ -15078,8 +15078,8 @@ impl< ) } - /// Gets a [`PaymentSecret`] for a given [`PaymentHash`], for which the payment preimage is - /// stored external to LDK. + /// Gets a [`PaymentSecret`] for a given [`PaymentHash`] (for which the payment preimage is + /// stored external to LDK) and encrypts the `payment_metadata`. /// /// A [`PaymentClaimable`] event will only be generated if the [`PaymentSecret`] matches a /// payment secret fetched via this method or [`create_inbound_payment`], and which is at least @@ -15115,41 +15115,34 @@ impl< /// Note that a malicious eavesdropper can intuit whether an inbound payment was created by /// `create_inbound_payment` or `create_inbound_payment_for_hash` based on runtime. /// - /// # Note - /// - /// If you register an inbound payment with this method, then serialize the `ChannelManager`, then - /// deserialize it with a node running 0.0.103 and earlier, the payment will fail to be received. - /// /// Errors if `min_value_msat` is greater than total bitcoin supply. /// - /// If `min_final_cltv_expiry_delta` is set to some value, then the payment will not be receivable - /// on versions of LDK prior to 0.0.114. - /// /// [`create_inbound_payment`]: Self::create_inbound_payment /// [`PaymentClaimable`]: events::Event::PaymentClaimable pub fn create_inbound_payment_for_hash( &self, payment_hash: PaymentHash, min_value_msat: Option, invoice_expiry_delta_secs: u32, min_final_cltv_expiry: Option, - payment_metadata: Option<&[u8]>, - ) -> Result { + payment_metadata: Option>, + ) -> Result<(PaymentSecret, Option>), ()> { inbound_payment::create_from_hash( &self.inbound_payment_key, min_value_msat, payment_hash, invoice_expiry_delta_secs, + &self.entropy_source, self.highest_seen_timestamp.load(Ordering::Acquire) as u64, min_final_cltv_expiry, payment_metadata, ) } - /// Gets an LDK-generated payment preimage from a payment hash, metadata and secret that were - /// previously returned from [`create_inbound_payment`]. + /// Gets an LDK-generated payment preimage from a payment hash and secret and decrypts the + /// metadata (if any) that were previously returned from [`create_inbound_payment`]. /// /// [`create_inbound_payment`]: Self::create_inbound_payment - pub fn get_payment_preimage( + pub fn get_payment_preimage_decrypt_metadata( &self, payment_hash: PaymentHash, payment_secret: PaymentSecret, - payment_metadata: Option<&[u8]>, + payment_metadata: Option<&mut [u8]>, ) -> Result { let expanded_key = &self.inbound_payment_key; inbound_payment::get_payment_preimage( @@ -17235,7 +17228,9 @@ impl< relative_expiry, None, None, - ).map_err(|_| Bolt12SemanticError::InvalidAmount) + ) + .map_err(|_| Bolt12SemanticError::InvalidAmount) + .map(|(preimage, secret, _no_metadata)| (preimage, secret)) }; let (result, context) = match invoice_request { @@ -22137,7 +22132,8 @@ pub mod bench { payment_preimage.0[0..8].copy_from_slice(&payment_count.to_le_bytes()); payment_count += 1; let payment_hash = PaymentHash(Sha256::hash(&payment_preimage.0[..]).to_byte_array()); - let payment_secret = $node_b.create_inbound_payment_for_hash(payment_hash, None, 7200, None, None).unwrap(); + let (payment_secret, _no_payment_metadata) = + $node_b.create_inbound_payment_for_hash(payment_hash, None, 7200, None, None).unwrap(); $node_a.send_payment(payment_hash, RecipientOnionFields::secret_only(payment_secret, 10_000), PaymentId(payment_hash.0), diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index bbb184d2e48..ac6f137d5bb 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -2800,7 +2800,7 @@ pub fn get_payment_preimage_hash( let payment_preimage = PaymentPreimage([*payment_count; 32]); *payment_count += 1; let payment_hash = PaymentHash(Sha256::hash(&payment_preimage.0[..]).to_byte_array()); - let payment_secret = recipient + let (payment_secret, _) = recipient .node .create_inbound_payment_for_hash( payment_hash, diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index 7393f354010..37dd5187700 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -293,7 +293,7 @@ pub fn test_duplicate_htlc_different_direction_onchain() { let (payment_preimage, payment_hash, ..) = route_payment(&nodes[0], &[&nodes[1]], 900_000); let (route, _, _, _) = get_route_and_payment_hash!(nodes[1], nodes[0], payment_value_msats); - let node_a_payment_secret = nodes[0] + let (node_a_payment_secret, _) = nodes[0] .node .create_inbound_payment_for_hash(payment_hash, None, 7200, None, None) .unwrap(); @@ -4159,7 +4159,7 @@ pub fn test_duplicate_payment_hash_one_failure_one_success() { let (our_payment_preimage, dup_payment_hash, ..) = route_payment(&nodes[0], &[&nodes[1], &nodes[2], &nodes[3]], 900_000); - let payment_secret = nodes[4] + let (payment_secret, _) = nodes[4] .node .create_inbound_payment_for_hash(dup_payment_hash, None, 7200, None, None) .unwrap(); @@ -4428,13 +4428,13 @@ fn do_test_fail_backwards_unrevoked_remote_announce(deliver_last_raa: bool, anno // 2nd HTLC (not added - smaller than dust limit + HTLC tx fee): let path_5: &[&[_]] = &[&[&nodes[2], &nodes[3], &nodes[5]]]; - let payment_secret = + let (payment_secret, _) = nodes[5].node.create_inbound_payment_for_hash(hash_1, None, 7200, None, None).unwrap(); let route = route_to_5.clone(); send_along_route_with_secret(&nodes[1], route, path_5, dust_limit_msat, hash_1, payment_secret); // 3rd HTLC (not added - smaller than dust limit + HTLC tx fee): - let payment_secret = + let (payment_secret, _) = nodes[5].node.create_inbound_payment_for_hash(hash_2, None, 7200, None, None).unwrap(); let route = route_to_5; send_along_route_with_secret(&nodes[1], route, path_5, dust_limit_msat, hash_2, payment_secret); @@ -4447,12 +4447,12 @@ fn do_test_fail_backwards_unrevoked_remote_announce(deliver_last_raa: bool, anno let (route, _, _, _) = get_route_and_payment_hash!(nodes[1], nodes[5], 1000000); // 6th HTLC: - let payment_secret = + let (payment_secret, _) = nodes[5].node.create_inbound_payment_for_hash(hash_3, None, 7200, None, None).unwrap(); send_along_route_with_secret(&nodes[1], route.clone(), path_5, 1000000, hash_3, payment_secret); // 7th HTLC: - let payment_secret = + let (payment_secret, _) = nodes[5].node.create_inbound_payment_for_hash(hash_4, None, 7200, None, None).unwrap(); send_along_route_with_secret(&nodes[1], route, path_5, 1000000, hash_4, payment_secret); @@ -4461,7 +4461,7 @@ fn do_test_fail_backwards_unrevoked_remote_announce(deliver_last_raa: bool, anno // 9th HTLC (not added - smaller than dust limit + HTLC tx fee): let (route, _, _, _) = get_route_and_payment_hash!(nodes[1], nodes[5], dust_limit_msat); - let payment_secret = + let (payment_secret, _) = nodes[5].node.create_inbound_payment_for_hash(hash_5, None, 7200, None, None).unwrap(); send_along_route_with_secret(&nodes[1], route, path_5, dust_limit_msat, hash_5, payment_secret); @@ -4470,7 +4470,7 @@ fn do_test_fail_backwards_unrevoked_remote_announce(deliver_last_raa: bool, anno // 11th HTLC: let (route, _, _, _) = get_route_and_payment_hash!(nodes[1], nodes[5], 1000000); - let payment_secret = + let (payment_secret, _) = nodes[5].node.create_inbound_payment_for_hash(hash_6, None, 7200, None, None).unwrap(); send_along_route_with_secret(&nodes[1], route, path_5, 1000000, hash_6, payment_secret); @@ -6064,7 +6064,7 @@ pub fn test_check_htlc_underpaying() { .unwrap(); let (_, our_payment_hash, _) = get_payment_preimage_hash(&nodes[0], None, None); - let our_payment_secret = nodes[1] + let (our_payment_secret, _) = nodes[1] .node .create_inbound_payment_for_hash(our_payment_hash, Some(100_000), 7200, None, None) .unwrap(); @@ -7233,7 +7233,7 @@ pub fn test_preimage_storage() { create_announced_chan_between_nodes(&nodes, 0, 1); { - let (payment_hash, payment_secret) = + let (payment_hash, payment_secret, _) = nodes[1].node.create_inbound_payment(Some(100_000), 7200, None, None).unwrap(); let (route, _, _, _) = get_route_and_payment_hash!(nodes[0], nodes[1], 100_000); let onion = RecipientOnionFields::secret_only(payment_secret, 100_000); @@ -7278,7 +7278,7 @@ pub fn test_bad_secret_hash() { let random_hash = PaymentHash([42; 32]); let random_secret = PaymentSecret([43; 32]); - let (our_payment_hash, our_payment_secret) = + let (our_payment_hash, our_payment_secret, _) = nodes[1].node.create_inbound_payment(Some(100_000), 2, None, None).unwrap(); let (route, _, _, _) = get_route_and_payment_hash!(nodes[0], nodes[1], 100_000); @@ -9496,13 +9496,16 @@ fn do_payment_with_custom_min_final_cltv_expiry(valid_delta: bool, use_user_hash get_payment_preimage_hash(&nodes[1], Some(recv_value), Some(min_cltv_expiry_delta)); (hash, payment_preimage, payment_secret) } else { - let (hash, payment_secret) = nodes[1] + let (hash, payment_secret, _) = nodes[1] .node .create_inbound_payment(Some(recv_value), 7200, Some(min_cltv_expiry_delta), None) .unwrap(); ( hash, - nodes[1].node.get_payment_preimage(hash, payment_secret, None).unwrap(), + nodes[1] + .node + .get_payment_preimage_decrypt_metadata(hash, payment_secret, None) + .unwrap(), payment_secret, ) }; diff --git a/lightning/src/ln/inbound_payment.rs b/lightning/src/ln/inbound_payment.rs index b81c111f7a1..40b04777427 100644 --- a/lightning/src/ln/inbound_payment.rs +++ b/lightning/src/ln/inbound_payment.rs @@ -15,7 +15,7 @@ use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::{Hash, HashEngine}; use chacha20_poly1305::chacha20::{ChaCha20, Key, Nonce}; -use crate::crypto::utils::hkdf_extract_expand_7x; +use crate::crypto::utils::hkdf_extract_expand_8x; use crate::ln::msgs; use crate::ln::msgs::MAX_VALUE_MSAT; use crate::offers::nonce::Nonce as LocalNonce; @@ -60,6 +60,8 @@ pub struct ExpandedKey { /// that this is not used for blinded paths that are not expected to be shared across nodes /// participating in a "phantom node". pub(crate) phantom_node_blinded_path_key: [u8; 32], + /// The key used to encrypt payment metadata. + metadata_enc_key: [u8; 32], } impl ExpandedKey { @@ -75,7 +77,8 @@ impl ExpandedKey { offers_encryption_key, spontaneous_pmt_key, phantom_node_blinded_path_key, - ) = hkdf_extract_expand_7x(b"LDK Inbound Payment Key Expansion", &key_material); + metadata_enc_key, + ) = hkdf_extract_expand_8x(b"LDK Inbound Payment Key Expansion", &key_material); Self { info_key, ldk_pmt_hash_key, @@ -84,6 +87,7 @@ impl ExpandedKey { offers_encryption_key, spontaneous_pmt_key, phantom_node_blinded_path_key, + metadata_enc_key, } } @@ -150,13 +154,16 @@ fn min_final_cltv_expiry_delta_from_info(bytes: [u8; INFO_LEN]) -> u16 { /// Note that if `min_final_cltv_expiry_delta` is set to some value, then the payment will not be receivable /// on versions of LDK prior to 0.0.114. /// +/// Returns an encrypted copy of the `payment_metadata` (if any) which must be included as a part of +/// validation. +/// /// [phantom node payments]: crate::sign::PhantomKeysManager /// [`NodeSigner::get_expanded_key`]: crate::sign::NodeSigner::get_expanded_key pub fn create( keys: &ExpandedKey, min_value_msat: Option, invoice_expiry_delta_secs: u32, entropy_source: &ES, current_time: u64, min_final_cltv_expiry_delta: Option, - payment_metadata: Option<&[u8]>, -) -> Result<(PaymentHash, PaymentSecret), ()> { + mut payment_metadata: Option>, +) -> Result<(PaymentHash, PaymentSecret, Option>), ()> { let info_bytes = construct_info_bytes( min_value_msat, if min_final_cltv_expiry_delta.is_some() { @@ -173,10 +180,19 @@ pub fn create( let rand_bytes = entropy_source.get_secure_random_bytes(); iv_bytes.copy_from_slice(&rand_bytes[..IV_LEN]); + if let Some(metadata) = payment_metadata.as_mut() { + ChaCha20::new_from_block( + Key::new(keys.metadata_enc_key), + Nonce::new(iv_bytes[4..].try_into().unwrap()), + u32::from_le_bytes(iv_bytes[..4].try_into().unwrap()), + ) + .apply_keystream(metadata.as_mut_slice()); + } + let mut hmac = HmacEngine::::new(&keys.ldk_pmt_hash_key); hmac.input(&iv_bytes); hmac.input(&info_bytes); - if let Some(metadata) = payment_metadata { + if let Some(metadata) = payment_metadata.as_ref() { hmac.input(&(metadata.len() as u64).to_le_bytes()); hmac.input(metadata); } @@ -184,7 +200,7 @@ pub fn create( let ldk_pmt_hash = PaymentHash(Sha256::hash(&payment_preimage_bytes).to_byte_array()); let payment_secret = construct_payment_secret(&iv_bytes, &info_bytes, &keys.info_key); - Ok((ldk_pmt_hash, payment_secret)) + Ok((ldk_pmt_hash, payment_secret, payment_metadata)) } /// Equivalent to [`crate::ln::channelmanager::ChannelManager::create_inbound_payment_for_hash`], @@ -196,12 +212,15 @@ pub fn create( /// Note that if `min_final_cltv_expiry_delta` is set to some value, then the payment will not be receivable /// on versions of LDK prior to 0.0.114. /// +/// Returns an encrypted copy of the `payment_metadata` (if any) which must be included as a part of +/// validation. +/// /// [phantom node payments]: crate::sign::PhantomKeysManager -pub fn create_from_hash( +pub fn create_from_hash( keys: &ExpandedKey, min_value_msat: Option, payment_hash: PaymentHash, - invoice_expiry_delta_secs: u32, current_time: u64, min_final_cltv_expiry_delta: Option, - payment_metadata: Option<&[u8]>, -) -> Result { + invoice_expiry_delta_secs: u32, entropy_source: &ES, current_time: u64, + min_final_cltv_expiry_delta: Option, mut payment_metadata: Option>, +) -> Result<(PaymentSecret, Option>), ()> { let info_bytes = construct_info_bytes( min_value_msat, if min_final_cltv_expiry_delta.is_some() { @@ -214,10 +233,24 @@ pub fn create_from_hash( min_final_cltv_expiry_delta, )?; + if let Some(metadata) = payment_metadata.as_mut() { + let mut iv_bytes = [0 as u8; IV_LEN]; + let rand_bytes = entropy_source.get_secure_random_bytes(); + iv_bytes.copy_from_slice(&rand_bytes[..IV_LEN]); + + ChaCha20::new_from_block( + Key::new(keys.metadata_enc_key), + Nonce::new(iv_bytes[4..16].try_into().unwrap()), + u32::from_le_bytes(iv_bytes[..4].try_into().unwrap()), + ) + .apply_keystream(metadata.as_mut_slice()); + metadata.extend_from_slice(&iv_bytes); + } + let mut hmac = HmacEngine::::new(&keys.user_pmt_hash_key); hmac.input(&info_bytes); hmac.input(&payment_hash.0); - if let Some(metadata) = payment_metadata { + if let Some(metadata) = payment_metadata.as_ref() { hmac.input(&(metadata.len() as u64).to_le_bytes()); hmac.input(metadata); } @@ -226,7 +259,7 @@ pub fn create_from_hash( let mut iv_bytes = [0 as u8; IV_LEN]; iv_bytes.copy_from_slice(&hmac_bytes[..IV_LEN]); - Ok(construct_payment_secret(&iv_bytes, &info_bytes, &keys.info_key)) + Ok((construct_payment_secret(&iv_bytes, &info_bytes, &keys.info_key), payment_metadata)) } pub(crate) fn create_for_spontaneous_payment( @@ -364,7 +397,8 @@ fn construct_payment_secret( /// [`create_inbound_payment_for_hash`]: crate::ln::channelmanager::ChannelManager::create_inbound_payment_for_hash pub(super) fn verify( payment_hash: PaymentHash, payment_data: &msgs::FinalOnionHopData, - payment_metadata: Option<&[u8]>, highest_seen_timestamp: u64, keys: &ExpandedKey, logger: &L, + mut payment_metadata: Option<&mut Vec>, highest_seen_timestamp: u64, keys: &ExpandedKey, + logger: &L, ) -> Result<(Option, Option), ()> { let (iv_bytes, info_bytes) = decrypt_info(payment_data.payment_secret, keys); @@ -385,7 +419,7 @@ pub(super) fn verify( let mut hmac = HmacEngine::::new(&keys.user_pmt_hash_key); hmac.input(&info_bytes[..]); hmac.input(&payment_hash.0); - if let Some(metadata) = payment_metadata { + if let Some(metadata) = payment_metadata.as_deref() { hmac.input(&(metadata.len() as u64).to_le_bytes()); hmac.input(metadata); } @@ -399,6 +433,23 @@ pub(super) fn verify( &payment_hash ); return Err(()); + }; + + if let Some(metadata) = payment_metadata.as_mut() { + if metadata.len() < IV_LEN { + log_trace!(logger, "payment_metadata was shorter than expected IV. Failing HTLC with payment_hash {payment_hash}"); + return Err(()); + } + let new_len = metadata.len() - IV_LEN; + let (metadata_enc, metadata_iv) = metadata.split_at_mut(new_len); + + ChaCha20::new_from_block( + Key::new(keys.metadata_enc_key), + Nonce::new(metadata_iv[4..16].try_into().unwrap()), + u32::from_le_bytes(metadata_iv[..4].try_into().unwrap()), + ) + .apply_keystream(metadata_enc); + metadata.truncate(new_len); } }, Ok(Method::LdkPaymentHash) | Ok(Method::LdkPaymentHashCustomFinalCltv) => { @@ -406,7 +457,7 @@ pub(super) fn verify( payment_hash, &iv_bytes, &info_bytes, - payment_metadata, + payment_metadata.as_deref().map(Vec::as_slice), keys, ) { Ok(preimage) => payment_preimage = Some(preimage), @@ -420,8 +471,21 @@ pub(super) fn verify( return Err(()); }, } + + if let Some(metadata) = payment_metadata { + ChaCha20::new_from_block( + Key::new(keys.metadata_enc_key), + Nonce::new(iv_bytes[4..].try_into().unwrap()), + u32::from_le_bytes(iv_bytes[..4].try_into().unwrap()), + ) + .apply_keystream(metadata); + } }, Ok(Method::SpontaneousPayment) => { + if payment_metadata.is_some() { + log_trace!(logger, "Shouldn't have a payment_metadata for a spontaneous payment, failing payment with hash {payment_hash}"); + return Err(()); + } let mut hmac = HmacEngine::::new(&keys.spontaneous_pmt_key); hmac.input(&info_bytes[..]); if !fixed_time_eq( @@ -470,18 +534,18 @@ pub(super) fn verify( } pub(super) fn get_payment_preimage( - payment_hash: PaymentHash, payment_secret: PaymentSecret, payment_metadata: Option<&[u8]>, + payment_hash: PaymentHash, payment_secret: PaymentSecret, payment_metadata: Option<&mut [u8]>, keys: &ExpandedKey, ) -> Result { let (iv_bytes, info_bytes) = decrypt_info(payment_secret, keys); match Method::from_bits((info_bytes[0] & 0b1110_0000) >> METHOD_TYPE_OFFSET) { Ok(Method::LdkPaymentHash) | Ok(Method::LdkPaymentHashCustomFinalCltv) => { - derive_ldk_payment_preimage( + let preimage = derive_ldk_payment_preimage( payment_hash, &iv_bytes, &info_bytes, - payment_metadata, + payment_metadata.as_deref(), keys, ) .map_err(|bad_preimage_bytes| APIError::APIMisuseError { @@ -490,7 +554,17 @@ pub(super) fn get_payment_preimage( &payment_hash, log_bytes!(bad_preimage_bytes) ), - }) + })?; + + if let Some(metadata) = payment_metadata { + ChaCha20::new_from_block( + Key::new(keys.metadata_enc_key), + Nonce::new(iv_bytes[4..].try_into().unwrap()), + u32::from_le_bytes(iv_bytes[..4].try_into().unwrap()), + ) + .apply_keystream(metadata); + } + Ok(preimage) }, Ok(Method::UserPaymentHash) | Ok(Method::UserPaymentHashCustomFinalCltv) => { Err(APIError::APIMisuseError { diff --git a/lightning/src/ln/invoice_utils.rs b/lightning/src/ln/invoice_utils.rs index 564203bf524..98996fa28bb 100644 --- a/lightning/src/ln/invoice_utils.rs +++ b/lightning/src/ln/invoice_utils.rs @@ -184,11 +184,12 @@ fn _create_phantom_invoice( let keys = node_signer.get_expanded_key(); let (payment_hash, payment_secret) = if let Some(payment_hash) = payment_hash { - let payment_secret = create_from_hash( + let (payment_secret, _no_metadata) = create_from_hash( &keys, amt_msat, payment_hash, invoice_expiry_delta_secs, + &entropy_source, duration_since_epoch.as_secs(), min_final_cltv_expiry_delta, None, @@ -196,7 +197,7 @@ fn _create_phantom_invoice( .map_err(|_| SignOrCreationError::CreationError(CreationError::InvalidAmount))?; (payment_hash, payment_secret) } else { - create( + let (payment_hash, payment_secret, _no_metadata) = create( &keys, amt_msat, invoice_expiry_delta_secs, @@ -205,7 +206,8 @@ fn _create_phantom_invoice( min_final_cltv_expiry_delta, None, ) - .map_err(|_| SignOrCreationError::CreationError(CreationError::InvalidAmount))? + .map_err(|_| SignOrCreationError::CreationError(CreationError::InvalidAmount))?; + (payment_hash, payment_secret) }; log_trace!( @@ -672,8 +674,10 @@ mod test { let (payment_hash, payment_secret) = (invoice.payment_hash(), *invoice.payment_secret()); - let preimage = - nodes[1].node.get_payment_preimage(payment_hash, payment_secret, None).unwrap(); + let preimage = nodes[1] + .node + .get_payment_preimage_decrypt_metadata(payment_hash, payment_secret, None) + .unwrap(); // Invoice SCIDs should always use inbound SCID aliases over the real channel ID, if one is // available. @@ -1258,7 +1262,10 @@ mod test { let payment_preimage = if user_generated_pmt_hash { user_payment_preimage } else { - nodes[1].node.get_payment_preimage(payment_hash, payment_secret, None).unwrap() + nodes[1] + .node + .get_payment_preimage_decrypt_metadata(payment_hash, payment_secret, None) + .unwrap() }; assert_eq!(invoice.min_final_cltv_expiry_delta(), MIN_FINAL_CLTV_EXPIRY_DELTA as u64); @@ -1365,7 +1372,7 @@ mod test { create_unannounced_chan_between_nodes_with_value(&nodes, 0, 2, 100000, 10001); let payment_amt = 20_000; - let (payment_hash, _payment_secret) = + let (payment_hash, _payment_secret, _) = nodes[1].node.create_inbound_payment(Some(payment_amt), 3600, None, None).unwrap(); let route_hints = vec![nodes[1].node.get_phantom_route_hints(), nodes[2].node.get_phantom_route_hints()]; diff --git a/lightning/src/ln/max_payment_path_len_tests.rs b/lightning/src/ln/max_payment_path_len_tests.rs index 0bf73dbd8fb..c066f2c6d7b 100644 --- a/lightning/src/ln/max_payment_path_len_tests.rs +++ b/lightning/src/ln/max_payment_path_len_tests.rs @@ -32,7 +32,7 @@ use crate::routing::router::{ }; use crate::sign::NodeSigner; use crate::types::features::BlindedHopFeatures; -use crate::types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; +use crate::types::payment::PaymentSecret; use crate::util::errors::APIError; use crate::util::ser::Writeable; use crate::util::test_utils; @@ -80,36 +80,33 @@ fn large_payment_metadata() { - final_payload_len_without_metadata; let mut payment_metadata = vec![42; max_metadata_len]; - let mut counter = 42; macro_rules! get_payment_hash { ($node: expr, $metadata: expr) => {{ - let payment_preimage = PaymentPreimage([counter; 32]); - #[allow(unused_assignments)] - { - counter += 1; - } - let payment_hash: PaymentHash = payment_preimage.into(); - let payment_secret = $node + let (payment_hash, payment_secret, encrypted_metadata) = $node .node - .create_inbound_payment_for_hash( + .create_inbound_payment(Some(amt_msat), 7200, None, Some($metadata)) + .unwrap(); + let encrypted_metadata = encrypted_metadata.unwrap(); + let mut metadata_for_preimage = encrypted_metadata.clone(); + let payment_preimage = $node + .node + .get_payment_preimage_decrypt_metadata( payment_hash, - Some(amt_msat), - 7200, - None, - Some($metadata), + payment_secret, + Some(metadata_for_preimage.as_mut_slice()), ) .unwrap(); - (payment_hash, payment_preimage, payment_secret) + (payment_hash, payment_preimage, payment_secret, encrypted_metadata) }}; } // Check that the maximum-size metadata is sendable. - let (payment_hash, payment_preimage, payment_secret) = - get_payment_hash!(nodes[1], &payment_metadata); + let (payment_hash, payment_preimage, payment_secret, encrypted_metadata) = + get_payment_hash!(nodes[1], payment_metadata.clone()); let (mut route_0_1, ..) = get_route_and_payment_hash!(&nodes[0], &nodes[1], amt_msat); let mut max_sized_onion = RecipientOnionFields { payment_secret: Some(payment_secret), - payment_metadata: Some(payment_metadata.clone()), + payment_metadata: Some(encrypted_metadata), custom_tlvs: Vec::new(), total_mpp_amount_msat: amt_msat, }; @@ -126,6 +123,7 @@ fn large_payment_metadata() { let args = PassAlongPathArgs::new(&nodes[0], path, amt_msat, payment_hash, events.pop().unwrap()) .with_payment_secret(payment_secret) + .with_payment_preimage(payment_preimage) .with_payment_metadata(payment_metadata.clone()); do_pass_along_path(args); claim_payment_along_route(ClaimAlongRouteArgs::new( @@ -137,13 +135,14 @@ fn large_payment_metadata() { // Check that the payment parameter for max path length will prevent us from routing past our // next-hop peer given the payment_metadata size. - let (payment_hash_2, _, payment_secret_2) = - get_payment_hash!(nodes[2], &max_sized_onion.payment_metadata.as_ref().unwrap()); + let (payment_hash_2, _, payment_secret_2, encrypted_metadata_2) = + get_payment_hash!(nodes[2], payment_metadata.clone()); let (mut route_0_2, ..) = get_route_and_payment_hash!(&nodes[0], &nodes[2], amt_msat); let mut route_params_0_2 = route_0_2.route_params.clone().unwrap(); route_params_0_2.payment_params.max_path_length = 1; nodes[0].router.expect_find_route_query(route_params_0_2); max_sized_onion.payment_secret = Some(payment_secret_2); + max_sized_onion.payment_metadata = Some(encrypted_metadata_2); let id = PaymentId(payment_hash_2.0); let mut route_params = route_0_2.route_params.clone().unwrap(); @@ -155,10 +154,11 @@ fn large_payment_metadata() { // If our payment_metadata contains 1 additional byte, we'll fail prior to pathfinding. let mut too_large_onion = max_sized_onion.clone(); - too_large_onion.payment_metadata.as_mut().map(|mut md| md.push(42)); + too_large_onion.payment_metadata.as_mut().map(|md| md.push(42)); too_large_onion.total_mpp_amount_msat = MIN_FINAL_VALUE_ESTIMATE_WITH_OVERPAY; - let (payment_hash_2, _, payment_secret_2) = - get_payment_hash!(nodes[2], &too_large_onion.payment_metadata.as_ref().unwrap()); + let mut too_large_metadata = payment_metadata.clone(); + too_large_metadata.push(42); + let (payment_hash_2, _, payment_secret_2, _) = get_payment_hash!(nodes[2], too_large_metadata); too_large_onion.payment_secret = Some(payment_secret_2); // First confirm we'll fail to create the onion packet directly. @@ -194,11 +194,11 @@ fn large_payment_metadata() { // If we remove enough payment_metadata bytes to allow for 2 hops, we're now able to send to // nodes[2]. let two_hop_metadata = vec![42; max_metadata_len - INTERMED_PAYLOAD_LEN_ESTIMATE]; - let (payment_hash_2, payment_preimage_2, payment_secret_2) = - get_payment_hash!(nodes[2], &two_hop_metadata); + let (payment_hash_2, payment_preimage_2, payment_secret_2, two_hop_encrypted_metadata) = + get_payment_hash!(nodes[2], two_hop_metadata.clone()); let mut onion_allowing_2_hops = RecipientOnionFields { payment_secret: Some(payment_secret_2), - payment_metadata: Some(two_hop_metadata.clone()), + payment_metadata: Some(two_hop_encrypted_metadata), custom_tlvs: Vec::new(), total_mpp_amount_msat: amt_msat, }; @@ -217,6 +217,7 @@ fn large_payment_metadata() { let args = PassAlongPathArgs::new(&nodes[0], path, amt_msat, payment_hash_2, events.pop().unwrap()) .with_payment_secret(payment_secret_2) + .with_payment_preimage(payment_preimage_2) .with_payment_metadata(two_hop_metadata); do_pass_along_path(args); claim_payment_along_route(ClaimAlongRouteArgs::new( diff --git a/lightning/src/ln/payment_tests.rs b/lightning/src/ln/payment_tests.rs index 2eb5d4ee85c..33c7df93ddb 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -25,8 +25,9 @@ use crate::ln::channel::{ EXPIRE_PREV_CONFIG_TICKS, }; use crate::ln::channelmanager::{ - HTLCForwardInfo, PaymentId, PendingAddHTLCInfo, PendingHTLCRouting, RecentPaymentDetails, - BREAKDOWN_TIMEOUT, MIN_CLTV_EXPIRY_DELTA, MPP_TIMEOUT_TICKS, + Bolt11InvoiceParameters, HTLCForwardInfo, OptionalBolt11PaymentParams, PaymentId, + PendingAddHTLCInfo, PendingHTLCRouting, RecentPaymentDetails, BREAKDOWN_TIMEOUT, + MIN_CLTV_EXPIRY_DELTA, MPP_TIMEOUT_TICKS, }; use crate::ln::msgs; use crate::ln::msgs::{BaseMessageHandler, ChannelMessageHandler, MessageSendEvent}; @@ -52,6 +53,8 @@ use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::Hash; use bitcoin::secp256k1::{Secp256k1, SecretKey}; +use lightning_invoice::{Bolt11InvoiceDescription, Description}; + use crate::prelude::*; use crate::ln::functional_test_utils; @@ -1547,7 +1550,7 @@ fn get_ldk_payment_preimage() { let amt_msat = 60_000; let expiry_secs = 60 * 60; - let (payment_hash, payment_secret) = + let (payment_hash, payment_secret, _) = nodes[1].node.create_inbound_payment(Some(amt_msat), expiry_secs, None, None).unwrap(); let payment_params = PaymentParameters::from_node_id(node_b_id, TEST_FINAL_CLTV) @@ -1560,9 +1563,12 @@ fn get_ldk_payment_preimage() { nodes[0].node.send_payment_with_route(route, payment_hash, onion, id).unwrap(); check_added_monitors(&nodes[0], 1); - // Make sure to use `get_payment_preimage` - let preimage = - Some(nodes[1].node.get_payment_preimage(payment_hash, payment_secret, None).unwrap()); + let preimage = Some( + nodes[1] + .node + .get_payment_preimage_decrypt_metadata(payment_hash, payment_secret, None) + .unwrap(), + ); let mut events = nodes[0].node.get_and_clear_pending_msg_events(); assert_eq!(events.len(), 1); let event = events.pop().unwrap(); @@ -1572,6 +1578,182 @@ fn get_ldk_payment_preimage() { claim_payment_along_route(ClaimAlongRouteArgs::new(&nodes[0], &[path], preimage.unwrap())); } +#[derive(Clone, Copy)] +enum PaymentMetadataSource { + Bolt11Invoice, + CreateInboundPayment, + CreateInboundPaymentForHash, +} + +fn do_payment_metadata_end_to_end(source: PaymentMetadataSource) { + // Generate a payment under each source, send a payment for it from another node, and verify + // that the `PaymentClaimable` event sees the (decrypted) payment_metadata that was originally + // provided. For sources which generate the preimage on our behalf, also check that + // `get_payment_preimage_decrypt_metadata` recovers the preimage and decrypts the metadata. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + create_announced_chan_between_nodes(&nodes, 0, 1); + + let amt_msat = 50_000; + let node_b_id = nodes[1].node.get_our_node_id(); + let plaintext_metadata = vec![0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04, 0x05]; + + // Whenever LDK is computing the preimage itself (the `Bolt11Invoice` and + // `CreateInboundPayment` cases), `encrypted_metadata` holds the encrypted bytes so we can feed + // them back into `get_payment_preimage_decrypt_metadata` below. For the user-hash case we know + // the preimage up front so we stash it in `provided_preimage` instead. + let (payment_hash, payment_secret, encrypted_metadata, provided_preimage) = match source { + PaymentMetadataSource::Bolt11Invoice => { + let description = + Bolt11InvoiceDescription::Direct(Description::new("test".to_string()).unwrap()); + let invoice_params = Bolt11InvoiceParameters { + amount_msats: Some(amt_msat), + description, + payment_metadata: Some(plaintext_metadata.clone()), + ..Default::default() + }; + let invoice = nodes[1].node.create_bolt11_invoice(invoice_params).unwrap(); + let payment_hash = invoice.payment_hash(); + let payment_secret = *invoice.payment_secret(); + let encrypted_metadata = invoice.payment_metadata().unwrap().clone(); + // The encryption must produce different bytes than the plaintext for this test to be + // meaningful (otherwise the decryption could be a no-op and we wouldn't notice). + assert_ne!(encrypted_metadata, plaintext_metadata); + + nodes[0] + .node + .pay_for_bolt11_invoice( + &invoice, + PaymentId(payment_hash.0), + None, + OptionalBolt11PaymentParams::default(), + ) + .unwrap(); + (payment_hash, payment_secret, encrypted_metadata, None) + }, + PaymentMetadataSource::CreateInboundPayment => { + let (payment_hash, payment_secret, encrypted_metadata) = nodes[1] + .node + .create_inbound_payment( + Some(amt_msat), + 7200, + None, + Some(plaintext_metadata.clone()), + ) + .unwrap(); + let encrypted_metadata = encrypted_metadata.unwrap(); + assert_ne!(encrypted_metadata, plaintext_metadata); + + let payment_params = PaymentParameters::from_node_id(node_b_id, TEST_FINAL_CLTV) + .with_bolt11_features(nodes[1].node.bolt11_invoice_features()) + .unwrap(); + let route_params = + RouteParameters::from_payment_params_and_value(payment_params, amt_msat); + let route = get_route(&nodes[0], &route_params).unwrap(); + let onion = RecipientOnionFields { + payment_secret: Some(payment_secret), + payment_metadata: Some(encrypted_metadata.clone()), + custom_tlvs: vec![], + total_mpp_amount_msat: amt_msat, + }; + nodes[0] + .node + .send_payment_with_route(route, payment_hash, onion, PaymentId(payment_hash.0)) + .unwrap(); + (payment_hash, payment_secret, encrypted_metadata, None) + }, + PaymentMetadataSource::CreateInboundPaymentForHash => { + let payment_preimage = PaymentPreimage([0x77; 32]); + let payment_hash = PaymentHash(Sha256::hash(&payment_preimage.0).to_byte_array()); + let (payment_secret, encrypted_metadata) = nodes[1] + .node + .create_inbound_payment_for_hash( + payment_hash, + Some(amt_msat), + 7200, + None, + Some(plaintext_metadata.clone()), + ) + .unwrap(); + let encrypted_metadata = encrypted_metadata.unwrap(); + assert_ne!(encrypted_metadata, plaintext_metadata); + + let payment_params = PaymentParameters::from_node_id(node_b_id, TEST_FINAL_CLTV) + .with_bolt11_features(nodes[1].node.bolt11_invoice_features()) + .unwrap(); + let route_params = + RouteParameters::from_payment_params_and_value(payment_params, amt_msat); + let route = get_route(&nodes[0], &route_params).unwrap(); + let onion = RecipientOnionFields { + payment_secret: Some(payment_secret), + payment_metadata: Some(encrypted_metadata.clone()), + custom_tlvs: vec![], + total_mpp_amount_msat: amt_msat, + }; + nodes[0] + .node + .send_payment_with_route(route, payment_hash, onion, PaymentId(payment_hash.0)) + .unwrap(); + (payment_hash, payment_secret, encrypted_metadata, Some(payment_preimage)) + }, + }; + + check_added_monitors(&nodes[0], 1); + + // For sources where LDK derived the preimage, exercise + // `get_payment_preimage_decrypt_metadata`: it must recover the preimage *and* decrypt the + // metadata buffer in place. For the user-hash source we just use the preimage we picked. + let preimage = if let Some(preimage) = provided_preimage { + preimage + } else { + let mut decrypted_metadata = encrypted_metadata.clone(); + let preimage = nodes[1] + .node + .get_payment_preimage_decrypt_metadata( + payment_hash, + payment_secret, + Some(decrypted_metadata.as_mut_slice()), + ) + .unwrap(); + assert_eq!(decrypted_metadata, plaintext_metadata); + preimage + }; + assert_eq!(PaymentHash(Sha256::hash(&preimage.0).to_byte_array()), payment_hash); + + let mut events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + let ev = events.pop().unwrap(); + let path = &[&nodes[1]]; + let mut args = PassAlongPathArgs::new(&nodes[0], path, amt_msat, payment_hash, ev) + .with_payment_secret(payment_secret) + .with_payment_metadata(plaintext_metadata.clone()); + // Only set the expected preimage when LDK is responsible for surfacing it on the receiver + // side (i.e. LDK-derived hashes). For user-supplied hashes, `PaymentClaimable` carries + // `payment_preimage: None`. + if provided_preimage.is_none() { + args = args.with_payment_preimage(preimage); + } + do_pass_along_path(args); + claim_payment_along_route(ClaimAlongRouteArgs::new(&nodes[0], &[path], preimage)); +} + +#[test] +fn payment_metadata_end_to_end_bolt11_invoice() { + do_payment_metadata_end_to_end(PaymentMetadataSource::Bolt11Invoice); +} + +#[test] +fn payment_metadata_end_to_end_create_inbound_payment() { + do_payment_metadata_end_to_end(PaymentMetadataSource::CreateInboundPayment); +} + +#[test] +fn payment_metadata_end_to_end_create_inbound_payment_for_hash() { + do_payment_metadata_end_to_end(PaymentMetadataSource::CreateInboundPaymentForHash); +} + #[test] fn sent_probe_is_probe_of_sending_node() { let chanmon_cfgs = create_chanmon_cfgs(3); @@ -2305,7 +2487,7 @@ fn do_test_intercepted_payment(test: InterceptTest) { let route_params = RouteParameters::from_payment_params_and_value(payment_params, amt_msat); let route = get_route(&nodes[0], &route_params).unwrap(); - let (hash, payment_secret) = + let (hash, payment_secret, _) = nodes[2].node.create_inbound_payment(Some(amt_msat), 60 * 60, None, None).unwrap(); let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let id = PaymentId(hash.0); @@ -2415,8 +2597,12 @@ fn do_test_intercepted_payment(test: InterceptTest) { do_commitment_signed_dance(&nodes[2], &nodes[1], commitment, false, true); expect_and_process_pending_htlcs(&nodes[2], false); - let preimage = - Some(nodes[2].node.get_payment_preimage(hash, payment_secret, None).unwrap()); + let preimage = Some( + nodes[2] + .node + .get_payment_preimage_decrypt_metadata(hash, payment_secret, None) + .unwrap(), + ); expect_payment_claimable!(&nodes[2], hash, payment_secret, amt_msat, preimage, node_c_id); let path: &[&[_]] = &[&[&nodes[1], &nodes[2]]]; @@ -2542,7 +2728,7 @@ fn do_accept_underpaying_htlcs_config(num_mpp_parts: usize) { .with_bolt11_features(nodes[2].node.bolt11_invoice_features()) .unwrap(); let route_params = RouteParameters::from_payment_params_and_value(payment_params, amt_msat); - let (payment_hash, payment_secret) = + let (payment_hash, payment_secret, _) = nodes[2].node.create_inbound_payment(Some(amt_msat), 60 * 60, None, None).unwrap(); let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); @@ -2598,8 +2784,10 @@ fn do_accept_underpaying_htlcs_config(num_mpp_parts: usize) { } // Claim the payment and check that the skimmed fee is as expected. - let payment_preimage = - nodes[2].node.get_payment_preimage(payment_hash, payment_secret, None).unwrap(); + let payment_preimage = nodes[2] + .node + .get_payment_preimage_decrypt_metadata(payment_hash, payment_secret, None) + .unwrap(); let events = nodes[2].node.get_and_clear_pending_events(); assert_eq!(events.len(), 1); match events[0] { @@ -4890,14 +5078,14 @@ fn do_test_payment_metadata_consistency(do_reload: bool, do_modify: bool) { let payment_metadata = vec![44, 49, 52, 142]; let payment_preimage = PaymentPreimage([42; 32]); let payment_hash: PaymentHash = payment_preimage.into(); - let payment_secret = nodes[3] + let (payment_secret, encrypted_metadata) = nodes[3] .node .create_inbound_payment_for_hash( payment_hash, Some(amt_msat), 7200, None, - Some(&payment_metadata), + Some(payment_metadata.clone()), ) .unwrap(); let payment_id = PaymentId(payment_hash.0); @@ -4910,7 +5098,7 @@ fn do_test_payment_metadata_consistency(do_reload: bool, do_modify: bool) { // Send the MPP payment, delivering the updated commitment state to nodes[1]. let onion = RecipientOnionFields { payment_secret: Some(payment_secret), - payment_metadata: Some(payment_metadata), + payment_metadata: encrypted_metadata, custom_tlvs: vec![], total_mpp_amount_msat: amt_msat, }; From 4fac0fe1c174612b156a3adf664c8a9551a7b171 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 21 May 2026 18:57:26 +0000 Subject: [PATCH 438/627] Unify and simplify the application of simple chacha20 passes Most of our `chacha20` calls don't actually care about the concept of ChaCha20's "seek" vs "nonce" - we just want to use the full 128 bits of nonce space as nonce. Here we unify those calls to keep a consistent API and consolidate the `unwrap`s to one place. --- lightning/src/crypto/utils.rs | 11 ++++++ lightning/src/ln/inbound_payment.rs | 60 +++++------------------------ lightning/src/sign/mod.rs | 10 +---- 3 files changed, 23 insertions(+), 58 deletions(-) diff --git a/lightning/src/crypto/utils.rs b/lightning/src/crypto/utils.rs index 749f7d423c0..d6fa2044d79 100644 --- a/lightning/src/crypto/utils.rs +++ b/lightning/src/crypto/utils.rs @@ -3,6 +3,8 @@ use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::{Hash, HashEngine}; use bitcoin::secp256k1::{ecdsa::Signature, Message, Secp256k1, SecretKey, Signing}; +use chacha20_poly1305::chacha20::{ChaCha20, Key, Nonce}; + use crate::sign::EntropySource; macro_rules! hkdf_extract_expand { @@ -96,3 +98,12 @@ pub fn sign_with_aux_rand( let sig = sign(ctx, msg, sk); sig } + +pub fn apply_chacha20(key: [u8; 32], nonce: [u8; 16], data: &mut [u8]) { + ChaCha20::new_from_block( + Key::new(key), + Nonce::new(nonce[4..].try_into().unwrap()), + u32::from_le_bytes(nonce[..4].try_into().unwrap()), + ) + .apply_keystream(data); +} diff --git a/lightning/src/ln/inbound_payment.rs b/lightning/src/ln/inbound_payment.rs index 40b04777427..077d2df60e5 100644 --- a/lightning/src/ln/inbound_payment.rs +++ b/lightning/src/ln/inbound_payment.rs @@ -13,9 +13,8 @@ use bitcoin::hashes::cmp::fixed_time_eq; use bitcoin::hashes::hmac::{Hmac, HmacEngine}; use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::{Hash, HashEngine}; -use chacha20_poly1305::chacha20::{ChaCha20, Key, Nonce}; -use crate::crypto::utils::hkdf_extract_expand_8x; +use crate::crypto::utils::{apply_chacha20, hkdf_extract_expand_8x}; use crate::ln::msgs; use crate::ln::msgs::MAX_VALUE_MSAT; use crate::offers::nonce::Nonce as LocalNonce; @@ -101,12 +100,7 @@ impl ExpandedKey { /// Encrypts or decrypts the given `bytes`. Used for data included in an offer message's /// metadata (e.g., payment id). pub(crate) fn crypt_for_offer(&self, mut bytes: [u8; 32], nonce: LocalNonce) -> [u8; 32] { - ChaCha20::new_from_block( - Key::new(self.offers_encryption_key), - Nonce::new(nonce.0[4..].try_into().unwrap()), - u32::from_le_bytes(nonce.0[..4].try_into().unwrap()), - ) - .apply_keystream(&mut bytes); + apply_chacha20(self.offers_encryption_key, nonce.0, &mut bytes); bytes } } @@ -181,12 +175,7 @@ pub fn create( iv_bytes.copy_from_slice(&rand_bytes[..IV_LEN]); if let Some(metadata) = payment_metadata.as_mut() { - ChaCha20::new_from_block( - Key::new(keys.metadata_enc_key), - Nonce::new(iv_bytes[4..].try_into().unwrap()), - u32::from_le_bytes(iv_bytes[..4].try_into().unwrap()), - ) - .apply_keystream(metadata.as_mut_slice()); + apply_chacha20(keys.metadata_enc_key, iv_bytes, metadata.as_mut_slice()); } let mut hmac = HmacEngine::::new(&keys.ldk_pmt_hash_key); @@ -238,12 +227,7 @@ pub fn create_from_hash( let rand_bytes = entropy_source.get_secure_random_bytes(); iv_bytes.copy_from_slice(&rand_bytes[..IV_LEN]); - ChaCha20::new_from_block( - Key::new(keys.metadata_enc_key), - Nonce::new(iv_bytes[4..16].try_into().unwrap()), - u32::from_le_bytes(iv_bytes[..4].try_into().unwrap()), - ) - .apply_keystream(metadata.as_mut_slice()); + apply_chacha20(keys.metadata_enc_key, iv_bytes, metadata.as_mut_slice()); metadata.extend_from_slice(&iv_bytes); } @@ -349,12 +333,7 @@ fn construct_payment_secret( iv_slice.copy_from_slice(iv_bytes); encrypted_info_slice.copy_from_slice(info_bytes); - ChaCha20::new_from_block( - Key::new(*info_key), - Nonce::new(iv_bytes[4..].try_into().unwrap()), - u32::from_le_bytes(iv_bytes[..4].try_into().unwrap()), - ) - .apply_keystream(encrypted_info_slice); + apply_chacha20(*info_key, *iv_bytes, encrypted_info_slice); PaymentSecret(payment_secret_bytes) } @@ -442,13 +421,9 @@ pub(super) fn verify( } let new_len = metadata.len() - IV_LEN; let (metadata_enc, metadata_iv) = metadata.split_at_mut(new_len); + let metadata_iv: [u8; IV_LEN] = metadata_iv.try_into().expect("len checked"); - ChaCha20::new_from_block( - Key::new(keys.metadata_enc_key), - Nonce::new(metadata_iv[4..16].try_into().unwrap()), - u32::from_le_bytes(metadata_iv[..4].try_into().unwrap()), - ) - .apply_keystream(metadata_enc); + apply_chacha20(keys.metadata_enc_key, metadata_iv, metadata_enc); metadata.truncate(new_len); } }, @@ -473,12 +448,7 @@ pub(super) fn verify( } if let Some(metadata) = payment_metadata { - ChaCha20::new_from_block( - Key::new(keys.metadata_enc_key), - Nonce::new(iv_bytes[4..].try_into().unwrap()), - u32::from_le_bytes(iv_bytes[..4].try_into().unwrap()), - ) - .apply_keystream(metadata); + apply_chacha20(keys.metadata_enc_key, iv_bytes, metadata); } }, Ok(Method::SpontaneousPayment) => { @@ -557,12 +527,7 @@ pub(super) fn get_payment_preimage( })?; if let Some(metadata) = payment_metadata { - ChaCha20::new_from_block( - Key::new(keys.metadata_enc_key), - Nonce::new(iv_bytes[4..].try_into().unwrap()), - u32::from_le_bytes(iv_bytes[..4].try_into().unwrap()), - ) - .apply_keystream(metadata); + apply_chacha20(keys.metadata_enc_key, iv_bytes, metadata); } Ok(preimage) }, @@ -590,12 +555,7 @@ fn decrypt_info( let mut info_bytes: [u8; INFO_LEN] = [0; INFO_LEN]; info_bytes.copy_from_slice(encrypted_info_bytes); - ChaCha20::new_from_block( - Key::new(keys.info_key), - Nonce::new(iv_bytes[4..].try_into().unwrap()), - u32::from_le_bytes(iv_bytes[..4].try_into().unwrap()), - ) - .apply_keystream(&mut info_bytes); + apply_chacha20(keys.info_key, iv_bytes, &mut info_bytes); (iv_bytes, info_bytes) } diff --git a/lightning/src/sign/mod.rs b/lightning/src/sign/mod.rs index a3dc72042cc..3adc6380297 100644 --- a/lightning/src/sign/mod.rs +++ b/lightning/src/sign/mod.rs @@ -34,12 +34,11 @@ use bitcoin::secp256k1::schnorr; use bitcoin::secp256k1::All; use bitcoin::secp256k1::{Keypair, PublicKey, Scalar, Secp256k1, SecretKey, Signing}; use bitcoin::{secp256k1, Psbt, Sequence, Txid, WPubkeyHash, Witness}; -use chacha20_poly1305::chacha20::{ChaCha20, Key, Nonce}; use lightning_invoice::RawBolt11Invoice; use crate::chain::transaction::OutPoint; -use crate::crypto::utils::{hkdf_extract_expand_twice, sign, sign_with_aux_rand}; +use crate::crypto::utils::{apply_chacha20, hkdf_extract_expand_twice, sign, sign_with_aux_rand}; use crate::ln::chan_utils; use crate::ln::chan_utils::{ get_countersigner_payment_script, get_revokeable_redeemscript, make_funding_redeemscript, @@ -2704,12 +2703,7 @@ impl EntropySource for RandomBytes { let mut nonce = [0u8; 16]; nonce[..8].copy_from_slice(&index.to_be_bytes()); let mut chacha_bytes = [0; 32]; - ChaCha20::new_from_block( - Key::new(self.seed), - Nonce::new(nonce[4..].try_into().unwrap()), - u32::from_le_bytes(nonce[..4].try_into().unwrap()), - ) - .apply_keystream(&mut chacha_bytes); + apply_chacha20(self.seed, nonce, &mut chacha_bytes); chacha_bytes } } From 90621cfc3c0cd2403dea71ea43d4532dc922c735 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Fri, 22 May 2026 14:49:58 -0700 Subject: [PATCH 439/627] Manually exit quiescence in fuzzing upon disconnect In certain cases, we may need to terminate quiescence as a result of some error via a `ChannelError::WarnAndDisconnect`. We don't need to necessarily reconnect the peers, so we choose to manually terminate quiescence via the existing `ChannelManager::exit_quiescence` test helper. --- fuzz/src/chanmon_consistency.rs | 40 ++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 4af1d1301b0..532d4fc3068 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -769,19 +769,19 @@ type ChanMan<'a> = ChannelManager< >; #[inline] -fn assert_action_timeout_awaiting_response(action: &msgs::ErrorAction) { +fn assert_disconnect_action(action: &msgs::ErrorAction) -> (&msgs::WarningMessage, bool) { // Since sending/receiving messages may be delayed, `timer_tick_occurred` may cause a node to // disconnect their counterparty if they're expecting a timely response. - assert!( - matches!( - action, - msgs::ErrorAction::DisconnectPeerWithWarning { msg } - if msg.data.contains("Disconnecting due to timeout awaiting response") - || msg.data.contains("already sent splice_locked, cannot RBF") - ), - "Expected timeout disconnect, got: {:?}", - action, - ); + if let msgs::ErrorAction::DisconnectPeerWithWarning { ref msg } = action { + let is_quiescent_msg = msg.data.contains("already sent splice_locked, cannot RBF"); + if !msg.data.contains("Disconnecting due to timeout awaiting response") && !is_quiescent_msg + { + panic!("Unexpected disconnect case: {}", msg.data); + } + (msg, is_quiescent_msg) + } else { + panic!("Expected disconnect, got: {:?}", action); + } } #[derive(Clone, Copy, PartialEq)] @@ -1286,7 +1286,7 @@ impl EventQueues { *node_id == a_id }, MessageSendEvent::HandleError { ref action, ref node_id } => { - assert_action_timeout_awaiting_response(action); + assert_disconnect_action(action); if Some(*node_id) == expect_drop_id { panic!( "peer_disconnected should drop msgs bound for the disconnected peer" @@ -1335,7 +1335,7 @@ impl EventQueues { MessageSendEvent::BroadcastChannelUpdate { .. } => {}, MessageSendEvent::SendChannelUpdate { .. } => {}, MessageSendEvent::HandleError { ref action, .. } => { - assert_action_timeout_awaiting_response(action); + assert_disconnect_action(action); }, _ => panic!("Unhandled message event"), } @@ -1354,7 +1354,7 @@ impl EventQueues { MessageSendEvent::BroadcastChannelUpdate { .. } => {}, MessageSendEvent::SendChannelUpdate { .. } => {}, MessageSendEvent::HandleError { ref action, .. } => { - assert_action_timeout_awaiting_response(action); + assert_disconnect_action(action); }, _ => panic!("Unhandled message event"), } @@ -2645,8 +2645,16 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { nodes[dest_idx].handle_splice_locked(source_node_id, msg); None }, - MessageSendEvent::HandleError { ref action, .. } => { - assert_action_timeout_awaiting_response(action); + MessageSendEvent::HandleError { ref action, ref node_id, .. } => { + let (msg, is_quiescent) = assert_disconnect_action(action); + let dest_idx = log_peer_message(node_idx, node_id, nodes, out, "warning"); + if is_quiescent { + nodes[node_idx].node.exit_quiescence(node_id, &msg.channel_id).unwrap(); + nodes[dest_idx] + .node + .exit_quiescence(&source_node_id, &msg.channel_id) + .unwrap(); + } None }, MessageSendEvent::SendChannelReady { .. } From 836bc386bdbbbf03dd81cf064e12021c93893b40 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Fri, 22 May 2026 14:49:59 -0700 Subject: [PATCH 440/627] Restore splice fuzzing by default This removes the temporary cfg flag that was added while the splice fuzzer was broken. We also include coverage for the newly supported async signing of a splice's shared input. --- fuzz/Cargo.toml | 1 - fuzz/src/chanmon_consistency.rs | 53 +++++++++++++++++---------------- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 76b4968f043..bf0d463f0fe 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -43,6 +43,5 @@ check-cfg = [ "cfg(fuzzing)", "cfg(secp256k1_fuzz)", "cfg(hashes_fuzz)", - "cfg(splicing)", "cfg(chacha20_poly1305_fuzz)" ] diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 532d4fc3068..ea2b93e89fc 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -720,10 +720,11 @@ impl SignerProvider for KeyProvider { // Since this fuzzer is only concerned with live-channel operations, we don't need to worry about // any signer operations that come after a force close. -const SUPPORTED_SIGNER_OPS: [SignerOp; 3] = [ +const SUPPORTED_SIGNER_OPS: [SignerOp; 4] = [ SignerOp::SignCounterpartyCommitment, SignerOp::GetPerCommitmentPoint, SignerOp::ReleaseCommitmentSecret, + SignerOp::SignSpliceSharedInput, ]; impl KeyProvider { @@ -3125,59 +3126,35 @@ pub fn do_test(data: &[u8], out: Out) { 0x89 => harness.nodes[2].reset_fee_estimate(), 0xa0 => { - if !cfg!(splicing) { - break 'fuzz_loop; - } let cp_node_id = harness.nodes[1].get_our_node_id(); harness.nodes[0].splice_in(&cp_node_id, &harness.chan_a_id()); }, 0xa1 => { - if !cfg!(splicing) { - break 'fuzz_loop; - } let cp_node_id = harness.nodes[0].get_our_node_id(); harness.nodes[1].splice_in(&cp_node_id, &harness.chan_a_id()); }, 0xa2 => { - if !cfg!(splicing) { - break 'fuzz_loop; - } let cp_node_id = harness.nodes[2].get_our_node_id(); harness.nodes[1].splice_in(&cp_node_id, &harness.chan_b_id()); }, 0xa3 => { - if !cfg!(splicing) { - break 'fuzz_loop; - } let cp_node_id = harness.nodes[1].get_our_node_id(); harness.nodes[2].splice_in(&cp_node_id, &harness.chan_b_id()); }, 0xa4 => { - if !cfg!(splicing) { - break 'fuzz_loop; - } let cp_node_id = harness.nodes[1].get_our_node_id(); harness.nodes[0].splice_out(&cp_node_id, &harness.chan_a_id()); }, 0xa5 => { - if !cfg!(splicing) { - break 'fuzz_loop; - } let cp_node_id = harness.nodes[0].get_our_node_id(); harness.nodes[1].splice_out(&cp_node_id, &harness.chan_a_id()); }, 0xa6 => { - if !cfg!(splicing) { - break 'fuzz_loop; - } let cp_node_id = harness.nodes[2].get_our_node_id(); harness.nodes[1].splice_out(&cp_node_id, &harness.chan_b_id()); }, 0xa7 => { - if !cfg!(splicing) { - break 'fuzz_loop; - } let cp_node_id = harness.nodes[1].get_our_node_id(); harness.nodes[2].splice_out(&cp_node_id, &harness.chan_b_id()); }, @@ -3306,6 +3283,32 @@ pub fn do_test(data: &[u8], out: Out) { .enable_op_for_all_signers(SignerOp::ReleaseCommitmentSecret); harness.nodes[2].signer_unblocked(None); }, + 0xcf => { + harness.nodes[0] + .keys_manager + .enable_op_for_all_signers(SignerOp::SignSpliceSharedInput); + harness.nodes[0].signer_unblocked(None); + }, + 0xd0 => { + harness.nodes[1] + .keys_manager + .enable_op_for_all_signers(SignerOp::SignSpliceSharedInput); + let filter = Some((harness.nodes[0].get_our_node_id(), harness.chan_a_id())); + harness.nodes[1].signer_unblocked(filter); + }, + 0xd1 => { + harness.nodes[1] + .keys_manager + .enable_op_for_all_signers(SignerOp::SignSpliceSharedInput); + let filter = Some((harness.nodes[2].get_our_node_id(), harness.chan_b_id())); + harness.nodes[1].signer_unblocked(filter); + }, + 0xd2 => { + harness.nodes[2] + .keys_manager + .enable_op_for_all_signers(SignerOp::SignSpliceSharedInput); + harness.nodes[2].signer_unblocked(None); + }, 0xf0 => harness.ab_link.complete_monitor_updates_for_node( 0, From f0ce340c6096c04d47ba663d2e6754e804022f3c Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Fri, 22 May 2026 14:50:02 -0700 Subject: [PATCH 441/627] Fix fuzz build warnings --- fuzz/src/lsps_message.rs | 1 - lightning/src/ln/outbound_payment.rs | 2 +- lightning/src/ln/peer_channel_encryptor.rs | 2 +- lightning/src/routing/gossip.rs | 2 +- lightning/src/util/time.rs | 2 +- 5 files changed, 4 insertions(+), 5 deletions(-) diff --git a/fuzz/src/lsps_message.rs b/fuzz/src/lsps_message.rs index 83fa5ddab6d..7a3cb0cf7e0 100644 --- a/fuzz/src/lsps_message.rs +++ b/fuzz/src/lsps_message.rs @@ -5,7 +5,6 @@ use bitcoin::hashes::{sha256, Hash}; use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use bitcoin::Network; -use lightning::chain::Filter; use lightning::chain::{chainmonitor, BlockLocator}; use lightning::ln::channelmanager::{ChainParameters, ChannelManager}; use lightning::ln::peer_handler::CustomMessageHandler; diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 7259f60796f..273ed4ec1d2 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -38,7 +38,7 @@ use crate::types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; use crate::util::errors::APIError; use crate::util::logger::{Logger, WithContext}; use crate::util::ser::ReadableArgs; -#[cfg(feature = "std")] +#[cfg(all(feature = "std", not(fuzzing)))] use crate::util::time::Instant; use core::fmt::{self, Display, Formatter}; diff --git a/lightning/src/ln/peer_channel_encryptor.rs b/lightning/src/ln/peer_channel_encryptor.rs index d9fc6dd2c6a..5f461315712 100644 --- a/lightning/src/ln/peer_channel_encryptor.rs +++ b/lightning/src/ln/peer_channel_encryptor.rs @@ -556,7 +556,7 @@ impl PeerChannelEncryptor { /// Encrypts the given message, returning the encrypted version. /// panics if the length of `message`, once encoded, is greater than 65535 or if the Noise /// handshake has not finished. - pub fn encrypt_message(&mut self, message: wire::Message) -> Vec { + pub(crate) fn encrypt_message(&mut self, message: wire::Message) -> Vec { // Allocate a buffer with 2KB, fitting most common messages. Reserve the first 16+2 bytes // for the 2-byte message type prefix and its MAC. let mut res = VecWriter(Vec::with_capacity(MSG_BUF_ALLOC_SIZE)); diff --git a/lightning/src/routing/gossip.rs b/lightning/src/routing/gossip.rs index adeb67a9e6c..7688db15311 100644 --- a/lightning/src/routing/gossip.rs +++ b/lightning/src/routing/gossip.rs @@ -57,7 +57,7 @@ use core::{cmp, fmt}; pub use lightning_types::routing::RoutingFees; -#[cfg(feature = "std")] +#[cfg(all(feature = "std", not(fuzzing)))] use std::time::{SystemTime, UNIX_EPOCH}; /// We remove stale channel directional info two weeks after the last update, per BOLT 7's diff --git a/lightning/src/util/time.rs b/lightning/src/util/time.rs index c6041543572..626e96e1350 100644 --- a/lightning/src/util/time.rs +++ b/lightning/src/util/time.rs @@ -7,7 +7,7 @@ //! A simple module which either re-exports [`std::time::Instant`] or a mocked version of it for //! tests. -#[cfg(not(test))] +#[cfg(all(not(test), not(fuzzing)))] pub use std::time::Instant; #[cfg(test)] pub use test::Instant; From c5fa6131f7c8603435848100712dc1082a5b9479 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Wed, 27 May 2026 11:57:17 +0200 Subject: [PATCH 442/627] Allow stdin fuzz targets to suppress logs Add an environment-variable switch that lets stdin fuzz targets use the dev-null test logger. This keeps direct invocations verbose by default, while external runners can opt into quieter passing-case replays. --- fuzz/fuzz-fake-hashes/src/bin/base32_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/bech32_parse_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/bolt11_deser_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/chanmon_deser_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/feature_flags_target.rs | 6 +++++- .../src/bin/fromstr_to_netaddress_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/fs_store_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/full_stack_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/gossip_discovery_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/indexedmap_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/invoice_deser_target.rs | 6 +++++- .../src/bin/invoice_request_deser_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/lsps_message_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_accept_channel_target.rs | 6 +++++- .../src/bin/msg_accept_channel_v2_target.rs | 6 +++++- .../src/bin/msg_announcement_signatures_target.rs | 6 +++++- .../src/bin/msg_blinded_message_path_target.rs | 6 +++++- .../src/bin/msg_channel_announcement_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_channel_details_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_channel_ready_target.rs | 6 +++++- .../src/bin/msg_channel_reestablish_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_channel_update_target.rs | 6 +++++- .../fuzz-fake-hashes/src/bin/msg_closing_complete_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_closing_sig_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_closing_signed_target.rs | 6 +++++- .../src/bin/msg_commitment_signed_target.rs | 6 +++++- .../src/bin/msg_decoded_onion_error_packet_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_error_message_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_funding_created_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_funding_signed_target.rs | 6 +++++- .../src/bin/msg_gossip_timestamp_filter_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_init_target.rs | 6 +++++- .../src/bin/msg_node_announcement_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_open_channel_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_open_channel_v2_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_ping_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_pong_target.rs | 6 +++++- .../src/bin/msg_query_channel_range_target.rs | 6 +++++- .../src/bin/msg_query_short_channel_ids_target.rs | 6 +++++- .../src/bin/msg_reply_channel_range_target.rs | 6 +++++- .../src/bin/msg_reply_short_channel_ids_end_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_revoke_and_ack_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_shutdown_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_splice_ack_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_splice_init_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_splice_locked_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_stfu_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_tx_abort_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_tx_ack_rbf_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_tx_add_input_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_tx_add_output_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_tx_complete_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_tx_init_rbf_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_tx_remove_input_target.rs | 6 +++++- .../fuzz-fake-hashes/src/bin/msg_tx_remove_output_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_tx_signatures_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_update_add_htlc_target.rs | 6 +++++- .../fuzz-fake-hashes/src/bin/msg_update_fail_htlc_target.rs | 6 +++++- .../src/bin/msg_update_fail_malformed_htlc_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/msg_update_fee_target.rs | 6 +++++- .../src/bin/msg_update_fulfill_htlc_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/offer_deser_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/onion_hop_data_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/onion_message_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/peer_crypt_target.rs | 6 +++++- .../src/bin/process_network_graph_target.rs | 6 +++++- .../src/bin/process_onion_failure_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/refund_deser_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/router_target.rs | 6 +++++- .../fuzz-fake-hashes/src/bin/static_invoice_deser_target.rs | 6 +++++- fuzz/fuzz-fake-hashes/src/bin/zbase32_target.rs | 6 +++++- fuzz/fuzz-real-hashes/src/bin/chanmon_consistency_target.rs | 6 +++++- fuzz/src/bin/target_template.txt | 6 +++++- 73 files changed, 365 insertions(+), 73 deletions(-) diff --git a/fuzz/fuzz-fake-hashes/src/bin/base32_target.rs b/fuzz/fuzz-fake-hashes/src/bin/base32_target.rs index e3cd1a66dd2..58f2799a1a8 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/base32_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/base32_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - base32_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + base32_test(&data, test_logger::DevNull {}); + } else { + base32_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/bech32_parse_target.rs b/fuzz/fuzz-fake-hashes/src/bin/bech32_parse_target.rs index 226ff19c472..947d04f4b0e 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/bech32_parse_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/bech32_parse_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - bech32_parse_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + bech32_parse_test(&data, test_logger::DevNull {}); + } else { + bech32_parse_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/bolt11_deser_target.rs b/fuzz/fuzz-fake-hashes/src/bin/bolt11_deser_target.rs index befa78fc105..f79b82019d0 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/bolt11_deser_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/bolt11_deser_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - bolt11_deser_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + bolt11_deser_test(&data, test_logger::DevNull {}); + } else { + bolt11_deser_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/chanmon_deser_target.rs b/fuzz/fuzz-fake-hashes/src/bin/chanmon_deser_target.rs index 259f9d36ad2..95be82b89ee 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/chanmon_deser_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/chanmon_deser_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - chanmon_deser_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + chanmon_deser_test(&data, test_logger::DevNull {}); + } else { + chanmon_deser_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/feature_flags_target.rs b/fuzz/fuzz-fake-hashes/src/bin/feature_flags_target.rs index d54bba994e8..d7f04b00c7f 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/feature_flags_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/feature_flags_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - feature_flags_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + feature_flags_test(&data, test_logger::DevNull {}); + } else { + feature_flags_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/fromstr_to_netaddress_target.rs b/fuzz/fuzz-fake-hashes/src/bin/fromstr_to_netaddress_target.rs index 94cedd91157..76cdbb96d3a 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/fromstr_to_netaddress_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/fromstr_to_netaddress_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - fromstr_to_netaddress_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + fromstr_to_netaddress_test(&data, test_logger::DevNull {}); + } else { + fromstr_to_netaddress_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/fs_store_target.rs b/fuzz/fuzz-fake-hashes/src/bin/fs_store_target.rs index e34cab13def..b02d69ad1b1 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/fs_store_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/fs_store_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - fs_store_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + fs_store_test(&data, test_logger::DevNull {}); + } else { + fs_store_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/full_stack_target.rs b/fuzz/fuzz-fake-hashes/src/bin/full_stack_target.rs index 81a49776b4b..6d0710f249d 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/full_stack_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/full_stack_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - full_stack_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + full_stack_test(&data, test_logger::DevNull {}); + } else { + full_stack_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/gossip_discovery_target.rs b/fuzz/fuzz-fake-hashes/src/bin/gossip_discovery_target.rs index 470ad17fe26..7c5a55f1036 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/gossip_discovery_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/gossip_discovery_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - gossip_discovery_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + gossip_discovery_test(&data, test_logger::DevNull {}); + } else { + gossip_discovery_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/indexedmap_target.rs b/fuzz/fuzz-fake-hashes/src/bin/indexedmap_target.rs index e8d7626a238..b375c768cde 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/indexedmap_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/indexedmap_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - indexedmap_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + indexedmap_test(&data, test_logger::DevNull {}); + } else { + indexedmap_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/invoice_deser_target.rs b/fuzz/fuzz-fake-hashes/src/bin/invoice_deser_target.rs index c1338f62e0e..14dcbfeaf3c 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/invoice_deser_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/invoice_deser_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - invoice_deser_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + invoice_deser_test(&data, test_logger::DevNull {}); + } else { + invoice_deser_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/invoice_request_deser_target.rs b/fuzz/fuzz-fake-hashes/src/bin/invoice_request_deser_target.rs index 2198b64b207..25ce271043e 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/invoice_request_deser_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/invoice_request_deser_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - invoice_request_deser_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + invoice_request_deser_test(&data, test_logger::DevNull {}); + } else { + invoice_request_deser_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/lsps_message_target.rs b/fuzz/fuzz-fake-hashes/src/bin/lsps_message_target.rs index 68e1c8b0e06..e82d75f6c5d 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/lsps_message_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/lsps_message_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - lsps_message_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + lsps_message_test(&data, test_logger::DevNull {}); + } else { + lsps_message_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_accept_channel_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_accept_channel_target.rs index 798e2d9e5aa..47f4fa074b4 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_accept_channel_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_accept_channel_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_accept_channel_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_accept_channel_test(&data, test_logger::DevNull {}); + } else { + msg_accept_channel_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_accept_channel_v2_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_accept_channel_v2_target.rs index eff73d11ded..656e3906914 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_accept_channel_v2_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_accept_channel_v2_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_accept_channel_v2_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_accept_channel_v2_test(&data, test_logger::DevNull {}); + } else { + msg_accept_channel_v2_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_announcement_signatures_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_announcement_signatures_target.rs index 09b76396873..a215f0e4816 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_announcement_signatures_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_announcement_signatures_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_announcement_signatures_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_announcement_signatures_test(&data, test_logger::DevNull {}); + } else { + msg_announcement_signatures_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_blinded_message_path_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_blinded_message_path_target.rs index 92c0976dc79..241493902b7 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_blinded_message_path_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_blinded_message_path_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_blinded_message_path_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_blinded_message_path_test(&data, test_logger::DevNull {}); + } else { + msg_blinded_message_path_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_channel_announcement_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_channel_announcement_target.rs index 482dbbc4345..1597fb05502 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_channel_announcement_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_channel_announcement_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_channel_announcement_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_channel_announcement_test(&data, test_logger::DevNull {}); + } else { + msg_channel_announcement_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_channel_details_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_channel_details_target.rs index 04af6755917..c1f8d9a24ee 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_channel_details_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_channel_details_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_channel_details_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_channel_details_test(&data, test_logger::DevNull {}); + } else { + msg_channel_details_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_channel_ready_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_channel_ready_target.rs index 34511509f39..3330fc6679c 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_channel_ready_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_channel_ready_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_channel_ready_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_channel_ready_test(&data, test_logger::DevNull {}); + } else { + msg_channel_ready_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_channel_reestablish_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_channel_reestablish_target.rs index 0541cedafe2..77bc4b5579f 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_channel_reestablish_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_channel_reestablish_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_channel_reestablish_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_channel_reestablish_test(&data, test_logger::DevNull {}); + } else { + msg_channel_reestablish_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_channel_update_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_channel_update_target.rs index 7d08ee24005..a7ef9d294ba 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_channel_update_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_channel_update_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_channel_update_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_channel_update_test(&data, test_logger::DevNull {}); + } else { + msg_channel_update_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_closing_complete_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_closing_complete_target.rs index 7bcb76d2fbd..bfbcb25b7f9 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_closing_complete_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_closing_complete_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_closing_complete_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_closing_complete_test(&data, test_logger::DevNull {}); + } else { + msg_closing_complete_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_closing_sig_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_closing_sig_target.rs index 54669e259c3..99e173378b1 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_closing_sig_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_closing_sig_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_closing_sig_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_closing_sig_test(&data, test_logger::DevNull {}); + } else { + msg_closing_sig_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_closing_signed_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_closing_signed_target.rs index f5813a7919d..f5162b3f960 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_closing_signed_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_closing_signed_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_closing_signed_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_closing_signed_test(&data, test_logger::DevNull {}); + } else { + msg_closing_signed_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_commitment_signed_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_commitment_signed_target.rs index a62449b1673..b0e0908772d 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_commitment_signed_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_commitment_signed_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_commitment_signed_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_commitment_signed_test(&data, test_logger::DevNull {}); + } else { + msg_commitment_signed_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_decoded_onion_error_packet_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_decoded_onion_error_packet_target.rs index 75e37116d79..c2f08b932db 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_decoded_onion_error_packet_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_decoded_onion_error_packet_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_decoded_onion_error_packet_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_decoded_onion_error_packet_test(&data, test_logger::DevNull {}); + } else { + msg_decoded_onion_error_packet_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_error_message_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_error_message_target.rs index 23c9524478d..94744288387 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_error_message_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_error_message_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_error_message_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_error_message_test(&data, test_logger::DevNull {}); + } else { + msg_error_message_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_funding_created_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_funding_created_target.rs index c423e6e9c24..f680fa2146e 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_funding_created_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_funding_created_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_funding_created_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_funding_created_test(&data, test_logger::DevNull {}); + } else { + msg_funding_created_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_funding_signed_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_funding_signed_target.rs index de10f0e71dc..1421741339a 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_funding_signed_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_funding_signed_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_funding_signed_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_funding_signed_test(&data, test_logger::DevNull {}); + } else { + msg_funding_signed_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_gossip_timestamp_filter_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_gossip_timestamp_filter_target.rs index cef5bc576c2..0164801f68a 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_gossip_timestamp_filter_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_gossip_timestamp_filter_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_gossip_timestamp_filter_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_gossip_timestamp_filter_test(&data, test_logger::DevNull {}); + } else { + msg_gossip_timestamp_filter_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_init_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_init_target.rs index 7e51e6e63e5..bc55ee4c036 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_init_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_init_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_init_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_init_test(&data, test_logger::DevNull {}); + } else { + msg_init_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_node_announcement_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_node_announcement_target.rs index c7aaecb644a..3f8e42848b5 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_node_announcement_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_node_announcement_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_node_announcement_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_node_announcement_test(&data, test_logger::DevNull {}); + } else { + msg_node_announcement_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_open_channel_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_open_channel_target.rs index bb49be7d994..6b59ece9eb0 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_open_channel_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_open_channel_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_open_channel_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_open_channel_test(&data, test_logger::DevNull {}); + } else { + msg_open_channel_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_open_channel_v2_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_open_channel_v2_target.rs index a6d45dc3a45..57492d4bd4f 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_open_channel_v2_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_open_channel_v2_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_open_channel_v2_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_open_channel_v2_test(&data, test_logger::DevNull {}); + } else { + msg_open_channel_v2_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_ping_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_ping_target.rs index 70bb751c594..dc2061a81cc 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_ping_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_ping_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_ping_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_ping_test(&data, test_logger::DevNull {}); + } else { + msg_ping_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_pong_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_pong_target.rs index 74df6d86474..2dc355438cc 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_pong_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_pong_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_pong_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_pong_test(&data, test_logger::DevNull {}); + } else { + msg_pong_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_query_channel_range_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_query_channel_range_target.rs index e497491083f..392371e036b 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_query_channel_range_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_query_channel_range_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_query_channel_range_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_query_channel_range_test(&data, test_logger::DevNull {}); + } else { + msg_query_channel_range_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_query_short_channel_ids_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_query_short_channel_ids_target.rs index 31169f9e665..d52489d6a81 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_query_short_channel_ids_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_query_short_channel_ids_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_query_short_channel_ids_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_query_short_channel_ids_test(&data, test_logger::DevNull {}); + } else { + msg_query_short_channel_ids_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_reply_channel_range_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_reply_channel_range_target.rs index a0aaadf321d..149d9592058 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_reply_channel_range_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_reply_channel_range_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_reply_channel_range_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_reply_channel_range_test(&data, test_logger::DevNull {}); + } else { + msg_reply_channel_range_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_reply_short_channel_ids_end_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_reply_short_channel_ids_end_target.rs index 8931538e4f5..65f3145c21e 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_reply_short_channel_ids_end_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_reply_short_channel_ids_end_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_reply_short_channel_ids_end_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_reply_short_channel_ids_end_test(&data, test_logger::DevNull {}); + } else { + msg_reply_short_channel_ids_end_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_revoke_and_ack_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_revoke_and_ack_target.rs index 6ed40d3ab91..8ba9474da9b 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_revoke_and_ack_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_revoke_and_ack_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_revoke_and_ack_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_revoke_and_ack_test(&data, test_logger::DevNull {}); + } else { + msg_revoke_and_ack_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_shutdown_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_shutdown_target.rs index a731a1dd91f..ad5fda13b1f 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_shutdown_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_shutdown_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_shutdown_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_shutdown_test(&data, test_logger::DevNull {}); + } else { + msg_shutdown_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_splice_ack_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_splice_ack_target.rs index 20625fc759c..23860eef49f 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_splice_ack_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_splice_ack_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_splice_ack_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_splice_ack_test(&data, test_logger::DevNull {}); + } else { + msg_splice_ack_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_splice_init_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_splice_init_target.rs index b3d30a660a1..229e3f298ca 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_splice_init_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_splice_init_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_splice_init_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_splice_init_test(&data, test_logger::DevNull {}); + } else { + msg_splice_init_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_splice_locked_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_splice_locked_target.rs index deb57b61974..86cebcaf52f 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_splice_locked_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_splice_locked_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_splice_locked_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_splice_locked_test(&data, test_logger::DevNull {}); + } else { + msg_splice_locked_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_stfu_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_stfu_target.rs index de3a64f542b..8adc61075c0 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_stfu_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_stfu_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_stfu_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_stfu_test(&data, test_logger::DevNull {}); + } else { + msg_stfu_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_tx_abort_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_abort_target.rs index 0b335c23b18..368e694591d 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_tx_abort_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_abort_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_abort_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_tx_abort_test(&data, test_logger::DevNull {}); + } else { + msg_tx_abort_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_tx_ack_rbf_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_ack_rbf_target.rs index d69077c9075..26bcf2ac201 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_tx_ack_rbf_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_ack_rbf_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_ack_rbf_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_tx_ack_rbf_test(&data, test_logger::DevNull {}); + } else { + msg_tx_ack_rbf_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_tx_add_input_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_add_input_target.rs index 8dff0a621c9..4d54686c173 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_tx_add_input_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_add_input_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_add_input_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_tx_add_input_test(&data, test_logger::DevNull {}); + } else { + msg_tx_add_input_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_tx_add_output_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_add_output_target.rs index f6808399aba..c7d06753dd3 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_tx_add_output_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_add_output_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_add_output_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_tx_add_output_test(&data, test_logger::DevNull {}); + } else { + msg_tx_add_output_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_tx_complete_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_complete_target.rs index 2edccfbf690..8a201ec0739 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_tx_complete_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_complete_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_complete_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_tx_complete_test(&data, test_logger::DevNull {}); + } else { + msg_tx_complete_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_tx_init_rbf_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_init_rbf_target.rs index 80acf0f11c8..cc889207ba1 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_tx_init_rbf_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_init_rbf_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_init_rbf_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_tx_init_rbf_test(&data, test_logger::DevNull {}); + } else { + msg_tx_init_rbf_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_tx_remove_input_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_remove_input_target.rs index b1555a2412a..f28ad5951ea 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_tx_remove_input_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_remove_input_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_remove_input_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_tx_remove_input_test(&data, test_logger::DevNull {}); + } else { + msg_tx_remove_input_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_tx_remove_output_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_remove_output_target.rs index a8e5b20d06d..de691b20fc0 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_tx_remove_output_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_remove_output_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_remove_output_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_tx_remove_output_test(&data, test_logger::DevNull {}); + } else { + msg_tx_remove_output_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_tx_signatures_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_signatures_target.rs index 2a1fbf9d16e..260ffde5695 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_tx_signatures_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_tx_signatures_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_tx_signatures_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_tx_signatures_test(&data, test_logger::DevNull {}); + } else { + msg_tx_signatures_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_update_add_htlc_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_update_add_htlc_target.rs index d3b45d589eb..2fb5ad1bc41 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_update_add_htlc_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_update_add_htlc_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_update_add_htlc_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_update_add_htlc_test(&data, test_logger::DevNull {}); + } else { + msg_update_add_htlc_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_update_fail_htlc_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_update_fail_htlc_target.rs index bec5bc9e331..8500c3e0b6f 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_update_fail_htlc_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_update_fail_htlc_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_update_fail_htlc_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_update_fail_htlc_test(&data, test_logger::DevNull {}); + } else { + msg_update_fail_htlc_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_update_fail_malformed_htlc_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_update_fail_malformed_htlc_target.rs index 190412bc0a7..e8ed16dbb13 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_update_fail_malformed_htlc_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_update_fail_malformed_htlc_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_update_fail_malformed_htlc_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_update_fail_malformed_htlc_test(&data, test_logger::DevNull {}); + } else { + msg_update_fail_malformed_htlc_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_update_fee_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_update_fee_target.rs index 386db47ae9f..aec31d4892c 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_update_fee_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_update_fee_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_update_fee_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_update_fee_test(&data, test_logger::DevNull {}); + } else { + msg_update_fee_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/msg_update_fulfill_htlc_target.rs b/fuzz/fuzz-fake-hashes/src/bin/msg_update_fulfill_htlc_target.rs index ab49c21043e..89b1e845fd9 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/msg_update_fulfill_htlc_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/msg_update_fulfill_htlc_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - msg_update_fulfill_htlc_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + msg_update_fulfill_htlc_test(&data, test_logger::DevNull {}); + } else { + msg_update_fulfill_htlc_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/offer_deser_target.rs b/fuzz/fuzz-fake-hashes/src/bin/offer_deser_target.rs index 25eda5618f0..34514e5439e 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/offer_deser_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/offer_deser_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - offer_deser_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + offer_deser_test(&data, test_logger::DevNull {}); + } else { + offer_deser_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/onion_hop_data_target.rs b/fuzz/fuzz-fake-hashes/src/bin/onion_hop_data_target.rs index 05ce4d76aeb..e35213a3b24 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/onion_hop_data_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/onion_hop_data_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - onion_hop_data_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + onion_hop_data_test(&data, test_logger::DevNull {}); + } else { + onion_hop_data_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/onion_message_target.rs b/fuzz/fuzz-fake-hashes/src/bin/onion_message_target.rs index f5a0eb60171..c85b7008a65 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/onion_message_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/onion_message_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - onion_message_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + onion_message_test(&data, test_logger::DevNull {}); + } else { + onion_message_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/peer_crypt_target.rs b/fuzz/fuzz-fake-hashes/src/bin/peer_crypt_target.rs index 3095f2a870c..2564fde509e 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/peer_crypt_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/peer_crypt_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - peer_crypt_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + peer_crypt_test(&data, test_logger::DevNull {}); + } else { + peer_crypt_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/process_network_graph_target.rs b/fuzz/fuzz-fake-hashes/src/bin/process_network_graph_target.rs index 36ea42bcb6a..c684f35e5a3 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/process_network_graph_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/process_network_graph_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - process_network_graph_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + process_network_graph_test(&data, test_logger::DevNull {}); + } else { + process_network_graph_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/process_onion_failure_target.rs b/fuzz/fuzz-fake-hashes/src/bin/process_onion_failure_target.rs index 1d6c64c5863..05a209fefea 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/process_onion_failure_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/process_onion_failure_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - process_onion_failure_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + process_onion_failure_test(&data, test_logger::DevNull {}); + } else { + process_onion_failure_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/refund_deser_target.rs b/fuzz/fuzz-fake-hashes/src/bin/refund_deser_target.rs index a5295b8d793..57eb4c9c074 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/refund_deser_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/refund_deser_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - refund_deser_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + refund_deser_test(&data, test_logger::DevNull {}); + } else { + refund_deser_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/router_target.rs b/fuzz/fuzz-fake-hashes/src/bin/router_target.rs index ecf6dbe9b57..cca57db2b05 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/router_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/router_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - router_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + router_test(&data, test_logger::DevNull {}); + } else { + router_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/static_invoice_deser_target.rs b/fuzz/fuzz-fake-hashes/src/bin/static_invoice_deser_target.rs index 787817de00e..a06cd51cffb 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/static_invoice_deser_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/static_invoice_deser_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - static_invoice_deser_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + static_invoice_deser_test(&data, test_logger::DevNull {}); + } else { + static_invoice_deser_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-fake-hashes/src/bin/zbase32_target.rs b/fuzz/fuzz-fake-hashes/src/bin/zbase32_target.rs index 1007df19acf..f66381ad3a5 100644 --- a/fuzz/fuzz-fake-hashes/src/bin/zbase32_target.rs +++ b/fuzz/fuzz-fake-hashes/src/bin/zbase32_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - zbase32_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + zbase32_test(&data, test_logger::DevNull {}); + } else { + zbase32_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/fuzz-real-hashes/src/bin/chanmon_consistency_target.rs b/fuzz/fuzz-real-hashes/src/bin/chanmon_consistency_target.rs index 335c8169c75..86791e46905 100644 --- a/fuzz/fuzz-real-hashes/src/bin/chanmon_consistency_target.rs +++ b/fuzz/fuzz-real-hashes/src/bin/chanmon_consistency_target.rs @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - chanmon_consistency_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + chanmon_consistency_test(&data, test_logger::DevNull {}); + } else { + chanmon_consistency_test(&data, test_logger::Stdout {}); + } } #[test] diff --git a/fuzz/src/bin/target_template.txt b/fuzz/src/bin/target_template.txt index 78bc7f37d87..1dbd40aa6b8 100644 --- a/fuzz/src/bin/target_template.txt +++ b/fuzz/src/bin/target_template.txt @@ -71,7 +71,11 @@ fn main() { let mut data = Vec::with_capacity(8192); std::io::stdin().read_to_end(&mut data).unwrap(); - TARGET_NAME_test(&data, test_logger::Stdout {}); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + TARGET_NAME_test(&data, test_logger::DevNull {}); + } else { + TARGET_NAME_test(&data, test_logger::Stdout {}); + } } #[test] From bda8e69eb75425b24bcf0139c3042fd3616275c5 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 29 May 2026 01:05:49 +0000 Subject: [PATCH 443/627] Don't validate a splice if updates are pending `FundedChannel::get_next_splice_out_maximum` is called in `FundedChannel::splice_channel`, which can be called when updates are pending in the channel. If this is the case, `FundedChannel::get_next_splice_out_maximum` may report a value that is not yet valid on both commitments, and thus fails `FundedChannel::validate_splice_contributions`. That value will nonetheless be valid on both commitments once the updates are cleared from the channel, and splice negotiation actually begins. So, we now validate `FundedChannel::get_next_splice_out_maximum` with `FundedChannel::validate_splice_contributions` only if there are no pending updates in the channel. Joost and Wilmer's fuzzing runs caught this discrepancy. --- lightning/src/ln/channel.rs | 2 ++ lightning/src/ln/splicing_tests.rs | 41 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index d0072da226a..7d7b8352b9c 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -13774,6 +13774,8 @@ where remote_stats.available_balances.next_splice_out_maximum_sat; #[cfg(debug_assertions)] + if !self.context.is_waiting_on_peer_pending_channel_update() + && !self.context.is_monitor_or_signer_pending_channel_update() { // After this max splice out, validation passes, accounting for the updated reserves self.validate_splice_contributions( diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index c0ec89faf93..dfcc339b83b 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -9770,3 +9770,44 @@ fn test_splice_out_maximum_on_both_commitments_dust_on_funder_commitment() { CHANNEL_VALUE_SAT - node_0_payment_sat - TOTAL_ANCHORS_SAT - reserved_fee_sat; assert_eq!(details.next_splice_out_maximum_sat, expected_next_splice_out_maximum_sat); } + +// When we advertise the next splice out maximum, we include any HTLCs in the state +// `InboundHTLCState::LocalRemoved(Fulfill { .. })` in our balance; by the time we clear this update +// and splice the channel, our settled balance will include it. +#[test] +fn test_splice_out_maximum_includes_pending_claimed_inbound_htlc() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + const CHANNEL_VALUE_MSAT: u64 = 100_000_000; + const PENDING_CLAIMED_INBOUND_HTLC_MSAT: u64 = 10_000_000; + + let node_id_0 = nodes[0].node.get_our_node_id(); + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, CHANNEL_VALUE_MSAT / 1000, 0); + + let (payment_preimage, payment_hash, ..) = + route_payment(&nodes[0], &[&nodes[1]], PENDING_CLAIMED_INBOUND_HTLC_MSAT); + + nodes[1].node.claim_funds(payment_preimage); + check_added_monitors(&nodes[1], 1); + expect_payment_claimed!(nodes[1], payment_hash, PENDING_CLAIMED_INBOUND_HTLC_MSAT); + + let updates = get_htlc_update_msgs(&nodes[1], &node_id_0); + assert!(updates.update_add_htlcs.is_empty()); + assert_eq!(updates.update_fulfill_htlcs.len(), 1); + assert!(updates.update_fail_htlcs.is_empty()); + assert!(updates.update_fail_malformed_htlcs.is_empty()); + assert!(updates.update_fee.is_none()); + assert_eq!(updates.commitment_signed.len(), 1); + + let node_1_details = &nodes[1].node.list_channels()[0]; + let local_balance_before_fee_sat = PENDING_CLAIMED_INBOUND_HTLC_MSAT / 1000; + let dividend_sat = local_balance_before_fee_sat * 100 + 100 - CHANNEL_VALUE_MSAT / 1000; + let expected_splice_out_max = (dividend_sat - 1) / 99; + assert_eq!(node_1_details.next_splice_out_maximum_sat, expected_splice_out_max); + + assert!(nodes[1].node.splice_channel(&channel_id, &node_id_0).is_ok()); +} From 668b35b2ea9e61c3ace0be4bef37729aefcea3eb Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sat, 30 May 2026 12:34:23 +0200 Subject: [PATCH 444/627] Detect nested v1 filesystem data FilesystemStoreV2 already rejected v1 data when a key file was found at the store root, but it did not inspect namespace directories. This missed v1 layouts such as primary/key, where v2 expects primary to contain secondary namespace directories. For example, an ldk-node store can contain a BDK descriptor below a namespace directory. The previous check would accept that directory as v2 data because the root contained only directories, leaving the incompatible descriptor file undetected. --- lightning-persister/src/fs_store/v2.rs | 75 ++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/lightning-persister/src/fs_store/v2.rs b/lightning-persister/src/fs_store/v2.rs index 2f79cae0da3..6154d22d35c 100644 --- a/lightning-persister/src/fs_store/v2.rs +++ b/lightning-persister/src/fs_store/v2.rs @@ -21,8 +21,8 @@ use std::sync::Arc; /// An error returned when constructing a [`FilesystemStoreV2`]. #[derive(Debug)] pub enum FilesystemStoreV2Error { - /// The data directory contains a file at the top level, indicating it was previously used - /// by [`FilesystemStore`] (v1). Contains the path of the offending file. + /// The data directory contains a file where v2 expects a namespace directory, indicating it + /// was previously used by [`FilesystemStore`] (v1). Contains the path of the offending file. /// /// [`FilesystemStore`]: crate::fs_store::v1::FilesystemStore V1DataDetected(PathBuf), @@ -35,7 +35,7 @@ impl fmt::Display for FilesystemStoreV2Error { match self { Self::V1DataDetected(path) => write!( f, - "Found file `{}` in the top-level data directory. \ + "Found file `{}` where FilesystemStoreV2 expects a namespace directory. \ This indicates the directory was previously used by FilesystemStore (v1). \ Please migrate your data or use a different directory.", path.display() @@ -97,18 +97,28 @@ impl FilesystemStoreV2 { /// Constructs a new [`FilesystemStoreV2`]. /// /// Returns [`FilesystemStoreV2Error::V1DataDetected`] if the data directory already exists - /// and contains files at the top level, which would indicate it was previously used by a - /// [`FilesystemStore`] (v1). The v2 store expects only directories (namespaces) at the top - /// level. + /// and contains files where v2 expects namespace directories, which would indicate it was + /// previously used by a [`FilesystemStore`] (v1). The v2 store expects only directories at + /// the top level and one level down. /// /// [`FilesystemStore`]: crate::fs_store::v1::FilesystemStore pub fn new(data_dir: PathBuf) -> Result { if data_dir.exists() { for entry in fs::read_dir(&data_dir)? { let entry = entry?; - if entry.file_type()?.is_file() { + let file_type = entry.file_type()?; + if file_type.is_file() { return Err(FilesystemStoreV2Error::V1DataDetected(entry.path())); } + + if file_type.is_dir() { + for child_entry in fs::read_dir(entry.path())? { + let child_entry = child_entry?; + if child_entry.file_type()?.is_file() { + return Err(FilesystemStoreV2Error::V1DataDetected(child_entry.path())); + } + } + } } } @@ -699,6 +709,7 @@ mod tests { fs::create_dir_all(&temp_path).unwrap(); // Create a file at the top level, as v1 would for an empty primary namespace + // and an empty secondary namespace. fs::write(temp_path.join("some_key"), b"data").unwrap(); // V2 construction should fail @@ -713,13 +724,59 @@ mod tests { // Clean up let _ = fs::remove_dir_all(&temp_path); + // Create a file one level down, as v1 would for a non-empty primary namespace + // and an empty secondary namespace. + fs::create_dir_all(temp_path.join("some_namespace")).unwrap(); + fs::write(temp_path.join("some_namespace").join("some_key"), b"data").unwrap(); + + match FilesystemStoreV2::new(temp_path.clone()) { + Err(FilesystemStoreV2Error::V1DataDetected(path)) => { + assert_eq!(path, temp_path.join("some_namespace").join("some_key")); + }, + Err(err) => panic!("Expected V1DataDetected, got {:?}", err), + Ok(_) => panic!("Expected error for directory with files one level down"), + } + + let _ = fs::remove_dir_all(&temp_path); + + // A v1 write with an empty primary namespace and non-empty secondary namespace + // is rejected by the KVStore API, but its filesystem layout would be the same + // one-level shape. + fs::create_dir_all(temp_path.join("some_secondary_namespace")).unwrap(); + fs::write(temp_path.join("some_secondary_namespace").join("some_key"), b"data").unwrap(); + + match FilesystemStoreV2::new(temp_path.clone()) { + Err(FilesystemStoreV2Error::V1DataDetected(path)) => { + assert_eq!(path, temp_path.join("some_secondary_namespace").join("some_key")); + }, + Err(err) => panic!("Expected V1DataDetected, got {:?}", err), + Ok(_) => panic!("Expected error for directory with files one level down"), + } + + let _ = fs::remove_dir_all(&temp_path); + // An empty directory should succeed fs::create_dir_all(&temp_path).unwrap(); let result = FilesystemStoreV2::new(temp_path.clone()); assert!(result.is_ok()); - // A directory with only subdirectories should succeed - fs::create_dir_all(temp_path.join("some_namespace")).unwrap(); + // A directory with only namespace subdirectories should succeed + fs::create_dir_all(temp_path.join("some_namespace").join("some_sub_namespace")).unwrap(); + let result = FilesystemStoreV2::new(temp_path.clone()); + assert!(result.is_ok()); + + // V1 data with non-empty primary and secondary namespaces has the same filesystem + // layout as valid v2 data, so construction must not reject this shape. + let fs_store = result.unwrap(); + KVStoreSync::write( + &fs_store, + "some_namespace", + "some_sub_namespace", + "some_key", + b"data".to_vec(), + ) + .unwrap(); + let result = FilesystemStoreV2::new(temp_path); assert!(result.is_ok()); } From ba7e36e0063cc3652ba429763d6ae612dad58456 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Mon, 1 Jun 2026 11:13:51 +0200 Subject: [PATCH 445/627] Rename read-write TLV ser macros Rename TLV macros that generate both Readable and Writeable impls to use the impl_ser_tlv_based prefix. Keep the MaybeReadable upgradable enum helpers and shared write-only enum helper under writeable naming so macro names match the traits they generate. --- lightning-liquidity/src/lsps1/msgs.rs | 18 +++++----- lightning-liquidity/src/lsps1/peer_state.rs | 8 ++--- lightning-liquidity/src/lsps2/event.rs | 4 +-- lightning-liquidity/src/lsps2/msgs.rs | 4 +-- .../src/lsps2/payment_queue.rs | 8 ++--- lightning-liquidity/src/lsps2/service.rs | 10 +++--- lightning-liquidity/src/lsps5/event.rs | 4 +-- lightning-liquidity/src/lsps5/msgs.rs | 6 ++-- lightning-liquidity/src/lsps5/service.rs | 6 ++-- lightning/src/blinded_path/message.rs | 8 ++--- lightning/src/blinded_path/payment.rs | 8 ++--- lightning/src/chain/chaininterface.rs | 6 ++-- lightning/src/chain/channelmonitor.rs | 8 ++--- lightning/src/chain/mod.rs | 2 +- lightning/src/chain/package.rs | 6 ++-- lightning/src/crypto/streams.rs | 2 +- lightning/src/events/mod.rs | 14 ++++---- lightning/src/ln/chan_utils.rs | 12 +++---- lightning/src/ln/channel.rs | 8 ++--- lightning/src/ln/channel_state.rs | 12 +++---- lightning/src/ln/channelmanager.rs | 32 ++++++++--------- lightning/src/ln/funding.rs | 4 +-- lightning/src/ln/interactivetxs.rs | 16 ++++----- lightning/src/ln/onion_utils.rs | 2 +- lightning/src/ln/our_peer_storage.rs | 2 +- lightning/src/ln/outbound_payment.rs | 8 ++--- lightning/src/ln/script.rs | 2 +- .../src/offers/async_receive_offer_cache.rs | 4 +-- lightning/src/onion_message/async_payments.rs | 12 +++---- lightning/src/onion_message/messenger.rs | 2 +- lightning/src/routing/gossip.rs | 2 +- lightning/src/routing/router.rs | 12 +++---- lightning/src/routing/scoring.rs | 4 +-- lightning/src/sign/mod.rs | 10 +++--- lightning/src/util/config.rs | 2 +- lightning/src/util/ser_macros.rs | 36 +++++++++---------- lightning/src/util/sweep.rs | 6 ++-- lightning/src/util/wallet_utils.rs | 4 +-- 38 files changed, 157 insertions(+), 157 deletions(-) diff --git a/lightning-liquidity/src/lsps1/msgs.rs b/lightning-liquidity/src/lsps1/msgs.rs index b754f0438aa..9d0d54e2daf 100644 --- a/lightning-liquidity/src/lsps1/msgs.rs +++ b/lightning-liquidity/src/lsps1/msgs.rs @@ -21,7 +21,7 @@ use crate::lsps0::ser::{ use bitcoin::{Address, FeeRate, OutPoint}; use lightning::offers::offer::Offer; use lightning::util::ser::{Readable, Writeable}; -use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; +use lightning::{impl_ser_tlv_based, impl_ser_tlv_based_enum}; use lightning_invoice::Bolt11Invoice; use serde::{Deserialize, Serialize}; @@ -145,7 +145,7 @@ pub struct LSPS1OrderParams { pub announce_channel: bool, } -impl_writeable_tlv_based!(LSPS1OrderParams, { +impl_ser_tlv_based!(LSPS1OrderParams, { (0, lsp_balance_sat, required), (2, client_balance_sat, required), (4, required_channel_confirmations, required), @@ -185,7 +185,7 @@ pub enum LSPS1OrderState { Failed, } -impl_writeable_tlv_based_enum!(LSPS1OrderState, +impl_ser_tlv_based_enum!(LSPS1OrderState, (0, Created) => {}, (2, Completed) => {}, (4, Failed) => {} @@ -202,7 +202,7 @@ pub struct LSPS1PaymentInfo { pub onchain: Option, } -impl_writeable_tlv_based!(LSPS1PaymentInfo, { +impl_ser_tlv_based!(LSPS1PaymentInfo, { (0, bolt11, option), (2, bolt12, option), (4, onchain, option), @@ -225,7 +225,7 @@ pub struct LSPS1Bolt11PaymentInfo { pub invoice: Bolt11Invoice, } -impl_writeable_tlv_based!(LSPS1Bolt11PaymentInfo, { +impl_ser_tlv_based!(LSPS1Bolt11PaymentInfo, { (0, state, required), (2, expires_at, required), (4, fee_total_sat, required), @@ -251,7 +251,7 @@ pub struct LSPS1Bolt12PaymentInfo { pub offer: Offer, } -impl_writeable_tlv_based!(LSPS1Bolt12PaymentInfo, { +impl_ser_tlv_based!(LSPS1Bolt12PaymentInfo, { (0, state, required), (2, expires_at, required), (4, fee_total_sat, required), @@ -290,7 +290,7 @@ pub struct LSPS1OnchainPaymentInfo { pub refund_onchain_address: Option
        , } -impl_writeable_tlv_based!(LSPS1OnchainPaymentInfo, { +impl_ser_tlv_based!(LSPS1OnchainPaymentInfo, { (0, state, required), (2, expires_at, required), (4, fee_total_sat, required), @@ -322,7 +322,7 @@ pub enum LSPS1PaymentState { Refunded, } -impl_writeable_tlv_based_enum!(LSPS1PaymentState, +impl_ser_tlv_based_enum!(LSPS1PaymentState, (0, ExpectPayment) => {}, (2, Hold) => {}, (4, Paid) => {}, @@ -340,7 +340,7 @@ pub struct LSPS1ChannelInfo { pub expires_at: LSPSDateTime, } -impl_writeable_tlv_based!(LSPS1ChannelInfo, { +impl_ser_tlv_based!(LSPS1ChannelInfo, { (0, funded_at, required), (2, funding_outpoint, required), (4, expires_at, required), diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 6e1889749ae..26842e8e799 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -18,7 +18,7 @@ use crate::lsps0::ser::{LSPSDateTime, LSPSRequestId}; use crate::prelude::HashMap; use lightning::util::hash_tables::new_hash_map; -use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; +use lightning::{impl_ser_tlv_based, impl_ser_tlv_based_enum}; use core::fmt; @@ -251,7 +251,7 @@ impl ChannelOrderState { } } -impl_writeable_tlv_based_enum!(ChannelOrderState, +impl_ser_tlv_based_enum!(ChannelOrderState, (0, ExpectingPayment) => { (0, payment_details, required), }, @@ -415,7 +415,7 @@ impl PeerState { } } -impl_writeable_tlv_based!(PeerState, { +impl_ser_tlv_based!(PeerState, { (0, outbound_channels_by_order_id, required), (_unused, pending_requests, (static_value, new_hash_map())), (_unused, needs_persist, (static_value, false)), @@ -491,7 +491,7 @@ impl ChannelOrder { } } -impl_writeable_tlv_based!(ChannelOrder, { +impl_ser_tlv_based!(ChannelOrder, { (0, order_params, required), (2, state, required), (4, created_at, required), diff --git a/lightning-liquidity/src/lsps2/event.rs b/lightning-liquidity/src/lsps2/event.rs index 502429b79ec..956da403e11 100644 --- a/lightning-liquidity/src/lsps2/event.rs +++ b/lightning-liquidity/src/lsps2/event.rs @@ -16,7 +16,7 @@ use alloc::vec::Vec; use bitcoin::secp256k1::PublicKey; -use lightning::impl_writeable_tlv_based_enum; +use lightning::impl_ser_tlv_based_enum; /// An event which an LSPS2 client should take some action in response to. #[derive(Clone, Debug, PartialEq, Eq)] @@ -181,7 +181,7 @@ pub enum LSPS2ServiceEvent { }, } -impl_writeable_tlv_based_enum!(LSPS2ServiceEvent, +impl_ser_tlv_based_enum!(LSPS2ServiceEvent, (0, GetInfo) => { (0, request_id, required), (2, counterparty_node_id, required), diff --git a/lightning-liquidity/src/lsps2/msgs.rs b/lightning-liquidity/src/lsps2/msgs.rs index ba4d0fea4cd..9375069ca0a 100644 --- a/lightning-liquidity/src/lsps2/msgs.rs +++ b/lightning-liquidity/src/lsps2/msgs.rs @@ -21,7 +21,7 @@ use bitcoin::secp256k1::PublicKey; use serde::{Deserialize, Serialize}; -use lightning::impl_writeable_tlv_based; +use lightning::impl_ser_tlv_based; use lightning::util::scid_utils; use crate::lsps0::ser::{ @@ -123,7 +123,7 @@ pub struct LSPS2OpeningFeeParams { pub promise: String, } -impl_writeable_tlv_based!(LSPS2OpeningFeeParams, { +impl_ser_tlv_based!(LSPS2OpeningFeeParams, { (0, min_fee_msat, required), (2, proportional, required), (4, valid_until, required), diff --git a/lightning-liquidity/src/lsps2/payment_queue.rs b/lightning-liquidity/src/lsps2/payment_queue.rs index 003939d699d..421e42d7706 100644 --- a/lightning-liquidity/src/lsps2/payment_queue.rs +++ b/lightning-liquidity/src/lsps2/payment_queue.rs @@ -9,7 +9,7 @@ use alloc::vec::Vec; -use lightning::impl_writeable_tlv_based; +use lightning::impl_ser_tlv_based; use lightning::ln::channelmanager::InterceptId; use lightning_types::payment::PaymentHash; @@ -63,7 +63,7 @@ impl PaymentQueue { } } -impl_writeable_tlv_based!(PaymentQueue, { +impl_ser_tlv_based!(PaymentQueue, { (0, payments, optional_vec), }); @@ -73,7 +73,7 @@ pub(crate) struct PaymentQueueEntry { pub(crate) htlcs: Vec, } -impl_writeable_tlv_based!(PaymentQueueEntry, { +impl_ser_tlv_based!(PaymentQueueEntry, { (0, payment_hash, required), (2, htlcs, optional_vec), }); @@ -85,7 +85,7 @@ pub(crate) struct InterceptedHTLC { pub(crate) payment_hash: PaymentHash, } -impl_writeable_tlv_based!(InterceptedHTLC, { +impl_ser_tlv_based!(InterceptedHTLC, { (0, intercept_id, required), (2, expected_outbound_amount_msat, required), (4, payment_hash, required), diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index b7f6f2fc64d..5f318fc077e 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -48,7 +48,7 @@ use lightning::ln::types::ChannelId; use lightning::util::errors::APIError; use lightning::util::logger::Level; use lightning::util::ser::Writeable; -use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; +use lightning::{impl_ser_tlv_based, impl_ser_tlv_based_enum}; use lightning_types::payment::PaymentHash; @@ -181,7 +181,7 @@ impl TrustModel { } } -impl_writeable_tlv_based_enum!(TrustModel, +impl_ser_tlv_based_enum!(TrustModel, (0, ClientTrustsLsp) => { (0, funding_tx_broadcast_safe, required), (2, funding_tx, option), @@ -468,7 +468,7 @@ impl OutboundJITChannelState { } } -impl_writeable_tlv_based_enum!(OutboundJITChannelState, +impl_ser_tlv_based_enum!(OutboundJITChannelState, (0, PendingInitialPayment) => { (0, payment_queue, required), }, @@ -499,7 +499,7 @@ struct OutboundJITChannel { trust_model: TrustModel, } -impl_writeable_tlv_based!(OutboundJITChannel, { +impl_ser_tlv_based!(OutboundJITChannel, { (0, state, required), (2, user_channel_id, required), (4, opening_fee_params, required), @@ -660,7 +660,7 @@ impl PeerState { } } -impl_writeable_tlv_based!(PeerState, { +impl_ser_tlv_based!(PeerState, { (0, outbound_channels_by_intercept_scid, required), (2, intercept_scid_by_user_channel_id, required), (4, intercept_scid_by_channel_id, required), diff --git a/lightning-liquidity/src/lsps5/event.rs b/lightning-liquidity/src/lsps5/event.rs index f6ad6e17b02..fbfbf153421 100644 --- a/lightning-liquidity/src/lsps5/event.rs +++ b/lightning-liquidity/src/lsps5/event.rs @@ -14,7 +14,7 @@ use alloc::string::String; use alloc::vec::Vec; use bitcoin::secp256k1::PublicKey; -use lightning::impl_writeable_tlv_based_enum; +use lightning::impl_ser_tlv_based_enum; use super::msgs::LSPS5AppName; use super::msgs::LSPS5Error; @@ -76,7 +76,7 @@ pub enum LSPS5ServiceEvent { }, } -impl_writeable_tlv_based_enum!(LSPS5ServiceEvent, +impl_ser_tlv_based_enum!(LSPS5ServiceEvent, (0, SendWebhookNotification) => { (0, counterparty_node_id, required), (2, app_name, required), diff --git a/lightning-liquidity/src/lsps5/msgs.rs b/lightning-liquidity/src/lsps5/msgs.rs index 41e05d687c5..47f9d6341d8 100644 --- a/lightning-liquidity/src/lsps5/msgs.rs +++ b/lightning-liquidity/src/lsps5/msgs.rs @@ -18,7 +18,7 @@ use super::url_utils::LSPSUrl; use lightning::ln::msgs::DecodeError; use lightning::util::ser::{Readable, Writeable}; -use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; +use lightning::{impl_ser_tlv_based, impl_ser_tlv_based_enum}; use lightning_types::string::UntrustedString; use serde::de::{self, Deserializer, MapAccess, Visitor}; @@ -527,7 +527,7 @@ pub enum WebhookNotificationMethod { LSPS5OnionMessageIncoming, } -impl_writeable_tlv_based_enum!(WebhookNotificationMethod, +impl_ser_tlv_based_enum!(WebhookNotificationMethod, (0, LSPS5WebhookRegistered) => {}, (2, LSPS5PaymentIncoming) => {}, (4, LSPS5ExpirySoon) => { @@ -688,7 +688,7 @@ impl<'de> Deserialize<'de> for WebhookNotification { } } -impl_writeable_tlv_based!(WebhookNotification, { +impl_ser_tlv_based!(WebhookNotification, { (0, method, required), }); diff --git a/lightning-liquidity/src/lsps5/service.rs b/lightning-liquidity/src/lsps5/service.rs index 55d96e186d1..7360131a9e9 100644 --- a/lightning-liquidity/src/lsps5/service.rs +++ b/lightning-liquidity/src/lsps5/service.rs @@ -27,7 +27,7 @@ use crate::utils::time::TimeProvider; use bitcoin::secp256k1::PublicKey; -use lightning::impl_writeable_tlv_based; +use lightning::impl_ser_tlv_based; use lightning::ln::channelmanager::AChannelManager; use lightning::ln::msgs::{ErrorAction, LightningError}; use lightning::sign::NodeSigner; @@ -66,7 +66,7 @@ struct Webhook { last_notification_sent: Option, } -impl_writeable_tlv_based!(Webhook, { +impl_ser_tlv_based!(Webhook, { (0, _app_name, required), (2, url, required), (4, _counterparty_node_id, required), @@ -834,7 +834,7 @@ impl Default for PeerState { } } -impl_writeable_tlv_based!(PeerState, { +impl_ser_tlv_based!(PeerState, { (0, webhooks, required_vec), (_unused, needs_persist, (static_value, false)), }); diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs index bd2b59c2d15..417c66374a9 100644 --- a/lightning/src/blinded_path/message.rs +++ b/lightning/src/blinded_path/message.rs @@ -660,7 +660,7 @@ pub enum AsyncPaymentsContext { }, } -impl_writeable_tlv_based_enum!(MessageContext, +impl_ser_tlv_based_enum!(MessageContext, {0, Offers} => (), {1, Custom} => (), {2, AsyncPayments} => (), @@ -671,7 +671,7 @@ impl_writeable_tlv_based_enum!(MessageContext, // introduction of `ReceiveAuthKey`-based authentication for inbound `BlindedMessagePath`s. Because // we do not support receiving to those contexts anymore (they will fail the `ReceiveAuthKey`-based // authentication checks), we can reuse those fields here. -impl_writeable_tlv_based_enum!(OffersContext, +impl_ser_tlv_based_enum!(OffersContext, (0, InvoiceRequest) => { (0, nonce, required), (1, payment_metadata, (option, encoding: (BTreeMap>, BigSizeKeyedMap))), @@ -694,7 +694,7 @@ impl_writeable_tlv_based_enum!(OffersContext, }, ); -impl_writeable_tlv_based_enum!(AsyncPaymentsContext, +impl_ser_tlv_based_enum!(AsyncPaymentsContext, (0, OutboundPayment) => { (0, payment_id, required), }, @@ -737,7 +737,7 @@ pub struct DNSResolverContext { pub nonce: [u8; 16], } -impl_writeable_tlv_based!(DNSResolverContext, { +impl_ser_tlv_based!(DNSResolverContext, { (0, nonce, required), }); diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs index a01ee230c31..a5319350d3b 100644 --- a/lightning/src/blinded_path/payment.rs +++ b/lightning/src/blinded_path/payment.rs @@ -1083,7 +1083,7 @@ impl Readable for PaymentConstraints { } } -impl_writeable_tlv_based_enum_legacy!(PaymentContext, +impl_ser_tlv_based_enum_legacy!(PaymentContext, ; // 0 for Unknown removed in version 0.1. (1, Bolt12Offer), @@ -1108,18 +1108,18 @@ impl<'a> Writeable for PaymentContextRef<'a> { } } -impl_writeable_tlv_based!(Bolt12OfferContext, { +impl_ser_tlv_based!(Bolt12OfferContext, { (0, offer_id, required), (1, payment_metadata, (option, encoding: (BTreeMap>, BigSizeKeyedMap))), (2, invoice_request, required), }); -impl_writeable_tlv_based!(AsyncBolt12OfferContext, { +impl_ser_tlv_based!(AsyncBolt12OfferContext, { (0, offer_nonce, required), (1, payment_metadata, (option, encoding: (BTreeMap>, BigSizeKeyedMap))), }); -impl_writeable_tlv_based!(Bolt12RefundContext, { +impl_ser_tlv_based!(Bolt12RefundContext, { (1, payment_metadata, (option, encoding: (BTreeMap>, BigSizeKeyedMap))), }); diff --git a/lightning/src/chain/chaininterface.rs b/lightning/src/chain/chaininterface.rs index bb5f6de95ab..3bc7d20af03 100644 --- a/lightning/src/chain/chaininterface.rs +++ b/lightning/src/chain/chaininterface.rs @@ -159,19 +159,19 @@ pub enum FundingPurpose { // Needed so downstream consumers can persist these without needing to define wrapper types // mirroring the type structure. -impl_writeable_tlv_based!(FundingCandidate, { +impl_ser_tlv_based!(FundingCandidate, { (1, txid, required), (3, channels, required_vec), }); -impl_writeable_tlv_based!(ChannelFunding, { +impl_ser_tlv_based!(ChannelFunding, { (1, counterparty_node_id, required), (3, channel_id, required), (5, purpose, required), (7, contribution, option), }); -impl_writeable_tlv_based_enum!(FundingPurpose, +impl_ser_tlv_based_enum!(FundingPurpose, (0, Establishment) => {}, (2, Splice) => {}, ); diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs index 42d04e0f8ce..a2412bbaf5e 100644 --- a/lightning/src/chain/channelmonitor.rs +++ b/lightning/src/chain/channelmonitor.rs @@ -256,7 +256,7 @@ pub struct HTLCUpdate { pub(crate) source: HTLCSource, pub(crate) htlc_value_satoshis: Option, } -impl_writeable_tlv_based!(HTLCUpdate, { +impl_ser_tlv_based!(HTLCUpdate, { (0, payment_hash, required), (1, htlc_value_satoshis, option), (2, source, required), @@ -345,7 +345,7 @@ struct HolderSignedTx { } // Any changes made here must also reflect in `write_legacy_holder_commitment_data`. -impl_writeable_tlv_based!(HolderSignedTx, { +impl_ser_tlv_based!(HolderSignedTx, { (0, txid, required), (1, to_self_value_sat, required), // Added in 0.0.100, required in 0.2. (2, revocation_key, required), @@ -1104,7 +1104,7 @@ impl CommitmentHTLCData { } } -impl_writeable_tlv_based!(CommitmentHTLCData, { +impl_ser_tlv_based!(CommitmentHTLCData, { (1, nondust_htlc_sources, required_vec), (3, dust_htlcs, required_vec), }); @@ -1201,7 +1201,7 @@ impl FundingScope { } } -impl_writeable_tlv_based!(FundingScope, { +impl_ser_tlv_based!(FundingScope, { (1, channel_parameters, (required: ReadableArgs, None)), (3, current_counterparty_commitment_txid, required), (5, prev_counterparty_commitment_txid, option), diff --git a/lightning/src/chain/mod.rs b/lightning/src/chain/mod.rs index d72d58b3149..72006f78205 100644 --- a/lightning/src/chain/mod.rs +++ b/lightning/src/chain/mod.rs @@ -143,7 +143,7 @@ impl BlockLocator { } } -impl_writeable_tlv_based!(BlockLocator, { +impl_ser_tlv_based!(BlockLocator, { (0, block_hash, required), // Note that any change to the previous_blocks array length will change the serialization // format and thus it is specified without constants here. diff --git a/lightning/src/chain/package.rs b/lightning/src/chain/package.rs index 0ef8855242b..269a8dd1d7d 100644 --- a/lightning/src/chain/package.rs +++ b/lightning/src/chain/package.rs @@ -172,7 +172,7 @@ impl RevokedOutput { } } -impl_writeable_tlv_based!(RevokedOutput, { +impl_ser_tlv_based!(RevokedOutput, { (0, per_commitment_point, required), (1, outpoint_confirmation_height, option), // Added in 0.1.4/0.2 and always set (2, counterparty_delayed_payment_base_key, required), @@ -238,7 +238,7 @@ impl RevokedHTLCOutput { } } -impl_writeable_tlv_based!(RevokedHTLCOutput, { +impl_ser_tlv_based!(RevokedHTLCOutput, { (0, per_commitment_point, required), (1, outpoint_confirmation_height, option), // Added in 0.1.4/0.2 and always set (2, counterparty_delayed_payment_base_key, required), @@ -1066,7 +1066,7 @@ impl PackageSolvingData { } } -impl_writeable_tlv_based_enum_legacy!(PackageSolvingData, ; +impl_ser_tlv_based_enum_legacy!(PackageSolvingData, ; (0, RevokedOutput), (1, RevokedHTLCOutput), (2, CounterpartyOfferedHTLCOutput), diff --git a/lightning/src/crypto/streams.rs b/lightning/src/crypto/streams.rs index 8d46a8d8422..ff34b86755e 100644 --- a/lightning/src/crypto/streams.rs +++ b/lightning/src/crypto/streams.rs @@ -358,7 +358,7 @@ mod tests { field2: Vec, field3: Vec, } - impl_writeable_tlv_based!(TestWriteable, { + impl_ser_tlv_based!(TestWriteable, { (1, field1, required_vec), (2, field2, required_vec), (3, field3, required_vec), diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 271e135d51d..b0947183384 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -87,7 +87,7 @@ pub enum FundingInfo { }, } -impl_writeable_tlv_based_enum!(FundingInfo, +impl_ser_tlv_based_enum!(FundingInfo, (0, Tx) => { (0, transaction, required) }, @@ -341,7 +341,7 @@ impl PaymentPurpose { } } -impl_writeable_tlv_based_enum_legacy!(PaymentPurpose, +impl_ser_tlv_based_enum_legacy!(PaymentPurpose, (0, Bolt11InvoicePayment) => { (0, payment_preimage, option), (2, payment_secret, required), @@ -391,7 +391,7 @@ pub struct ClaimedHTLC { /// 0.0.119. pub counterparty_skimmed_fee_msat: u64, } -impl_writeable_tlv_based!(ClaimedHTLC, { +impl_ser_tlv_based!(ClaimedHTLC, { (0, channel_id, required), (1, counterparty_skimmed_fee_msat, (default_value, 0u64)), (2, user_channel_id, required), @@ -736,7 +736,7 @@ pub enum HTLCHandlingFailureReason { }, } -impl_writeable_tlv_based_enum!(HTLCHandlingFailureReason, +impl_ser_tlv_based_enum!(HTLCHandlingFailureReason, (1, Downstream) => {}, (3, Local) => { (0, reason, required), @@ -757,7 +757,7 @@ enum InterceptNextHop { FakeScid { requested_next_hop_scid: u64 }, } -impl_writeable_tlv_based_enum!(InterceptNextHop, +impl_ser_tlv_based_enum!(InterceptNextHop, (0, FakeScid) => { (0, requested_next_hop_scid, required), }, @@ -878,7 +878,7 @@ pub struct HTLCLocator { pub node_id: Option, } -impl_writeable_tlv_based!(HTLCLocator, { +impl_ser_tlv_based!(HTLCLocator, { (1, channel_id, required), (3, user_channel_id, option), (5, node_id, option), @@ -3290,7 +3290,7 @@ pub enum PaidBolt12Invoice { StaticInvoice(StaticInvoice), } -impl_writeable_tlv_based_enum!(PaidBolt12Invoice, +impl_ser_tlv_based_enum!(PaidBolt12Invoice, {0, Bolt12Invoice} => (), {2, StaticInvoice} => (), ); diff --git a/lightning/src/ln/chan_utils.rs b/lightning/src/ln/chan_utils.rs index 4bb8ffac9ef..238ef71e13e 100644 --- a/lightning/src/ln/chan_utils.rs +++ b/lightning/src/ln/chan_utils.rs @@ -590,7 +590,7 @@ pub struct TxCreationKeys { pub broadcaster_delayed_payment_key: DelayedPaymentKey, } -impl_writeable_tlv_based!(TxCreationKeys, { +impl_ser_tlv_based!(TxCreationKeys, { (0, per_commitment_point, required), (2, revocation_key, required), (4, broadcaster_htlc_key, required), @@ -622,7 +622,7 @@ pub struct ChannelPublicKeys { pub htlc_basepoint: HtlcBasepoint, } -impl_writeable_tlv_based!(ChannelPublicKeys, { +impl_ser_tlv_based!(ChannelPublicKeys, { (0, funding_pubkey, required), (2, revocation_basepoint, required), (4, payment_point, required), @@ -738,7 +738,7 @@ impl HTLCOutputInCommitment { } } -impl_writeable_tlv_based!(HTLCOutputInCommitment, { +impl_ser_tlv_based!(HTLCOutputInCommitment, { (0, offered, required), (2, amount_msat, required), (4, cltv_expiry, required), @@ -1164,7 +1164,7 @@ impl ChannelTransactionParameters { } } -impl_writeable_tlv_based!(CounterpartyChannelTransactionParameters, { +impl_ser_tlv_based!(CounterpartyChannelTransactionParameters, { (0, pubkeys, required), (2, selected_contest_delay, required), }); @@ -1336,7 +1336,7 @@ impl PartialEq for HolderCommitmentTransaction { } } -impl_writeable_tlv_based!(HolderCommitmentTransaction, { +impl_ser_tlv_based!(HolderCommitmentTransaction, { (0, inner, required), (2, counterparty_sig, required), (4, holder_sig_first, required), @@ -1424,7 +1424,7 @@ pub struct BuiltCommitmentTransaction { pub txid: Txid, } -impl_writeable_tlv_based!(BuiltCommitmentTransaction, { +impl_ser_tlv_based!(BuiltCommitmentTransaction, { (0, transaction, required), (2, txid, required), }); diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index d0072da226a..cfa7304734d 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -172,7 +172,7 @@ enum InboundHTLCResolution { Pending { update_add_htlc: msgs::UpdateAddHTLC }, } -impl_writeable_tlv_based_enum!(InboundHTLCResolution, +impl_ser_tlv_based_enum!(InboundHTLCResolution, (0, Resolved) => { (0, pending_htlc_status, required), }, @@ -337,7 +337,7 @@ pub(super) struct OutboundHop { pub(super) user_channel_id: u128, } -impl_writeable_tlv_based!(OutboundHop, { +impl_ser_tlv_based!(OutboundHop, { (0, amt_msat, required), (2, channel_id, required), (4, node_id, required), @@ -1517,7 +1517,7 @@ struct PendingChannelMonitorUpdate { update: ChannelMonitorUpdate, } -impl_writeable_tlv_based!(PendingChannelMonitorUpdate, { +impl_ser_tlv_based!(PendingChannelMonitorUpdate, { (0, update, required), }); @@ -2994,7 +2994,7 @@ struct PendingFunding { contributions: Vec, } -impl_writeable_tlv_based!(PendingFunding, { +impl_ser_tlv_based!(PendingFunding, { (1, funding_negotiation, upgradable_option), (3, negotiated_candidates, required_vec), (5, sent_funding_txid, option), diff --git a/lightning/src/ln/channel_state.rs b/lightning/src/ln/channel_state.rs index 28e8bedf41b..e3a4f7f4815 100644 --- a/lightning/src/ln/channel_state.rs +++ b/lightning/src/ln/channel_state.rs @@ -106,7 +106,7 @@ pub struct InboundHTLCDetails { pub is_dust: bool, } -impl_writeable_tlv_based!(InboundHTLCDetails, { +impl_ser_tlv_based!(InboundHTLCDetails, { (0, htlc_id, required), (2, amount_msat, required), (4, cltv_expiry, required), @@ -200,7 +200,7 @@ pub struct OutboundHTLCDetails { pub is_dust: bool, } -impl_writeable_tlv_based!(OutboundHTLCDetails, { +impl_ser_tlv_based!(OutboundHTLCDetails, { (0, htlc_id, required), (2, amount_msat, required), (4, cltv_expiry, required), @@ -223,7 +223,7 @@ pub struct CounterpartyForwardingInfo { pub cltv_expiry_delta: u16, } -impl_writeable_tlv_based!(CounterpartyForwardingInfo, { +impl_ser_tlv_based!(CounterpartyForwardingInfo, { (2, fee_base_msat, required), (4, fee_proportional_millionths, required), (6, cltv_expiry_delta, required), @@ -258,7 +258,7 @@ pub struct ChannelCounterparty { pub outbound_htlc_maximum_msat: Option, } -impl_writeable_tlv_based!(ChannelCounterparty, { +impl_ser_tlv_based!(ChannelCounterparty, { (2, node_id, required), (4, features, required), (6, unspendable_punishment_reserve, required), @@ -621,7 +621,7 @@ impl ChannelDetails { } } -impl_writeable_tlv_based!(ChannelDetails, { +impl_ser_tlv_based!(ChannelDetails, { (1, inbound_scid_alias, option), (2, channel_id, required), (3, channel_type, option), @@ -686,7 +686,7 @@ pub enum ChannelShutdownState { ShutdownComplete, } -impl_writeable_tlv_based_enum!(ChannelShutdownState, +impl_ser_tlv_based_enum!(ChannelShutdownState, (0, NotShuttingDown) => {}, (2, ShutdownInitiated) => {}, (4, ResolvingHTLCs) => {}, diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index c6002408f01..0ae4c87d511 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -819,7 +819,7 @@ impl SentHTLCId { } } } -impl_writeable_tlv_based_enum!(SentHTLCId, +impl_ser_tlv_based_enum!(SentHTLCId, (0, PreviousHopData) => { (0, prev_outbound_scid_alias, required), (2, htlc_id, required), @@ -1228,7 +1228,7 @@ struct ClaimingPayment { /// outpoint), allowing us to remove this field. durable_preimage_channel: Option<(OutPoint, PublicKey, ChannelId)>, } -impl_writeable_tlv_based!(ClaimingPayment, { +impl_ser_tlv_based!(ClaimingPayment, { (0, amount_msat, required), (1, durable_preimage_channel, option), (2, payment_purpose, required), @@ -1614,7 +1614,7 @@ pub(crate) struct PaymentCompleteUpdate { htlc_id: SentHTLCId, } -impl_writeable_tlv_based!(PaymentCompleteUpdate, { +impl_ser_tlv_based!(PaymentCompleteUpdate, { (1, channel_funding_outpoint, required), (3, counterparty_node_id, required), (5, channel_id, required), @@ -1636,7 +1636,7 @@ pub(crate) enum EventCompletionAction { /// Note that this action will be dropped on downgrade to LDK prior to 0.2! ReleasePaymentCompleteChannelMonitorUpdate(PaymentCompleteUpdate), } -impl_writeable_tlv_based_enum!(EventCompletionAction, +impl_ser_tlv_based_enum!(EventCompletionAction, (0, ReleaseRAAChannelMonitorUpdate) => { (0, channel_funding_outpoint, option), (2, counterparty_node_id, required), @@ -1691,7 +1691,7 @@ struct MPPClaimHTLCSource { htlc_id: u64, } -impl_writeable_tlv_based!(MPPClaimHTLCSource, { +impl_ser_tlv_based!(MPPClaimHTLCSource, { (0, counterparty_node_id, required), (2, funding_txo, required), (4, channel_id, required), @@ -1710,7 +1710,7 @@ pub(crate) struct PaymentClaimDetails { claiming_payment: ClaimingPayment, } -impl_writeable_tlv_based!(PaymentClaimDetails, { +impl_ser_tlv_based!(PaymentClaimDetails, { (0, mpp_parts, required_vec), (2, claiming_payment, required), }); @@ -17835,19 +17835,19 @@ const MIN_SERIALIZATION_VERSION: u8 = 1; // Left as `None` for now until we are committed to writing inbound committed onions in `Channel`s. const RECONSTRUCT_HTLCS_FROM_CHANS_VERSION: Option = None; -impl_writeable_tlv_based!(PhantomRouteHints, { +impl_ser_tlv_based!(PhantomRouteHints, { (2, channels, required_vec), (4, phantom_scid, required), (6, real_node_pubkey, required), }); -impl_writeable_tlv_based!(BlindedForward, { +impl_ser_tlv_based!(BlindedForward, { (0, inbound_blinding_point, required), (1, failure, (default_value, BlindedFailure::FromIntroductionNode)), (3, next_blinding_override, option), }); -impl_writeable_tlv_based_enum!(PendingHTLCRouting, +impl_ser_tlv_based_enum!(PendingHTLCRouting, (0, Forward) => { (0, onion_packet, required), (1, blinded, option), @@ -17885,7 +17885,7 @@ impl_writeable_tlv_based_enum!(PendingHTLCRouting, } ); -impl_writeable_tlv_based!(PendingHTLCInfo, { +impl_ser_tlv_based!(PendingHTLCInfo, { (0, routing, required), (2, incoming_shared_secret, required), (4, payment_hash, required), @@ -17970,17 +17970,17 @@ impl Readable for HTLCFailureMsg { } } -impl_writeable_tlv_based_enum_legacy!(PendingHTLCStatus, ; +impl_ser_tlv_based_enum_legacy!(PendingHTLCStatus, ; (0, Forward), (1, Fail), ); -impl_writeable_tlv_based_enum!(BlindedFailure, +impl_ser_tlv_based_enum!(BlindedFailure, (0, FromIntroductionNode) => {}, (2, FromBlindedNode) => {}, ); -impl_writeable_tlv_based!(HTLCPreviousHopData, { +impl_ser_tlv_based!(HTLCPreviousHopData, { (0, prev_outbound_scid_alias, required), (1, phantom_shared_secret, option), (2, outpoint, required), @@ -18155,7 +18155,7 @@ impl Writeable for HTLCSource { } } -impl_writeable_tlv_based!(PendingAddHTLCInfo, { +impl_ser_tlv_based!(PendingAddHTLCInfo, { (0, forward_info, required), (1, prev_user_channel_id, (default_value, 0)), (2, prev_outbound_scid_alias, required), @@ -18167,7 +18167,7 @@ impl_writeable_tlv_based!(PendingAddHTLCInfo, { (9, prev_counterparty_node_id, required), }); -impl_writeable_tlv_based!(TrampolineDispatch, { +impl_ser_tlv_based!(TrampolineDispatch, { (1, payment_id, required), (3, path, required), (5, session_priv, required), @@ -18244,7 +18244,7 @@ impl Readable for HTLCForwardInfo { } } -impl_writeable_tlv_based!(PendingInboundPayment, { +impl_ser_tlv_based!(PendingInboundPayment, { (0, payment_secret, required), (2, expiry_time, required), (4, user_payment_id, required), diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index fd9fc298285..bcc5c665a86 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -598,7 +598,7 @@ enum FundingInputMode { ManuallySelected, } -impl_writeable_tlv_based_enum!(FundingInputMode, +impl_ser_tlv_based_enum!(FundingInputMode, (1, CoinSelected) => {}, (3, ManuallySelected) => {} ); @@ -640,7 +640,7 @@ pub struct FundingContribution { input_mode: Option, } -impl_writeable_tlv_based!(FundingContribution, { +impl_ser_tlv_based!(FundingContribution, { (1, estimated_fee, required), (3, inputs, optional_vec), (5, outputs, optional_vec), diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index 0deb119890d..dfb702a2657 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -253,16 +253,16 @@ impl TxOutMetadata { } } -impl_writeable_tlv_based!(TxInMetadata, { +impl_ser_tlv_based!(TxInMetadata, { (1, serial_id, required), (3, prev_output, required), }); -impl_writeable_tlv_based!(TxOutMetadata, { +impl_ser_tlv_based!(TxOutMetadata, { (1, serial_id, required), }); -impl_writeable_tlv_based!(ConstructedTransaction, { +impl_ser_tlv_based!(ConstructedTransaction, { (1, holder_is_initiator, required), (3, input_metadata, required), (5, output_metadata, required), @@ -530,7 +530,7 @@ pub(crate) struct SharedInputSignature { witness_script: ScriptBuf, } -impl_writeable_tlv_based!(SharedInputSignature, { +impl_ser_tlv_based!(SharedInputSignature, { (1, holder_signature_first, required), (3, witness_script, required), }); @@ -930,7 +930,7 @@ impl InteractiveTxSigningSession { } } -impl_writeable_tlv_based!(InteractiveTxSigningSession, { +impl_ser_tlv_based!(InteractiveTxSigningSession, { (1, unsigned_tx, required), (3, has_received_commitment_signed, required), (5, holder_tx_signatures, required), @@ -1656,7 +1656,7 @@ enum AddingRole { Remote, } -impl_writeable_tlv_based_enum!(AddingRole, +impl_ser_tlv_based_enum!(AddingRole, (1, Local) => {}, (3, Remote) => {}, ); @@ -1806,7 +1806,7 @@ pub(super) struct SharedOwnedOutput { local_owned: u64, } -impl_writeable_tlv_based!(SharedOwnedOutput, { +impl_ser_tlv_based!(SharedOwnedOutput, { (1, tx_out, required), (3, local_owned, required), }); @@ -1836,7 +1836,7 @@ enum OutputOwned { Shared(SharedOwnedOutput), } -impl_writeable_tlv_based_enum!(OutputOwned, +impl_ser_tlv_based_enum!(OutputOwned, {1, Single} => (), {3, Shared} => (), ); diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index fe41bc1c6dc..234af588eae 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -1960,7 +1960,7 @@ impl Readable for HTLCFailReason { } } -impl_writeable_tlv_based_enum!(HTLCFailReasonRepr, +impl_ser_tlv_based_enum!(HTLCFailReasonRepr, (0, LightningError) => { (0, data, (legacy, Vec, |_| Ok(()), |us| if let &HTLCFailReasonRepr::LightningError { err: msgs::OnionErrorPacket { ref data, .. }, .. } = us { diff --git a/lightning/src/ln/our_peer_storage.rs b/lightning/src/ln/our_peer_storage.rs index 937e446bcff..e8939a15f15 100644 --- a/lightning/src/ln/our_peer_storage.rs +++ b/lightning/src/ln/our_peer_storage.rs @@ -170,7 +170,7 @@ pub(crate) struct PeerStorageMonitorHolder { pub(crate) monitor_bytes: Vec, } -impl_writeable_tlv_based!(PeerStorageMonitorHolder, { +impl_ser_tlv_based!(PeerStorageMonitorHolder, { (0, channel_id, required), (2, counterparty_node_id, required), (4, min_seen_secret, required), diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 273ed4ec1d2..67fea5092c3 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -174,7 +174,7 @@ pub(crate) struct RetryableInvoiceRequest { pub(super) needs_retry: bool, } -impl_writeable_tlv_based!(RetryableInvoiceRequest, { +impl_ser_tlv_based!(RetryableInvoiceRequest, { (0, invoice_request, required), (1, needs_retry, (default_value, true)), (2, nonce, required), @@ -427,13 +427,13 @@ pub enum Retry { } #[cfg(not(feature = "std"))] -impl_writeable_tlv_based_enum_legacy!(Retry, +impl_ser_tlv_based_enum_legacy!(Retry, ; (0, Attempts) ); #[cfg(feature = "std")] -impl_writeable_tlv_based_enum_legacy!(Retry, +impl_ser_tlv_based_enum_legacy!(Retry, ; (0, Attempts), (2, Timeout) @@ -517,7 +517,7 @@ pub(crate) enum StaleExpiration { AbsoluteTimeout(core::time::Duration), } -impl_writeable_tlv_based_enum_legacy!(StaleExpiration, +impl_ser_tlv_based_enum_legacy!(StaleExpiration, ; (0, TimerTicks), (2, AbsoluteTimeout) diff --git a/lightning/src/ln/script.rs b/lightning/src/ln/script.rs index 5258b8f3283..44a7cc1778c 100644 --- a/lightning/src/ln/script.rs +++ b/lightning/src/ln/script.rs @@ -56,7 +56,7 @@ impl Readable for ShutdownScript { } } -impl_writeable_tlv_based_enum_legacy!(ShutdownScriptImpl, ; +impl_ser_tlv_based_enum_legacy!(ShutdownScriptImpl, ; (0, Legacy), (1, Bolt2), ); diff --git a/lightning/src/offers/async_receive_offer_cache.rs b/lightning/src/offers/async_receive_offer_cache.rs index c4442b4dd8f..dd96b5d1c42 100644 --- a/lightning/src/offers/async_receive_offer_cache.rs +++ b/lightning/src/offers/async_receive_offer_cache.rs @@ -76,7 +76,7 @@ impl AsyncReceiveOffer { } } -impl_writeable_tlv_based_enum!(OfferStatus, +impl_ser_tlv_based_enum!(OfferStatus, (0, Used) => { (0, invoice_created_at, required), }, @@ -86,7 +86,7 @@ impl_writeable_tlv_based_enum!(OfferStatus, (2, Pending) => {}, ); -impl_writeable_tlv_based!(AsyncReceiveOffer, { +impl_ser_tlv_based!(AsyncReceiveOffer, { (0, offer, required), (2, offer_nonce, required), (4, status, required), diff --git a/lightning/src/onion_message/async_payments.rs b/lightning/src/onion_message/async_payments.rs index 41108cdccd7..96914518b6b 100644 --- a/lightning/src/onion_message/async_payments.rs +++ b/lightning/src/onion_message/async_payments.rs @@ -279,25 +279,25 @@ impl OnionMessageContents for ReleaseHeldHtlc { } } -impl_writeable_tlv_based!(OfferPathsRequest, { +impl_ser_tlv_based!(OfferPathsRequest, { (0, invoice_slot, required), }); -impl_writeable_tlv_based!(OfferPaths, { +impl_ser_tlv_based!(OfferPaths, { (0, paths, required_vec), (2, paths_absolute_expiry, option), }); -impl_writeable_tlv_based!(ServeStaticInvoice, { +impl_ser_tlv_based!(ServeStaticInvoice, { (0, invoice, required), (2, forward_invoice_request_path, required), }); -impl_writeable_tlv_based!(StaticInvoicePersisted, {}); +impl_ser_tlv_based!(StaticInvoicePersisted, {}); -impl_writeable_tlv_based!(HeldHtlcAvailable, {}); +impl_ser_tlv_based!(HeldHtlcAvailable, {}); -impl_writeable_tlv_based!(ReleaseHeldHtlc, {}); +impl_ser_tlv_based!(ReleaseHeldHtlc, {}); impl AsyncPaymentsMessage { /// Returns whether `tlv_type` corresponds to a TLV record for async payment messages. diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index 98a54e21b17..dd93dceb188 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -358,7 +358,7 @@ pub struct Responder { reply_path: BlindedMessagePath, } -impl_writeable_tlv_based!(Responder, { +impl_ser_tlv_based!(Responder, { (0, reply_path, required), }); diff --git a/lightning/src/routing/gossip.rs b/lightning/src/routing/gossip.rs index 7688db15311..3aa77ff7ff2 100644 --- a/lightning/src/routing/gossip.rs +++ b/lightning/src/routing/gossip.rs @@ -1350,7 +1350,7 @@ impl EffectiveCapacity { } } -impl_writeable_tlv_based!(RoutingFees, { +impl_ser_tlv_based!(RoutingFees, { (0, base_msat, required), (2, proportional_millionths, required) }); diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index c64a9207580..2032eb680af 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -545,7 +545,7 @@ pub struct RouteHop { pub maybe_announced_channel: bool, } -impl_writeable_tlv_based!(RouteHop, { +impl_ser_tlv_based!(RouteHop, { (0, pubkey, required), (1, maybe_announced_channel, (default_value, true)), (2, node_features, required), @@ -574,7 +574,7 @@ pub struct TrampolineHop { pub cltv_expiry_delta: u32, } -impl_writeable_tlv_based!(TrampolineHop, { +impl_ser_tlv_based!(TrampolineHop, { (0, pubkey, required), (2, node_features, required), (4, fee_msat, required), @@ -604,7 +604,7 @@ pub struct BlindedTail { pub final_value_msat: u64, } -impl_writeable_tlv_based!(BlindedTail, { +impl_ser_tlv_based!(BlindedTail, { (0, hops, required_vec), (2, blinding_point, required), (4, excess_final_cltv_expiry_delta, required), @@ -666,7 +666,7 @@ impl Path { } } -impl_writeable_tlv_based!(Path,{ +impl_ser_tlv_based!(Path,{ (1, hops, required_vec), (3, blinded_tail, option), }); @@ -1373,7 +1373,7 @@ pub struct RouteParametersConfig { pub max_channel_saturation_power_of_half: u8, } -impl_writeable_tlv_based!(RouteParametersConfig, { +impl_ser_tlv_based!(RouteParametersConfig, { (1, max_total_routing_fee_msat, option), (3, max_total_cltv_expiry_delta, required), (5, max_path_count, required), @@ -1578,7 +1578,7 @@ impl Readable for RouteHint { } } -impl_writeable_tlv_based!(RouteHintHop, { +impl_ser_tlv_based!(RouteHintHop, { (0, src_node_id, required), (1, htlc_minimum_msat, option), (2, short_channel_id, required), diff --git a/lightning/src/routing/scoring.rs b/lightning/src/routing/scoring.rs index 1592bc0ccb2..c74f27b92f4 100644 --- a/lightning/src/routing/scoring.rs +++ b/lightning/src/routing/scoring.rs @@ -2131,8 +2131,8 @@ mod bucketed_history { } } - impl_writeable_tlv_based!(HistoricalBucketRangeTracker, { (0, buckets, required) }); - impl_writeable_tlv_based!(LegacyHistoricalBucketRangeTracker, { (0, buckets, required) }); + impl_ser_tlv_based!(HistoricalBucketRangeTracker, { (0, buckets, required) }); + impl_ser_tlv_based!(LegacyHistoricalBucketRangeTracker, { (0, buckets, required) }); #[derive(Clone, Copy)] #[repr(C)] // Force the fields in memory to be in the order we specify. diff --git a/lightning/src/sign/mod.rs b/lightning/src/sign/mod.rs index 3adc6380297..70bd9e68f60 100644 --- a/lightning/src/sign/mod.rs +++ b/lightning/src/sign/mod.rs @@ -119,7 +119,7 @@ impl DelayedPaymentOutputDescriptor { + chan_utils::REVOKEABLE_REDEEMSCRIPT_MAX_LENGTH) as u64; } -impl_writeable_tlv_based!(DelayedPaymentOutputDescriptor, { +impl_ser_tlv_based!(DelayedPaymentOutputDescriptor, { (0, outpoint, required), (2, per_commitment_point, required), (4, to_self_delay, required), @@ -217,7 +217,7 @@ impl StaticPaymentOutputDescriptor { chan_params.is_some_and(|p| p.channel_type_features.supports_anchors_zero_fee_htlc_tx()) } } -impl_writeable_tlv_based!(StaticPaymentOutputDescriptor, { +impl_ser_tlv_based!(StaticPaymentOutputDescriptor, { (0, outpoint, required), (2, output, required), (4, channel_keys_id, required), @@ -320,7 +320,7 @@ pub enum SpendableOutputDescriptor { StaticPaymentOutput(StaticPaymentOutputDescriptor), } -impl_writeable_tlv_based_enum_legacy!(SpendableOutputDescriptor, +impl_ser_tlv_based_enum_legacy!(SpendableOutputDescriptor, (0, StaticOutput) => { (0, outpoint, required), (1, channel_keys_id, option), @@ -582,7 +582,7 @@ pub struct ChannelDerivationParameters { pub transaction_parameters: ChannelTransactionParameters, } -impl_writeable_tlv_based!(ChannelDerivationParameters, { +impl_ser_tlv_based!(ChannelDerivationParameters, { (0, value_satoshis, required), (2, keys_id, required), (4, transaction_parameters, (required: ReadableArgs, Some(value_satoshis.0.unwrap()))), @@ -616,7 +616,7 @@ pub struct HTLCDescriptor { pub counterparty_sig: Signature, } -impl_writeable_tlv_based!(HTLCDescriptor, { +impl_ser_tlv_based!(HTLCDescriptor, { (0, channel_derivation_parameters, required), (1, feerate_per_kw, (default_value, 0)), (2, commitment_txid, required), diff --git a/lightning/src/util/config.rs b/lightning/src/util/config.rs index fa01f8e21b4..54977f47409 100644 --- a/lightning/src/util/config.rs +++ b/lightning/src/util/config.rs @@ -481,7 +481,7 @@ pub enum MaxDustHTLCExposure { FeeRateMultiplier(u64), } -impl_writeable_tlv_based_enum_legacy!(MaxDustHTLCExposure, ; +impl_ser_tlv_based_enum_legacy!(MaxDustHTLCExposure, ; (1, FixedLimitMsat), (3, FeeRateMultiplier), ); diff --git a/lightning/src/util/ser_macros.rs b/lightning/src/util/ser_macros.rs index 53777d26130..716be6851bf 100644 --- a/lightning/src/util/ser_macros.rs +++ b/lightning/src/util/ser_macros.rs @@ -1059,7 +1059,7 @@ macro_rules! _decode_and_build { /// /// For example, /// ``` -/// # use lightning::impl_writeable_tlv_based; +/// # use lightning::impl_ser_tlv_based; /// struct LightningMessage { /// tlv_integer: u32, /// tlv_default_integer: u32, @@ -1068,7 +1068,7 @@ macro_rules! _decode_and_build { /// tlv_upgraded_integer: u32, /// } /// -/// impl_writeable_tlv_based!(LightningMessage, { +/// impl_ser_tlv_based!(LightningMessage, { /// (0, tlv_integer, required), /// (1, tlv_default_integer, (default_value, 7)), /// (2, tlv_optional_integer, option), @@ -1083,7 +1083,7 @@ macro_rules! _decode_and_build { /// [`Writeable`]: crate::util::ser::Writeable /// [`Vec`]: crate::prelude::Vec #[macro_export] -macro_rules! impl_writeable_tlv_based { +macro_rules! impl_ser_tlv_based { ($st: ident, {$(($type: expr, $field: ident, $fieldty: tt)),* $(,)*}) => { impl $crate::util::ser::Writeable for $st { fn write(&self, writer: &mut W) -> Result<(), $crate::io::Error> { @@ -1206,9 +1206,9 @@ macro_rules! _impl_writeable_tlv_based_enum_common { ($st: ident, $(($variant_id: expr, $variant_name: ident) => {$(($type: expr, $field: ident, $fieldty: tt)),* $(,)*} ),* $(,)?; - // $tuple_variant_* are only passed from `impl_writeable_tlv_based_enum_*_legacy` + // $tuple_variant_* are only passed from legacy enum macros. $(($tuple_variant_id: expr, $tuple_variant_name: ident)),* $(,)?; - // $length_prefixed_* are only passed from `impl_writeable_tlv_based_enum_*` non-`legacy` + // $length_prefixed_* are only passed from non-legacy enum macros. $(($length_prefixed_tuple_variant_id: expr, $length_prefixed_tuple_variant_name: ident)),* $(,)?) => { impl $crate::util::ser::Writeable for $st { fn write(&self, writer: &mut W) -> Result<(), $crate::io::Error> { @@ -1256,8 +1256,8 @@ macro_rules! _impl_writeable_tlv_based_enum_common { /// TupleVariantA(), /// TupleVariantB(Vec), /// } -/// # use lightning::impl_writeable_tlv_based_enum; -/// impl_writeable_tlv_based_enum!(EnumName, +/// # use lightning::impl_ser_tlv_based_enum; +/// impl_ser_tlv_based_enum!(EnumName, /// (0, StructVariantA) => {(0, required_variant_field, required), (1, optional_variant_field, option)}, /// (1, StructVariantB) => {(0, variant_field_a, required), (1, variant_field_b, required), (2, variant_vec_field, optional_vec)}, /// (2, TupleVariantA) => {}, // Note that empty tuple variants have to use the struct syntax due to rust limitations @@ -1276,7 +1276,7 @@ macro_rules! _impl_writeable_tlv_based_enum_common { /// [`Writeable`]: crate::util::ser::Writeable /// [`DecodeError::UnknownRequiredFeature`]: crate::ln::msgs::DecodeError::UnknownRequiredFeature #[macro_export] -macro_rules! impl_writeable_tlv_based_enum { +macro_rules! impl_ser_tlv_based_enum { ($st: ident, $(($variant_id: expr, $variant_name: ident) => {$(($type: expr, $field: ident, $fieldty: tt)),* $(,)*} @@ -1321,9 +1321,9 @@ macro_rules! impl_writeable_tlv_based_enum { } } -/// See [`impl_writeable_tlv_based_enum`] and use that unless backwards-compatibility with tuple +/// See [`impl_ser_tlv_based_enum`] and use that unless backwards-compatibility with tuple /// variants is required. -macro_rules! impl_writeable_tlv_based_enum_legacy { +macro_rules! impl_ser_tlv_based_enum_legacy { ($st: ident, $(($variant_id: expr, $variant_name: ident) => {$(($type: expr, $field: ident, $fieldty: tt)),* $(,)*} ),* $(,)*; @@ -1359,7 +1359,7 @@ macro_rules! impl_writeable_tlv_based_enum_legacy { /// Implement [`MaybeReadable`] and [`Writeable`] for an enum, with struct variants stored as TLVs and /// tuple variants stored directly. /// -/// This is largely identical to [`impl_writeable_tlv_based_enum`], except that odd variants will +/// This is largely identical to [`impl_ser_tlv_based_enum`], except that odd variants will /// return `Ok(None)` instead of `Err(`[`DecodeError::UnknownRequiredFeature`]`)`. It should generally be preferred /// when [`MaybeReadable`] is practical instead of just [`Readable`] as it provides an upgrade path for /// new variants to be added which are simply ignored by existing clients. @@ -1620,7 +1620,7 @@ mod tests { other_field: u32, } - impl_writeable_tlv_based!(OuterStructOptionalEnumV1, { + impl_ser_tlv_based!(OuterStructOptionalEnumV1, { (0, inner_enum, upgradable_option), (2, other_field, required), }); @@ -1645,7 +1645,7 @@ mod tests { other_field: u32, } - impl_writeable_tlv_based!(OuterStructOptionalEnumV2, { + impl_ser_tlv_based!(OuterStructOptionalEnumV2, { (0, inner_enum, upgradable_option), (2, other_field, required), }); @@ -1696,7 +1696,7 @@ mod tests { other_field: u32, } - impl_writeable_tlv_based!(OuterOuterStruct, { + impl_ser_tlv_based!(OuterOuterStruct, { (0, outer_struct, upgradable_option), (2, other_field, required), }); @@ -1964,7 +1964,7 @@ mod tests { // old_field: u8, new_field: (u8, u8), } - impl_writeable_tlv_based!(ExpandedField, { + impl_ser_tlv_based!(ExpandedField, { (0, old_field, (legacy, u8, |_| Ok(()), |us: &ExpandedField| Some(us.new_field.0))), (1, new_field, (default_value, (old_field.ok_or(DecodeError::InvalidValue)?, 0))), }); @@ -1990,7 +1990,7 @@ mod tests { struct DefaultValueVecStruct { items: Vec, } - impl_writeable_tlv_based!(DefaultValueVecStruct, { + impl_ser_tlv_based!(DefaultValueVecStruct, { (1, items, (default_value_vec, vec![4, 5, 6])), }); @@ -2018,7 +2018,7 @@ mod tests { struct LegacyToVecStruct { new_items: Vec, } - impl_writeable_tlv_based!(LegacyToVecStruct, { + impl_ser_tlv_based!(LegacyToVecStruct, { (0, old_item, (legacy, u32, |_| Ok(()), |us: &LegacyToVecStruct| us.new_items.first().copied())), (1, new_items, (default_value_vec, @@ -2047,7 +2047,7 @@ mod tests { struct MyCustomStruct { tlv_field: Vec, } - impl_writeable_tlv_based!(MyCustomStruct, { + impl_ser_tlv_based!(MyCustomStruct, { (0, tlv_field, (required_vec, encoding: (Vec, WithoutLength))), }); diff --git a/lightning/src/util/sweep.rs b/lightning/src/util/sweep.rs index 8d539b0a5e6..883cc4a4d8e 100644 --- a/lightning/src/util/sweep.rs +++ b/lightning/src/util/sweep.rs @@ -97,7 +97,7 @@ impl TrackedSpendableOutput { } } -impl_writeable_tlv_based!(TrackedSpendableOutput, { +impl_ser_tlv_based!(TrackedSpendableOutput, { (0, descriptor, required), (2, channel_id, option), (3, counterparty_node_id, option), @@ -309,7 +309,7 @@ impl OutputSpendStatus { } } -impl_writeable_tlv_based_enum!(OutputSpendStatus, +impl_ser_tlv_based_enum!(OutputSpendStatus, (0, PendingInitialBroadcast) => { (0, delayed_until_height, option), }, @@ -864,7 +864,7 @@ struct SweeperState { dirty: bool, } -impl_writeable_tlv_based!(SweeperState, { +impl_ser_tlv_based!(SweeperState, { (0, outputs, required_vec), (2, best_block, required), (_unused, dirty, (static_value, false)), diff --git a/lightning/src/util/wallet_utils.rs b/lightning/src/util/wallet_utils.rs index cd79b3615c7..fe6b4f129e3 100644 --- a/lightning/src/util/wallet_utils.rs +++ b/lightning/src/util/wallet_utils.rs @@ -79,7 +79,7 @@ pub struct Utxo { pub sequence: Sequence, } -impl_writeable_tlv_based!(Utxo, { +impl_ser_tlv_based!(Utxo, { (1, outpoint, required), (3, output, required), (5, satisfaction_weight, required), @@ -164,7 +164,7 @@ pub struct ConfirmedUtxo { pub(crate) prevtx: Transaction, } -impl_writeable_tlv_based!(ConfirmedUtxo, { +impl_ser_tlv_based!(ConfirmedUtxo, { (1, utxo, required), (3, _sequence, (legacy, Sequence, |read_val: Option<&Sequence>| { From f22f509b3a1fdb1b416544a7789e72615a5f495d Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Thu, 28 May 2026 13:33:09 +0200 Subject: [PATCH 446/627] Avoid nested TLV length counting writes Add direct serialized length implementations for common serialization wrappers. This avoids routing field payload length calculations through in-memory writers for common nested serialization paths used by the existing TLV length helpers. --- lightning/src/util/ser.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs index 154c5bd8fde..b93be6446ce 100644 --- a/lightning/src/util/ser.rs +++ b/lightning/src/util/ser.rs @@ -317,6 +317,11 @@ impl<'a, T: Writeable> Writeable for &'a T { fn write(&self, writer: &mut W) -> Result<(), io::Error> { (*self).write(writer) } + + #[inline] + fn serialized_length(&self) -> usize { + (*self).serialized_length() + } } /// A trait that various LDK types implement allowing them to be read in from a [`Read`]. @@ -846,6 +851,11 @@ impl Writeable for WithoutLength { } Ok(()) } + + #[inline] + fn serialized_length(&self) -> usize { + self.0.as_slice().iter().map(|v| v.serialized_length()).sum() + } } impl LengthReadable for WithoutLength> { @@ -1366,6 +1376,11 @@ impl Writeable for Box { fn write(&self, w: &mut W) -> Result<(), io::Error> { T::write(&**self, w) } + + #[inline] + fn serialized_length(&self) -> usize { + T::serialized_length(&**self) + } } impl Readable for Box { @@ -1385,6 +1400,17 @@ impl Writeable for Option { } Ok(()) } + + #[inline] + fn serialized_length(&self) -> usize { + match *self { + None => 1, + Some(ref data) => { + let data_len = data.serialized_length(); + BigSize(data_len as u64 + 1).serialized_length() + data_len + }, + } + } } impl Readable for Option { From eb477426b459f2d923e7eb42eb4544ca8a3c7f39 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Thu, 28 May 2026 13:33:33 +0200 Subject: [PATCH 447/627] Generate TLV write length impls Add a writeable TLV helper macro that emits both write and serialized_length from the same field list. Reuse the shared TLV length helper from impl_ser_tlv_based so the generated read/write path and the new custom-read path stay aligned. Use the new helper for the hot channel funding and commitment transaction TLV writers while leaving their custom read implementations unchanged. --- lightning/src/ln/chan_utils.rs | 68 +++++++++++++++----------------- lightning/src/ln/channel.rs | 27 ++++++------- lightning/src/util/ser_macros.rs | 67 +++++++++++++++++++++++++------ 3 files changed, 97 insertions(+), 65 deletions(-) diff --git a/lightning/src/ln/chan_utils.rs b/lightning/src/ln/chan_utils.rs index 238ef71e13e..dd334776736 100644 --- a/lightning/src/ln/chan_utils.rs +++ b/lightning/src/ln/chan_utils.rs @@ -1169,24 +1169,21 @@ impl_ser_tlv_based!(CounterpartyChannelTransactionParameters, { (2, selected_contest_delay, required), }); -impl Writeable for ChannelTransactionParameters { - #[rustfmt::skip] - fn write(&self, writer: &mut W) -> Result<(), io::Error> { - let legacy_deserialization_prevention_marker = legacy_deserialization_prevention_marker_for_channel_type_features(&self.channel_type_features); - write_tlv_fields!(writer, { - (0, self.holder_pubkeys, required), - (2, self.holder_selected_contest_delay, required), - (4, self.is_outbound_from_holder, required), - (6, self.counterparty_parameters, option), - (8, self.funding_outpoint, option), - (10, legacy_deserialization_prevention_marker, option), - (11, self.channel_type_features, required), - (12, self.splice_parent_funding_txid, option), - (13, self.channel_value_satoshis, required), - }); - Ok(()) - } -} +impl_writeable_tlv_based!(ChannelTransactionParameters, self, { + (0, self.holder_pubkeys, required), + (2, self.holder_selected_contest_delay, required), + (4, self.is_outbound_from_holder, required), + (6, self.counterparty_parameters, option), + (8, self.funding_outpoint, option), + ( + 10, + legacy_deserialization_prevention_marker_for_channel_type_features(&self.channel_type_features), + option + ), + (11, self.channel_type_features, required), + (12, self.splice_parent_funding_txid, option), + (13, self.channel_value_satoshis, required), +}); impl ReadableArgs> for ChannelTransactionParameters { #[rustfmt::skip] @@ -1634,25 +1631,22 @@ impl PartialEq for CommitmentTransaction { } } -impl Writeable for CommitmentTransaction { - #[rustfmt::skip] - fn write(&self, writer: &mut W) -> Result<(), io::Error> { - let legacy_deserialization_prevention_marker = legacy_deserialization_prevention_marker_for_channel_type_features(&self.channel_type_features); - write_tlv_fields!(writer, { - (0, self.commitment_number, required), - (1, self.to_broadcaster_delay, option), - (2, self.to_broadcaster_value_sat, required), - (4, self.to_countersignatory_value_sat, required), - (6, self.feerate_per_kw, required), - (8, self.keys, required), - (10, self.built, required), - (12, self.nondust_htlcs, required_vec), - (14, legacy_deserialization_prevention_marker, option), - (15, self.channel_type_features, required), - }); - Ok(()) - } -} +impl_writeable_tlv_based!(CommitmentTransaction, self, { + (0, self.commitment_number, required), + (1, self.to_broadcaster_delay, option), + (2, self.to_broadcaster_value_sat, required), + (4, self.to_countersignatory_value_sat, required), + (6, self.feerate_per_kw, required), + (8, self.keys, required), + (10, self.built, required), + (12, self.nondust_htlcs, required_vec), + ( + 14, + legacy_deserialization_prevention_marker_for_channel_type_features(&self.channel_type_features), + option + ), + (15, self.channel_type_features, required), +}); impl Readable for CommitmentTransaction { #[rustfmt::skip] diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index cfa7304734d..5d43af066d2 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2596,22 +2596,17 @@ pub(super) struct FundingScope { minimum_depth_override: Option, } -impl Writeable for FundingScope { - fn write(&self, writer: &mut W) -> Result<(), io::Error> { - write_tlv_fields!(writer, { - (1, self.value_to_self_msat, required), - (3, self.counterparty_selected_channel_reserve_satoshis, option), - (5, self.holder_selected_channel_reserve_satoshis, required), - (7, self.channel_transaction_parameters, (required: ReadableArgs, None)), - (9, self.funding_transaction, option), - (11, self.funding_tx_confirmed_in, option), - (13, self.funding_tx_confirmation_height, required), - (15, self.short_channel_id, option), - (17, self.minimum_depth_override, option), - }); - Ok(()) - } -} +impl_writeable_tlv_based!(FundingScope, self, { + (1, self.value_to_self_msat, required), + (3, self.counterparty_selected_channel_reserve_satoshis, option), + (5, self.holder_selected_channel_reserve_satoshis, required), + (7, self.channel_transaction_parameters, (required: ReadableArgs, None)), + (9, self.funding_transaction, option), + (11, self.funding_tx_confirmed_in, option), + (13, self.funding_tx_confirmation_height, required), + (15, self.short_channel_id, option), + (17, self.minimum_depth_override, option), +}); impl Readable for FundingScope { #[rustfmt::skip] diff --git a/lightning/src/util/ser_macros.rs b/lightning/src/util/ser_macros.rs index 716be6851bf..e6f558b1071 100644 --- a/lightning/src/util/ser_macros.rs +++ b/lightning/src/util/ser_macros.rs @@ -839,6 +839,58 @@ macro_rules! write_tlv_fields { } } +#[doc(hidden)] +#[macro_export] +macro_rules! _tlv_fields_serialized_length { + ({$(($type: expr, $field: expr, $fieldty: tt $(, $self: ident)?)),* $(,)*}) => { { + use $crate::util::ser::BigSize; + let len = { + #[allow(unused_mut)] + let mut len = $crate::util::ser::LengthCalculatingWriter(0); + $( + $crate::_get_varint_length_prefixed_tlv_length!(len, $type, &$field, $fieldty $(, $self)?); + )* + len.0 + }; + let mut len_calc = $crate::util::ser::LengthCalculatingWriter(0); + BigSize(len as u64).write(&mut len_calc).expect("No in-memory data may fail to serialize"); + len + len_calc.0 + } } +} + +/// Implements [`Writeable`] for a type serialized as a length-prefixed TLV stream. +/// +/// This is useful for types that share the TLV-writing format used by +/// [`impl_ser_tlv_based`] but need a custom read implementation. The field list uses the +/// same entries accepted by [`write_tlv_fields`], and the macro derives both `write` and +/// `serialized_length` from that list so the two paths stay aligned. +/// +/// The `$self` argument names the generated `self` binding, allowing field expressions to refer +/// to it explicitly. +/// +/// [`Writeable`]: crate::util::ser::Writeable +/// [`impl_ser_tlv_based`]: crate::impl_ser_tlv_based +/// [`write_tlv_fields`]: crate::write_tlv_fields +macro_rules! impl_writeable_tlv_based { + ($st: ty, $self: ident, {$(($type: expr, $field: expr, $fieldty: tt)),* $(,)*}) => { + impl $crate::util::ser::Writeable for $st { + fn write(&$self, writer: &mut W) -> Result<(), $crate::io::Error> { + write_tlv_fields!(writer, { + $(($type, $field, $fieldty)),* + }); + Ok(()) + } + + #[inline] + fn serialized_length(&$self) -> usize { + $crate::_tlv_fields_serialized_length!({ + $(($type, $field, $fieldty)),* + }) + } + } + } +} + /// Reads a prefix added by [`write_ver_prefix`], above. Takes the current version of the /// serialization logic for this object. This is compared against the /// `$min_version_that_can_read_this` added by [`write_ver_prefix`]. @@ -1095,18 +1147,9 @@ macro_rules! impl_ser_tlv_based { #[inline] fn serialized_length(&self) -> usize { - use $crate::util::ser::BigSize; - let len = { - #[allow(unused_mut)] - let mut len = $crate::util::ser::LengthCalculatingWriter(0); - $( - $crate::_get_varint_length_prefixed_tlv_length!(len, $type, &self.$field, $fieldty, self); - )* - len.0 - }; - let mut len_calc = $crate::util::ser::LengthCalculatingWriter(0); - BigSize(len as u64).write(&mut len_calc).expect("No in-memory data may fail to serialize"); - len + len_calc.0 + $crate::_tlv_fields_serialized_length!({ + $(($type, self.$field, $fieldty, self)),* + }) } } From 069d190ab763e6a12ee9721cc5cdac5b57ea32c8 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 1 Jun 2026 13:52:33 -0500 Subject: [PATCH 448/627] Add AGENTS.md symlink Add AGENTS.md as a symlink to CLAUDE.md so Codex can load the same repository guidance. --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) create mode 120000 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 00000000000..681311eb9cf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file From 4060b623777e1766f9ba0918b7197a33e567ee11 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 3 Jun 2026 09:37:16 +0200 Subject: [PATCH 449/627] Rename migratable KV store trait for sync API Prepare the migration API naming for an async variant by giving the existing synchronous trait an explicit Sync suffix. Co-Authored-By: HAL 9000 --- lightning-persister/src/fs_store/v1.rs | 4 ++-- lightning-persister/src/fs_store/v2.rs | 4 ++-- lightning-persister/src/test_utils.rs | 4 ++-- lightning/src/util/persist.rs | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lightning-persister/src/fs_store/v1.rs b/lightning-persister/src/fs_store/v1.rs index 7f47c59a362..4768b819032 100644 --- a/lightning-persister/src/fs_store/v1.rs +++ b/lightning-persister/src/fs_store/v1.rs @@ -1,7 +1,7 @@ //! Objects related to [`FilesystemStore`] live here. use crate::fs_store::common::FilesystemStoreState; -use lightning::util::persist::{KVStoreSync, MigratableKVStore}; +use lightning::util::persist::{KVStoreSync, MigratableKVStoreSync}; use std::path::PathBuf; @@ -88,7 +88,7 @@ impl KVStore for FilesystemStore { } } -impl MigratableKVStore for FilesystemStore { +impl MigratableKVStoreSync for FilesystemStore { fn list_all_keys(&self) -> Result, lightning::io::Error> { self.state.list_all_keys_impl(false) } diff --git a/lightning-persister/src/fs_store/v2.rs b/lightning-persister/src/fs_store/v2.rs index 6154d22d35c..fd18e20df02 100644 --- a/lightning-persister/src/fs_store/v2.rs +++ b/lightning-persister/src/fs_store/v2.rs @@ -4,7 +4,7 @@ use crate::fs_store::common::{ }; use lightning::util::persist::{ - KVStoreSync, MigratableKVStore, PageToken, PaginatedKVStoreSync, PaginatedListResponse, + KVStoreSync, MigratableKVStoreSync, PageToken, PaginatedKVStoreSync, PaginatedListResponse, }; use std::fs; @@ -315,7 +315,7 @@ impl PaginatedKVStore for FilesystemStoreV2 { } } -impl MigratableKVStore for FilesystemStoreV2 { +impl MigratableKVStoreSync for FilesystemStoreV2 { fn list_all_keys(&self) -> Result, lightning::io::Error> { self.inner.list_all_keys_impl(true) } diff --git a/lightning-persister/src/test_utils.rs b/lightning-persister/src/test_utils.rs index b8f3eb0bd99..115f251edf9 100644 --- a/lightning-persister/src/test_utils.rs +++ b/lightning-persister/src/test_utils.rs @@ -1,7 +1,7 @@ use lightning::events::ClosureReason; use lightning::ln::functional_test_utils::*; use lightning::util::persist::{ - migrate_kv_store_data, read_channel_monitors, KVStoreSync, MigratableKVStore, + migrate_kv_store_data, read_channel_monitors, KVStoreSync, MigratableKVStoreSync, KVSTORE_NAMESPACE_KEY_ALPHABET, KVSTORE_NAMESPACE_KEY_MAX_LEN, }; use lightning::util::test_utils; @@ -59,7 +59,7 @@ pub(crate) fn do_read_write_remove_list_persist( assert_eq!(listed_keys.len(), 0); } -pub(crate) fn do_test_data_migration( +pub(crate) fn do_test_data_migration( source_store: &mut S, target_store: &mut T, ) { // We fill the source with some bogus keys. diff --git a/lightning/src/util/persist.rs b/lightning/src/util/persist.rs index 95d6032e130..10e6df47334 100644 --- a/lightning/src/util/persist.rs +++ b/lightning/src/util/persist.rs @@ -554,9 +554,9 @@ pub trait PaginatedKVStore: KVStore { ) -> impl Future> + 'static + MaybeSend; } -/// Provides additional interface methods that are required for [`KVStore`]-to-[`KVStore`] +/// Provides additional interface methods that are required for [`KVStoreSync`]-to-[`KVStoreSync`] /// data migration. -pub trait MigratableKVStore: KVStoreSync { +pub trait MigratableKVStoreSync: KVStoreSync { /// Returns *all* known keys as a list of `primary_namespace`, `secondary_namespace`, `key` tuples. /// /// This is useful for migrating data from [`KVStoreSync`] implementation to [`KVStoreSync`] @@ -575,7 +575,7 @@ pub trait MigratableKVStore: KVStoreSync { /// /// Will abort and return an error if any IO operation fails. Note that in this case the /// `target_store` might get left in an intermediate state. -pub fn migrate_kv_store_data( +pub fn migrate_kv_store_data( source_store: &mut S, target_store: &mut T, ) -> Result<(), io::Error> { let keys_to_migrate = source_store.list_all_keys()?; From 9b11fdad7f7853497b35eb3ce6576bd12f26d4f3 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 3 Jun 2026 09:45:34 +0200 Subject: [PATCH 450/627] Add async migratable KV store migration API Expose an async migratable KV store trait and async migration helper so async stores can migrate data without using the synchronous API. Co-Authored-By: HAL 9000 --- lightning/src/util/persist.rs | 150 ++++++++++++++++++++++++++++++++-- 1 file changed, 143 insertions(+), 7 deletions(-) diff --git a/lightning/src/util/persist.rs b/lightning/src/util/persist.rs index 10e6df47334..bf8a0cf8342 100644 --- a/lightning/src/util/persist.rs +++ b/lightning/src/util/persist.rs @@ -567,6 +567,128 @@ pub trait MigratableKVStoreSync: KVStoreSync { fn list_all_keys(&self) -> Result, io::Error>; } +/// Provides additional interface methods that are required for [`KVStore`]-to-[`KVStore`] +/// data migration. +/// +/// This is not exported to bindings users as async is only supported in Rust. +pub trait MigratableKVStore: KVStore { + /// Returns *all* known keys as a list of `primary_namespace`, `secondary_namespace`, `key` tuples. + /// + /// This is useful for migrating data from [`KVStore`] implementation to [`KVStore`] + /// implementation. + /// + /// Must exhaustively return all entries known to the store to ensure no data is missed, but + /// may return the items in arbitrary order. + fn list_all_keys( + &self, + ) -> impl Future, io::Error>> + 'static + MaybeSend; +} + +impl MigratableKVStore for K +where + K: Deref, + K::Target: MigratableKVStore, +{ + fn list_all_keys( + &self, + ) -> impl Future, io::Error>> + 'static + MaybeSend + { + self.deref().list_all_keys() + } +} + +/// This is not exported to bindings users as async is only supported in Rust. +impl MigratableKVStore for KVStoreSyncWrapper +where + K::Target: MigratableKVStoreSync, +{ + fn list_all_keys( + &self, + ) -> impl Future, io::Error>> + 'static + MaybeSend + { + let res = self.0.list_all_keys(); + + async move { res } + } +} + +type MigrationKey = (String, String, String); + +trait MigrationKVStore { + fn list_all_keys( + &self, + ) -> impl Future, io::Error>> + MaybeSend; + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> impl Future, io::Error>> + MaybeSend; + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> impl Future> + MaybeSend; +} + +struct MigrationKVStoreSyncAdapter<'a, K: ?Sized>(&'a K); + +impl MigrationKVStore for MigrationKVStoreSyncAdapter<'_, K> { + fn list_all_keys( + &self, + ) -> impl Future, io::Error>> + MaybeSend { + let res = MigratableKVStoreSync::list_all_keys(self.0); + + async move { res } + } + + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> impl Future, io::Error>> + MaybeSend { + let res = KVStoreSync::read(self.0, primary_namespace, secondary_namespace, key); + + async move { res } + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> impl Future> + MaybeSend { + let res = KVStoreSync::write(self.0, primary_namespace, secondary_namespace, key, buf); + + async move { res } + } +} + +struct MigrationKVStoreAsyncAdapter<'a, K: ?Sized>(&'a K); + +impl MigrationKVStore for MigrationKVStoreAsyncAdapter<'_, K> { + fn list_all_keys( + &self, + ) -> impl Future, io::Error>> + MaybeSend { + MigratableKVStore::list_all_keys(self.0) + } + + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> impl Future, io::Error>> + MaybeSend { + KVStore::read(self.0, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> impl Future> + MaybeSend { + KVStore::write(self.0, primary_namespace, secondary_namespace, key, buf) + } +} + +async fn migrate_kv_store_data_inner( + source_store: S, target_store: T, +) -> Result<(), io::Error> { + let keys_to_migrate = source_store.list_all_keys().await?; + + for (primary_namespace, secondary_namespace, key) in &keys_to_migrate { + let data = source_store.read(primary_namespace, secondary_namespace, key).await?; + target_store.write(primary_namespace, secondary_namespace, key, data).await?; + } + + Ok(()) +} + /// Migrates all data from one store to another. /// /// This operation assumes that `target_store` is empty, i.e., any data present under copied keys @@ -578,14 +700,28 @@ pub trait MigratableKVStoreSync: KVStoreSync { pub fn migrate_kv_store_data( source_store: &mut S, target_store: &mut T, ) -> Result<(), io::Error> { - let keys_to_migrate = source_store.list_all_keys()?; - - for (primary_namespace, secondary_namespace, key) in &keys_to_migrate { - let data = source_store.read(primary_namespace, secondary_namespace, key)?; - target_store.write(primary_namespace, secondary_namespace, key, data)?; - } + poll_sync_future(migrate_kv_store_data_inner( + MigrationKVStoreSyncAdapter(source_store), + MigrationKVStoreSyncAdapter(target_store), + )) +} - Ok(()) +/// Migrates all data from one asynchronous store to another. +/// +/// This operation assumes that `target_store` is empty, i.e., any data present under copied keys +/// might get overriden. User must ensure `source_store` is not modified during operation, +/// otherwise no consistency guarantees can be given. +/// +/// Will abort and return an error if any IO operation fails. Note that in this case the +/// `target_store` might get left in an intermediate state. +pub async fn migrate_kv_store_data_async( + source_store: &S, target_store: &T, +) -> Result<(), io::Error> { + migrate_kv_store_data_inner( + MigrationKVStoreAsyncAdapter(source_store), + MigrationKVStoreAsyncAdapter(target_store), + ) + .await } impl Persist for K { From 94cff3bee250de042198e0f8948a2a82e892e51d Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 3 Jun 2026 09:47:47 +0200 Subject: [PATCH 451/627] Add async migratable filesystem stores Allow the filesystem stores to use the async migration helper and cover both store versions with async migration tests. Co-Authored-By: HAL 9000 --- lightning-persister/src/fs_store/common.rs | 188 ++++++++++++--------- lightning-persister/src/fs_store/v1.rs | 26 +++ lightning-persister/src/fs_store/v2.rs | 26 +++ lightning-persister/src/test_utils.rs | 69 ++++++-- 4 files changed, 216 insertions(+), 93 deletions(-) diff --git a/lightning-persister/src/fs_store/common.rs b/lightning-persister/src/fs_store/common.rs index 6eaa0dbc455..885f806b344 100644 --- a/lightning-persister/src/fs_store/common.rs +++ b/lightning-persister/src/fs_store/common.rs @@ -470,6 +470,94 @@ impl FilesystemStoreInner { Ok(keys) } + + fn list_all_keys( + &self, use_empty_ns_dir: bool, + ) -> Result, lightning::io::Error> { + let prefixed_dest = &self.data_dir; + if !prefixed_dest.exists() { + return Ok(Vec::new()); + } + + let mut keys = Vec::new(); + + 'primary_loop: for primary_entry in fs::read_dir(prefixed_dest)? { + let primary_entry = primary_entry?; + let primary_path = primary_entry.path(); + if dir_entry_is_store_artifact(&primary_path) { + continue 'primary_loop; + } + + if dir_entry_is_key(&primary_entry)? { + let primary_namespace = String::new(); + let secondary_namespace = String::new(); + let key = get_key_from_dir_entry_path(&primary_path, prefixed_dest, false)?; + keys.push((primary_namespace, secondary_namespace, key)); + continue 'primary_loop; + } + + // The primary_entry is actually also a directory. + 'secondary_loop: for secondary_entry in fs::read_dir(&primary_path)? { + let secondary_entry = secondary_entry?; + let secondary_path = secondary_entry.path(); + if dir_entry_is_store_artifact(&secondary_path) { + continue 'secondary_loop; + } + + if dir_entry_is_key(&secondary_entry)? { + let primary_namespace = get_key_from_dir_entry_path( + &primary_path, + prefixed_dest, + use_empty_ns_dir, + )?; + let secondary_namespace = String::new(); + let key = get_key_from_dir_entry_path(&secondary_path, &primary_path, false)?; + keys.push((primary_namespace, secondary_namespace, key)); + continue 'secondary_loop; + } + + // The secondary_entry is actually also a directory. + for tertiary_entry in fs::read_dir(&secondary_path)? { + let tertiary_entry = tertiary_entry?; + let tertiary_path = tertiary_entry.path(); + if dir_entry_is_store_artifact(&tertiary_path) { + continue; + } + + if dir_entry_is_key(&tertiary_entry)? { + let primary_namespace = get_key_from_dir_entry_path( + &primary_path, + prefixed_dest, + use_empty_ns_dir, + )?; + let secondary_namespace = get_key_from_dir_entry_path( + &secondary_path, + &primary_path, + use_empty_ns_dir, + )?; + let key = + get_key_from_dir_entry_path(&tertiary_path, &secondary_path, false)?; + keys.push((primary_namespace, secondary_namespace, key)); + } else { + debug_assert!( + false, + "Failed to list keys of path {}: only two levels of namespaces are supported", + PrintableString(tertiary_path.to_str().unwrap_or_default()) + ); + let msg = format!( + "Failed to list keys of path {}: only two levels of namespaces are supported", + PrintableString(tertiary_path.to_str().unwrap_or_default()) + ); + return Err(lightning::io::Error::new( + lightning::io::ErrorKind::Other, + msg, + )); + } + } + } + } + Ok(keys) + } } impl FilesystemStoreState { @@ -640,92 +728,26 @@ impl FilesystemStoreState { } } - pub(crate) fn list_all_keys_impl( + #[cfg(feature = "tokio")] + pub(crate) fn list_all_keys_async( &self, use_empty_ns_dir: bool, - ) -> Result, lightning::io::Error> { - let prefixed_dest = &self.inner.data_dir; - if !prefixed_dest.exists() { - return Ok(Vec::new()); - } - - let mut keys = Vec::new(); - - 'primary_loop: for primary_entry in fs::read_dir(prefixed_dest)? { - let primary_entry = primary_entry?; - let primary_path = primary_entry.path(); - if dir_entry_is_store_artifact(&primary_path) { - continue 'primary_loop; - } - - if dir_entry_is_key(&primary_entry)? { - let primary_namespace = String::new(); - let secondary_namespace = String::new(); - let key = get_key_from_dir_entry_path(&primary_path, prefixed_dest, false)?; - keys.push((primary_namespace, secondary_namespace, key)); - continue 'primary_loop; - } - - // The primary_entry is actually also a directory. - 'secondary_loop: for secondary_entry in fs::read_dir(&primary_path)? { - let secondary_entry = secondary_entry?; - let secondary_path = secondary_entry.path(); - if dir_entry_is_store_artifact(&secondary_path) { - continue 'secondary_loop; - } - - if dir_entry_is_key(&secondary_entry)? { - let primary_namespace = get_key_from_dir_entry_path( - &primary_path, - prefixed_dest, - use_empty_ns_dir, - )?; - let secondary_namespace = String::new(); - let key = get_key_from_dir_entry_path(&secondary_path, &primary_path, false)?; - keys.push((primary_namespace, secondary_namespace, key)); - continue 'secondary_loop; - } - - // The secondary_entry is actually also a directory. - for tertiary_entry in fs::read_dir(&secondary_path)? { - let tertiary_entry = tertiary_entry?; - let tertiary_path = tertiary_entry.path(); - if dir_entry_is_store_artifact(&tertiary_path) { - continue; - } + ) -> impl Future, lightning::io::Error>> + 'static + Send + { + let this = Arc::clone(&self.inner); - if dir_entry_is_key(&tertiary_entry)? { - let primary_namespace = get_key_from_dir_entry_path( - &primary_path, - prefixed_dest, - use_empty_ns_dir, - )?; - let secondary_namespace = get_key_from_dir_entry_path( - &secondary_path, - &primary_path, - use_empty_ns_dir, - )?; - let key = - get_key_from_dir_entry_path(&tertiary_path, &secondary_path, false)?; - keys.push((primary_namespace, secondary_namespace, key)); - } else { - debug_assert!( - false, - "Failed to list keys of path {}: only two levels of namespaces are supported", - PrintableString(tertiary_path.to_str().unwrap_or_default()) - ); - let msg = format!( - "Failed to list keys of path {}: only two levels of namespaces are supported", - PrintableString(tertiary_path.to_str().unwrap_or_default()) - ); - return Err(lightning::io::Error::new( - lightning::io::ErrorKind::Other, - msg, - )); - } - } - } + async move { + tokio::task::spawn_blocking(move || this.list_all_keys(use_empty_ns_dir)) + .await + .unwrap_or_else(|e| { + Err(lightning::io::Error::new(lightning::io::ErrorKind::Other, e)) + }) } - Ok(keys) + } + + pub(crate) fn list_all_keys_impl( + &self, use_empty_ns_dir: bool, + ) -> Result, lightning::io::Error> { + self.inner.list_all_keys(use_empty_ns_dir) } } diff --git a/lightning-persister/src/fs_store/v1.rs b/lightning-persister/src/fs_store/v1.rs index 4768b819032..4f24d8d961f 100644 --- a/lightning-persister/src/fs_store/v1.rs +++ b/lightning-persister/src/fs_store/v1.rs @@ -94,9 +94,21 @@ impl MigratableKVStoreSync for FilesystemStore { } } +#[cfg(feature = "tokio")] +impl lightning::util::persist::MigratableKVStore for FilesystemStore { + fn list_all_keys( + &self, + ) -> impl Future, lightning::io::Error>> + 'static + Send + { + self.state.list_all_keys_async(false) + } +} + #[cfg(test)] mod tests { use super::*; + #[cfg(feature = "tokio")] + use crate::test_utils::do_test_data_migration_async; use crate::test_utils::{ do_read_write_remove_list_persist, do_test_data_migration, do_test_store, }; @@ -221,6 +233,20 @@ mod tests { do_test_data_migration(&mut source_store, &mut target_store); } + #[cfg(feature = "tokio")] + #[tokio::test] + async fn test_data_migration_async() { + let mut source_temp_path = std::env::temp_dir(); + source_temp_path.push("test_data_migration_source_async"); + let source_store = FilesystemStore::new(source_temp_path); + + let mut target_temp_path = std::env::temp_dir(); + target_temp_path.push("test_data_migration_target_async"); + let target_store = FilesystemStore::new(target_temp_path); + + do_test_data_migration_async(&source_store, &target_store).await; + } + #[test] fn test_if_monitors_is_not_dir() { let store = FilesystemStore::new("test_monitors_is_not_dir".into()); diff --git a/lightning-persister/src/fs_store/v2.rs b/lightning-persister/src/fs_store/v2.rs index fd18e20df02..fe1fdf60c7a 100644 --- a/lightning-persister/src/fs_store/v2.rs +++ b/lightning-persister/src/fs_store/v2.rs @@ -321,6 +321,16 @@ impl MigratableKVStoreSync for FilesystemStoreV2 { } } +#[cfg(feature = "tokio")] +impl lightning::util::persist::MigratableKVStore for FilesystemStoreV2 { + fn list_all_keys( + &self, + ) -> impl Future, lightning::io::Error>> + 'static + Send + { + self.inner.list_all_keys_async(true) + } +} + /// Formats a page token from mtime (millis since epoch) and key. pub(crate) fn format_page_token(mtime_millis: u64, key: &str) -> String { format!("{mtime_millis:016}:{key}") @@ -351,6 +361,8 @@ pub(crate) fn parse_page_token(token: &str) -> lightning::io::Result<(u64, Strin mod tests { use super::*; use crate::fs_store::common::EMPTY_NAMESPACE_DIR; + #[cfg(feature = "tokio")] + use crate::test_utils::do_test_data_migration_async; use crate::test_utils::{ do_read_write_remove_list_persist, do_test_data_migration, do_test_store, }; @@ -445,6 +457,20 @@ mod tests { do_test_data_migration(&mut source_store, &mut target_store); } + #[cfg(feature = "tokio")] + #[tokio::test] + async fn test_data_migration_async() { + let mut source_temp_path = std::env::temp_dir(); + source_temp_path.push("test_data_migration_source_async_v2"); + let source_store = FilesystemStoreV2::new(source_temp_path).unwrap(); + + let mut target_temp_path = std::env::temp_dir(); + target_temp_path.push("test_data_migration_target_async_v2"); + let target_store = FilesystemStoreV2::new(target_temp_path).unwrap(); + + do_test_data_migration_async(&source_store, &target_store).await; + } + #[test] fn test_filesystem_store_v2() { // Create the nodes, giving them FilesystemStoreV2s for data stores. diff --git a/lightning-persister/src/test_utils.rs b/lightning-persister/src/test_utils.rs index 115f251edf9..34e0619b34a 100644 --- a/lightning-persister/src/test_utils.rs +++ b/lightning-persister/src/test_utils.rs @@ -59,15 +59,11 @@ pub(crate) fn do_read_write_remove_list_persist( assert_eq!(listed_keys.len(), 0); } -pub(crate) fn do_test_data_migration( - source_store: &mut S, target_store: &mut T, -) { - // We fill the source with some bogus keys. - let dummy_data = vec![42u8; 32]; +fn data_migration_test_keys() -> Vec<(String, String, String)> { let num_primary_namespaces = 3; let num_secondary_namespaces = 3; let num_keys = 3; - let mut expected_keys = Vec::new(); + let mut keys = Vec::new(); for i in 0..num_primary_namespaces { let primary_namespace = if i == 0 { String::new() @@ -83,13 +79,25 @@ pub(crate) fn do_test_data_migration( + source_store: &mut S, target_store: &mut T, +) { + // We fill the source with some bogus keys. + let dummy_data = vec![42u8; 32]; + let mut expected_keys = data_migration_test_keys(); + for (primary_namespace, secondary_namespace, key) in &expected_keys { + source_store + .write(primary_namespace, secondary_namespace, key, dummy_data.clone()) + .unwrap(); + } expected_keys.sort(); expected_keys.dedup(); @@ -108,6 +116,47 @@ pub(crate) fn do_test_data_migration( + source_store: &S, target_store: &T, +) { + use lightning::util::persist::{migrate_kv_store_data_async, KVStore, MigratableKVStore}; + + // We fill the source with some bogus keys. + let dummy_data = vec![42u8; 32]; + let mut expected_keys = data_migration_test_keys(); + for (primary_namespace, secondary_namespace, key) in &expected_keys { + KVStore::write( + source_store, + primary_namespace, + secondary_namespace, + key, + dummy_data.clone(), + ) + .await + .unwrap(); + } + expected_keys.sort(); + expected_keys.dedup(); + + let mut source_list = MigratableKVStore::list_all_keys(source_store).await.unwrap(); + source_list.sort(); + assert_eq!(source_list, expected_keys); + + migrate_kv_store_data_async(source_store, target_store).await.unwrap(); + + let mut target_list = MigratableKVStore::list_all_keys(target_store).await.unwrap(); + target_list.sort(); + assert_eq!(target_list, expected_keys); + + for (p, s, k) in expected_keys.iter() { + assert_eq!(KVStore::read(target_store, p, s, k).await.unwrap(), dummy_data.clone()); + } +} + // Integration-test the given KVStore implementation. Test relaying a few payments and check that // the persisted data is updated the appropriate number of times. pub(crate) fn do_test_store(store_0: &K, store_1: &K) { From f3575c5dbe4d4e1020a8b5c0bd5b860715551d72 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Mon, 1 Jun 2026 15:05:33 -0700 Subject: [PATCH 452/627] Persist negotiated splice candidates on reload Prior to supporting RBF, we would avoid persisting `FundedChannel::pending_splice` when there was a pending funding negotiation that could not be resumed on channel reestablishment. With the addition of RBF support, this would cause our previously negotiated splices (but still pending) to be dropped unintentionally. --- lightning/src/ln/channel.rs | 76 +++++++++++++++++++++++++----- lightning/src/ln/splicing_tests.rs | 49 +++++++++++++++++++ 2 files changed, 112 insertions(+), 13 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 9c16c3b02f8..4aa9380f249 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2989,15 +2989,6 @@ struct PendingFunding { contributions: Vec, } -impl_ser_tlv_based!(PendingFunding, { - (1, funding_negotiation, upgradable_option), - (3, negotiated_candidates, required_vec), - (5, sent_funding_txid, option), - (7, received_funding_txid, option), - (8, last_funding_feerate_sat_per_1000_weight, option), - (10, contributions, optional_vec), -}); - #[derive(Debug)] enum FundingNegotiation { AwaitingAck { @@ -3037,6 +3028,57 @@ impl_writeable_tlv_based_enum_upgradable!(FundingNegotiation, unread_variants: AwaitingAck, ConstructingTransaction ); +struct PendingFundingWriteable<'a> { + pending_funding: &'a PendingFunding, + reset_funding_negotiation: bool, +} + +impl Writeable for PendingFundingWriteable<'_> { + fn write(&self, writer: &mut W) -> Result<(), io::Error> { + let funding_negotiation = if self.reset_funding_negotiation { + None + } else { + self.pending_funding.funding_negotiation.as_ref() + }; + debug_assert!( + funding_negotiation.is_none() + || matches!( + funding_negotiation, + Some(FundingNegotiation::AwaitingSignatures { .. }) + ) + ); + let contributions_len = if self.reset_funding_negotiation + && self.pending_funding.funding_negotiation.is_some() + { + self.pending_funding.contributions.len().saturating_sub(1) + } else { + self.pending_funding.contributions.len() + }; + write_tlv_fields!(writer, { + (1, funding_negotiation, upgradable_option), + (3, self.pending_funding.negotiated_candidates, required_vec), + (5, self.pending_funding.sent_funding_txid, option), + (7, self.pending_funding.received_funding_txid, option), + (8, self.pending_funding.last_funding_feerate_sat_per_1000_weight, option), + (10, self.pending_funding.contributions[..contributions_len], optional_vec), + }); + Ok(()) + } +} + +impl Readable for PendingFunding { + fn read(reader: &mut R) -> Result { + Ok(_decode_and_build!(reader, Self, { + (1, funding_negotiation, upgradable_option), + (3, negotiated_candidates, required_vec), + (5, sent_funding_txid, option), + (7, received_funding_txid, option), + (8, last_funding_feerate_sat_per_1000_weight, option), + (10, contributions, optional_vec), + })) + } +} + impl FundingNegotiation { fn as_funding(&self) -> Option<&FundingScope> { match self { @@ -16305,10 +16347,18 @@ impl Writeable for FundedChannel { let holder_commitment_point_next = self.holder_commitment_point.next_point(); let holder_commitment_point_pending_next = self.holder_commitment_point.pending_next_point; - // We don't have to worry about resetting the pending `FundingNegotiation` because we - // can only read `FundingNegotiation::AwaitingSignatures` variants anyway. - let pending_splice = - self.pending_splice.as_ref().filter(|_| !self.should_reset_pending_splice_state(true)); + // Avoid writing any negotiations that are not at the signing stage yet, as they cannot be + // resumed on reestablishment, but keep any already-negotiated candidates. + let reset_funding_negotiation = self.should_reset_pending_splice_state(true); + let should_persist_pending_splice = + !reset_funding_negotiation || !self.pending_funding().is_empty(); + let pending_splice = should_persist_pending_splice + .then(|| ()) + .and_then(|_| self.pending_splice.as_ref()) + .map(|pending_funding| PendingFundingWriteable { + pending_funding, + reset_funding_negotiation, + }); let monitor_pending_tx_signatures = self.context.monitor_pending_tx_signatures.then_some(()); diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index dfcc339b83b..75ff238bc35 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -1152,6 +1152,55 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); } +#[test] +fn test_reload_resets_splice_negotiation_without_dropping_candidates() { + // A reload should abort an in-flight RBF negotiation, but it must not drop the previously + // negotiated splice candidate that the monitor is still tracking. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let (persister_0, chain_monitor_0); + let node_0; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_1 = nodes[1].node.get_our_node_id(); + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_splice_tx, _) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution.clone()); + + let rbf_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + assert_eq!(funding_template.min_rbf_feerate(), Some(rbf_feerate)); + assert!(funding_template.prior_contribution().is_some()); + + let rbf_contribution = + funding_template.with_prior_contribution(rbf_feerate, FeeRate::MAX).build().unwrap(); + nodes[0].node.funding_contributed(&channel_id, &node_id_1, rbf_contribution, None).unwrap(); + complete_rbf_handshake(&nodes[0], &nodes[1]); + + let encoded_monitor_0 = get_monitor!(nodes[0], channel_id).encode(); + reload_node!( + nodes[0], + nodes[0].node.encode(), + &[&encoded_monitor_0], + persister_0, + chain_monitor_0, + node_0 + ); + let _ = get_event!(&nodes[0], Event::SpliceNegotiationFailed); + + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + assert_eq!(funding_template.min_rbf_feerate(), Some(rbf_feerate)); + assert_eq!(funding_template.prior_contribution().unwrap(), &funding_contribution); +} + #[test] fn test_config_reject_inbound_splices() { // Tests that nodes with `reject_inbound_splices` properly reject inbound splices but still From 3e9e6e9324e8e8916d7f921485982789a860f61a Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Mon, 1 Jun 2026 14:38:58 -0400 Subject: [PATCH 453/627] Set PaymentSent::fee_paid_msat in abandoned case If an outbound payment was abandoned with htlcs in-flight and later claimed, we would previously have the PaymentSent::fee_paid_msat be set to None. This contradicted some docs on the event that stated the field would always be Some after 0.0.103. --- lightning/src/events/mod.rs | 3 ++- lightning/src/ln/functional_tests.rs | 2 +- lightning/src/ln/outbound_payment.rs | 7 +++++++ lightning/src/ln/payment_tests.rs | 30 ++++++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index b0947183384..4853c83b19c 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -1201,7 +1201,8 @@ pub enum Event { /// If the recipient or an intermediate node misbehaves and gives us free money, this may /// overstate the amount paid, though this is unlikely. /// - /// This is only `None` for payments initiated on LDK versions prior to 0.0.103. + /// This is only `None` for payments abandoned but ultimately claimed when using LDK versions + /// prior to 0.3, 0.2.3, or 0.1.10. /// /// [`Route::get_total_fees`]: crate::routing::router::Route::get_total_fees fee_paid_msat: Option, diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index 37dd5187700..52e2f2e96bf 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -8579,7 +8579,7 @@ pub fn test_inconsistent_mpp_params() { pass_along_path(&nodes[0], path_b, real_amt, hash, Some(payment_secret), event, true, None); do_claim_payment_along_route(ClaimAlongRouteArgs::new(&nodes[0], &[path_a, path_b], preimage)); - expect_payment_sent(&nodes[0], preimage, Some(None), true, true); + expect_payment_sent(&nodes[0], preimage, Some(Some(2000)), true, true); } #[xtest(feature = "_externalize_tests")] diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 67fea5092c3..04e80038cc9 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -164,6 +164,9 @@ pub(crate) enum PendingOutboundPayment { /// The total payment amount across all paths, used to be able to issue `PaymentSent` if /// an HTLC still happens to succeed after we marked the payment as abandoned. total_msat: Option, + /// Preserved from `Retryable` so we can still report `fee_paid_msat` if an HTLC succeeds after + /// the payment was abandoned. Added in 0.3. + pending_fee_msat: Option, }, } @@ -252,6 +255,7 @@ impl PendingOutboundPayment { fn get_pending_fee_msat(&self) -> Option { match self { PendingOutboundPayment::Retryable { pending_fee_msat, .. } => pending_fee_msat.clone(), + PendingOutboundPayment::Abandoned { pending_fee_msat, .. } => pending_fee_msat.clone(), _ => None, } } @@ -308,6 +312,7 @@ impl PendingOutboundPayment { _ => new_hash_set(), }; let total_msat = self.total_msat(); + let pending_fee_msat = self.get_pending_fee_msat(); match self { Self::Retryable { payment_hash, .. } | Self::InvoiceReceived { payment_hash, .. } | @@ -318,6 +323,7 @@ impl PendingOutboundPayment { payment_hash: *payment_hash, reason: Some(reason), total_msat, + pending_fee_msat, }; }, _ => {} @@ -2778,6 +2784,7 @@ impl_writeable_tlv_based_enum_upgradable!(PendingOutboundPayment, (1, reason, upgradable_option), (2, payment_hash, required), (3, total_msat, option), + (5, pending_fee_msat, option), }, (5, AwaitingInvoice) => { (0, expiration, required), diff --git a/lightning/src/ln/payment_tests.rs b/lightning/src/ln/payment_tests.rs index 33c7df93ddb..90656b34429 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -2241,6 +2241,36 @@ fn abandoned_send_payment_idempotent() { claim_payment(&nodes[0], &[&nodes[1]], second_payment_preimage); } +#[test] +fn abandoned_payment_fulfilled_preserves_fee_paid_msat() { + // Previously, if we abandoned a payment with HTLCs in-flight and the payment eventually + // succeeded, we would set the `Event::PaymentSent::fee_paid_msat` to None, even though we had + // docs guaranteeing that it would always be Some after 0.0.103. + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes(&nodes, 0, 1); + create_announced_chan_between_nodes(&nodes, 1, 2); + + let amt_msat = 10_000_000; + let (route, payment_hash, payment_preimage, payment_secret) = + get_route_and_payment_hash!(&nodes[0], nodes[2], amt_msat); + let payment_id = PaymentId(payment_hash.0); + let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); + nodes[0].node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); + check_added_monitors(&nodes[0], 1); + + let path: &[&Node] = &[&nodes[1], &nodes[2]]; + pass_along_route(&nodes[0], &[path], amt_msat, payment_hash, payment_secret); + + nodes[0].node.abandon_payment(payment_id); + assert!(nodes[0].node.get_and_clear_pending_events().is_empty()); + + claim_payment_along_route(ClaimAlongRouteArgs::new(&nodes[0], &[path], payment_preimage)); +} + #[derive(PartialEq)] enum InterceptTest { Forward, From 8c08a3065a1ff20f9b3b6f06e4c928e235e01f74 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Fri, 29 May 2026 13:54:34 +0000 Subject: [PATCH 454/627] Stop using an introduction node in blinded message paths lnd is preparing to ship a release with opt-in onion messages without support for forwarding onion messages from non-channel peers. This breaks the common BOLT 12 OM flow today where we direct-connect to the blinded path introduction point and send the `invoice_request` without a channel. For CLN it turns out this is fine as they never select a peer for their introduction point at all. However, for LDK this would break existing nodes as nodes might now pick an lnd peer as an introduction node but it won't forward the onion message. For now, we just drop the separate introduction point selection and just always use ourselves as an introduction point (assuming we're an announced node). This should also have the side-effect of making offers marginally more robust, which may be worth it, even if it sucks to drop any pretense of privacy. --- lightning/src/ln/offers_tests.rs | 157 +---------------------- lightning/src/onion_message/messenger.rs | 69 +++++----- 2 files changed, 35 insertions(+), 191 deletions(-) diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 5eaf64b838b..8f073168465 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -56,7 +56,7 @@ use crate::ln::channelmanager::{PaymentId, RecentPaymentDetails, self}; use crate::ln::outbound_payment::{Bolt12PaymentError, RecipientOnionFields, Retry}; use crate::types::features::Bolt12InvoiceFeatures; use crate::ln::functional_test_utils::*; -use crate::ln::msgs::{BaseMessageHandler, ChannelMessageHandler, Init, NodeAnnouncement, OnionMessage, OnionMessageHandler, RoutingMessageHandler, SocketAddress, UnsignedGossipMessage, UnsignedNodeAnnouncement}; +use crate::ln::msgs::{BaseMessageHandler, ChannelMessageHandler, Init, OnionMessage, OnionMessageHandler}; use crate::ln::outbound_payment::IDEMPOTENCY_TIMEOUT_TICKS; use crate::offers::invoice::Bolt12Invoice; use crate::offers::invoice_error::InvoiceError; @@ -66,9 +66,8 @@ use crate::offers::offer::OfferBuilder; use crate::offers::parse::Bolt12SemanticError; use crate::onion_message::messenger::{DefaultMessageRouter, Destination, MessageRouter, MessageSendInstructions, NodeIdMessageRouter, NullMessageRouter, PeeledOnion, DUMMY_HOPS_PATH_LENGTH, QR_CODED_DUMMY_HOPS_PATH_LENGTH}; use crate::onion_message::offers::OffersMessage; -use crate::routing::gossip::{NodeAlias, NodeId}; use crate::routing::router::{DEFAULT_PAYMENT_DUMMY_HOPS, PaymentParameters, RouteParameters, RouteParametersConfig}; -use crate::sign::{NodeSigner, Recipient}; +use crate::sign::NodeSigner; use crate::util::ser::Writeable; /// This used to determine whether we built a compact path or not, but now its just a random @@ -125,38 +124,6 @@ fn disconnect_peers<'a, 'b, 'c>(node_a: &Node<'a, 'b, 'c>, peers: &[&Node<'a, 'b } } -fn announce_node_address<'a, 'b, 'c>( - node: &Node<'a, 'b, 'c>, peers: &[&Node<'a, 'b, 'c>], address: SocketAddress, -) { - let features = node.onion_messenger.provided_node_features() - | node.gossip_sync.provided_node_features(); - let rgb = [0u8; 3]; - let announcement = UnsignedNodeAnnouncement { - features, - timestamp: 1000, - node_id: NodeId::from_pubkey(&node.keys_manager.get_node_id(Recipient::Node).unwrap()), - rgb, - alias: NodeAlias([0u8; 32]), - addresses: vec![address], - excess_address_data: Vec::new(), - excess_data: Vec::new(), - }; - let signature = node.keys_manager.sign_gossip_message( - UnsignedGossipMessage::NodeAnnouncement(&announcement) - ).unwrap(); - - let msg = NodeAnnouncement { - signature, - contents: announcement - }; - - let node_pubkey = node.node.get_our_node_id(); - node.gossip_sync.handle_node_announcement(None, &msg).unwrap(); - for peer in peers { - peer.gossip_sync.handle_node_announcement(Some(node_pubkey), &msg).unwrap(); - } -} - fn resolve_introduction_node<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, path: &BlindedMessagePath) -> PublicKey { path.public_introduction_node_id(&node.network_graph.read_only()) .and_then(|node_id| node_id.as_pubkey().ok()) @@ -362,126 +329,6 @@ fn create_refund_with_no_blinded_path() { assert!(refund.paths().is_empty()); } -/// Checks that blinded paths without Tor-only nodes are preferred when constructing an offer. -#[test] -fn prefers_non_tor_nodes_in_blinded_paths() { - let mut accept_forward_cfg = test_default_channel_config(); - accept_forward_cfg.accept_forwards_to_priv_channels = true; - - let mut features = channelmanager::provided_init_features(&accept_forward_cfg); - features.set_onion_messages_optional(); - features.set_route_blinding_optional(); - - let chanmon_cfgs = create_chanmon_cfgs(6); - let node_cfgs = create_node_cfgs(6, &chanmon_cfgs); - - *node_cfgs[1].override_init_features.borrow_mut() = Some(features); - - let node_chanmgrs = create_node_chanmgrs( - 6, &node_cfgs, &[None, Some(accept_forward_cfg), None, None, None, None] - ); - let nodes = create_network(6, &node_cfgs, &node_chanmgrs); - - create_unannounced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); - create_unannounced_chan_between_nodes_with_value(&nodes, 2, 3, 10_000_000, 1_000_000_000); - create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 10_000_000, 1_000_000_000); - create_announced_chan_between_nodes_with_value(&nodes, 1, 4, 10_000_000, 1_000_000_000); - create_announced_chan_between_nodes_with_value(&nodes, 1, 5, 10_000_000, 1_000_000_000); - create_announced_chan_between_nodes_with_value(&nodes, 2, 4, 10_000_000, 1_000_000_000); - create_announced_chan_between_nodes_with_value(&nodes, 2, 5, 10_000_000, 1_000_000_000); - - // Add an extra channel so that more than one of Bob's peers have MIN_PEER_CHANNELS. - create_announced_chan_between_nodes_with_value(&nodes, 4, 5, 10_000_000, 1_000_000_000); - - let (alice, bob, charlie, david) = (&nodes[0], &nodes[1], &nodes[2], &nodes[3]); - let bob_id = bob.node.get_our_node_id(); - let charlie_id = charlie.node.get_our_node_id(); - - disconnect_peers(alice, &[charlie, david, &nodes[4], &nodes[5]]); - disconnect_peers(david, &[bob, &nodes[4], &nodes[5]]); - - let tor = SocketAddress::OnionV2([255, 254, 253, 252, 251, 250, 249, 248, 247, 246, 38, 7]); - announce_node_address(charlie, &[alice, bob, david, &nodes[4], &nodes[5]], tor.clone()); - - let offer = bob.node - .create_offer_builder().unwrap() - .amount_msats(10_000_000) - .build().unwrap(); - assert_ne!(offer.issuer_signing_pubkey(), Some(bob_id)); - assert!(!offer.paths().is_empty()); - for path in offer.paths() { - let introduction_node_id = resolve_introduction_node(david, &path); - assert_ne!(introduction_node_id, bob_id); - assert_ne!(introduction_node_id, charlie_id); - } - - // Use a one-hop blinded path when Bob is announced and all his peers are Tor-only. - announce_node_address(&nodes[4], &[alice, bob, charlie, david, &nodes[5]], tor.clone()); - announce_node_address(&nodes[5], &[alice, bob, charlie, david, &nodes[4]], tor.clone()); - - let offer = bob.node - .create_offer_builder().unwrap() - .amount_msats(10_000_000) - .build().unwrap(); - assert_ne!(offer.issuer_signing_pubkey(), Some(bob_id)); - assert!(!offer.paths().is_empty()); - for path in offer.paths() { - let introduction_node_id = resolve_introduction_node(david, &path); - assert_eq!(introduction_node_id, bob_id); - } -} - -/// Checks that blinded paths prefer an introduction node that is the most connected. -#[test] -fn prefers_more_connected_nodes_in_blinded_paths() { - let mut accept_forward_cfg = test_default_channel_config(); - accept_forward_cfg.accept_forwards_to_priv_channels = true; - - let mut features = channelmanager::provided_init_features(&accept_forward_cfg); - features.set_onion_messages_optional(); - features.set_route_blinding_optional(); - - let chanmon_cfgs = create_chanmon_cfgs(6); - let node_cfgs = create_node_cfgs(6, &chanmon_cfgs); - - *node_cfgs[1].override_init_features.borrow_mut() = Some(features); - - let node_chanmgrs = create_node_chanmgrs( - 6, &node_cfgs, &[None, Some(accept_forward_cfg), None, None, None, None] - ); - let nodes = create_network(6, &node_cfgs, &node_chanmgrs); - - create_unannounced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); - create_unannounced_chan_between_nodes_with_value(&nodes, 2, 3, 10_000_000, 1_000_000_000); - create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 10_000_000, 1_000_000_000); - create_announced_chan_between_nodes_with_value(&nodes, 1, 4, 10_000_000, 1_000_000_000); - create_announced_chan_between_nodes_with_value(&nodes, 1, 5, 10_000_000, 1_000_000_000); - create_announced_chan_between_nodes_with_value(&nodes, 2, 4, 10_000_000, 1_000_000_000); - create_announced_chan_between_nodes_with_value(&nodes, 2, 5, 10_000_000, 1_000_000_000); - - // Add extra channels so that more than one of Bob's peers have MIN_PEER_CHANNELS and one has - // more than the others. - create_announced_chan_between_nodes_with_value(&nodes, 0, 4, 10_000_000, 1_000_000_000); - create_announced_chan_between_nodes_with_value(&nodes, 3, 4, 10_000_000, 1_000_000_000); - - let (alice, bob, charlie, david) = (&nodes[0], &nodes[1], &nodes[2], &nodes[3]); - let bob_id = bob.node.get_our_node_id(); - - disconnect_peers(alice, &[charlie, david, &nodes[4], &nodes[5]]); - disconnect_peers(david, &[bob, &nodes[4], &nodes[5]]); - - let offer = bob.node - .create_offer_builder().unwrap() - .amount_msats(10_000_000) - .build().unwrap(); - assert_ne!(offer.issuer_signing_pubkey(), Some(bob_id)); - assert!(!offer.paths().is_empty()); - for path in offer.paths() { - let introduction_node_id = resolve_introduction_node(david, &path); - assert_eq!(introduction_node_id, nodes[4].node.get_our_node_id()); - } -} - /// Tests the dummy hop behavior of Offers based on the message router used: /// - Compact paths (`DefaultMessageRouter`) should not include dummy hops. /// - Node ID paths (`NodeIdMessageRouter`) may include 0 to [`MAX_DUMMY_HOPS_COUNT`] dummy hops. diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index 98a54e21b17..617f438cb49 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -563,10 +563,6 @@ impl>, L: Logger, ES: EntropySource> // Limit the number of blinded paths that are computed. const MAX_PATHS: usize = 3; - // Ensure peers have at least three channels so that it is more difficult to infer the - // recipient's node_id. - const MIN_PEER_CHANNELS: usize = 3; - let network_graph = network_graph.deref().read_only(); let is_recipient_announced = network_graph.nodes().contains_key(&NodeId::from_pubkey(&recipient)); @@ -596,32 +592,6 @@ impl>, L: Logger, ES: EntropySource> let compact_paths = !never_compact_path && size_constrained; - let has_one_peer = peers.len() == 1; - let mut peer_info = peers - .map(|peer| MessageForwardNode { - short_channel_id: if compact_paths { peer.short_channel_id } else { None }, - ..peer - }) - // Limit to peers with announced channels unless the recipient is unannounced. - .filter_map(|peer| { - network_graph - .node(&NodeId::from_pubkey(&peer.node_id)) - .filter(|info| { - !is_recipient_announced || info.channels.len() >= MIN_PEER_CHANNELS - }) - .map(|info| (peer, info.is_tor_only(), info.channels.len())) - // Allow messages directly with the only peer when unannounced. - .or_else(|| (!is_recipient_announced && has_one_peer).then(|| (peer, false, 0))) - }) - // Exclude Tor-only nodes when the recipient is announced. - .filter(|(_, is_tor_only, _)| !(*is_tor_only && is_recipient_announced)) - .collect::>(); - - // Prefer using non-Tor nodes with the most channels as the introduction node. - peer_info.sort_unstable_by(|(_, a_tor_only, a_channels), (_, b_tor_only, b_channels)| { - a_tor_only.cmp(b_tor_only).then(a_channels.cmp(b_channels).reverse()) - }); - let build_path = |intermediate_hops: &[MessageForwardNode]| { // Calculate the dummy hops given the total hop count target (including the recipient). let dummy_hops_count = path_len_incl_dummys.saturating_sub(intermediate_hops.len() + 1); @@ -638,12 +608,39 @@ impl>, L: Logger, ES: EntropySource> ) }; - // Try to create paths from peer info, fall back to direct path if needed - let mut paths = peer_info - .into_iter() - .map(|(peer, _, _)| build_path(&[peer])) - .take(MAX_PATHS) - .collect::>(); + let has_one_peer = peers.len() == 1; + let mut paths = if !is_recipient_announced { + let mut peer_info = peers + .map(|peer| MessageForwardNode { + short_channel_id: if compact_paths { peer.short_channel_id } else { None }, + ..peer + }) + .filter_map(|peer| { + network_graph + .node(&NodeId::from_pubkey(&peer.node_id)) + .map(|info| (peer, info.is_tor_only(), info.channels.len())) + // Allow messages directly with the only peer + .or_else(|| has_one_peer.then(|| (peer, false, 0))) + }) + .collect::>(); + + // Prefer using non-Tor nodes with the most channels as the introduction node. + peer_info.sort_unstable_by( + |(_, a_tor_only, a_channels), (_, b_tor_only, b_channels)| { + a_tor_only.cmp(b_tor_only).then(a_channels.cmp(b_channels).reverse()) + }, + ); + + // Try to create paths from peer info, fall back to direct path if needed + peer_info + .into_iter() + .map(|(peer, _, _)| build_path(&[peer])) + .take(MAX_PATHS) + .collect::>() + } else { + vec![] + }; + if paths.is_empty() { if is_recipient_announced { paths = vec![build_path(&[])]; From 7276466c279bca2cb211a0b8dacced73a3f74c46 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 8 Jun 2026 20:53:53 +0000 Subject: [PATCH 455/627] Correct HashMap preallocation amount copy/paste typo --- lightning/src/chain/onchaintx.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning/src/chain/onchaintx.rs b/lightning/src/chain/onchaintx.rs index 3eb6d64f3a2..75a4e1977d5 100644 --- a/lightning/src/chain/onchaintx.rs +++ b/lightning/src/chain/onchaintx.rs @@ -413,7 +413,7 @@ impl<'a, 'b, ES: EntropySource, SP: SignerProvider> ReadableArgs<(&'a ES, &'b SP } let claimable_outpoints_len: u64 = Readable::read(reader)?; - let mut claimable_outpoints = hash_map_with_capacity(cmp::min(pending_claim_requests_len as usize, MAX_ALLOC_SIZE / 128)); + let mut claimable_outpoints = hash_map_with_capacity(cmp::min(claimable_outpoints_len as usize, MAX_ALLOC_SIZE / 128)); for _ in 0..claimable_outpoints_len { let outpoint = Readable::read(reader)?; let ancestor_claim_txid = Readable::read(reader)?; From 59fd10ce866c90ba92ac4bdc8bb4e70d99f5f047 Mon Sep 17 00:00:00 2001 From: Alkamal01 Date: Tue, 14 Apr 2026 06:21:51 -0400 Subject: [PATCH 456/627] Prefer outbound_scid_alias over short_channel_id in get_outbound_payment_scid With splicing, the real SCID changes when a splice confirms while the outbound_scid_alias remains stable. Prefer alias-first in get_outbound_payment_scid so routes built before a splice confirmation stay valid after. Also fix route direction in fail_splice_on_tx_complete_error and update onion_route_tests comment to state intent rather than describe change. --- lightning/src/ln/channel_state.rs | 30 +++++++++++---------- lightning/src/ln/onion_route_tests.rs | 11 +++++--- lightning/src/ln/payment_tests.rs | 33 ++++++++++++++++------- lightning/src/ln/priv_short_conf_tests.rs | 22 ++++++++++----- lightning/src/ln/reload_tests.rs | 10 +++++-- lightning/src/ln/splicing_tests.rs | 2 +- lightning/src/routing/router.rs | 2 +- 7 files changed, 73 insertions(+), 37 deletions(-) diff --git a/lightning/src/ln/channel_state.rs b/lightning/src/ln/channel_state.rs index e3a4f7f4815..6e5d633e920 100644 --- a/lightning/src/ln/channel_state.rs +++ b/lightning/src/ln/channel_state.rs @@ -312,8 +312,10 @@ pub struct ChannelDetails { /// Note that if [`inbound_scid_alias`] is set, it must be used for invoices and inbound /// payments instead of this. See [`get_inbound_payment_scid`]. /// - /// For channels with [`confirmations_required`] set to `Some(0)`, [`outbound_scid_alias`] may - /// be used in place of this in outbound routes. See [`get_outbound_payment_scid`]. + /// For routing outbound payments, this value should not be used if [`outbound_scid_alias`] is + /// set. [`outbound_scid_alias`] provides a stable routing identifier across splices, whereas + /// this value will change when a splice confirms. + /// Use [`get_outbound_payment_scid`] to pick the appropriate value. /// /// When a channel is spliced, this continues to refer to the original pre-splice channel /// state until the splice transaction reaches sufficient confirmations to be locked (and we @@ -323,21 +325,17 @@ pub struct ChannelDetails { /// [`outbound_scid_alias`]: Self::outbound_scid_alias /// [`get_inbound_payment_scid`]: Self::get_inbound_payment_scid /// [`get_outbound_payment_scid`]: Self::get_outbound_payment_scid - /// [`confirmations_required`]: Self::confirmations_required pub short_channel_id: Option, /// An optional [`short_channel_id`] alias for this channel, randomly generated by us and - /// usable in place of [`short_channel_id`] to reference the channel in outbound routes when - /// the channel has not yet been confirmed (as long as [`confirmations_required`] is - /// `Some(0)`). + /// usable in place of [`short_channel_id`] to route outbound payments. Because this alias is + /// assigned at channel open and remains stable across splices, it should be used for routing + /// instead of the real [`short_channel_id`] (which changes each time a splice confirms). + /// See [`get_outbound_payment_scid`]. /// /// This will be `None` as long as the channel is not available for routing outbound payments. /// - /// When a channel is spliced, this continues to refer to the original pre-splice channel - /// state until the splice transaction reaches sufficient confirmations to be locked (and we - /// exchange `splice_locked` messages with our peer). - /// /// [`short_channel_id`]: Self::short_channel_id - /// [`confirmations_required`]: Self::confirmations_required + /// [`get_outbound_payment_scid`]: Self::get_outbound_payment_scid pub outbound_scid_alias: Option, /// An optional [`short_channel_id`] alias for this channel, randomly generated by our /// counterparty and usable in place of [`short_channel_id`] in invoice route hints. Our @@ -513,12 +511,16 @@ impl ChannelDetails { /// This should be used in [`Route`]s to describe the first hop or in other contexts where /// we're sending or forwarding a payment outbound over this channel. /// - /// This is either the [`ChannelDetails::short_channel_id`], if set, or the - /// [`ChannelDetails::outbound_scid_alias`]. See those for more information. + /// Returns [`outbound_scid_alias`] if set, otherwise [`short_channel_id`]. The alias is + /// preferred because when a splice confirms the real SCID changes, whereas the alias assigned + /// at channel open remains stable. + /// + /// [`outbound_scid_alias`]: ChannelDetails::outbound_scid_alias + /// [`short_channel_id`]: ChannelDetails::short_channel_id /// /// [`Route`]: crate::routing::router::Route pub fn get_outbound_payment_scid(&self) -> Option { - self.short_channel_id.or(self.outbound_scid_alias) + self.outbound_scid_alias.or(self.short_channel_id) } /// Gets the funding output for this channel, if available. diff --git a/lightning/src/ln/onion_route_tests.rs b/lightning/src/ln/onion_route_tests.rs index 019d8faf98c..df5e98a62dd 100644 --- a/lightning/src/ln/onion_route_tests.rs +++ b/lightning/src/ln/onion_route_tests.rs @@ -815,13 +815,16 @@ fn test_onion_failure() { let (_, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[2], None, None); // Our immediate peer sent UpdateFailMalformedHTLC because it couldn't understand the onion in - // the UpdateAddHTLC that we sent. + // the UpdateAddHTLC that we sent. These tests explicitly route via the real SCID (not the + // alias) so the expected_short_channel_id assertions below match. let short_channel_id = channels[0].0.contents.short_channel_id; + let mut route_via_real_scid = route.clone(); + route_via_real_scid.paths[0].hops[0].short_channel_id = short_channel_id; run_onion_failure_test( "invalid_onion_version", 0, &nodes, - &route, + &route_via_real_scid, &payment_hash, &payment_secret, |msg| { @@ -839,7 +842,7 @@ fn test_onion_failure() { "invalid_onion_hmac", 0, &nodes, - &route, + &route_via_real_scid, &payment_hash, &payment_secret, |msg| { @@ -857,7 +860,7 @@ fn test_onion_failure() { "invalid_onion_key", 0, &nodes, - &route, + &route_via_real_scid, &payment_hash, &payment_secret, |msg| { diff --git a/lightning/src/ln/payment_tests.rs b/lightning/src/ln/payment_tests.rs index 33c7df93ddb..ebce4314ecc 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -250,13 +250,16 @@ fn mpp_retry_overpay() { let (mut route, hash, payment_preimage, pay_secret) = get_route_and_payment_hash!(nodes[0], nodes[3], payment_params, amt_msat, max_fee); - // Check we overpay on the second path which we're about to fail. + // Check we overpay on the second path which we're about to fail. Path ordering is not fixed, + // so we identify paths by first-hop pubkey. assert_eq!(chan_1_update.contents.fee_proportional_millionths, 0); - let overpaid_amount_1 = route.paths[0].fee_msat() as u32 - chan_1_update.contents.fee_base_msat; + let path_via_b = route.paths.iter().find(|p| p.hops[0].pubkey == node_b_id).unwrap(); + let overpaid_amount_1 = path_via_b.fee_msat() as u32 - chan_1_update.contents.fee_base_msat; assert_eq!(overpaid_amount_1, 0); assert_eq!(chan_2_update.contents.fee_proportional_millionths, 0); - let overpaid_amount_2 = route.paths[1].fee_msat() as u32 - chan_2_update.contents.fee_base_msat; + let path_via_c = route.paths.iter().find(|p| p.hops[0].pubkey == node_c_id).unwrap(); + let overpaid_amount_2 = path_via_c.fee_msat() as u32 - chan_2_update.contents.fee_base_msat; let total_overpaid_amount = overpaid_amount_1 + overpaid_amount_2; @@ -304,11 +307,13 @@ fn mpp_retry_overpay() { // Rebalance the channel so the second half of the payment can succeed. send_payment(&nodes[3], &[&nodes[2]], 38_000_000); - // Retry the second half of the payment and make sure it succeeds. - let first_path_value = route.paths[0].final_value_msat(); + // Retry the second half of the payment and make sure it succeeds. Identify the successful + // path (through nodes[1]) by first-hop pubkey, since path ordering is not stable. + let path_via_b_idx = route.paths.iter().position(|p| p.hops[0].pubkey == node_b_id).unwrap(); + let first_path_value = route.paths[path_via_b_idx].final_value_msat(); assert_eq!(first_path_value, 36_000_000); - route.paths.remove(0); + route.paths.remove(path_via_b_idx); route_params.final_value_msat -= first_path_value; let chan_4_scid = chan_4_update.contents.short_channel_id; route_params.payment_params.previously_failed_channels.push(chan_4_scid); @@ -2023,8 +2028,18 @@ fn preflight_probes_yield_event() { let route_params = RouteParameters::from_payment_params_and_value(payment_params, recv_value); let res = nodes[0].node.send_preflight_probes(route_params, None).unwrap(); + // Path ordering depends on outbound SCID selection. Determine which res entry corresponds + // to which path by comparing the alias SCIDs of the two channels. + let node_b_id = nodes[1].node.get_our_node_id(); + let node_c_id = nodes[2].node.get_our_node_id(); + let chans = nodes[0].node.list_usable_channels(); + let chan_to_b = chans.iter().find(|c| c.counterparty.node_id == node_b_id).unwrap(); + let chan_to_c = chans.iter().find(|c| c.counterparty.node_id == node_c_id).unwrap(); + let b_first = chan_to_b.get_outbound_payment_scid() < chan_to_c.get_outbound_payment_scid(); + let (hash_b, hash_c) = if b_first { (res[0].0, res[1].0) } else { (res[1].0, res[0].0) }; + let expected_route: &[(&[&Node], PaymentHash)] = - &[(&[&nodes[1], &nodes[3]], res[0].0), (&[&nodes[2], &nodes[3]], res[1].0)]; + &[(&[&nodes[1], &nodes[3]], hash_b), (&[&nodes[2], &nodes[3]], hash_c)]; assert_eq!(res.len(), expected_route.len()); @@ -2319,7 +2334,7 @@ fn test_trivial_inflight_htlc_tracking() { let chan_1_used_liquidity = inflight_htlcs.used_liquidity_msat( &NodeId::from_pubkey(&node_a_id), &NodeId::from_pubkey(&node_b_id), - channel_1.funding().get_short_channel_id().unwrap(), + channel_1.context().outbound_scid_alias(), ); // First hop accounts for expected 1000 msat fee assert_eq!(chan_1_used_liquidity, Some(501000)); @@ -2429,7 +2444,7 @@ fn test_holding_cell_inflight_htlcs() { let used_liquidity = inflight_htlcs.used_liquidity_msat( &NodeId::from_pubkey(&node_a_id), &NodeId::from_pubkey(&node_b_id), - channel.funding().get_short_channel_id().unwrap(), + channel.context().outbound_scid_alias(), ); assert_eq!(used_liquidity, Some(2000000)); diff --git a/lightning/src/ln/priv_short_conf_tests.rs b/lightning/src/ln/priv_short_conf_tests.rs index 979c896e15d..7e3adb4adc9 100644 --- a/lightning/src/ln/priv_short_conf_tests.rs +++ b/lightning/src/ln/priv_short_conf_tests.rs @@ -1108,8 +1108,8 @@ fn test_0conf_channel_reorg() { mine_transaction(&nodes[1], &tx); mine_transaction(&nodes[2], &tx); - // Send a payment using the channel's real SCID, which will be public in a few blocks once we - // can generate a channel_announcement. + // Send a payment using the channel's alias SCID. The channel itself will be public in a few + // blocks once we can generate a channel_announcement. let bs_chans = nodes[1].node.list_usable_channels(); let bs_chan = bs_chans.iter().find(|chan| chan.counterparty.node_id == node_c_id).unwrap(); let original_scid = bs_chan.short_channel_id.unwrap(); @@ -1117,7 +1117,7 @@ fn test_0conf_channel_reorg() { let (mut route, payment_hash, payment_preimage, payment_secret) = get_route_and_payment_hash!(nodes[1], nodes[2], 10_000); - assert_eq!(route.paths[0].hops[0].short_channel_id, original_scid); + assert_eq!(route.paths[0].hops[0].short_channel_id, bs_chan.outbound_scid_alias.unwrap()); send_along_route_with_secret( &nodes[1], route.clone(), @@ -1188,7 +1188,7 @@ fn test_0conf_channel_reorg() { assert_ne!(original_scid, new_scid); assert_eq!(nodes[2].node.list_usable_channels()[0].short_channel_id.unwrap(), new_scid); - // At this point, the channel should happily forward or send payments with either the old SCID + // At this point, the channel should happily forward or send payments with either the alias SCID // or the new SCID... send_along_route_with_secret( &nodes[1], @@ -1286,12 +1286,22 @@ fn test_0conf_channel_reorg() { let onion = RecipientOnionFields::secret_only(payment_secret, 10_000); let id = PaymentId([0; 32]); - nodes[1].node.send_payment_with_route(route, payment_hash, onion.clone(), id).unwrap(); + + // The route uses the alias SCID, which is stable across reorgs. To verify the old real SCID + // is invalidated after propagation delay, we explicitly build a route using original_scid. + let mut old_scid_route = route.clone(); + old_scid_route.paths[0].hops[0].short_channel_id = original_scid; + nodes[1].node.send_payment_with_route(old_scid_route, payment_hash, onion.clone(), id).unwrap(); let mut conditions = PaymentFailedConditions::new(); conditions.reason = Some(PaymentFailureReason::RouteNotFound); expect_payment_failed_conditions(&nodes[1], payment_hash, false, conditions); - nodes[0].node.send_payment_with_route(forwarded_route, payment_hash, onion, id).unwrap(); + let mut old_scid_forwarded_route = forwarded_route.clone(); + old_scid_forwarded_route.paths[0].hops[1].short_channel_id = original_scid; + nodes[0] + .node + .send_payment_with_route(old_scid_forwarded_route, payment_hash, onion, id) + .unwrap(); check_added_monitors(&nodes[0], 1); let mut ev = nodes[0].node.get_and_clear_pending_msg_events(); assert_eq!(ev.len(), 1); diff --git a/lightning/src/ln/reload_tests.rs b/lightning/src/ln/reload_tests.rs index 9da90d95109..90bdff48724 100644 --- a/lightning/src/ln/reload_tests.rs +++ b/lightning/src/ln/reload_tests.rs @@ -956,6 +956,12 @@ fn test_mpp_claim_htlc_fulfills_unblocked_on_reload() { let chan_id_b = chan_b.2; let scid_a = chan_a.0.contents.short_channel_id; let scid_b = chan_b.0.contents.short_channel_id; + // Routes to a directly-connected peer use the outbound SCID alias, so payment path success + // events report the alias rather than the real SCID announced in gossip. + let payment_scid_a = nodes[0].node.list_channels().iter() + .find(|chan| chan.channel_id == chan_id_a).unwrap().get_outbound_payment_scid().unwrap(); + let payment_scid_b = nodes[0].node.list_channels().iter() + .find(|chan| chan.channel_id == chan_id_b).unwrap().get_outbound_payment_scid().unwrap(); // Send an MPP payment to nodes[1]. `send_along_route_with_secret` leaves the payment // claimable but unclaimed, so nodes[1] still has both inbound HTLCs live when we start @@ -1214,7 +1220,7 @@ fn test_mpp_claim_htlc_fulfills_unblocked_on_reload() { } } assert!(saw_startup_payment_sent); - assert_eq!(startup_success_scids, vec![scid_a]); + assert_eq!(startup_success_scids, vec![payment_scid_a]); // Handling the claim event runs the event-completion action that releases the remaining // RAA-blocked monitor update. The startup unblock path already released channel A, so channel B @@ -1270,7 +1276,7 @@ fn test_mpp_claim_htlc_fulfills_unblocked_on_reload() { Event::PaymentPathSuccessful { payment_hash: Some(path_hash), path, .. } => { assert_eq!(*path_hash, payment_hash); assert_eq!(path.hops.len(), 1); - assert_eq!(path.hops[0].short_channel_id, scid_b); + assert_eq!(path.hops[0].short_channel_id, payment_scid_b); }, _ => panic!("Unexpected final payment event: {:?}", final_payment_events[0]), } diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 75ff238bc35..c95a91dc795 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -3880,7 +3880,7 @@ fn fail_splice_on_tx_complete_error() { // Queue an outgoing HTLC to the holding cell. It should be freed once we exit quiescence. let (route, payment_hash, _payment_preimage, payment_secret) = - get_route_and_payment_hash!(initiator, acceptor, 1_000_000); + get_route_and_payment_hash!(acceptor, initiator, 1_000_000); let onion = RecipientOnionFields::secret_only(payment_secret, 1_000_000); let payment_id = PaymentId(payment_hash.0); acceptor.node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 2032eb680af..364bd86704e 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -9187,7 +9187,7 @@ mod tests { assert_eq!(route.paths.len(), 1); assert_eq!(route.get_total_amount(), amt_msat); assert_eq!(route.paths[0].hops.len(), 2); - assert_eq!(route.paths[0].hops[0].short_channel_id, 1); + assert_eq!(route.paths[0].hops[0].short_channel_id, 44); assert_eq!(route.paths[0].hops[1].short_channel_id, 45); assert_eq!(route.get_total_fees(), 123); } From 762012430a61f855e2abae7324e959b6d87f1bb2 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 9 Jun 2026 11:23:33 -0500 Subject: [PATCH 457/627] Reject quantity of 0 for offers with bounded quantity An offer advertising Quantity::Bounded expects at least one item, but is_valid_quantity accepted a quantity of 0 since it only checked the upper bound. Require the quantity to be greater than 0 so that an invoice request for 0 items is rejected as an InvalidQuantity. Co-Authored-By: Claude --- lightning/src/offers/invoice_request.rs | 13 +++++++++++++ lightning/src/offers/offer.rs | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 7805882ef73..2b4379e76e7 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -2221,6 +2221,19 @@ mod tests { Err(e) => assert_eq!(e, Bolt12SemanticError::InvalidQuantity), } + match OfferBuilder::new(recipient_pubkey()) + .amount_msats(1000) + .supported_quantity(Quantity::Bounded(ten)) + .build() + .unwrap() + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .unwrap() + .quantity(0) + { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!(e, Bolt12SemanticError::InvalidQuantity), + } + let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .supported_quantity(Quantity::Unbounded) diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index b2703454169..8bafb004aaf 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -975,7 +975,7 @@ impl OfferContents { fn is_valid_quantity(&self, quantity: u64) -> bool { match self.supported_quantity { - Quantity::Bounded(n) => quantity <= n.get(), + Quantity::Bounded(n) => quantity > 0 && quantity <= n.get(), Quantity::Unbounded => quantity > 0, Quantity::One => quantity == 1, } From 294fbbae1e93e3808210a031e729f7125092e177 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Mon, 1 Jun 2026 15:06:47 -0700 Subject: [PATCH 458/627] Send splice_locked for promoted splice on reconnect When a splice confirms after our `channel_reestablish` was generated and sent, but prior to processing the counterparty's, we may promote the splice and clear `pending_splice`. In such cases, we're still required to send an explicit `splice_locked` as the `channel_reestablish` we sent did not consider the splice confirmation. --- lightning/src/ln/channel.rs | 13 +++++ lightning/src/ln/splicing_tests.rs | 94 ++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 9c16c3b02f8..067914333a5 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -10784,6 +10784,19 @@ where channel_id: self.context.channel_id, splice_txid, }) + }).or_else(|| { + // If a splice confirms after we've sent `channel_reestablish` but before we've received + // theirs, we may promote the splice and clear `pending_splice`. We still need to send + // `splice_locked` after reestablishing as it was not included in our + // `channel_reestablish`. + let current_funding_txid = self.funding.get_funding_txid()?; + (self.pending_splice.is_none() + && self.funding.channel_transaction_parameters.splice_parent_funding_txid.is_some() + && Some(current_funding_txid) != funding_locked_txid_sent_in_reestablish) + .then(|| msgs::SpliceLocked { + channel_id: self.context.channel_id, + splice_txid: current_funding_txid, + }) }); if msg.next_local_commitment_number == next_counterparty_commitment_number { diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index dfcc339b83b..dd0e704c4fd 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -2676,6 +2676,100 @@ fn test_splice_locked_waits_for_channel_reestablish() { send_payment(&nodes[0], &[&nodes[1]], 1_000_000); } +#[test] +fn test_promoted_splice_locked_sent_after_channel_reestablish() { + // Test that a splice gets promoted for both nodes if one of the nodes sees the splice lock + // before reestablishment and the other after. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + let prev_funding_txo = get_monitor!(nodes[0], channel_id).get_funding_txo(); + + send_payment(&nodes[0], &[&nodes[1]], 1_000_000); + + let outputs = vec![ + TxOut { + value: Amount::from_sat(initial_channel_value_sat / 4), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }, + TxOut { + value: Amount::from_sat(initial_channel_value_sat / 4), + script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), + }, + ]; + let funding_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); + let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Confirm the splice for node 0 first. This should result in them sending `splice_locked`, but + // node 1 should not send it back yet as it hasn't seen the confirmation. + confirm_transaction(&nodes[0], &splice_tx); + let splice_locked_0 = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1); + nodes[1].node.handle_splice_locked(node_id_0, &splice_locked_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + + // Reconnect the peers. + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + connect_nodes(&nodes[0], &nodes[1]); + let reestablish_0 = + get_event_msg!(nodes[0], MessageSendEvent::SendChannelReestablish, node_id_1); + let reestablish_1 = + get_event_msg!(nodes[1], MessageSendEvent::SendChannelReestablish, node_id_0); + + // Before delivering the reestablish message to each other, confirm the splice for node 1. We + // should see a `ChannelReady` event for node 1 as the pending splice should have been promoted, + // but `splice_locked` should not be sent until it receives node 0's reestablish. + confirm_transaction(&nodes[1], &splice_tx); + check_added_monitors(&nodes[1], 1); + let new_funding_txo = + get_monitor!(nodes[1], channel_id).get_funding_txo().into_bitcoin_outpoint(); + let channel_ready_1 = get_event!(&nodes[1], Event::ChannelReady); + assert!(matches!( + channel_ready_1, Event::ChannelReady { funding_txo, .. } + if funding_txo == Some(new_funding_txo) + )); + + nodes[1].node.handle_channel_reestablish(node_id_0, &reestablish_0); + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 3, "{msg_events:?}"); + assert!(matches!(&msg_events[0], MessageSendEvent::SendAnnouncementSignatures { .. })); + let splice_locked_1 = if let MessageSendEvent::SendSpliceLocked { msg, .. } = &msg_events[1] { + msg + } else { + panic!("Unexpected event {:?}", msg_events[0]); + }; + assert!(matches!(&msg_events[2], MessageSendEvent::SendChannelUpdate { .. })); + + // Deliver node 1's reestablish to node 0. Since it was generated prior to the splice + // confirmation, it should not promote the splice for node 0 yet. + nodes[0].node.handle_channel_reestablish(node_id_1, &reestablish_1); + let _ = get_event_msg!(nodes[0], MessageSendEvent::SendChannelUpdate, node_id_1); + + // Deliver node 1's splice locked to node 0, allowing the splice to be promoted on node 0's side + // as well. + nodes[0].node.handle_splice_locked(node_id_1, splice_locked_1); + check_added_monitors(&nodes[0], 1); + let _ = get_event_msg!(nodes[0], MessageSendEvent::SendAnnouncementSignatures, node_id_1); + let channel_ready_0 = get_event!(&nodes[0], Event::ChannelReady); + assert!(matches!( + channel_ready_0, Event::ChannelReady { funding_txo, .. } + if funding_txo == Some(new_funding_txo) + )); + + for node in [&nodes[0], &nodes[1]] { + node.chain_source.remove_watched_by_txid(prev_funding_txo.txid); + } +} + #[test] fn test_splice_reestablish_waits_for_holder_tx_signatures_before_commitment_signed() { let chanmon_cfgs = create_chanmon_cfgs(2); From 13791448067babf22d86ff2f77c3edc27b281f62 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Mon, 1 Jun 2026 15:08:10 -0700 Subject: [PATCH 459/627] Ignore stale splice signing fuzz events Now that the fuzz target supports canceling splice funding attempts, we may see failed signing attempts due to the cancellation. --- fuzz/src/chanmon_consistency.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index a0aa7bbe7ef..da622eaff0d 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -2814,9 +2814,20 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { .. } => { let signed_tx = nodes[node_idx].wallet.sign_tx(unsigned_transaction).unwrap(); - nodes[node_idx] - .funding_transaction_signed(&channel_id, &counterparty_node_id, signed_tx) - .unwrap(); + match nodes[node_idx].funding_transaction_signed( + &channel_id, + &counterparty_node_id, + signed_tx, + ) { + Ok(()) => {}, + Err(APIError::APIMisuseError { ref err }) + if err.contains("not expecting funding signatures") => + { + // A queued signing event can be invalidated by a later `tx_abort` + // before the application handles it. + }, + Err(e) => panic!("{e:?}"), + } }, events::Event::SpliceNegotiated { new_funding_txo, .. } => { let mut txs = nodes[node_idx].broadcaster.txn_broadcasted.borrow_mut(); From 099bb09e2a0f3d35cffa79ecb32cee06c61b76c4 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Mon, 1 Jun 2026 15:08:15 -0700 Subject: [PATCH 460/627] Raise iteration capacity in chanmon consistency when settling state LDK and the chanmon_consistency fuzz target have grown in complexity recently and thus require more iterations than previously assumed to fully settle the state of all active channels. --- fuzz/src/chanmon_consistency.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index da622eaff0d..519ae515e7f 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -102,6 +102,7 @@ use std::sync::atomic; use std::sync::{Arc, Mutex}; const MAX_FEE: u32 = 10_000; +const MAX_SETTLE_ITERATIONS: usize = 256; struct FuzzEstimator { ret_val: atomic::AtomicU32, } @@ -2865,9 +2866,9 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { fn process_all_events(&mut self) { let mut last_pass_no_updates = false; for i in 0..std::usize::MAX { - if i == 100 { + if i == MAX_SETTLE_ITERATIONS { panic!( - "It may take may iterations to settle the state, but it should not take forever" + "It may take many iterations to settle the state, but it should not take forever" ); } let mut made_progress = self.checkpoint_manager_persistences(); From f0c4af91f57e286398ec38604727dc5cbc77900e Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Mon, 1 Jun 2026 15:06:52 -0700 Subject: [PATCH 461/627] Send splice_locked before reestablish commitment If we have pending updates to send to our counterparty on reestablishment, while also pending a `splice_locked` send, then we must send our `splice_locked` first as the pending updates are considering the post-splice-locked state. --- lightning/src/ln/channelmanager.rs | 26 ++--- lightning/src/ln/splicing_tests.rs | 153 ++++++++++++++++++++++++++++- 2 files changed, 163 insertions(+), 16 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 0ae4c87d511..6398613a762 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -11052,6 +11052,19 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } } + if let Some(funding_tx_signed) = funding_tx_signed.as_ref() { + // These [`FundingTxSigned`] fields are only expected as a result of calling + // [`ChannelManager::funding_transaction_signed`]. + debug_assert!(funding_tx_signed.commitment_signed.is_none()); + debug_assert!(funding_tx_signed.counterparty_initial_commitment_signed_result.is_none()); + } + if let Some(msg) = funding_tx_signed.as_mut().and_then(|v| v.splice_locked.take()) { + pending_msg_events.push(MessageSendEvent::SendSpliceLocked { + node_id: counterparty_node_id, + msg, + }); + } + macro_rules! handle_cs { () => { if let Some(update) = commitment_update { pending_msg_events.push(MessageSendEvent::UpdateHTLCs { @@ -11080,12 +11093,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }, } - if let Some(funding_tx_signed) = funding_tx_signed.as_ref() { - // These [`FundingTxSigned`] fields are only expected as a result of calling - // [`ChannelManager::funding_transaction_signed`]. - debug_assert!(funding_tx_signed.commitment_signed.is_none()); - debug_assert!(funding_tx_signed.counterparty_initial_commitment_signed_result.is_none()); - } if let Some(msg) = funding_tx_signed.as_mut().and_then(|v| v.tx_signatures.take()) { pending_msg_events.push(MessageSendEvent::SendTxSignatures { node_id: counterparty_node_id, @@ -11111,13 +11118,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }); } } - - if let Some(msg) = funding_tx_signed.as_mut().and_then(|v| v.splice_locked.take()) { - pending_msg_events.push(MessageSendEvent::SendSpliceLocked { - node_id: counterparty_node_id, - msg, - }); - } } else if let Some(msg) = channel_ready { self.send_channel_ready(pending_msg_events, channel, msg); } diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index dd0e704c4fd..eefd74e0ec0 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -2709,6 +2709,23 @@ fn test_promoted_splice_locked_sent_after_channel_reestablish() { initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + // Send a payment from node 0 to node 1 but don't fully commit it to make sure node 1 + // sends `splice_locked` first when it responds. + let payment_amount = 1_000_000; + let (route, payment_hash, _payment_preimage, payment_secret) = + get_route_and_payment_hash!(nodes[0], nodes[1], payment_amount); + let onion = RecipientOnionFields::secret_only(payment_secret, payment_amount); + let payment_id = PaymentId(payment_hash.0); + nodes[0].node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); + let update = get_htlc_update_msgs(&nodes[0], &node_id_1); + check_added_monitors(&nodes[0], 1); + + nodes[1].node.handle_update_add_htlc(node_id_0, &update.update_add_htlcs[0]); + nodes[1].node.handle_commitment_signed_batch_test(node_id_0, &update.commitment_signed); + check_added_monitors(&nodes[1], 1); + let (_dropped_raa, dropped_commitment_signed) = get_revoke_commit_msgs(&nodes[1], &node_id_0); + assert!(dropped_commitment_signed.len() > 1, "{dropped_commitment_signed:?}"); + // Confirm the splice for node 0 first. This should result in them sending `splice_locked`, but // node 1 should not send it back yet as it hasn't seen the confirmation. confirm_transaction(&nodes[0], &splice_tx); @@ -2740,14 +2757,25 @@ fn test_promoted_splice_locked_sent_after_channel_reestablish() { nodes[1].node.handle_channel_reestablish(node_id_0, &reestablish_0); let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), 3, "{msg_events:?}"); + assert_eq!(msg_events.len(), 5, "{msg_events:?}"); assert!(matches!(&msg_events[0], MessageSendEvent::SendAnnouncementSignatures { .. })); let splice_locked_1 = if let MessageSendEvent::SendSpliceLocked { msg, .. } = &msg_events[1] { msg } else { - panic!("Unexpected event {:?}", msg_events[0]); + panic!("Unexpected event {:?}", msg_events[1]); + }; + let revoke_and_ack = if let MessageSendEvent::SendRevokeAndACK { msg, .. } = &msg_events[2] { + msg + } else { + panic!("Unexpected event {:?}", msg_events[2]); + }; + let commit_sig = if let MessageSendEvent::UpdateHTLCs { updates, .. } = &msg_events[3] { + assert_eq!(updates.commitment_signed.len(), 1); + updates.commitment_signed.first().unwrap() + } else { + panic!("Unexpected event {:?}", msg_events[3]); }; - assert!(matches!(&msg_events[2], MessageSendEvent::SendChannelUpdate { .. })); + assert!(matches!(&msg_events[4], MessageSendEvent::SendChannelUpdate { .. })); // Deliver node 1's reestablish to node 0. Since it was generated prior to the splice // confirmation, it should not promote the splice for node 0 yet. @@ -2765,6 +2793,21 @@ fn test_promoted_splice_locked_sent_after_channel_reestablish() { if funding_txo == Some(new_funding_txo) )); + // And finally, deliver the remaining messages to fully commit the sent HTLC. + nodes[0].node.handle_revoke_and_ack(node_id_1, revoke_and_ack); + check_added_monitors(&nodes[0], 1); + nodes[0].node.handle_commitment_signed(node_id_1, commit_sig); + check_added_monitors(&nodes[0], 1); + + let revoke_and_ack = get_event_msg!(&nodes[0], MessageSendEvent::SendRevokeAndACK, node_id_1); + nodes[1].node.handle_revoke_and_ack(node_id_0, &revoke_and_ack); + check_added_monitors(&nodes[1], 1); + nodes[1].node.process_pending_htlc_forwards(); + expect_payment_claimable!(&nodes[1], payment_hash, payment_secret, payment_amount); + + // We should be able to send payments again now that the state is fully committed. + send_payment(&nodes[0], &[&nodes[1]], payment_amount); + for node in [&nodes[0], &nodes[1]] { node.chain_source.remove_watched_by_txid(prev_funding_txo.txid); } @@ -2841,6 +2884,110 @@ fn test_splice_reestablish_waits_for_holder_tx_signatures_before_commitment_sign expect_splice_pending_event(&nodes[1], &node_id_0); } +#[test] +fn test_splice_reestablish_sends_commitment_signed_before_tx_signatures() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let outputs = vec![TxOut { + value: Amount::from_sat(initial_channel_value_sat / 4), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]; + let initiator_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); + negotiate_splice_tx(&nodes[0], &nodes[1], channel_id, initiator_contribution); + + let signing_event = get_event!(nodes[0], Event::FundingTransactionReadyForSigning); + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + + // Drop node 1's initial `commitment_signed` so node 0 requests it on reconnect. + let acceptor_commit_sig = get_htlc_update_msgs(&nodes[1], &node_id_0); + assert_eq!(acceptor_commit_sig.commitment_signed.len(), 1); + + let unsigned_transaction = if let Event::FundingTransactionReadyForSigning { + unsigned_transaction, + .. + } = signing_event + { + unsigned_transaction + } else { + panic!("Expected FundingTransactionReadyForSigning event"); + }; + let tx = nodes[0].wallet_source.sign_tx(unsigned_transaction).unwrap(); + nodes[0].node.funding_transaction_signed(&channel_id, &node_id_1, tx).unwrap(); + check_added_monitors(&nodes[0], 0); + + let initiator_commit_sig = get_htlc_update_msgs(&nodes[0], &node_id_1); + nodes[1] + .node + .handle_commitment_signed_batch_test(node_id_0, &initiator_commit_sig.commitment_signed); + check_added_monitors(&nodes[1], 1); + + // Drop node 1's `tx_signatures`. At this point node 0 has not received node 1's + // `commitment_signed`, while node 1 has its `tx_signatures` ready, so one + // `channel_reestablish` should trigger both retransmissions. + let _ = get_event_msg!(&nodes[1], MessageSendEvent::SendTxSignatures, node_id_0); + + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + connect_nodes(&nodes[0], &nodes[1]); + let reestablish_0 = + get_event_msg!(nodes[0], MessageSendEvent::SendChannelReestablish, node_id_1); + let _reestablish_1 = + get_event_msg!(nodes[1], MessageSendEvent::SendChannelReestablish, node_id_0); + let next_funding = reestablish_0.next_funding.as_ref().expect("next_funding should be set"); + assert!(next_funding.should_retransmit(msgs::NextFundingFlag::CommitmentSigned)); + + nodes[1].node.handle_channel_reestablish(node_id_0, &reestablish_0); + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + let commitment_update_idx = msg_events + .iter() + .position(|event| { + matches!(event, MessageSendEvent::UpdateHTLCs { updates, .. } + if updates.commitment_signed.len() == 1) + }) + .expect("commitment_signed should be retransmitted"); + let tx_signatures_idx = msg_events + .iter() + .position(|event| matches!(event, MessageSendEvent::SendTxSignatures { .. })) + .expect("tx_signatures should be retransmitted"); + assert!( + commitment_update_idx < tx_signatures_idx, + "commitment_signed should be retransmitted before tx_signatures: {msg_events:?}" + ); + + let commitment_signed = + if let MessageSendEvent::UpdateHTLCs { updates, .. } = &msg_events[commitment_update_idx] { + updates.commitment_signed.clone() + } else { + panic!("Expected UpdateHTLCs"); + }; + let tx_signatures = + if let MessageSendEvent::SendTxSignatures { msg, .. } = &msg_events[tx_signatures_idx] { + msg.clone() + } else { + panic!("Expected SendTxSignatures"); + }; + nodes[0].node.handle_commitment_signed_batch_test(node_id_1, &commitment_signed); + check_added_monitors(&nodes[0], 1); + nodes[0].node.handle_tx_signatures(node_id_1, &tx_signatures); + let initiator_tx_signatures = + get_event_msg!(nodes[0], MessageSendEvent::SendTxSignatures, node_id_1); + nodes[1].node.handle_tx_signatures(node_id_0, &initiator_tx_signatures); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); +} + #[test] fn test_splice_confirms_on_both_sides_while_disconnected() { // Regression test: when a splice transaction confirms on both sides while peers are From c58cba3aa164f16972fe55bbdd4ee63ee5d7ddb4 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Mon, 1 Jun 2026 15:06:58 -0700 Subject: [PATCH 462/627] Clear stale monitor pending resends on reestablish A stale ChannelManager can be reloaded after a monitor update has already completed in a prior runtime and released its post-update messages to the counterparty. The latest ChannelMonitor is not stale, but the serialized manager may still contain the old in-flight monitor state and `monitor_pending_*` resend flags from before the completion action ran. This becomes observable when startup monitor-completion background events are interleaved with splice promotion. On reload, the completed monitor update is queued as a background event. If a splice confirmation is processed before that background event fully resumes the channel, splice promotion can create a new `RenegotiatedFundingLocked` monitor update. The old completion is then blocked behind the new in-flight splice update. Once the channel reconnects and the splice update completes, `monitor_updating_restored` may consume the stale `monitor_pending_revoke_and_ack` / `monitor_pending_commitment_signed` flags and release a duplicate `revoke_and_ack` or `commitment_signed`. The peer's `channel_reestablish` commitment numbers are authoritative for this case. If `next_remote_commitment_number` says the peer is not waiting for a `revoke_and_ack`, clear `monitor_pending_revoke_and_ack`. Likewise, if `next_local_commitment_number` says the peer already has our latest `commitment_signed`, clear `monitor_pending_commitment_signed`. --- lightning/src/ln/channel.rs | 6 + lightning/src/ln/functional_test_utils.rs | 2 +- lightning/src/ln/splicing_tests.rs | 147 ++++++++++++++++++++++ 3 files changed, 154 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 067914333a5..d8318103f15 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -10714,6 +10714,9 @@ where let required_revoke = if msg.next_remote_commitment_number == our_commitment_transaction { // Remote isn't waiting on any RevokeAndACK from us! // Note that if we need to repeat our ChannelReady we'll do that in the next if block. + // If a stale ChannelManager replayed a completed update, the monitor-pending state may + // still think we owe one; the reestablish proof is authoritative here. + self.context.monitor_pending_revoke_and_ack = false; None } else if msg.next_remote_commitment_number + 1 == our_commitment_transaction { if self.context.channel_state.is_monitor_update_in_progress() { @@ -10800,6 +10803,9 @@ where }); if msg.next_local_commitment_number == next_counterparty_commitment_number { + // If a stale ChannelManager replayed a completed update, the monitor-pending state may + // still think we owe one. + self.context.monitor_pending_commitment_signed = false; if required_revoke.is_some() || self.context.signer_pending_revoke_and_ack { log_debug!(logger, "Reconnected with only lost outbound RAA"); } else { diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index ac6f137d5bb..20fcbef0dba 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -1023,7 +1023,7 @@ pub fn get_updates_and_revoke>( macro_rules! get_event_msg { ($node: expr, $event_type: path, $node_id: expr) => {{ let events = $node.node.get_and_clear_pending_msg_events(); - assert_eq!(events.len(), 1); + assert_eq!(events.len(), 1, "{events:?}"); match events[0] { $event_type { ref node_id, ref msg } => { assert_eq!(*node_id, $node_id); diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index eefd74e0ec0..d6e78a7fb39 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -3191,6 +3191,153 @@ fn test_holding_cell_claim_freed_after_inferred_splice_locked() { .remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script); } +#[test] +fn test_stale_monitor_pending_resends_cleared_by_reestablish() { + // A stale ChannelManager may be reloaded after a monitor update completed and released its + // messages to the peer. If a later splice-locked monitor update is in-flight while the channel + // reestablishes, completing it must not release the stale messages again. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let persister; + let chain_monitor; + let node_1_reload; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + let prev_funding_outpoint = get_monitor!(nodes[1], channel_id).get_funding_txo(); + let prev_funding_script = get_monitor!(nodes[1], channel_id).get_funding_script(); + + let outputs = vec![ + TxOut { + value: Amount::from_sat(initial_channel_value_sat / 4), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }, + TxOut { + value: Amount::from_sat(initial_channel_value_sat / 4), + script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), + }, + ]; + let funding_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); + let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Only let node 0 see the splice lock for now. + confirm_transaction(&nodes[0], &splice_tx); + let splice_locked_0 = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1); + nodes[1].node.handle_splice_locked(node_id_0, &splice_locked_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + + // Send an HTLC from node 0 to 1 that will get fully committed to. + let payment_amount = 1_000_000; + let (route, payment_hash, _payment_preimage, payment_secret) = + get_route_and_payment_hash!(nodes[0], nodes[1], payment_amount); + let onion = RecipientOnionFields::secret_only(payment_secret, payment_amount); + let payment_id = PaymentId(payment_hash.0); + nodes[0].node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); + let htlc_update = get_htlc_update_msgs(&nodes[0], &node_id_1); + check_added_monitors(&nodes[0], 1); + + chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress); + nodes[1].node.handle_update_add_htlc(node_id_0, &htlc_update.update_add_htlcs[0]); + nodes[1].node.handle_commitment_signed_batch_test(node_id_0, &htlc_update.commitment_signed); + check_added_monitors(&nodes[1], 1); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + + // Persist the `ChannelManager` while node 1 is still pending to send their RAA+CS to node 0. + let stale_manager_1 = nodes[1].node.encode(); + + // Let node 1 release its RAA+CS to node 0 and process them. + nodes[1].chain_monitor.complete_sole_pending_chan_update(&channel_id); + chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); + let (raa, commitment_signed) = get_revoke_commit_msgs(&nodes[1], &node_id_0); + nodes[0].node.handle_revoke_and_ack(node_id_1, &raa); + check_added_monitors(&nodes[0], 1); + nodes[0].node.handle_commitment_signed_batch_test(node_id_1, &commitment_signed); + check_added_monitors(&nodes[0], 1); + let _dropped_raa = get_event_msg!(nodes[0], MessageSendEvent::SendRevokeAndACK, node_id_1); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + + // Reload node 1 with the stale `ChannelManager` and confirm the splice. + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + let latest_monitor_1 = get_monitor!(nodes[1], channel_id).encode(); + reload_node!( + nodes[1], + &stale_manager_1, + &[&latest_monitor_1], + persister, + chain_monitor, + node_1_reload + ); + + mine_transaction_without_consistency_checks(&nodes[1], &splice_tx); + connect_blocks(&nodes[1], 5); + persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress); + + // Reestablish the channel. While the `ChannelManager` should think it still owes node 0 its + // RAA+CS, it should determine from node 0's `channel_reestablish` that they were already + // delivered. + connect_nodes(&nodes[0], &nodes[1]); + check_added_monitors(&nodes[1], 1); + let reestablish_0 = get_chan_reestablish_msgs!(nodes[0], nodes[1]); + let reestablish_1 = get_chan_reestablish_msgs!(nodes[1], nodes[0]); + nodes[1].node.handle_channel_reestablish(node_id_0, reestablish_0.first().unwrap()); + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert!( + msg_events.iter().all(|event| !matches!( + event, + MessageSendEvent::SendRevokeAndACK { .. } | MessageSendEvent::UpdateHTLCs { .. } + )), + "stale monitor-pending resend leaked during reestablish: {msg_events:?}" + ); + + nodes[1].chain_monitor.complete_sole_pending_chan_update(&channel_id); + persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert!( + msg_events.iter().all(|event| !matches!( + event, + MessageSendEvent::SendRevokeAndACK { .. } | MessageSendEvent::UpdateHTLCs { .. } + )), + "stale monitor-pending resend leaked after reestablish: {msg_events:?}" + ); + expect_channel_ready_event(&nodes[1], &node_id_0); + + nodes[0].node.handle_channel_reestablish(node_id_1, reestablish_1.first().unwrap()); + check_added_monitors(&nodes[0], 1); + expect_channel_ready_event(&nodes[0], &node_id_1); + + // Finish fully committing the HTLC and make sure we can still send more payments. + let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 3, "{msg_events:?}"); + if let MessageSendEvent::SendRevokeAndACK { msg, .. } = &msg_events[0] { + nodes[1].node.handle_revoke_and_ack(node_id_0, msg); + check_added_monitors(&nodes[1], 1); + nodes[1].chain_monitor.complete_sole_pending_chan_update(&channel_id); + persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); + + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + nodes[1].node.process_pending_htlc_forwards(); + expect_payment_claimable!(&nodes[1], payment_hash, payment_secret, payment_amount); + } else { + panic!("Unexpected event {:?}", &msg_events[0]); + } + + send_payment(&nodes[0], &[&nodes[1]], payment_amount); + + for node in &[&nodes[0], &nodes[1]] { + node.chain_source + .remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script.clone()); + } +} + #[test] fn test_stale_announcement_signatures_ignored_after_splice_lock() { // Regression test: a peer may transmit `announcement_signatures` signed over a pre-splice From c6099a8ac053d54b66eb0a3256bd51de8506e280 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Jun 2026 11:51:09 +0200 Subject: [PATCH 463/627] Throttle LSPS5 lifecycle cooldown resets LSPS5 resets notification cooldowns when a peer reconnects so clients can receive prompt wake-ups after coming online. A peer can otherwise churn connections to clear the webhook cooldown repeatedly, turning the LSP into an amplification source for registered notification URLs. Rate-limit how often peer lifecycle events may clear notification cooldowns while keeping the first reset immediate. Also make LSPSDateTime elapsed-time calculation directional so backwards clock movement does not make future timestamps look expired. Co-Authored-By: HAL 9000 This finding was discovered by Project Loupe --- lightning-liquidity/src/lsps0/ser.rs | 21 +++- lightning-liquidity/src/lsps5/service.rs | 103 +++++++++++++++++- .../tests/lsps5_integration_tests.rs | 29 ++++- 3 files changed, 142 insertions(+), 11 deletions(-) diff --git a/lightning-liquidity/src/lsps0/ser.rs b/lightning-liquidity/src/lsps0/ser.rs index 70649fe0f50..d28bba75e52 100644 --- a/lightning-liquidity/src/lsps0/ser.rs +++ b/lightning-liquidity/src/lsps0/ser.rs @@ -256,10 +256,14 @@ impl LSPSDateTime { now_seconds_since_epoch > datetime_seconds_since_epoch } - /// Returns the absolute difference between two datetimes as a `Duration`. + /// Returns the elapsed duration from `other` to `self`, or zero if `other` is later. pub fn duration_since(&self, other: &Self) -> Duration { - let diff_secs = self.0.timestamp().abs_diff(other.0.timestamp()); - Duration::from_secs(diff_secs) + let diff_secs = self.0.timestamp().saturating_sub(other.0.timestamp()); + if diff_secs <= 0 { + Duration::ZERO + } else { + Duration::from_secs(diff_secs as u64) + } } /// Returns the time in seconds since the unix epoch. @@ -971,6 +975,8 @@ pub(crate) mod u32_fee_rate { mod tests { use super::*; + use core::time::Duration; + use lightning::io::Cursor; #[test] @@ -981,4 +987,13 @@ mod tests { let decoded_datetime: LSPSDateTime = Readable::read(&mut Cursor::new(buf)).unwrap(); assert_eq!(expected_datetime, decoded_datetime); } + + #[test] + fn datetime_duration_since_is_directional() { + let earlier = LSPSDateTime::new_from_duration_since_epoch(Duration::from_secs(30)); + let later = LSPSDateTime::new_from_duration_since_epoch(Duration::from_secs(90)); + + assert_eq!(later.duration_since(&earlier), Duration::from_secs(60)); + assert_eq!(earlier.duration_since(&later), Duration::ZERO); + } } diff --git a/lightning-liquidity/src/lsps5/service.rs b/lightning-liquidity/src/lsps5/service.rs index 7360131a9e9..cb3c62f9d1d 100644 --- a/lightning-liquidity/src/lsps5/service.rs +++ b/lightning-liquidity/src/lsps5/service.rs @@ -61,8 +61,8 @@ struct Webhook { // Timestamp used for tracking when the webhook was created / updated, or when the last notification was sent. // This is used to determine if the webhook is stale and should be pruned. last_used: LSPSDateTime, - // Timestamp when we last sent a notification to the client. This is used to enforce - // notification cooldowns. + // Timestamp when we last sent a notification to the client. This enforces the notification + // cooldown that protects the client from repeated spammy wake-ups. last_notification_sent: Option, } @@ -85,6 +85,12 @@ pub struct LSPS5ServiceConfig { pub const DEFAULT_MAX_WEBHOOKS_PER_CLIENT: u32 = 10; /// Default notification cooldown time in minutes. pub const NOTIFICATION_COOLDOWN_TIME: Duration = Duration::from_secs(60); // 1 minute +/// Minimum time between peer lifecycle events that are allowed to reset notification cooldowns. +/// +/// This is distinct from [`NOTIFICATION_COOLDOWN_TIME`]: that cooldown protects the client from +/// repeated spammy wake-ups, while this reset throttle protects registered notification URLs from +/// amplification via rapid peer connect/disconnect churn. +const NOTIFICATION_COOLDOWN_RESET_INTERVAL: Duration = Duration::from_secs(10); // Default configuration for LSPS5 service. impl Default for LSPS5ServiceConfig { @@ -689,7 +695,10 @@ where pub(crate) fn peer_connected(&self, counterparty_node_id: &PublicKey) { let mut outer_state_lock = self.per_peer_state.write().unwrap(); if let Some(peer_state) = outer_state_lock.get_mut(counterparty_node_id) { - peer_state.reset_notification_cooldown(); + let now = LSPSDateTime::new_from_duration_since_epoch( + self.time_provider.duration_since_epoch(), + ); + peer_state.reset_notification_cooldown(now); } self.check_prune_stale_webhooks(&mut outer_state_lock); } @@ -697,7 +706,10 @@ where pub(crate) fn peer_disconnected(&self, counterparty_node_id: &PublicKey) { let mut outer_state_lock = self.per_peer_state.write().unwrap(); if let Some(peer_state) = outer_state_lock.get_mut(counterparty_node_id) { - peer_state.reset_notification_cooldown(); + let now = LSPSDateTime::new_from_duration_since_epoch( + self.time_provider.duration_since_epoch(), + ); + peer_state.reset_notification_cooldown(now); } self.check_prune_stale_webhooks(&mut outer_state_lock); } @@ -748,6 +760,11 @@ where #[derive(Debug)] pub(crate) struct PeerState { webhooks: Vec<(LSPS5AppName, Webhook)>, + // Timestamp of the last peer lifecycle event that was allowed to clear notification cooldowns. + // This is not the notification cooldown itself: `last_notification_sent` protects clients from + // repeated wake-ups, while this protects registered notification URLs from amplification via + // rapid connection churn. + last_notification_cooldown_reset: Option, needs_persist: bool, } @@ -803,10 +820,18 @@ impl PeerState { removed } - fn reset_notification_cooldown(&mut self) { + fn reset_notification_cooldown(&mut self, now: LSPSDateTime) { + let can_reset = self.last_notification_cooldown_reset.as_ref().map_or(true, |last_reset| { + now.duration_since(last_reset) >= NOTIFICATION_COOLDOWN_RESET_INTERVAL + }); + if !can_reset { + return; + } + for (_, h) in self.webhooks.iter_mut() { h.last_notification_sent = None; } + self.last_notification_cooldown_reset = Some(now); self.needs_persist |= true; } @@ -830,11 +855,77 @@ impl Default for PeerState { fn default() -> Self { let webhooks = Vec::new(); let needs_persist = true; - Self { webhooks, needs_persist } + let last_notification_cooldown_reset = None; + Self { webhooks, last_notification_cooldown_reset, needs_persist } } } impl_ser_tlv_based!(PeerState, { (0, webhooks, required_vec), + (_unused, last_notification_cooldown_reset, (static_value, None::)), (_unused, needs_persist, (static_value, false)), }); + +#[cfg(test)] +mod tests { + use super::*; + + use crate::alloc::string::ToString; + use crate::tests::utils::parse_pubkey; + + fn lsps_datetime(seconds: u64) -> LSPSDateTime { + LSPSDateTime::new_from_duration_since_epoch(Duration::from_secs(seconds)) + } + + fn test_webhook(last_notification_sent: Option) -> (LSPS5AppName, Webhook) { + let app_name = LSPS5AppName::new("test_app".to_string()).unwrap(); + let url = LSPS5WebhookUrl::new("https://example.com/webhook".to_string()).unwrap(); + let counterparty_node_id = + parse_pubkey("02c0ded160a4a70d71058509b647949a938924d3a6e109c6eb6aee8e2bb27dc79c") + .unwrap(); + let webhook = Webhook { + _app_name: app_name.clone(), + url, + _counterparty_node_id: counterparty_node_id, + last_used: lsps_datetime(1_000), + last_notification_sent, + }; + (app_name, webhook) + } + + fn test_peer_state(last_notification_sent: Option) -> PeerState { + PeerState { + webhooks: vec![test_webhook(last_notification_sent)], + last_notification_cooldown_reset: None, + needs_persist: false, + } + } + + #[test] + fn reset_notification_cooldown_is_throttled() { + let first_reset = lsps_datetime(2_000); + let mut peer_state = test_peer_state(Some(first_reset)); + + peer_state.reset_notification_cooldown(first_reset); + assert_eq!(peer_state.webhooks()[0].1.last_notification_sent, None); + assert_eq!(peer_state.last_notification_cooldown_reset, Some(first_reset)); + assert!(peer_state.needs_persist); + + peer_state.needs_persist = false; + let skipped_reset = lsps_datetime(2_009); + let recent_notification = lsps_datetime(2_009); + peer_state.webhooks_mut()[0].1.last_notification_sent = Some(recent_notification); + peer_state.needs_persist = false; + + peer_state.reset_notification_cooldown(skipped_reset); + assert_eq!(peer_state.webhooks()[0].1.last_notification_sent, Some(recent_notification)); + assert_eq!(peer_state.last_notification_cooldown_reset, Some(first_reset)); + assert!(!peer_state.needs_persist); + + let allowed_reset = lsps_datetime(2_010); + peer_state.reset_notification_cooldown(allowed_reset); + assert_eq!(peer_state.webhooks()[0].1.last_notification_sent, None); + assert_eq!(peer_state.last_notification_cooldown_reset, Some(allowed_reset)); + assert!(peer_state.needs_persist); + } +} diff --git a/lightning-liquidity/tests/lsps5_integration_tests.rs b/lightning-liquidity/tests/lsps5_integration_tests.rs index deed6b2f8b8..5e8c2b5bd28 100644 --- a/lightning-liquidity/tests/lsps5_integration_tests.rs +++ b/lightning-liquidity/tests/lsps5_integration_tests.rs @@ -1291,7 +1291,7 @@ fn test_notify_without_webhooks_does_nothing() { } #[test] -fn test_notifications_and_peer_connected_resets_cooldown() { +fn test_notifications_and_peer_connected_reset_is_throttled() { let mock_time_provider = Arc::new(MockTimeProvider::new(1000)); let time_provider = Arc::::clone(&mock_time_provider); let chanmon_cfgs = create_chanmon_cfgs(2); @@ -1369,7 +1369,7 @@ fn test_notifications_and_peer_connected_resets_cooldown() { "Should not emit event due to cooldown" ); - // 5. After peer_connected, notification should be sent again immediately + // 5. The first peer_connected reset should allow another notification immediately. let init_msg = Init { features: lightning_types::features::InitFeatures::empty(), remote_network_address: None, @@ -1387,6 +1387,31 @@ fn test_notifications_and_peer_connected_resets_cooldown() { }, _ => panic!("Expected SendWebhookNotification event after peer_connected"), } + + // 6. A rapid peer lifecycle update should not clear the cooldown again. + service_node.liquidity_manager.peer_disconnected(client_node_id); + let result = service_handler.notify_payment_incoming(client_node_id); + let error = result.unwrap_err(); + assert_eq!(error, LSPS5ProtocolError::SlowDownError); + assert!( + service_node.liquidity_manager.next_event().is_none(), + "Should not emit event after a rapid lifecycle reset" + ); + + // 7. Once the reset throttle has elapsed, peer_connected can reset the cooldown again. + mock_time_provider.advance_time(11); + service_node.liquidity_manager.peer_connected(client_node_id, &init_msg, false).unwrap(); + let _ = service_handler.notify_payment_incoming(client_node_id); + let event = service_node.liquidity_manager.next_event().unwrap(); + match event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { + notification, + .. + }) => { + assert_eq!(notification.method, WebhookNotificationMethod::LSPS5PaymentIncoming); + }, + _ => panic!("Expected SendWebhookNotification event after reset throttle elapsed"), + } } #[test] From ccf45e4fa4e466811f96f11b711b4aa0c1b6cfe0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Jun 2026 12:55:05 +0200 Subject: [PATCH 464/627] Return P2WSH script pubkey for keyed anchor prevouts AnchorDescriptor::previous_utxo is used for coin selection and PSBT witness_utxo metadata. For keyed anchors it should describe the on-chain P2WSH anchor output instead of the witness script so wallets can validate and sign the package. Co-Authored-By: HAL 9000 This finding was discovered by Project Loupe --- lightning/src/events/bump_transaction/mod.rs | 24 ++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/lightning/src/events/bump_transaction/mod.rs b/lightning/src/events/bump_transaction/mod.rs index 79f5aced1b6..af2709c335c 100644 --- a/lightning/src/events/bump_transaction/mod.rs +++ b/lightning/src/events/bump_transaction/mod.rs @@ -64,6 +64,7 @@ impl AnchorDescriptor { chan_utils::get_keyed_anchor_redeemscript( &channel_params.broadcaster_pubkeys().funding_pubkey, ) + .to_p2wsh() } else { assert!(tx_params.channel_type_features.supports_anchor_zero_fee_commitments()); shared_anchor_script_pubkey() @@ -1031,4 +1032,27 @@ mod tests { 1 /* witness items */ + 1 /* schnorr sig len */ + 64 /* schnorr sig */ ); } + + #[test] + fn test_anchor_descriptor_previous_utxo_script_pubkey_uses_p2wsh() { + let mut transaction_parameters = ChannelTransactionParameters::test_dummy(42_000_000); + transaction_parameters.channel_type_features = + ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies(); + + let funding_pubkey = transaction_parameters.holder_pubkeys.funding_pubkey; + let expected_script_pubkey = + chan_utils::get_keyed_anchor_redeemscript(&funding_pubkey).to_p2wsh(); + + let anchor_descriptor = AnchorDescriptor { + channel_derivation_parameters: ChannelDerivationParameters { + value_satoshis: 42_000_000, + keys_id: [42; 32], + transaction_parameters, + }, + outpoint: OutPoint::null(), + value: Amount::from_sat(ANCHOR_OUTPUT_VALUE_SATOSHI), + }; + + assert_eq!(anchor_descriptor.previous_utxo().script_pubkey, expected_script_pubkey); + } } From 5e7b7d3d2119f56503df091a23b78a4c4f85e99e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Jun 2026 13:13:36 +0200 Subject: [PATCH 465/627] Account for UTXO base weight in anchor reserve checks get_supportable_anchor_channels estimates how much each reserve UTXO can contribute after spending fees. Include the base input weight in that fee so UTXOs just below the public per-channel reserve are not counted as supporting another anchor channel. Co-Authored-By: HAL 9000 This finding was discovered by Project Loupe --- lightning/src/util/anchor_channel_reserves.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/lightning/src/util/anchor_channel_reserves.rs b/lightning/src/util/anchor_channel_reserves.rs index 000f5432529..d4db63a04c3 100644 --- a/lightning/src/util/anchor_channel_reserves.rs +++ b/lightning/src/util/anchor_channel_reserves.rs @@ -24,7 +24,7 @@ use crate::chain::chaininterface::FeeEstimator; use crate::chain::chainmonitor::ChainMonitor; use crate::chain::chainmonitor::Persist; use crate::chain::Filter; -use crate::ln::chan_utils::max_htlcs; +use crate::ln::chan_utils::{max_htlcs, BASE_INPUT_WEIGHT}; use crate::ln::channelmanager::AChannelManager; use crate::prelude::new_hash_set; use crate::sign::ecdsa::EcdsaChannelSigner; @@ -240,11 +240,11 @@ pub fn get_supportable_anchor_channels( let mut total_fractional_amount = Amount::from_sat(0); let mut num_whole_utxos = 0; for utxo in utxos { - let satisfaction_fee = context + let spend_fee = context .upper_bound_fee_rate - .fee_wu(Weight::from_wu(utxo.satisfaction_weight)) + .fee_wu(Weight::from_wu(BASE_INPUT_WEIGHT + utxo.satisfaction_weight)) .unwrap_or(Amount::MAX); - let amount = utxo.output.value.checked_sub(satisfaction_fee).unwrap_or(Amount::MIN); + let amount = utxo.output.value.checked_sub(spend_fee).unwrap_or(Amount::MIN); if amount >= reserve_per_channel { num_whole_utxos += 1; } else { @@ -370,6 +370,15 @@ mod test { assert_eq!(get_supportable_anchor_channels(&context, utxos.as_slice()), 3); } + #[test] + fn test_get_supportable_anchor_channels_accounts_for_input_weight() { + let context = AnchorChannelReserveContext::default(); + let reserve = get_reserve_per_channel(&context); + let utxo = make_p2wpkh_utxo(reserve - Amount::from_sat(1)); + + assert_eq!(get_supportable_anchor_channels(&context, &[utxo]), 0); + } + #[test] fn test_anchor_output_spend_transaction_weight() { // Example with smaller signatures: From c6f4d8fc0c54e770f5679176058b7a00c59bf541 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Jun 2026 13:32:49 +0200 Subject: [PATCH 466/627] Avoid repeated persisted async invoice refreshes When a used async receive offer's refreshed static invoice is persisted, advance the recorded invoice creation time. This keeps the refresh threshold anchored to the newest invoice instead of making the offer look stale on every timer tick. Add coverage that a used offer does not enqueue another ServeStaticInvoice immediately after the server confirms the refresh. Co-Authored-By: HAL 9000 This finding was discovered by Project Loupe --- lightning/src/ln/async_payments_tests.rs | 19 +++++++++++++++++++ .../src/offers/async_receive_offer_cache.rs | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index 7bd745dab0e..6e8f38f847a 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -2450,6 +2450,25 @@ fn refresh_static_invoices_for_used_offers() { .handle_onion_message(server.node.get_our_node_id(), &invoice_persisted_om); assert_eq!(recipient.node.flow.test_get_async_receive_offers().len(), 1); + // The invoice was just refreshed and persisted. A later timer tick must wait until the next + // refresh threshold before generating another invoice for the same offer. + recipient.node.timer_tick_occurred(); + let pending_oms_after = recipient.onion_messenger.release_pending_msgs(); + let mut extra_serve_invoices = 0; + if let Some(msgs) = pending_oms_after.get(&server.node.get_our_node_id()) { + for msg in msgs { + if let PeeledOnion::AsyncPayments(AsyncPaymentsMessage::ServeStaticInvoice(_), _, _) = + server.onion_messenger.peel_onion_message(&msg).unwrap() + { + extra_serve_invoices += 1; + } + } + } + assert_eq!( + extra_serve_invoices, 0, + "used offer invoice was refreshed again immediately after a successful refresh" + ); + // Remove the peer restriction added above. server.message_router.peers_override.lock().unwrap().clear(); recipient.message_router.peers_override.lock().unwrap().clear(); diff --git a/lightning/src/offers/async_receive_offer_cache.rs b/lightning/src/offers/async_receive_offer_cache.rs index dd96b5d1c42..367cdb68fc8 100644 --- a/lightning/src/offers/async_receive_offer_cache.rs +++ b/lightning/src/offers/async_receive_offer_cache.rs @@ -491,7 +491,7 @@ impl AsyncReceiveOfferCache { match offer.status { OfferStatus::Used { invoice_created_at: ref mut inv_created_at } | OfferStatus::Ready { invoice_created_at: ref mut inv_created_at } => { - *inv_created_at = core::cmp::min(invoice_created_at, *inv_created_at); + *inv_created_at = core::cmp::max(invoice_created_at, *inv_created_at); }, OfferStatus::Pending => offer.status = OfferStatus::Ready { invoice_created_at }, } From 6b75e5a4e86c3c3a375c933b723508162d5119aa Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Jun 2026 16:25:18 +0200 Subject: [PATCH 467/627] Release LSPS2 intercepted HTLCs on open failure When a JIT channel open fails, release queued intercepted HTLCs through the intercept API so they are not held until expiry. Keep resetting the LSPS2 state if an intercept has already been released. Co-Authored-By: HAL 9000 This finding was discovered by Project Loupe --- lightning-liquidity/src/lsps2/service.rs | 8 +- .../tests/lsps2_integration_tests.rs | 121 +++++++++++++++++- 2 files changed, 123 insertions(+), 6 deletions(-) diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index 5f318fc077e..b52d12e5168 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -42,7 +42,7 @@ use crate::utils::async_poll::dummy_waker; use lightning::chain::chaininterface::{BroadcasterInterface, TransactionType}; use lightning::events::HTLCHandlingFailureType; -use lightning::ln::channelmanager::{AChannelManager, FailureCode, InterceptId}; +use lightning::ln::channelmanager::{AChannelManager, InterceptId}; use lightning::ln::msgs::{ErrorAction, LightningError}; use lightning::ln::types::ChannelId; use lightning::util::errors::APIError; @@ -1375,10 +1375,8 @@ where { let intercepted_htlcs = payment_queue.clear(); for htlc in intercepted_htlcs { - self.channel_manager.get_cm().fail_htlc_backwards_with_reason( - &htlc.payment_hash, - FailureCode::TemporaryNodeFailure, - ); + // A missing intercept has already been released; still reset this LSPS2 state. + let _ = self.channel_manager.get_cm().fail_intercepted_htlc(htlc.intercept_id); } jit_channel.state = OutboundJITChannelState::PendingInitialPayment { diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index d361215822c..6ebf176e12d 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -7,7 +7,7 @@ use common::{ get_lsps_message, LSPSNodes, LSPSNodesWithPayer, LiquidityNode, }; -use lightning::events::{ClosureReason, Event}; +use lightning::events::{ClosureReason, Event, HTLCHandlingFailureType}; use lightning::get_event_msg; use lightning::ln::channelmanager::{ OptionalBolt11PaymentParams, PaymentId, TrustedChannelFeatures, @@ -453,6 +453,125 @@ fn channel_open_failed() { }; } +#[test] +fn channel_open_failed_releases_intercepted_htlcs() { + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let mut service_node_config = test_default_channel_config(); + service_node_config.htlc_interception_flags = HTLCInterceptionFlags::ToInterceptSCIDs as u8; + + let mut client_node_config = test_default_channel_config(); + client_node_config.channel_config.accept_underpaying_htlcs = true; + + let node_chanmgrs = create_node_chanmgrs( + 3, + &node_cfgs, + &[Some(service_node_config), Some(client_node_config), None], + ); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, promise_secret) = setup_test_lsps2_nodes_with_payer(nodes); + let LSPSNodesWithPayer { ref service_node, ref client_node, ref payer_node } = lsps_nodes; + + let payer_node_id = payer_node.node.get_our_node_id(); + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + + let service_handler = service_node.liquidity_manager.lsps2_service_handler().unwrap(); + create_chan_between_nodes_with_value(&payer_node, &service_node.inner, 2_000_000, 100_000); + + let intercept_scid = service_node.node.get_intercept_scid(); + let user_channel_id = 42u128; + let cltv_expiry_delta: u32 = 144; + let payment_size_msat = Some(1_000_000); + let fee_base_msat: u64 = 1_000; + + execute_lsps2_dance( + &lsps_nodes, + intercept_scid, + user_channel_id, + cltv_expiry_delta, + promise_secret, + payment_size_msat, + fee_base_msat, + ); + + let invoice = create_jit_invoice( + &client_node, + service_node_id, + intercept_scid, + cltv_expiry_delta, + payment_size_msat, + "channel-open-failed-cleanup", + 3600, + ) + .unwrap(); + + payer_node + .node + .pay_for_bolt11_invoice( + &invoice, + PaymentId(invoice.payment_hash().0), + None, + OptionalBolt11PaymentParams::default(), + ) + .unwrap(); + + check_added_monitors(&payer_node, 1); + let events = payer_node.node.get_and_clear_pending_msg_events(); + let ev = SendEvent::from_event(events[0].clone()); + service_node.inner.node.handle_update_add_htlc(payer_node_id, &ev.msgs[0]); + do_commitment_signed_dance(&service_node.inner, &payer_node, &ev.commitment_msg, false, true); + service_node.inner.node.process_pending_htlc_forwards(); + + let events = service_node.inner.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + let intercept_id = match &events[0] { + Event::HTLCIntercepted { + intercept_id, + requested_next_hop_scid, + payment_hash, + expected_outbound_amount_msat, + .. + } => { + assert_eq!(*requested_next_hop_scid, intercept_scid); + service_handler + .htlc_intercepted( + *requested_next_hop_scid, + *intercept_id, + *expected_outbound_amount_msat, + *payment_hash, + ) + .unwrap(); + *intercept_id + }, + other => panic!("Expected HTLCIntercepted, got {:?}", other), + }; + + match service_node.liquidity_manager.next_event().unwrap() { + LiquidityEvent::LSPS2Service(LSPS2ServiceEvent::OpenChannel { .. }) => {}, + other => panic!("Unexpected event: {:?}", other), + }; + + service_handler.channel_open_failed(&client_node_id, user_channel_id).unwrap(); + + let res = service_node.inner.node.fail_intercepted_htlc(intercept_id); + assert!( + res.is_err(), + "channel_open_failed must release the intercepted HTLC via fail_intercepted_htlc, but the entry is still pending: {:?}", + res, + ); + + let events = service_node.inner.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match &events[0] { + Event::HTLCHandlingFailed { + failure_type: HTLCHandlingFailureType::InvalidForward { requested_forward_scid }, + .. + } => assert_eq!(*requested_forward_scid, intercept_scid), + other => panic!("Expected HTLCHandlingFailed, got {:?}", other), + } +} + #[test] fn channel_open_failed_nonexistent_channel() { let chanmon_cfgs = create_chanmon_cfgs(2); From 7b36bc8beb5809562abe3120bf28188bd1a7190d Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Jun 2026 16:45:56 +0200 Subject: [PATCH 468/627] Prevent stale fs-store writes after lock cleanup Reserve write versions while holding the per-path lock map mutex so cleanup cannot remove the version state between version allocation and lock reference acquisition. Add a regression test for the ordering invariant. Co-Authored-By: HAL 9000 This finding was discovered by Project Loupe --- lightning-persister/src/fs_store/common.rs | 44 +++++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/lightning-persister/src/fs_store/common.rs b/lightning-persister/src/fs_store/common.rs index 885f806b344..5c73ad1afe7 100644 --- a/lightning-persister/src/fs_store/common.rs +++ b/lightning-persister/src/fs_store/common.rs @@ -91,14 +91,17 @@ impl FilesystemStoreState { } fn get_new_version_and_lock_ref(&self, dest_file_path: PathBuf) -> (Arc>, u64) { + let mut outer_lock = self.inner.locks.lock().unwrap(); + let version = self.next_version.fetch_add(1, Ordering::Relaxed); if version == u64::MAX { panic!("FilesystemStore version counter overflowed"); } // Get a reference to the inner lock. We do this early so that the arc can double as an in-flight counter for - // cleaning up unused locks. - let inner_lock_ref = self.inner.get_inner_lock_ref(dest_file_path); + // cleaning up unused locks. Allocate the version while holding the lock map mutex so that clean_locks cannot + // remove the entry after a version has been reserved but before its lock reference is cloned. + let inner_lock_ref = Arc::clone(&outer_lock.entry(dest_file_path).or_default()); (inner_lock_ref, version) } @@ -851,3 +854,40 @@ pub(crate) fn get_key_from_dir_entry_path( }, } } + +#[cfg(test)] +mod tests { + use super::*; + + use std::sync::Arc; + use std::thread; + use std::time::Duration; + + #[test] + fn version_is_not_reserved_before_lock_ref() { + let mut temp_path = std::env::temp_dir(); + temp_path.push("test_version_is_not_reserved_before_lock_ref"); + let state = Arc::new(FilesystemStoreState::new(temp_path)); + let path = + state.get_checked_dest_file_path("ns", "sub", Some("key"), "write", false).unwrap(); + + let outer_lock = state.inner.locks.lock().unwrap(); + let state_for_thread = Arc::clone(&state); + let path_for_thread = path.clone(); + let handle = + thread::spawn(move || state_for_thread.get_new_version_and_lock_ref(path_for_thread)); + + thread::sleep(Duration::from_millis(50)); + assert_eq!( + state.next_version.load(Ordering::Relaxed), + 1, + "version allocation must wait until the lock reference can be cloned" + ); + + drop(outer_lock); + + let (inner_lock_ref, version) = handle.join().unwrap(); + assert_eq!(version, 1); + state.inner.clean_locks(&inner_lock_ref, path); + } +} From af19ed5f7049dc225027a1753569de050bcefb83 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Wed, 10 Jun 2026 15:51:15 +0200 Subject: [PATCH 469/627] Add fees value for recent payments Introduce fields `pending_fee_msat` for `RecentPaymentDetails::Pending` and `fee_paid_msat` for `RecentPaymentDetails::Fulfilled`. --- lightning/src/ln/channelmanager.rs | 24 ++++++++++++++++++++---- lightning/src/ln/outbound_payment.rs | 7 ++++++- lightning/src/ln/payment_tests.rs | 7 ++++++- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 6398613a762..2c9f298e3ca 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -3291,9 +3291,13 @@ pub enum RecentPaymentDetails { /// Hash of the payment that is currently being sent but has yet to be fulfilled or /// abandoned. payment_hash: PaymentHash, - /// Total amount (in msat, excluding fees) across all paths for this payment, + /// Total amount (excluding fees) across all paths for this payment, /// not just the amount currently inflight. total_msat: u64, + /// Total routing fees of the HTLCs currently in-flight for this payment. + /// + /// `None` for payments serialized by LDK versions prior to 0.0.103. + pending_fee_msat: Option, /// Whether this payment is a liquidity probe. is_probe: bool, }, @@ -3310,6 +3314,13 @@ pub enum RecentPaymentDetails { /// Hash of the payment that was claimed. `None` for serializations of [`ChannelManager`] /// made before LDK version 0.0.104. payment_hash: Option, + /// Total routing fees paid for this payment, as also reported via the `fee_paid_msat` + /// field of [`Event::PaymentSent`]. + /// + /// `None` for payments serialized by LDK versions prior to 0.3.0. + /// + /// [`Event::PaymentSent`]: events::Event::PaymentSent + fee_paid_msat: Option, }, /// After a payment's retries are exhausted per the provided [`Retry`], or it is explicitly /// abandoned via [`ChannelManager::abandon_payment`], it is marked as abandoned until all @@ -4118,12 +4129,13 @@ impl< PendingOutboundPayment::StaticInvoiceReceived { .. } => { Some(RecentPaymentDetails::AwaitingInvoice { payment_id: *payment_id }) }, - PendingOutboundPayment::Retryable { payment_hash, total_msat, .. } => { + PendingOutboundPayment::Retryable { payment_hash, total_msat, pending_fee_msat, .. } => { let is_probe = outbound_payment::payment_is_probe(payment_hash, payment_id, self.probing_cookie_secret); Some(RecentPaymentDetails::Pending { payment_id: *payment_id, payment_hash: *payment_hash, total_msat: *total_msat, + pending_fee_msat: *pending_fee_msat, is_probe, }) }, @@ -4135,8 +4147,12 @@ impl< is_probe, }) }, - PendingOutboundPayment::Fulfilled { payment_hash, .. } => { - Some(RecentPaymentDetails::Fulfilled { payment_id: *payment_id, payment_hash: *payment_hash }) + PendingOutboundPayment::Fulfilled { payment_hash, fee_paid_msat, .. } => { + Some(RecentPaymentDetails::Fulfilled { + payment_id: *payment_id, + payment_hash: *payment_hash, + fee_paid_msat: *fee_paid_msat, + }) }, PendingOutboundPayment::Legacy { .. } => None }) diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 04e80038cc9..6805d9cec08 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -152,6 +152,8 @@ pub(crate) enum PendingOutboundPayment { timer_ticks_without_htlcs: u8, /// The total payment amount across all paths, used to be able to issue `PaymentSent`. total_msat: Option, + /// Total routing fees paid, as reported in `PaymentSent::fee_paid_msat`. + fee_paid_msat: Option, }, /// When we've decided to give up retrying a payment, we mark it as abandoned so we can eventually /// generate a `PaymentFailed` event when all HTLCs have irrevocably failed. @@ -256,6 +258,7 @@ impl PendingOutboundPayment { match self { PendingOutboundPayment::Retryable { pending_fee_msat, .. } => pending_fee_msat.clone(), PendingOutboundPayment::Abandoned { pending_fee_msat, .. } => pending_fee_msat.clone(), + PendingOutboundPayment::Fulfilled { fee_paid_msat, .. } => fee_paid_msat.clone(), _ => None, } } @@ -298,7 +301,8 @@ impl PendingOutboundPayment { }); let payment_hash = self.payment_hash(); let total_msat = self.total_msat(); - *self = PendingOutboundPayment::Fulfilled { session_privs, payment_hash, timer_ticks_without_htlcs: 0, total_msat }; + let fee_paid_msat = self.get_pending_fee_msat(); + *self = PendingOutboundPayment::Fulfilled { session_privs, payment_hash, timer_ticks_without_htlcs: 0, total_msat, fee_paid_msat }; } #[rustfmt::skip] @@ -2743,6 +2747,7 @@ impl_writeable_tlv_based_enum_upgradable!(PendingOutboundPayment, (1, payment_hash, option), (3, timer_ticks_without_htlcs, (default_value, 0)), (5, total_msat, option), + (7, fee_paid_msat, option), }, (2, Retryable) => { (0, session_privs, required), diff --git a/lightning/src/ln/payment_tests.rs b/lightning/src/ln/payment_tests.rs index 079a212cefb..77684919821 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -2343,7 +2343,11 @@ fn test_trivial_inflight_htlc_tracking() { } let pending_payments = nodes[0].node.list_recent_payments(); assert_eq!(pending_payments.len(), 1); - let details = RecentPaymentDetails::Fulfilled { payment_hash: Some(payment_hash), payment_id }; + let details = RecentPaymentDetails::Fulfilled { + payment_hash: Some(payment_hash), + payment_id, + fee_paid_msat: Some(1000), + }; assert_eq!(pending_payments[0], details); // Remove fulfilled payment @@ -2389,6 +2393,7 @@ fn test_trivial_inflight_htlc_tracking() { payment_id, payment_hash, total_msat: 500000, + pending_fee_msat: Some(1000), is_probe: false, }; assert_eq!(pending_payments[0], details); From 7106181ad1d7b7a33837efb251f871d44fc5664a Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 11 Jun 2026 10:31:38 +0200 Subject: [PATCH 470/627] f - Prevent stale fs-store writes Move the version-allocation ordering note to the allocation it describes so the cleanup invariant is easier to follow. Co-Authored-By: HAL 9000 --- lightning-persister/src/fs_store/common.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lightning-persister/src/fs_store/common.rs b/lightning-persister/src/fs_store/common.rs index 5c73ad1afe7..16a135221c4 100644 --- a/lightning-persister/src/fs_store/common.rs +++ b/lightning-persister/src/fs_store/common.rs @@ -93,14 +93,15 @@ impl FilesystemStoreState { fn get_new_version_and_lock_ref(&self, dest_file_path: PathBuf) -> (Arc>, u64) { let mut outer_lock = self.inner.locks.lock().unwrap(); + // Allocate the version while holding the lock map mutex so that clean_locks cannot remove the entry after a + // version has been reserved but before its lock reference is cloned. let version = self.next_version.fetch_add(1, Ordering::Relaxed); if version == u64::MAX { panic!("FilesystemStore version counter overflowed"); } // Get a reference to the inner lock. We do this early so that the arc can double as an in-flight counter for - // cleaning up unused locks. Allocate the version while holding the lock map mutex so that clean_locks cannot - // remove the entry after a version has been reserved but before its lock reference is cloned. + // cleaning up unused locks. let inner_lock_ref = Arc::clone(&outer_lock.entry(dest_file_path).or_default()); (inner_lock_ref, version) From 2c09a2610d1231b78704ae3e26d42136e8e89e90 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 11 Jun 2026 11:00:03 +0200 Subject: [PATCH 471/627] f - Prevent stale fs-store writes Exercise the stale-write race through the stored filesystem bytes so the regression test covers the user-visible overwrite bug. Co-Authored-By: HAL 9000 --- lightning-persister/src/fs_store/common.rs | 85 +++++++++++++++++----- 1 file changed, 65 insertions(+), 20 deletions(-) diff --git a/lightning-persister/src/fs_store/common.rs b/lightning-persister/src/fs_store/common.rs index 16a135221c4..96e58945f84 100644 --- a/lightning-persister/src/fs_store/common.rs +++ b/lightning-persister/src/fs_store/common.rs @@ -11,7 +11,11 @@ use std::collections::HashMap; use std::fs; use std::io::{ErrorKind, Read, Write}; use std::path::{Path, PathBuf}; +#[cfg(test)] +use std::sync::atomic::AtomicBool; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +#[cfg(test)] +use std::sync::mpsc; use std::sync::{Arc, Mutex, RwLock}; #[cfg(target_os = "windows")] @@ -99,6 +103,8 @@ impl FilesystemStoreState { if version == u64::MAX { panic!("FilesystemStore version counter overflowed"); } + #[cfg(test)] + maybe_pause_after_version_allocation(&self.inner, &dest_file_path); // Get a reference to the inner lock. We do this early so that the arc can double as an in-flight counter for // cleaning up unused locks. @@ -856,39 +862,78 @@ pub(crate) fn get_key_from_dir_entry_path( } } +#[cfg(test)] +struct VersionAllocatedHook { + dest_file_path: PathBuf, + version_allocated: mpsc::Sender<()>, + continue_write: Mutex>, + fired: AtomicBool, +} + +#[cfg(test)] +static VERSION_ALLOCATED_HOOK: Mutex>> = Mutex::new(None); + +#[cfg(test)] +fn maybe_pause_after_version_allocation(inner: &FilesystemStoreInner, dest_file_path: &Path) { + let hook = VERSION_ALLOCATED_HOOK.lock().unwrap().clone(); + if let Some(hook) = hook { + if hook.dest_file_path.as_path() != dest_file_path + || hook.fired.swap(true, Ordering::AcqRel) + { + return; + } + + let version_allocation_holds_lock = inner.locks.try_lock().is_err(); + hook.version_allocated.send(()).unwrap(); + if !version_allocation_holds_lock { + hook.continue_write.lock().unwrap().recv().unwrap(); + } + } +} + #[cfg(test)] mod tests { use super::*; use std::sync::Arc; use std::thread; - use std::time::Duration; #[test] - fn version_is_not_reserved_before_lock_ref() { + fn stale_write_after_lock_cleanup_does_not_overwrite_newer_write() { let mut temp_path = std::env::temp_dir(); - temp_path.push("test_version_is_not_reserved_before_lock_ref"); - let state = Arc::new(FilesystemStoreState::new(temp_path)); + temp_path.push("test_stale_write_after_lock_cleanup"); + let _ = std::fs::remove_dir_all(&temp_path); + + let state = Arc::new(FilesystemStoreState::new(temp_path.clone())); let path = state.get_checked_dest_file_path("ns", "sub", Some("key"), "write", false).unwrap(); + let (version_allocated, wait_for_version) = mpsc::channel(); + let (continue_write, wait_to_continue) = mpsc::channel(); + *VERSION_ALLOCATED_HOOK.lock().unwrap() = Some(Arc::new(VersionAllocatedHook { + dest_file_path: path.clone(), + version_allocated, + continue_write: Mutex::new(wait_to_continue), + fired: AtomicBool::new(false), + })); - let outer_lock = state.inner.locks.lock().unwrap(); let state_for_thread = Arc::clone(&state); let path_for_thread = path.clone(); - let handle = - thread::spawn(move || state_for_thread.get_new_version_and_lock_ref(path_for_thread)); - - thread::sleep(Duration::from_millis(50)); - assert_eq!( - state.next_version.load(Ordering::Relaxed), - 1, - "version allocation must wait until the lock reference can be cloned" - ); - - drop(outer_lock); - - let (inner_lock_ref, version) = handle.join().unwrap(); - assert_eq!(version, 1); - state.inner.clean_locks(&inner_lock_ref, path); + let stale_write = thread::spawn(move || { + let (inner_lock_ref, version) = + state_for_thread.get_new_version_and_lock_ref(path_for_thread.clone()); + state_for_thread + .inner + .write_version(inner_lock_ref, path_for_thread, b"stale".to_vec(), version, false) + .unwrap(); + }); + + wait_for_version.recv().unwrap(); + state.write_impl("ns", "sub", "key", b"newer".to_vec(), false).unwrap(); + continue_write.send(()).unwrap(); + stale_write.join().unwrap(); + *VERSION_ALLOCATED_HOOK.lock().unwrap() = None; + + assert_eq!(state.read_impl("ns", "sub", "key", false).unwrap(), b"newer"); + let _ = std::fs::remove_dir_all(temp_path); } } From 404d8c64a4de736402ad966fd40bb24bc923cfff Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 11 Jun 2026 16:22:54 -0500 Subject: [PATCH 472/627] Include payer nonce in payer metadata again InvoiceRequest and Refund have payer metadata consisting of an encrypted payment id and, originally, a nonce used to derive the payer signing keys and authenticate any corresponding invoices. The nonce was elided to save space once it was included in the OffersContext of blinded reply paths, but that means verifying a Bolt12Invoice requires state outside the invoice itself. Upcoming payment proofs (#4297) need the invoice signing keys derivable from the invoice request alone, so include the nonce in the payer metadata again and verify invoices using it rather than the context's nonce. This breaks verification of invoices for invoice requests and refunds with blinded paths created by prior versions, as their payer metadata lacks the nonce; such payments will fail and must be retried with a new payment id. Refunds without blinded paths are unaffected, as their metadata always included the nonce. Co-Authored-By: Claude --- lightning/src/offers/flow.rs | 24 ++++++++------- lightning/src/offers/invoice.rs | 39 ++++++++----------------- lightning/src/offers/invoice_request.rs | 22 +++++++------- lightning/src/offers/refund.rs | 26 ++++++----------- lightning/src/offers/signer.rs | 35 ++-------------------- 5 files changed, 47 insertions(+), 99 deletions(-) diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index bdc3475b554..7362a2974ea 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -484,14 +484,14 @@ impl OffersMessageFlow { Ok(InvreqResponseInstructions::SendInvoice(invoice_request)) } - /// Verifies a [`Bolt12Invoice`] using the provided [`OffersContext`] or the invoice's payer - /// metadata, returning the corresponding [`PaymentId`] if successful. + /// Verifies a [`Bolt12Invoice`] using the invoice's payer metadata, returning the + /// corresponding [`PaymentId`] if successful. /// /// - If an [`OffersContext::OutboundPaymentForOffer`] or - /// [`OffersContext::OutboundPaymentForRefund`] with a `nonce` is provided, verification is - /// performed using this to form the payer metadata. - /// - If no context is provided and the invoice corresponds to a [`Refund`] without blinded paths, - /// verification is performed using the [`Bolt12Invoice::payer_metadata`]. + /// [`OffersContext::OutboundPaymentForRefund`] is provided, the extracted [`PaymentId`] must + /// also match the context's `payment_id`. + /// - If no context is provided, the invoice must correspond to a [`Refund`] without blinded + /// paths. /// - If neither condition is met, verification fails. pub fn verify_bolt12_invoice( &self, invoice: &Bolt12Invoice, context: Option<&OffersContext>, @@ -503,16 +503,20 @@ impl OffersMessageFlow { None if invoice.is_for_refund_without_paths() => { invoice.verify_using_metadata(expanded_key, secp_ctx) }, - Some(&OffersContext::OutboundPaymentForOffer { payment_id, nonce, .. }) => { + Some(&OffersContext::OutboundPaymentForOffer { payment_id, .. }) => { if invoice.is_for_offer() { - invoice.verify_using_payer_data(payment_id, nonce, expanded_key, secp_ctx) + invoice.verify_using_metadata(expanded_key, secp_ctx).and_then(|extracted| { + (extracted == payment_id).then(|| payment_id).ok_or(()) + }) } else { Err(()) } }, - Some(&OffersContext::OutboundPaymentForRefund { payment_id, nonce, .. }) => { + Some(&OffersContext::OutboundPaymentForRefund { payment_id, .. }) => { if invoice.is_for_refund() { - invoice.verify_using_payer_data(payment_id, nonce, expanded_key, secp_ctx) + invoice.verify_using_metadata(expanded_key, secp_ctx).and_then(|extracted| { + (extracted == payment_id).then(|| payment_id).ok_or(()) + }) } else { Err(()) } diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index fd77595ca7d..2a42d0f4e96 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -133,7 +133,6 @@ use crate::offers::invoice_request::{ use crate::offers::merkle::{ self, SignError, SignFn, SignatureTlvStream, SignatureTlvStreamRef, TaggedHash, TlvStream, }; -use crate::offers::nonce::Nonce; use crate::offers::offer::{ Amount, ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, OfferId, OfferTlvStream, OfferTlvStreamRef, Quantity, EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES, @@ -1008,30 +1007,17 @@ impl Bolt12Invoice { (&invoice_request.inner.payer.0, INVOICE_REQUEST_IV_BYTES) }, InvoiceContents::ForRefund { refund, .. } => { - (&refund.payer.0, REFUND_IV_BYTES_WITH_METADATA) + let iv_bytes = if refund.paths().is_empty() { + REFUND_IV_BYTES_WITH_METADATA + } else { + REFUND_IV_BYTES_WITHOUT_METADATA + }; + (&refund.payer.0, iv_bytes) }, }; self.contents.verify(&self.bytes, metadata, key, iv_bytes, secp_ctx) } - /// Verifies that the invoice was for a request or refund created using the given key by - /// checking a payment id and nonce included with the [`BlindedMessagePath`] for which the invoice was - /// sent through. - pub fn verify_using_payer_data( - &self, payment_id: PaymentId, nonce: Nonce, key: &ExpandedKey, secp_ctx: &Secp256k1, - ) -> Result { - let metadata = Metadata::payer_data(payment_id, nonce, key); - let iv_bytes = match &self.contents { - InvoiceContents::ForOffer { .. } => INVOICE_REQUEST_IV_BYTES, - InvoiceContents::ForRefund { .. } => REFUND_IV_BYTES_WITHOUT_METADATA, - }; - self.contents.verify(&self.bytes, &metadata, key, iv_bytes, secp_ctx).and_then( - |extracted_payment_id| { - (payment_id == extracted_payment_id).then(|| payment_id).ok_or(()) - }, - ) - } - pub(crate) fn as_tlv_stream(&self) -> FullInvoiceTlvStreamRef<'_> { let ( payer_tlv_stream, @@ -1892,6 +1878,8 @@ mod tests { let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); let encrypted_payment_id = expanded_key.crypt_for_offer(payment_id.0, nonce); + let mut payer_metadata = encrypted_payment_id.to_vec(); + payer_metadata.extend_from_slice(nonce.as_slice()); let payment_paths = payment_paths(); let payment_hash = payment_hash(); @@ -1913,7 +1901,7 @@ mod tests { unsigned_invoice.write(&mut buffer).unwrap(); assert_eq!(unsigned_invoice.bytes, buffer.as_slice()); - assert_eq!(unsigned_invoice.payer_metadata(), &encrypted_payment_id); + assert_eq!(unsigned_invoice.payer_metadata(), payer_metadata.as_slice()); assert_eq!( unsigned_invoice.offer_chains(), Some(vec![ChainHash::using_genesis_block(Network::Bitcoin)]) @@ -1957,7 +1945,7 @@ mod tests { invoice.write(&mut buffer).unwrap(); assert_eq!(invoice.bytes, buffer.as_slice()); - assert_eq!(invoice.payer_metadata(), &encrypted_payment_id); + assert_eq!(invoice.payer_metadata(), payer_metadata.as_slice()); assert_eq!( invoice.offer_chains(), Some(vec![ChainHash::using_genesis_block(Network::Bitcoin)]) @@ -1975,10 +1963,7 @@ mod tests { assert_eq!(invoice.amount_msats(), 1000); assert_eq!(invoice.invoice_request_features(), &InvoiceRequestFeatures::empty()); assert_eq!(invoice.quantity(), None); - assert_eq!( - invoice.verify_using_payer_data(payment_id, nonce, &expanded_key, &secp_ctx), - Ok(payment_id), - ); + assert_eq!(invoice.verify_using_metadata(&expanded_key, &secp_ctx), Ok(payment_id)); assert_eq!(invoice.payer_note(), None); assert_eq!(invoice.payment_paths(), payment_paths.as_slice()); assert_eq!(invoice.created_at(), now); @@ -2001,7 +1986,7 @@ mod tests { assert_eq!( invoice.as_tlv_stream(), ( - PayerTlvStreamRef { metadata: Some(&encrypted_payment_id.to_vec()) }, + PayerTlvStreamRef { metadata: Some(&payer_metadata) }, OfferTlvStreamRef { chains: None, metadata: None, diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 2b4379e76e7..07bd15160b7 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -1588,6 +1588,8 @@ mod tests { let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); let encrypted_payment_id = expanded_key.crypt_for_offer(payment_id.0, nonce); + let mut payer_metadata = encrypted_payment_id.to_vec(); + payer_metadata.extend_from_slice(nonce.as_slice()); let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -1602,7 +1604,7 @@ mod tests { invoice_request.write(&mut buffer).unwrap(); assert_eq!(invoice_request.bytes, buffer.as_slice()); - assert_eq!(invoice_request.payer_metadata(), &encrypted_payment_id); + assert_eq!(invoice_request.payer_metadata(), payer_metadata.as_slice()); assert_eq!( invoice_request.chains(), vec![ChainHash::using_genesis_block(Network::Bitcoin)] @@ -1634,7 +1636,7 @@ mod tests { assert_eq!( invoice_request.as_tlv_stream(), ( - PayerTlvStreamRef { metadata: Some(&encrypted_payment_id.to_vec()) }, + PayerTlvStreamRef { metadata: Some(&payer_metadata) }, OfferTlvStreamRef { chains: None, metadata: None, @@ -1735,10 +1737,10 @@ mod tests { .unwrap() .sign(recipient_sign) .unwrap(); - assert!(invoice.verify_using_metadata(&expanded_key, &secp_ctx).is_err()); - assert!(invoice - .verify_using_payer_data(payment_id, nonce, &expanded_key, &secp_ctx) - .is_ok()); + match invoice.verify_using_metadata(&expanded_key, &secp_ctx) { + Ok(payment_id) => assert_eq!(payment_id, PaymentId([1; 32])), + Err(()) => panic!("verification failed"), + } // Fails verification with altered fields let ( @@ -1774,9 +1776,7 @@ mod tests { .unwrap(); let invoice = Bolt12Invoice::try_from(encoded_invoice).unwrap(); - assert!(invoice - .verify_using_payer_data(payment_id, nonce, &expanded_key, &secp_ctx) - .is_err()); + assert!(invoice.verify_using_metadata(&expanded_key, &secp_ctx).is_err()); // Fails verification with altered payer id let ( @@ -1812,9 +1812,7 @@ mod tests { .unwrap(); let invoice = Bolt12Invoice::try_from(encoded_invoice).unwrap(); - assert!(invoice - .verify_using_payer_data(payment_id, nonce, &expanded_key, &secp_ctx) - .is_err()); + assert!(invoice.verify_using_metadata(&expanded_key, &secp_ctx).is_err()); } #[test] diff --git a/lightning/src/offers/refund.rs b/lightning/src/offers/refund.rs index c0fd9dfdd3e..85ea3b61435 100644 --- a/lightning/src/offers/refund.rs +++ b/lightning/src/offers/refund.rs @@ -210,15 +210,12 @@ macro_rules! refund_builder_methods { ( /// /// Also, sets the metadata when [`RefundBuilder::build`] is called such that it can be used by /// [`Bolt12Invoice::verify_using_metadata`] to determine if the invoice was produced for the - /// refund given an [`ExpandedKey`]. However, if [`RefundBuilder::path`] is called, then the - /// metadata must be included in each [`BlindedMessagePath`] instead. In this case, use - /// [`Bolt12Invoice::verify_using_payer_data`]. + /// refund given an [`ExpandedKey`]. /// /// The `payment_id` is encrypted in the metadata and should be unique. This ensures that only /// one invoice will be paid for the refund and that payments can be uniquely identified. /// /// [`Bolt12Invoice::verify_using_metadata`]: crate::offers::invoice::Bolt12Invoice::verify_using_metadata - /// [`Bolt12Invoice::verify_using_payer_data`]: crate::offers::invoice::Bolt12Invoice::verify_using_payer_data /// [`ExpandedKey`]: crate::ln::inbound_payment::ExpandedKey pub fn deriving_signing_pubkey( node_id: PublicKey, expanded_key: &ExpandedKey, nonce: Nonce, @@ -329,6 +326,8 @@ macro_rules! refund_builder_methods { ( if $self.refund.payer.0.has_derivation_material() { let mut metadata = core::mem::take(&mut $self.refund.payer.0); + // Don't derive keys if no blinded paths were given since this means the payer id must + // be a public node id. let iv_bytes = if $self.refund.paths.is_none() { metadata = metadata.without_keys(); IV_BYTES_WITH_METADATA @@ -1167,9 +1166,6 @@ mod tests { Ok(payment_id) => assert_eq!(payment_id, PaymentId([1; 32])), Err(()) => panic!("verification failed"), } - assert!(invoice - .verify_using_payer_data(payment_id, nonce, &expanded_key, &secp_ctx) - .is_err()); let mut tlv_stream = refund.as_tlv_stream(); tlv_stream.2.amount = Some(2000); @@ -1248,10 +1244,10 @@ mod tests { .unwrap() .sign(recipient_sign) .unwrap(); - assert!(invoice.verify_using_metadata(&expanded_key, &secp_ctx).is_err()); - assert!(invoice - .verify_using_payer_data(payment_id, nonce, &expanded_key, &secp_ctx) - .is_ok()); + match invoice.verify_using_metadata(&expanded_key, &secp_ctx) { + Ok(payment_id) => assert_eq!(payment_id, PaymentId([1; 32])), + Err(()) => panic!("verification failed"), + } // Fails verification with altered fields let mut tlv_stream = refund.as_tlv_stream(); @@ -1268,9 +1264,7 @@ mod tests { .unwrap() .sign(recipient_sign) .unwrap(); - assert!(invoice - .verify_using_payer_data(payment_id, nonce, &expanded_key, &secp_ctx) - .is_err()); + assert!(invoice.verify_using_metadata(&expanded_key, &secp_ctx).is_err()); // Fails verification with altered payer_id let mut tlv_stream = refund.as_tlv_stream(); @@ -1288,9 +1282,7 @@ mod tests { .unwrap() .sign(recipient_sign) .unwrap(); - assert!(invoice - .verify_using_payer_data(payment_id, nonce, &expanded_key, &secp_ctx) - .is_err()); + assert!(invoice.verify_using_metadata(&expanded_key, &secp_ctx).is_err()); } #[test] diff --git a/lightning/src/offers/signer.rs b/lightning/src/offers/signer.rs index e51a120b6d7..43d1370238a 100644 --- a/lightning/src/offers/signer.rs +++ b/lightning/src/offers/signer.rs @@ -63,11 +63,6 @@ pub(super) enum Metadata { /// This variant should only be used at verification time, never when building. RecipientData(Nonce), - /// Metadata for deriving keys included as payer data in a blinded path. - /// - /// This variant should only be used at verification time, never when building. - PayerData([u8; PaymentId::LENGTH + Nonce::LENGTH]), - /// Metadata to be derived from message contents and given material. /// /// This variant should only be used at building time. @@ -80,16 +75,6 @@ pub(super) enum Metadata { } impl Metadata { - pub fn payer_data(payment_id: PaymentId, nonce: Nonce, expanded_key: &ExpandedKey) -> Self { - let encrypted_payment_id = expanded_key.crypt_for_offer(payment_id.0, nonce); - - let mut bytes = [0u8; PaymentId::LENGTH + Nonce::LENGTH]; - bytes[..PaymentId::LENGTH].copy_from_slice(encrypted_payment_id.as_slice()); - bytes[PaymentId::LENGTH..].copy_from_slice(nonce.as_slice()); - - Metadata::PayerData(bytes) - } - pub fn as_bytes(&self) -> Option<&Vec> { match self { Metadata::Bytes(bytes) => Some(bytes), @@ -107,10 +92,6 @@ impl Metadata { debug_assert!(false); false }, - Metadata::PayerData(_) => { - debug_assert!(false); - false - }, Metadata::Derived(_) => true, Metadata::DerivedSigningPubkey(_) => true, } @@ -125,7 +106,6 @@ impl Metadata { // Nonce::LENGTH had been set explicitly. Metadata::Bytes(bytes) => bytes.len() == PaymentId::LENGTH + Nonce::LENGTH, Metadata::RecipientData(_) => false, - Metadata::PayerData(_) => true, Metadata::Derived(_) => false, Metadata::DerivedSigningPubkey(_) => true, } @@ -140,7 +120,6 @@ impl Metadata { // been set explicitly. Metadata::Bytes(bytes) => bytes.len() == Nonce::LENGTH, Metadata::RecipientData(_) => true, - Metadata::PayerData(_) => false, Metadata::Derived(_) => false, Metadata::DerivedSigningPubkey(_) => true, } @@ -158,10 +137,6 @@ impl Metadata { debug_assert!(false); self }, - Metadata::PayerData(_) => { - debug_assert!(false); - self - }, Metadata::Derived(_) => self, Metadata::DerivedSigningPubkey(material) => Metadata::Derived(material), } @@ -176,10 +151,6 @@ impl Metadata { debug_assert!(false); (self, None) }, - Metadata::PayerData(_) => { - debug_assert!(false); - (self, None) - }, Metadata::Derived(metadata_material) => { (Metadata::Bytes(metadata_material.derive_metadata(iv_bytes, tlv_stream)), None) }, @@ -204,7 +175,6 @@ impl AsRef<[u8]> for Metadata { match self { Metadata::Bytes(bytes) => &bytes, Metadata::RecipientData(nonce) => &nonce.0, - Metadata::PayerData(bytes) => bytes.as_slice(), Metadata::Derived(_) => { debug_assert!(false); &[] @@ -222,7 +192,6 @@ impl fmt::Debug for Metadata { match self { Metadata::Bytes(bytes) => bytes.fmt(f), Metadata::RecipientData(Nonce(bytes)) => bytes.fmt(f), - Metadata::PayerData(bytes) => bytes.fmt(f), Metadata::Derived(_) => f.write_str("Derived"), Metadata::DerivedSigningPubkey(_) => f.write_str("DerivedSigningPubkey"), } @@ -241,7 +210,6 @@ impl PartialEq for Metadata { } }, Metadata::RecipientData(_) => false, - Metadata::PayerData(_) => false, Metadata::Derived(_) => false, Metadata::DerivedSigningPubkey(_) => false, } @@ -290,7 +258,8 @@ impl MetadataMaterial { self.hmac.input(DERIVED_METADATA_AND_KEYS_HMAC_INPUT); self.maybe_include_encrypted_payment_id(); - let bytes = self.encrypted_payment_id.map(|id| id.to_vec()).unwrap_or_default(); + let mut bytes = self.encrypted_payment_id.map(|id| id.to_vec()).unwrap_or_default(); + bytes.extend_from_slice(self.nonce.as_slice()); let hmac = Hmac::from_engine(self.hmac); let privkey = SecretKey::from_slice(hmac.as_byte_array()).unwrap(); From 2e7cc44cb1c47fb88afea15cee02aa6dd8bf520b Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Jun 2026 12:46:36 +0200 Subject: [PATCH 473/627] Intercept onion messages for unknown SCID hops Allow integrations to intercept blinded onion-message hops that identify the next node by short channel id, so LSPS-style protocols can resolve those hops out of band instead of dropping the message. Co-Authored-By: HAL 9000 --- .../src/upgrade_downgrade_tests.rs | 115 ++++++++++- lightning/src/blinded_path/message.rs | 5 + lightning/src/events/mod.rs | 51 +++-- lightning/src/ln/functional_test_utils.rs | 1 + .../src/onion_message/functional_tests.rs | 183 ++++++++++++++++-- lightning/src/onion_message/messenger.rs | 42 +++- 6 files changed, 364 insertions(+), 33 deletions(-) diff --git a/lightning-tests/src/upgrade_downgrade_tests.rs b/lightning-tests/src/upgrade_downgrade_tests.rs index 7cc59227af4..75413ef14f6 100644 --- a/lightning-tests/src/upgrade_downgrade_tests.rs +++ b/lightning-tests/src/upgrade_downgrade_tests.rs @@ -17,7 +17,10 @@ use lightning_0_2::ln::channelmanager::PaymentId as PaymentId_0_2; use lightning_0_2::ln::channelmanager::RecipientOnionFields as RecipientOnionFields_0_2; use lightning_0_2::ln::functional_test_utils as lightning_0_2_utils; use lightning_0_2::ln::msgs::ChannelMessageHandler as _; +use lightning_0_2::ln::msgs::OnionMessage as OnionMessage_0_2; +use lightning_0_2::onion_message::packet::Packet as Packet_0_2; use lightning_0_2::routing::router as router_0_2; +use lightning_0_2::util::ser::MaybeReadable as MaybeReadable_0_2; use lightning_0_2::util::ser::Writeable as _; use lightning_0_1::commitment_signed_dance as commitment_signed_dance_0_1; @@ -45,23 +48,29 @@ use lightning_0_0_125::ln::msgs::ChannelMessageHandler as _; use lightning_0_0_125::routing::router as router_0_0_125; use lightning_0_0_125::util::ser::Writeable as _; +use lightning::blinded_path::message::NextMessageHop; use lightning::chain::channelmonitor::{ANTI_REORG_DELAY, HTLC_FAIL_BACK_BUFFER}; use lightning::events::{ClosureReason, Event, HTLCHandlingFailureType}; use lightning::ln::functional_test_utils::*; +use lightning::ln::msgs; use lightning::ln::msgs::BaseMessageHandler as _; use lightning::ln::msgs::ChannelMessageHandler as _; use lightning::ln::msgs::MessageSendEvent; use lightning::ln::splicing_tests::*; use lightning::ln::types::ChannelId; +use lightning::onion_message::packet::Packet; use lightning::sign::OutputSpender; +use lightning::util::ser::{MaybeReadable, Writeable}; use lightning::util::wallet_utils::WalletSourceSync; use lightning_types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; use bitcoin::script::Builder; -use bitcoin::secp256k1::Secp256k1; +use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use bitcoin::{opcodes, Amount, TxOut}; +use lightning::io::Cursor; + use std::sync::Arc; #[test] @@ -700,3 +709,107 @@ fn do_upgrade_mid_htlc_forward(test: MidHtlcForwardCase) { expect_payment_claimable!(nodes[2], pay_hash, pay_secret, 1_000_000); claim_payment(&nodes[0], &[&nodes[1], &nodes[2]], pay_preimage); } + +/// Constructs a dummy `OnionMessage` (current version) for use in serialization tests. +fn dummy_onion_message() -> msgs::OnionMessage { + let pubkey = + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[42; 32]).unwrap()); + msgs::OnionMessage { + blinding_point: pubkey, + onion_routing_packet: Packet { + version: 0, + public_key: pubkey, + hop_data: vec![1; 64], + hmac: [2; 32], + }, + } +} + +/// Constructs a dummy `OnionMessage` (0.2 version) for use in serialization tests. +fn dummy_onion_message_0_2() -> OnionMessage_0_2 { + let pubkey = bitcoin::secp256k1::PublicKey::from_secret_key( + &Secp256k1::new(), + &SecretKey::from_slice(&[42; 32]).unwrap(), + ); + OnionMessage_0_2 { + blinding_point: pubkey, + onion_routing_packet: Packet_0_2 { + version: 0, + public_key: pubkey, + hop_data: vec![1; 64], + hmac: [2; 32], + }, + } +} + +#[test] +fn test_onion_message_intercepted_upgrade_from_0_2() { + // Ensure that an `Event::OnionMessageIntercepted` serialized by LDK 0.2 (which uses + // `peer_node_id: PublicKey` in TLV field 0) can be deserialized by the current version, + // producing `NextMessageHop::NodeId`. + let pubkey = + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[42; 32]).unwrap()); + + let event_0_2 = Event_0_2::OnionMessageIntercepted { + peer_node_id: pubkey, + message: dummy_onion_message_0_2(), + }; + + let serialized = lightning_0_2::util::ser::Writeable::encode(&event_0_2); + + let mut reader = Cursor::new(&serialized); + let deserialized = ::read(&mut reader).unwrap().unwrap(); + + match deserialized { + Event::OnionMessageIntercepted { next_hop, message } => { + assert_eq!(next_hop, NextMessageHop::NodeId(pubkey)); + assert_eq!(message, dummy_onion_message()); + }, + _ => panic!("Expected OnionMessageIntercepted event"), + } +} + +#[test] +fn test_onion_message_intercepted_node_id_downgrade_to_0_2() { + // Ensure that an `Event::OnionMessageIntercepted` with a `NodeId` next hop serialized by + // the current version can be deserialized by LDK 0.2 (which expects `peer_node_id` in TLV + // field 0). + let pubkey = + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[42; 32]).unwrap()); + + let event = Event::OnionMessageIntercepted { + next_hop: NextMessageHop::NodeId(pubkey), + message: dummy_onion_message(), + }; + + let serialized = event.encode(); + + let mut reader = Cursor::new(&serialized); + let deserialized = ::read(&mut reader).unwrap().unwrap(); + + match deserialized { + Event_0_2::OnionMessageIntercepted { peer_node_id, message } => { + assert_eq!(peer_node_id, pubkey); + assert_eq!(message, dummy_onion_message_0_2()); + }, + _ => panic!("Expected OnionMessageIntercepted event"), + } +} + +#[test] +fn test_onion_message_intercepted_scid_downgrade_to_0_2() { + // Ensure that an `Event::OnionMessageIntercepted` with a `ShortChannelId` next hop + // serialized by the current version cannot be deserialized by LDK 0.2, since the + // `peer_node_id` field (0) is not written for SCID variants and LDK 0.2 requires it. + let event = Event::OnionMessageIntercepted { + next_hop: NextMessageHop::ShortChannelId(42), + message: dummy_onion_message(), + }; + + let serialized = event.encode(); + + // LDK 0.2 will try to read field 0 as required. Since it's absent, the read will fail. + let mut reader = Cursor::new(&serialized); + let result = ::read(&mut reader); + assert!(result.is_err(), "LDK 0.2 should fail to decode a ShortChannelId variant"); +} diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs index 417c66374a9..65a3a4593b9 100644 --- a/lightning/src/blinded_path/message.rs +++ b/lightning/src/blinded_path/message.rs @@ -275,6 +275,11 @@ pub enum NextMessageHop { ShortChannelId(u64), } +impl_ser_tlv_based_enum!(NextMessageHop, + {0, NodeId} => (), + {2, ShortChannelId} => (), +); + /// An intermediate node, and possibly a short channel id leading to the next node. /// /// Note: diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 4853c83b19c..d6298a77f07 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -18,7 +18,7 @@ pub mod bump_transaction; pub use bump_transaction::BumpTransactionEvent; -use crate::blinded_path::message::{BlindedMessagePath, OffersContext}; +use crate::blinded_path::message::{BlindedMessagePath, NextMessageHop, OffersContext}; use crate::blinded_path::payment::{ Bolt12OfferContext, Bolt12RefundContext, PaymentContext, PaymentContextRef, }; @@ -1836,9 +1836,13 @@ pub enum Event { /// [`ChannelHandshakeConfig::negotiate_anchor_zero_fee_commitments`]: crate::util::config::ChannelHandshakeConfig::negotiate_anchor_zero_fee_commitments BumpTransaction(BumpTransactionEvent), /// We received an onion message that is intended to be forwarded to a peer - /// that is currently offline. This event will only be generated if the - /// `OnionMessenger` was initialized with - /// [`OnionMessenger::new_with_offline_peer_interception`], see its docs. + /// that is currently offline *or* that is intended to be forwarded along a channel with an + /// SCID unknown to us. + /// + /// This event will only be generated if the `OnionMessenger` was initialized with + /// [`OnionMessenger::new_with_offline_peer_interception`], see its docs. The + /// [`NextMessageHop::ShortChannelId`] variant is only generated if `intercept_for_unknown_scids` + /// was set when constructing the `OnionMessenger`. /// /// The offline peer should be awoken if possible on receipt of this event, such as via the LSPS5 /// protocol. @@ -1852,9 +1856,10 @@ pub enum Event { /// /// [`OnionMessenger::new_with_offline_peer_interception`]: crate::onion_message::messenger::OnionMessenger::new_with_offline_peer_interception OnionMessageIntercepted { - /// The node id of the offline peer. - peer_node_id: PublicKey, - /// The onion message intended to be forwarded to `peer_node_id`. + /// The next hop (offline peer or unknown SCID). + next_hop: NextMessageHop, + /// The onion message intended to be forwarded to the offline peer or via the unknown + /// channel once established. message: msgs::OnionMessage, }, /// Indicates that an onion message supporting peer has come online and any messages previously @@ -2436,12 +2441,25 @@ impl Writeable for Event { 35u8.write(writer)?; // Never write ConnectionNeeded events as buffered onion messages aren't serialized. }, - &Event::OnionMessageIntercepted { ref peer_node_id, ref message } => { + &Event::OnionMessageIntercepted { ref next_hop, ref message } => { 37u8.write(writer)?; - write_tlv_fields!(writer, { - (0, peer_node_id, required), - (2, message, required), - }); + match next_hop { + NextMessageHop::NodeId(peer_node_id) => { + // If we have the node_id, we keep writing it for backwards compatibility. + write_tlv_fields!(writer, { + (0, peer_node_id, required), + (1, next_hop, required), + (2, message, required), + }); + }, + NextMessageHop::ShortChannelId(_) => { + write_tlv_fields!(writer, { + // 0 used to be peer_node_id in LDK v0.2 and prior. + (1, next_hop, required), + (2, message, required), + }); + }, + } }, &Event::OnionMessagePeerConnected { ref peer_node_id } => { 39u8.write(writer)?; @@ -3069,11 +3087,16 @@ impl MaybeReadable for Event { 37u8 => { let mut f = || { _init_and_read_len_prefixed_tlv_fields!(reader, { - (0, peer_node_id, required), + (0, peer_node_id, option), + (1, next_hop, option), (2, message, required), }); + + let next_hop = next_hop + .or(peer_node_id.map(NextMessageHop::NodeId)) + .ok_or(msgs::DecodeError::InvalidValue)?; Ok(Some(Event::OnionMessageIntercepted { - peer_node_id: peer_node_id.0.unwrap(), + next_hop, message: message.0.unwrap(), })) }; diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 20fcbef0dba..82c1c619e82 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -4799,6 +4799,7 @@ pub fn create_network<'a, 'b: 'a, 'c: 'b>( &chan_mgrs[i], IgnoringMessageHandler {}, IgnoringMessageHandler {}, + true, ); let gossip_sync = P2PGossipSync::new(cfgs[i].network_graph.as_ref(), None, cfgs[i].logger); let wallet_source = Arc::new(test_utils::TestWalletSource::new( diff --git a/lightning/src/onion_message/functional_tests.rs b/lightning/src/onion_message/functional_tests.rs index 75e2aaf3c5f..3692858c8ff 100644 --- a/lightning/src/onion_message/functional_tests.rs +++ b/lightning/src/onion_message/functional_tests.rs @@ -24,10 +24,10 @@ use super::offers::{OffersMessage, OffersMessageHandler}; use super::packet::{OnionMessageContents, Packet}; use crate::blinded_path::message::{ AsyncPaymentsContext, BlindedMessagePath, DNSResolverContext, MessageContext, - MessageForwardNode, OffersContext, MESSAGE_PADDING_ROUND_OFF, + MessageForwardNode, NextMessageHop, OffersContext, MESSAGE_PADDING_ROUND_OFF, }; use crate::blinded_path::utils::is_padded; -use crate::blinded_path::EmptyNodeIdLookUp; +use crate::blinded_path::NodeIdLookUp; use crate::events::{Event, EventsProvider}; use crate::ln::msgs::{self, BaseMessageHandler, DecodeError, OnionMessageHandler}; use crate::routing::gossip::{NetworkGraph, P2PGossipSync}; @@ -60,17 +60,40 @@ struct MessengerNode { Arc, Arc, Arc, - Arc, + Arc, Arc, Arc, Arc, Arc, Arc, >, + node_id_lookup: Arc, custom_message_handler: Arc, gossip_sync: Arc, Arc, Arc>>, } +/// A [`NodeIdLookUp`] that resolves SCIDs to node ids from an insertable map (empty by default, +/// so it behaves like an empty lookup unless a mapping is added). +struct TestNodeIdLookUp { + scid_to_node_id: Mutex>, +} + +impl TestNodeIdLookUp { + fn new() -> Self { + Self { scid_to_node_id: Mutex::new(new_hash_map()) } + } + + fn add_mapping(&self, scid: u64, node_id: PublicKey) { + self.scid_to_node_id.lock().unwrap().insert(scid, node_id); + } +} + +impl NodeIdLookUp for TestNodeIdLookUp { + fn next_node_id(&self, short_channel_id: u64) -> Option { + self.scid_to_node_id.lock().unwrap().get(&short_channel_id).copied() + } +} + impl Drop for MessengerNode { fn drop(&mut self) { if std::thread::panicking() { @@ -275,10 +298,15 @@ fn create_nodes(num_messengers: u8) -> Vec { struct MessengerCfg { secret_override: Option, intercept_offline_peer_oms: bool, + intercept_unknown_scid_oms: bool, } impl MessengerCfg { fn new() -> Self { - Self { secret_override: None, intercept_offline_peer_oms: false } + Self { + secret_override: None, + intercept_offline_peer_oms: false, + intercept_unknown_scid_oms: false, + } } fn with_node_secret(mut self, secret: SecretKey) -> Self { self.secret_override = Some(secret); @@ -288,6 +316,10 @@ impl MessengerCfg { self.intercept_offline_peer_oms = true; self } + fn with_unknown_scid_interception(mut self) -> Self { + self.intercept_unknown_scid_oms = true; + self + } } fn create_nodes_using_cfgs(cfgs: Vec) -> Vec { @@ -304,31 +336,32 @@ fn create_nodes_using_cfgs(cfgs: Vec) -> Vec { let entropy_source = Arc::new(TestKeysInterface::new(&seed, Network::Testnet)); let node_signer = Arc::new(TestNodeSigner::new(secret_key)); - let node_id_lookup = Arc::new(EmptyNodeIdLookUp {}); + let node_id_lookup = Arc::new(TestNodeIdLookUp::new()); let message_router = DefaultMessageRouter::new(Arc::clone(&network_graph), Arc::clone(&entropy_source)); let offers_message_handler = Arc::new(TestOffersMessageHandler {}); let async_payments_message_handler = Arc::new(TestAsyncPaymentsMessageHandler {}); let dns_resolver_message_handler = Arc::new(TestDNSResolverMessageHandler {}); let custom_message_handler = Arc::new(TestCustomMessageHandler::new()); - let messenger = if cfg.intercept_offline_peer_oms { + let messenger = if cfg.intercept_offline_peer_oms || cfg.intercept_unknown_scid_oms { OnionMessenger::new_with_offline_peer_interception( Arc::clone(&entropy_source), Arc::clone(&node_signer), logger, - node_id_lookup, + Arc::clone(&node_id_lookup), Arc::new(message_router), offers_message_handler, async_payments_message_handler, dns_resolver_message_handler, Arc::clone(&custom_message_handler), + cfg.intercept_unknown_scid_oms, ) } else { OnionMessenger::new( Arc::clone(&entropy_source), Arc::clone(&node_signer), logger, - node_id_lookup, + Arc::clone(&node_id_lookup), Arc::new(message_router), offers_message_handler, async_payments_message_handler, @@ -341,6 +374,7 @@ fn create_nodes_using_cfgs(cfgs: Vec) -> Vec { node_id: node_signer.get_node_id(Recipient::Node).unwrap(), entropy_source, messenger, + node_id_lookup, custom_message_handler, gossip_sync: Arc::clone(&gossip_sync), }); @@ -1144,9 +1178,13 @@ fn intercept_offline_peer_oms() { let mut events = release_events(&nodes[1]); assert_eq!(events.len(), 1); let onion_message = match events.remove(0) { - Event::OnionMessageIntercepted { peer_node_id, message } => { - assert_eq!(peer_node_id, final_node_vec[0].node_id); - message + Event::OnionMessageIntercepted { next_hop, message } => { + if let NextMessageHop::NodeId(peer_node_id) = next_hop { + assert_eq!(peer_node_id, final_node_vec[0].node_id); + message + } else { + panic!(); + } }, _ => panic!(), }; @@ -1173,6 +1211,129 @@ fn intercept_offline_peer_oms() { pass_along_path(&vec![nodes.remove(1), final_node_vec.remove(0)]); } +#[test] +fn intercept_unknown_scid_oms() { + // Ensure that if OnionMessenger is initialized with + // new_with_offline_peer_interception and `intercept_for_unknown_scids` set, we will + // intercept OMs that use an unknown SCID as the next hop, generate the right events, and + // forward OMs when they are re-injected by the user. + let node_cfgs = vec![ + MessengerCfg::new(), + MessengerCfg::new().with_unknown_scid_interception(), + MessengerCfg::new(), + ]; + let mut nodes = create_nodes_using_cfgs(node_cfgs); + + let peer_conn_evs = release_events(&nodes[1]); + assert_eq!(peer_conn_evs.len(), 2); + for (i, ev) in peer_conn_evs.iter().enumerate() { + match ev { + Event::OnionMessagePeerConnected { peer_node_id } => { + let node_idx = if i == 0 { 0 } else { 2 }; + assert_eq!(peer_node_id, &nodes[node_idx].node_id); + }, + _ => panic!(), + } + } + + // Use a SCID-based intermediate hop to trigger the unknown SCID interception path. Since no + // mapping was added to the `TestNodeIdLookUp`, the SCID cannot be resolved, so the + // OnionMessenger will generate an `OnionMessageIntercepted` event with a `ShortChannelId` + // next hop. + let scid = 42; + let message = TestCustomMessage::Pong; + let intermediate_nodes = + [MessageForwardNode { node_id: nodes[1].node_id, short_channel_id: Some(scid) }]; + let blinded_path = BlindedMessagePath::new( + &intermediate_nodes, + nodes[2].node_id, + nodes[2].messenger.node_signer.get_receive_auth_key(), + MessageContext::Custom(Vec::new()), + false, + &*nodes[2].entropy_source, + &Secp256k1::new(), + ); + let destination = Destination::BlindedPath(blinded_path); + let instructions = MessageSendInstructions::WithoutReplyPath { destination }; + + nodes[0].messenger.send_onion_message(message, instructions).unwrap(); + let mut final_node_vec = nodes.split_off(2); + pass_along_path(&nodes); + + // We expect an `OnionMessageIntercepted` event with a `ShortChannelId` next hop since the + // SCID is not resolvable (no mapping was added to the `TestNodeIdLookUp`). + let mut events = release_events(&nodes[1]); + assert_eq!(events.len(), 1); + let onion_message = match events.remove(0) { + Event::OnionMessageIntercepted { next_hop, message } => { + if let NextMessageHop::ShortChannelId(intercepted_scid) = next_hop { + assert_eq!(intercepted_scid, scid); + message + } else { + panic!("Expected ShortChannelId next hop, got NodeId"); + } + }, + _ => panic!(), + }; + + // The user resolves the SCID externally and forwards the intercepted message to the + // correct peer. + nodes[1].messenger.forward_onion_message(onion_message, &final_node_vec[0].node_id).unwrap(); + final_node_vec[0].custom_message_handler.expect_message(TestCustomMessage::Pong); + pass_along_path(&vec![nodes.remove(1), final_node_vec.remove(0)]); +} + +#[test] +fn intercept_resolved_scid_offline_peer_oms() { + // Ensure that when a forwarded OM's next hop is a SCID that resolves to a known but offline + // peer, the offline-peer interception path reports the resolved node id rather than the SCID, + // even though `intercept_for_unknown_scids` is disabled. + let node_cfgs = vec![ + MessengerCfg::new(), + MessengerCfg::new().with_offline_peer_interception(), + MessengerCfg::new(), + ]; + let mut nodes = create_nodes_using_cfgs(node_cfgs); + + // Clear the initial `OnionMessagePeerConnected` events. + let _ = release_events(&nodes[1]); + + // Resolve the SCID to nodes[2] and disconnect it so it appears as a known-but-offline peer. + let scid = 42; + nodes[1].node_id_lookup.add_mapping(scid, nodes[2].node_id); + disconnect_peers(&nodes[1], &nodes[2]); + + let message = TestCustomMessage::Pong; + let intermediate_nodes = + [MessageForwardNode { node_id: nodes[1].node_id, short_channel_id: Some(scid) }]; + let blinded_path = BlindedMessagePath::new( + &intermediate_nodes, + nodes[2].node_id, + nodes[2].messenger.node_signer.get_receive_auth_key(), + MessageContext::Custom(Vec::new()), + false, + &*nodes[2].entropy_source, + &Secp256k1::new(), + ); + let destination = Destination::BlindedPath(blinded_path); + let instructions = MessageSendInstructions::WithoutReplyPath { destination }; + + nodes[0].messenger.send_onion_message(message, instructions).unwrap(); + let final_node_vec = nodes.split_off(2); + pass_along_path(&nodes); + + // The next hop resolved to a known (but offline) peer, so the event must carry its node id + // rather than the SCID variant (which `intercept_for_unknown_scids` would have produced). + let mut events = release_events(&nodes[1]); + assert_eq!(events.len(), 1); + match events.remove(0) { + Event::OnionMessageIntercepted { next_hop, .. } => { + assert_eq!(next_hop, NextMessageHop::NodeId(final_node_vec[0].node_id)); + }, + _ => panic!(), + } +} + #[test] fn spec_test_vector() { let node_cfgs = [ diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index 913a04637b9..2070ff9eb50 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -273,6 +273,7 @@ pub struct OnionMessenger< dns_resolver_handler: DRH, custom_handler: CMH, intercept_messages_for_offline_peers: bool, + intercept_for_unknown_scids: bool, pending_intercepted_msgs_events: Mutex>, pending_peer_connected_events: Mutex>, pending_events_processor: AtomicBool, @@ -1393,6 +1394,7 @@ impl< dns_resolver, custom_handler, false, + false, ) } @@ -1400,11 +1402,18 @@ impl< /// intended to be forwarded to offline peers, we will intercept them for /// later forwarding. /// + /// If `intercept_for_unknown_scids` is set, we will additionally intercept onion messages whose + /// next hop is a [`NextMessageHop::ShortChannelId`] that cannot be resolved to a connected + /// peer, generating an [`Event::OnionMessageIntercepted`] with a + /// [`NextMessageHop::ShortChannelId`] next hop. This variant of the event was introduced in + /// LDK 0.3, so users who persist [`Event::OnionMessageIntercepted`] events and may need to + /// downgrade to LDK 0.2 must leave this disabled. + /// /// Interception flow: - /// 1. If an onion message for an offline peer is received, `OnionMessenger` will - /// generate an [`Event::OnionMessageIntercepted`]. Event handlers can - /// then choose to persist this onion message for later forwarding, or drop - /// it. + /// 1. If an onion message for an offline peer or (if `intercept_for_unknown_scids` is set) an + /// unknown SCID is received, `OnionMessenger` will generate an + /// [`Event::OnionMessageIntercepted`]. Event handlers can then choose to persist this + /// onion message for later forwarding, or drop it. /// 2. When the offline peer later comes back online, `OnionMessenger` will /// generate an [`Event::OnionMessagePeerConnected`]. Event handlers will /// then fetch all previously intercepted onion messages for this peer. @@ -1420,6 +1429,7 @@ impl< pub fn new_with_offline_peer_interception( entropy_source: ES, node_signer: NS, logger: L, node_id_lookup: NL, message_router: MR, offers_handler: OMH, async_payments_handler: APH, dns_resolver: DRH, custom_handler: CMH, + intercept_for_unknown_scids: bool, ) -> Self { Self::new_inner( entropy_source, @@ -1432,13 +1442,14 @@ impl< dns_resolver, custom_handler, true, + intercept_for_unknown_scids, ) } fn new_inner( entropy_source: ES, node_signer: NS, logger: L, node_id_lookup: NL, message_router: MR, offers_handler: OMH, async_payments_handler: APH, dns_resolver: DRH, custom_handler: CMH, - intercept_messages_for_offline_peers: bool, + intercept_messages_for_offline_peers: bool, intercept_for_unknown_scids: bool, ) -> Self { let mut secp_ctx = Secp256k1::new(); secp_ctx.seeded_randomize(&entropy_source.get_secure_random_bytes()); @@ -1455,6 +1466,7 @@ impl< dns_resolver_handler: dns_resolver, custom_handler, intercept_messages_for_offline_peers, + intercept_for_unknown_scids, pending_intercepted_msgs_events: Mutex::new(Vec::new()), pending_peer_connected_events: Mutex::new(Vec::new()), pending_events_processor: AtomicBool::new(false), @@ -1666,7 +1678,20 @@ impl< NextMessageHop::ShortChannelId(scid) => match self.node_id_lookup.next_node_id(scid) { Some(pubkey) => pubkey, None => { - log_trace!(self.logger, "Dropping forwarded onion messager: unable to resolve next hop using SCID {} {}", scid, log_suffix); + if self.intercept_for_unknown_scids { + log_trace!( + self.logger, + "Generating OnionMessageIntercepted event for SCID {} {}", + scid, + log_suffix + ); + self.enqueue_intercepted_event(Event::OnionMessageIntercepted { + next_hop, + message: onion_message, + }); + return Ok(()); + } + log_trace!(self.logger, "Dropping forwarded onion message: unable to resolve next hop using SCID {} {}", scid, log_suffix); return Err(SendError::GetNodeIdFailed); }, }, @@ -1709,7 +1734,10 @@ impl< log_suffix ); self.enqueue_intercepted_event(Event::OnionMessageIntercepted { - peer_node_id: next_node_id, + // Report the resolved node id rather than `next_hop`, which may be a + // `ShortChannelId` that we resolved to a known-but-offline peer. The + // `ShortChannelId` variant is reserved for the unknown-SCID interception path. + next_hop: NextMessageHop::NodeId(next_node_id), message: onion_message, }); Ok(()) From 1a0e5304a58a2032c113d741438b10c5185e46b8 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 11 Jun 2026 13:20:33 -0500 Subject: [PATCH 474/627] Report the sending peer in Event::OnionMessageIntercepted When the OnionMessenger intercepts an onion message to forward, it now reports which peer sent us the message via a new `prev_hop` field, so handlers can apply source-based policy when deciding whether to forward. `prev_hop` is `None` when the forward is enqueued by a message handler (the BOLT 12 static-invoice-server flow), which isn't given the sending node; otherwise it is the node we received the message from. Co-Authored-By: Claude --- .../src/upgrade_downgrade_tests.rs | 10 ++++- lightning/src/events/mod.rs | 44 +++++++++++-------- .../src/onion_message/functional_tests.rs | 6 ++- lightning/src/onion_message/messenger.rs | 7 ++- 4 files changed, 44 insertions(+), 23 deletions(-) diff --git a/lightning-tests/src/upgrade_downgrade_tests.rs b/lightning-tests/src/upgrade_downgrade_tests.rs index 75413ef14f6..136ae919a97 100644 --- a/lightning-tests/src/upgrade_downgrade_tests.rs +++ b/lightning-tests/src/upgrade_downgrade_tests.rs @@ -761,7 +761,9 @@ fn test_onion_message_intercepted_upgrade_from_0_2() { let deserialized = ::read(&mut reader).unwrap().unwrap(); match deserialized { - Event::OnionMessageIntercepted { next_hop, message } => { + Event::OnionMessageIntercepted { prev_hop, next_hop, message } => { + // LDK 0.2 did not write a `prev_hop`, so it must default to `None`. + assert_eq!(prev_hop, None); assert_eq!(next_hop, NextMessageHop::NodeId(pubkey)); assert_eq!(message, dummy_onion_message()); }, @@ -773,11 +775,14 @@ fn test_onion_message_intercepted_upgrade_from_0_2() { fn test_onion_message_intercepted_node_id_downgrade_to_0_2() { // Ensure that an `Event::OnionMessageIntercepted` with a `NodeId` next hop serialized by // the current version can be deserialized by LDK 0.2 (which expects `peer_node_id` in TLV - // field 0). + // field 0 and ignores the newer `prev_hop` in TLV field 3). let pubkey = PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[42; 32]).unwrap()); + let prev_hop = + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[43; 32]).unwrap()); let event = Event::OnionMessageIntercepted { + prev_hop: Some(prev_hop), next_hop: NextMessageHop::NodeId(pubkey), message: dummy_onion_message(), }; @@ -802,6 +807,7 @@ fn test_onion_message_intercepted_scid_downgrade_to_0_2() { // serialized by the current version cannot be deserialized by LDK 0.2, since the // `peer_node_id` field (0) is not written for SCID variants and LDK 0.2 requires it. let event = Event::OnionMessageIntercepted { + prev_hop: None, next_hop: NextMessageHop::ShortChannelId(42), message: dummy_onion_message(), }; diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index d6298a77f07..ec0ad6ccd9b 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -1856,6 +1856,17 @@ pub enum Event { /// /// [`OnionMessenger::new_with_offline_peer_interception`]: crate::onion_message::messenger::OnionMessenger::new_with_offline_peer_interception OnionMessageIntercepted { + /// The node id of the peer that sent the message, if known. + /// + /// This is `None` when the message is sent with + /// [`MessageSendInstructions::ForwardedMessage`] (e.g., when calling + /// [`OffersMessageFlow::enqueue_invoice_request_to_forward`]) rather than forwarded + /// internally by the `OnionMessenger`, as well as for events serialized prior to LDK 0.3. + /// Otherwise it is the node we received the message from. + /// + /// [`MessageSendInstructions::ForwardedMessage`]: crate::onion_message::messenger::MessageSendInstructions::ForwardedMessage + /// [`OffersMessageFlow::enqueue_invoice_request_to_forward`]: crate::offers::flow::OffersMessageFlow::enqueue_invoice_request_to_forward + prev_hop: Option, /// The next hop (offline peer or unknown SCID). next_hop: NextMessageHop, /// The onion message intended to be forwarded to the offline peer or via the unknown @@ -2441,25 +2452,20 @@ impl Writeable for Event { 35u8.write(writer)?; // Never write ConnectionNeeded events as buffered onion messages aren't serialized. }, - &Event::OnionMessageIntercepted { ref next_hop, ref message } => { + &Event::OnionMessageIntercepted { ref prev_hop, ref next_hop, ref message } => { 37u8.write(writer)?; - match next_hop { - NextMessageHop::NodeId(peer_node_id) => { - // If we have the node_id, we keep writing it for backwards compatibility. - write_tlv_fields!(writer, { - (0, peer_node_id, required), - (1, next_hop, required), - (2, message, required), - }); - }, - NextMessageHop::ShortChannelId(_) => { - write_tlv_fields!(writer, { - // 0 used to be peer_node_id in LDK v0.2 and prior. - (1, next_hop, required), - (2, message, required), - }); - }, - } + // 0 used to be peer_node_id in LDK v0.2 and prior; we keep writing it when the next + // hop is a node id for backwards compatibility. + let legacy_peer_node_id = match next_hop { + NextMessageHop::NodeId(node_id) => Some(node_id), + NextMessageHop::ShortChannelId(_) => None, + }; + write_tlv_fields!(writer, { + (0, legacy_peer_node_id, option), + (1, next_hop, required), + (2, message, required), + (3, prev_hop, option), + }); }, &Event::OnionMessagePeerConnected { ref peer_node_id } => { 39u8.write(writer)?; @@ -3090,12 +3096,14 @@ impl MaybeReadable for Event { (0, peer_node_id, option), (1, next_hop, option), (2, message, required), + (3, prev_hop, option), }); let next_hop = next_hop .or(peer_node_id.map(NextMessageHop::NodeId)) .ok_or(msgs::DecodeError::InvalidValue)?; Ok(Some(Event::OnionMessageIntercepted { + prev_hop, next_hop, message: message.0.unwrap(), })) diff --git a/lightning/src/onion_message/functional_tests.rs b/lightning/src/onion_message/functional_tests.rs index 3692858c8ff..4adc126f4fd 100644 --- a/lightning/src/onion_message/functional_tests.rs +++ b/lightning/src/onion_message/functional_tests.rs @@ -1178,7 +1178,8 @@ fn intercept_offline_peer_oms() { let mut events = release_events(&nodes[1]); assert_eq!(events.len(), 1); let onion_message = match events.remove(0) { - Event::OnionMessageIntercepted { next_hop, message } => { + Event::OnionMessageIntercepted { prev_hop, next_hop, message } => { + assert_eq!(prev_hop, Some(nodes[0].node_id)); if let NextMessageHop::NodeId(peer_node_id) = next_hop { assert_eq!(peer_node_id, final_node_vec[0].node_id); message @@ -1265,7 +1266,8 @@ fn intercept_unknown_scid_oms() { let mut events = release_events(&nodes[1]); assert_eq!(events.len(), 1); let onion_message = match events.remove(0) { - Event::OnionMessageIntercepted { next_hop, message } => { + Event::OnionMessageIntercepted { prev_hop, next_hop, message } => { + assert_eq!(prev_hop, Some(nodes[0].node_id)); if let NextMessageHop::ShortChannelId(intercepted_scid) = next_hop { assert_eq!(intercepted_scid, scid); message diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index 2070ff9eb50..04697d9854b 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -1556,6 +1556,7 @@ impl< let result = if is_forward { self.enqueue_forwarded_onion_message( + None, NextMessageHop::NodeId(first_node_id), onion_message, log_suffix, @@ -1671,7 +1672,8 @@ impl< } fn enqueue_forwarded_onion_message( - &self, next_hop: NextMessageHop, onion_message: OnionMessage, log_suffix: fmt::Arguments, + &self, prev_hop: Option, next_hop: NextMessageHop, onion_message: OnionMessage, + log_suffix: fmt::Arguments, ) -> Result<(), SendError> { let next_node_id = match next_hop { NextMessageHop::NodeId(pubkey) => pubkey, @@ -1686,6 +1688,7 @@ impl< log_suffix ); self.enqueue_intercepted_event(Event::OnionMessageIntercepted { + prev_hop, next_hop, message: onion_message, }); @@ -1734,6 +1737,7 @@ impl< log_suffix ); self.enqueue_intercepted_event(Event::OnionMessageIntercepted { + prev_hop, // Report the resolved node id rather than `next_hop`, which may be a // `ShortChannelId` that we resolved to a known-but-offline peer. The // `ShortChannelId` variant is reserved for the unknown-SCID interception path. @@ -2318,6 +2322,7 @@ impl< }, Ok(PeeledOnion::Forward(next_hop, onion_message)) => { let _ = self.enqueue_forwarded_onion_message( + Some(peer_node_id), next_hop, onion_message, format_args!("when forwarding peeled onion message from {}", peer_node_id), From 5c83835baf999d48d51fa1e3fd1728e3ac86b120 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Wed, 3 Jun 2026 16:59:18 +0200 Subject: [PATCH 475/627] fuzz: model chanmon mempool mining Route chanmon broadcasts through an explicit harness mempool so relay, mining, wallet updates, and chain delivery share one path. This lets broadcast transactions enter the mempool before a modeled block confirms them. On restart, sync loaded monitors and managers from their own persisted best blocks so raw monitors catch up without rewinding ChannelManager state. Cap modeled mining before unresolved HTLC timeout deadlines and use the LDK anti-reorg depth for setup confirmations. --- fuzz/src/chanmon_consistency.rs | 675 +++++++++++++++++++++++++------- 1 file changed, 531 insertions(+), 144 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 519ae515e7f..d8291571884 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -14,9 +14,10 @@ //! To test this we stand up a network of three nodes and read bytes from the fuzz input to denote //! actions such as sending payments, handling events, or changing monitor update return values on //! a per-node basis. This should allow it to find any cases where the ordering of actions results -//! in us getting out of sync with ourselves, and, assuming at least one of our recieve- or -//! send-side handling is correct, other peers. We consider it a failure if any action results in a -//! channel being force-closed. +//! in us getting out of sync with ourselves, and, assuming at least one of our receive- or +//! send-side handling is correct, other peers. We consider it a failure if any action results in +//! a channel being force-closed. The fuzzer also models transaction relay through a harness +//! mempool, making transaction confirmation and block delivery closer to normal node behavior. use bitcoin::amount::Amount; use bitcoin::constants::genesis_block; @@ -27,6 +28,7 @@ use bitcoin::script::{Builder, ScriptBuf}; use bitcoin::transaction::Version; use bitcoin::transaction::{Transaction, TxOut}; use bitcoin::FeeRate; +use bitcoin::OutPoint as BitcoinOutPoint; use bitcoin::block::Header; use bitcoin::hash_types::Txid; @@ -41,7 +43,7 @@ use lightning::chain; use lightning::chain::chaininterface::{ BroadcasterInterface, ConfirmationTarget, FeeEstimator, TransactionType, }; -use lightning::chain::channelmonitor::ChannelMonitor; +use lightning::chain::channelmonitor::{ChannelMonitor, ANTI_REORG_DELAY}; use lightning::chain::{ chainmonitor, channelmonitor, BlockLocator, ChannelMonitorUpdateStatus, Confirm, Watch, }; @@ -103,6 +105,17 @@ use std::sync::{Arc, Mutex}; const MAX_FEE: u32 = 10_000; const MAX_SETTLE_ITERATIONS: usize = 256; +// Each wallet is seeded with enough confirmed UTXOs that repeated splice +// transactions don't run out of inputs mid-run. +const NUM_WALLET_UTXOS: u32 = 50; +// A single fuzz byte can mine more than one block so a corpus entry does not +// need long runs of identical "mine one block" commands to reach CSV or CLTV +// boundaries. Mining commands are capped in `safe_mine_block_count` if +// unresolved HTLCs are near expiry. +const MINE_BLOCK_COUNTS: [u32; 8] = [1, 2, 3, 6, 12, 24, 48, 144]; +// Finish-time relay/mining rounds are capped so cleanup cannot spin forever. +const MAX_FINISH_RELAY_MINE_ROUNDS: usize = 32; + struct FuzzEstimator { ret_val: atomic::AtomicU32, } @@ -184,9 +197,14 @@ impl BroadcasterInterface for TestBroadcaster { struct ChainState { blocks: Vec<(Header, Vec)>, confirmed_txids: HashSet, - /// Unconfirmed transactions (e.g., splice txs). Conflicting RBF candidates may coexist; - /// `confirm_pending_txs` determines which one confirms. + /// Unconfirmed transactions admitted to the mempool, in valid block order: + /// every input is either confirmed already or created by an earlier + /// transaction in this vector. pending_txs: Vec<(Txid, Transaction)>, + /// Unspent outputs created by confirmed transactions. Mempool admission + /// checks inputs against this set, adjusted for outputs created and spent + /// by the transactions already in `pending_txs`. + utxos: HashSet, } impl ChainState { @@ -197,6 +215,7 @@ impl ChainState { blocks: vec![(genesis_header, Vec::new())], confirmed_txids: HashSet::new(), pending_txs: Vec::new(), + utxos: HashSet::new(), } } @@ -204,81 +223,223 @@ impl ChainState { (self.blocks.len() - 1) as u32 } - fn is_outpoint_spent(&self, outpoint: &bitcoin::OutPoint) -> bool { - self.blocks.iter().any(|(_, txs)| { - txs.iter().any(|tx| tx.input.iter().any(|input| input.previous_output == *outpoint)) + fn is_unspent(&self, outpoint: &BitcoinOutPoint) -> bool { + self.utxos.contains(outpoint) + } + + fn confirmed_output(&self, outpoint: &BitcoinOutPoint) -> Option<&TxOut> { + if !self.confirmed_txids.contains(&outpoint.txid) { + return None; + } + self.blocks.iter().find_map(|(_, txs)| { + txs.iter().find_map(|tx| { + if tx.compute_txid() == outpoint.txid { + tx.output.get(outpoint.vout as usize) + } else { + None + } + }) }) } - fn confirm_tx(&mut self, tx: Transaction) -> bool { - let txid = tx.compute_txid(); - if self.confirmed_txids.contains(&txid) { - return false; + // Initial channel funding is represented by a no-input transaction. It is + // not a valid Bitcoin transaction, but it gives LDK a stable funding + // outpoint without modeling coin selection during channel setup. + fn is_synthetic_funding_tx(tx: &Transaction) -> bool { + !tx.is_coinbase() && tx.input.is_empty() + } + + // Checks whether a transaction spends an input twice or spends an output + // not present in `utxos`. + fn has_invalid_inputs(tx: &Transaction, utxos: &HashSet) -> bool { + let mut spent_inputs = HashSet::new(); + for input in &tx.input { + if !spent_inputs.insert(input.previous_output) { + return true; + } + if !utxos.contains(&input.previous_output) { + return true; + } + } + false + } + + fn apply_tx_to_utxos(&mut self, txid: Txid, tx: &Transaction) { + for input in &tx.input { + self.utxos.remove(&input.previous_output); } - if tx.input.iter().any(|input| self.is_outpoint_spent(&input.previous_output)) { - return false; + for idx in 0..tx.output.len() { + self.utxos.insert(BitcoinOutPoint { txid, vout: idx as u32 }); } - self.confirmed_txids.insert(txid); + } + fn mine_block(&mut self, txs: Vec) { let prev_hash = self.blocks.last().unwrap().0.block_hash(); let header = create_dummy_header(prev_hash, 42); - self.blocks.push((header, vec![tx])); + self.blocks.push((header, txs)); + } - for _ in 0..5 { - let prev_hash = self.blocks.last().unwrap().0.block_hash(); - let header = create_dummy_header(prev_hash, 42); - self.blocks.push((header, Vec::new())); + fn mine_empty_blocks(&mut self, count: u32) { + for _ in 0..count { + self.mine_block(Vec::new()); } - true } - /// Add a transaction to the pending pool (mempool). Multiple conflicting transactions (RBF - /// candidates) may coexist; `confirm_pending_txs` selects which one to confirm. - fn add_pending_tx(&mut self, tx: Transaction) { - self.pending_txs.push((tx.compute_txid(), tx)); + // Mines a setup transaction directly into a block, bypassing the mempool, + // and buries it to `depth`. Wallet seeding and synthetic funding + // transactions are not relayable, so they cannot go through normal + // admission. + fn mine_setup_tx_to_depth(&mut self, tx: Transaction, depth: u32) { + assert!( + tx.is_coinbase() || Self::is_synthetic_funding_tx(&tx), + "direct setup mining is only for coinbase and synthetic funding transactions: {:?}", + tx, + ); + let txid = tx.compute_txid(); + assert!( + self.confirmed_txids.insert(txid), + "direct setup transaction was already confirmed: {:?}", + tx, + ); + self.apply_tx_to_utxos(txid, &tx); + + self.mine_block(vec![tx]); + self.mine_empty_blocks(depth.saturating_sub(1)); } - /// Confirm pending transactions in a single block, selecting deterministically among - /// conflicting RBF candidates. Sorting by txid ensures the winner is determined by fuzz input - /// content. Transactions that double-spend an already-confirmed outpoint are skipped. - fn confirm_pending_txs(&mut self) { - let mut txs = std::mem::take(&mut self.pending_txs); - txs.sort_by_key(|(txid, _)| *txid); + // Attempts to admit a broadcast transaction to the mempool, enforcing + // locktime, input, and RBF rules. Mining later confirms the whole mempool + // without further selection. + fn admit_tx_to_mempool(&mut self, tx: Transaction) { + let txid = tx.compute_txid(); + let lock_time = tx.lock_time.to_consensus_u32(); + let locktime_enabled = + tx.input.iter().any(|input| input.sequence.enables_absolute_lock_time()); - let mut confirmed = Vec::new(); - let mut spent_outpoints = Vec::new(); - for (txid, tx) in txs { - if self.confirmed_txids.contains(&txid) { - continue; - } - if tx.input.iter().any(|input| { - self.is_outpoint_spent(&input.previous_output) - || spent_outpoints.contains(&input.previous_output) - }) { - continue; + let is_ldk_commitment_obscured_locktime = + tx.input.len() == 1 && tx.input[0].sequence.0 >> 24 == 0x80 && lock_time >> 24 == 0x20; + + let immature_absolute_locktime = + locktime_enabled && tx.lock_time.is_block_height() && self.tip_height() < lock_time; + assert!( + !immature_absolute_locktime, + "broadcast immature locktime transaction into chanmon harness mempool: {:?}", + tx, + ); + + let unmodeled_time_locktime = locktime_enabled + && tx.lock_time.is_block_time() + && !is_ldk_commitment_obscured_locktime; + assert!( + !unmodeled_time_locktime, + "broadcast time-locked transaction into chanmon harness mempool: {:?}", + tx, + ); + + assert!( + !tx.is_coinbase() && !Self::is_synthetic_funding_tx(&tx), + "setup-only transaction entered chanmon harness mempool: {:?}", + tx, + ); + + if self.confirmed_txids.contains(&txid) { + return; + } + if self.pending_txs.iter().any(|(pending_txid, _)| *pending_txid == txid) { + return; + } + + // Fee-rate policy is not modeled, so among conflicting RBF candidates + // the last one relayed wins. + let mut conflicting_pending_txids = HashSet::new(); + for (pending_txid, pending_tx) in &self.pending_txs { + let signals_rbf = pending_tx.input.iter().any(|input| input.sequence.is_rbf()); + let conflicts_with_new_tx = pending_tx.input.iter().any(|pending_input| { + tx.input.iter().any(|input| input.previous_output == pending_input.previous_output) + }); + if conflicts_with_new_tx { + if !signals_rbf { + return; + } + conflicting_pending_txids.insert(*pending_txid); } - self.confirmed_txids.insert(txid); - for input in &tx.input { - spent_outpoints.push(input.previous_output); + } + if !conflicting_pending_txids.is_empty() { + let mut removed_outputs = HashSet::new(); + let mut retained_txs = Vec::new(); + for (pending_txid, pending_tx) in self.pending_txs.drain(..) { + let direct_conflict = conflicting_pending_txids.contains(&pending_txid); + let spends_removed_tx = pending_tx + .input + .iter() + .any(|input| removed_outputs.contains(&input.previous_output)); + if direct_conflict || spends_removed_tx { + for idx in 0..pending_tx.output.len() { + removed_outputs + .insert(BitcoinOutPoint { txid: pending_txid, vout: idx as u32 }); + } + } else { + retained_txs.push((pending_txid, pending_tx)); + } } - confirmed.push(tx); + self.pending_txs = retained_txs; } - if confirmed.is_empty() { + // Build the UTXO set this transaction would see if the current mempool + // confirmed. + let mut available_utxos = self.utxos.clone(); + for (pending_txid, pending_tx) in &self.pending_txs { + for input in &pending_tx.input { + available_utxos.remove(&input.previous_output); + } + for idx in 0..pending_tx.output.len() { + available_utxos.insert(BitcoinOutPoint { txid: *pending_txid, vout: idx as u32 }); + } + } + if Self::has_invalid_inputs(&tx, &available_utxos) { return; } + self.pending_txs.push((txid, tx)); + } - let prev_hash = self.blocks.last().unwrap().0.block_hash(); - let header = create_dummy_header(prev_hash, 42); - self.blocks.push((header, confirmed)); - - for _ in 0..5 { - let prev_hash = self.blocks.last().unwrap().0.block_hash(); - let header = create_dummy_header(prev_hash, 42); - self.blocks.push((header, Vec::new())); + fn relay_transactions(&mut self, txs: Vec) { + for tx in txs { + self.admit_tx_to_mempool(tx); } } + // Mines `count` blocks, confirming the current mempool in the first block. + fn mine_blocks(&mut self, count: u32) -> Vec { + assert!(count > 0, "mining zero blocks should not be requested"); + + let mempool_txs = std::mem::take(&mut self.pending_txs); + let confirmed_txs = if mempool_txs.is_empty() { + self.mine_empty_blocks(1); + Vec::new() + } else { + let mut confirmed = Vec::new(); + for (txid, tx) in mempool_txs { + assert!( + !Self::has_invalid_inputs(&tx, &self.utxos), + "mempool transaction was no longer valid at mining time: {:?}", + tx, + ); + assert!( + self.confirmed_txids.insert(txid), + "mempool transaction was already confirmed at mining time: {:?}", + tx, + ); + self.apply_tx_to_utxos(txid, &tx); + confirmed.push(tx); + } + let confirmed_txs = confirmed.clone(); + self.mine_block(confirmed); + confirmed_txs + }; + self.mine_empty_blocks(count - 1); + confirmed_txs + } + fn block_at(&self, height: u32) -> &(Header, Vec) { &self.blocks[height as usize] } @@ -817,12 +978,12 @@ struct HarnessNode<'a> { logger: Arc, broadcaster: Arc, fee_estimator: Arc, - wallet: TestWalletSource, + wallet: Arc, + wallet_sync: WalletSync, Arc>, persistence_style: ChannelMonitorUpdateStatus, deferred: bool, serialized_manager: Vec, serialized_manager_generation: u64, - height: u32, last_htlc_clear_fee: u32, } @@ -866,7 +1027,7 @@ impl<'a> HarnessNode<'a> { } fn new( - node_id: u8, wallet: TestWalletSource, fee_estimator: Arc, + node_id: u8, wallet: Arc, fee_estimator: Arc, broadcaster: Arc, persistence_style: ChannelMonitorUpdateStatus, deferred: bool, out: &Out, router: &'a FuzzRouter, chan_type: ChanType, ) -> Self { @@ -890,6 +1051,7 @@ impl<'a> HarnessNode<'a> { &persister, deferred, ); + let wallet_sync = WalletSync::new(Arc::clone(&wallet), Arc::clone(&logger)); let network = Network::Bitcoin; let best_block_timestamp = genesis_block(network).header.time; let params = ChainParameters { network, best_block: BlockLocator::from_network(network) }; @@ -917,11 +1079,11 @@ impl<'a> HarnessNode<'a> { broadcaster, fee_estimator, wallet, + wallet_sync, persistence_style, deferred, serialized_manager: Vec::new(), serialized_manager_generation: 0, - height: 0, last_htlc_clear_fee: 253, } } @@ -958,22 +1120,77 @@ impl<'a> HarnessNode<'a> { } } + fn manager_height(&self) -> u32 { + self.node.current_best_block().height + } + + // Connects a block range to ChainMonitor and ChannelManager. The start + // heights are independent because reload may pair monitors and a manager + // persisted at different chain tips. + fn connect_chain_range( + &mut self, chain_state: &ChainState, monitor_start_height: u32, manager_start_height: u32, + target_height: u32, + ) { + assert!( + target_height >= monitor_start_height, + "connect_chain_range cannot move monitor height backward ({} -> {})", + monitor_start_height, + target_height + ); + assert!( + target_height >= manager_start_height, + "connect_chain_range cannot move manager height backward ({} -> {})", + manager_start_height, + target_height + ); + let start_height = cmp::min(monitor_start_height, manager_start_height); + let mut height = start_height; + while height < target_height { + let mut next_height = height + 1; + while next_height <= target_height && chain_state.block_at(next_height).1.is_empty() { + next_height += 1; + } + if next_height > target_height { + // The rest of the range is empty. One best-block update to the + // final height is enough because LDK's Confirm API explicitly + // allows best_block_updated to skip intermediary blocks. + height = target_height; + let (header, _) = chain_state.block_at(height); + if height > monitor_start_height { + self.monitor.best_block_updated(header, height); + } + if height > manager_start_height { + self.node.best_block_updated(header, height); + } + break; + } + height = next_height; + let (header, txn) = chain_state.block_at(height); + let txdata: Vec<_> = txn.iter().enumerate().map(|(i, tx)| (i + 1, tx)).collect(); + if height > monitor_start_height { + self.monitor.transactions_confirmed(header, &txdata, height); + } + if height > manager_start_height { + self.node.transactions_confirmed(header, &txdata, height); + } + if height > monitor_start_height { + self.monitor.best_block_updated(header, height); + } + if height > manager_start_height { + self.node.best_block_updated(header, height); + } + } + } + fn sync_with_chain_state(&mut self, chain_state: &ChainState, num_blocks: Option) { let target_height = if let Some(num_blocks) = num_blocks { - std::cmp::min(self.height + num_blocks, chain_state.tip_height()) + std::cmp::min(self.manager_height() + num_blocks, chain_state.tip_height()) } else { chain_state.tip_height() }; - while self.height < target_height { - self.height += 1; - let (header, txn) = chain_state.block_at(self.height); - let txdata: Vec<_> = txn.iter().enumerate().map(|(i, tx)| (i + 1, tx)).collect(); - if !txdata.is_empty() { - self.node.transactions_confirmed(header, &txdata, self.height); - } - self.node.best_block_updated(header, self.height); - } + let start_height = self.manager_height(); + self.connect_chain_range(chain_state, start_height, start_height, target_height); } fn checkpoint_manager_persistence(&mut self) -> bool { @@ -1034,7 +1251,6 @@ impl<'a> HarnessNode<'a> { } fn splice_in(&self, counterparty_node_id: &PublicKey, channel_id: &ChannelId) { - let wallet = WalletSync::new(&self.wallet, Arc::clone(&self.logger)); match self.node.splice_channel(channel_id, counterparty_node_id) { Ok(funding_template) => { let feerate = @@ -1043,7 +1259,7 @@ impl<'a> HarnessNode<'a> { Amount::from_sat(10_000), feerate, FeeRate::MAX, - &wallet, + &self.wallet_sync, ) { let _ = self.node.funding_contributed( channel_id, @@ -2125,7 +2341,7 @@ fn make_channel( tx.clone(), ) .unwrap(); - chain_state.confirm_tx(tx); + chain_state.mine_setup_tx_to_depth(tx, ANTI_REORG_DELAY); } else { panic!("Wrong event type"); } @@ -2242,24 +2458,27 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { config_byte & 0b1000_0000 != 0, ]; - let wallet_a = TestWalletSource::new(SecretKey::from_slice(&[1; 32]).unwrap()); - let wallet_b = TestWalletSource::new(SecretKey::from_slice(&[2; 32]).unwrap()); - let wallet_c = TestWalletSource::new(SecretKey::from_slice(&[3; 32]).unwrap()); - let wallets = [&wallet_a, &wallet_b, &wallet_c]; - let coinbase_tx = bitcoin::Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: bitcoin::absolute::LockTime::ZERO, - input: vec![bitcoin::TxIn { ..Default::default() }], - output: wallets - .iter() - .map(|wallet| TxOut { - value: Amount::from_sat(100_000), - script_pubkey: wallet.get_change_script().unwrap(), - }) - .collect(), - }; - for (idx, wallet) in wallets.iter().enumerate() { - wallet.add_utxo(coinbase_tx.clone(), idx as u32); + let wallet_a = Arc::new(TestWalletSource::new(SecretKey::from_slice(&[1; 32]).unwrap())); + let wallet_b = Arc::new(TestWalletSource::new(SecretKey::from_slice(&[2; 32]).unwrap())); + let wallet_c = Arc::new(TestWalletSource::new(SecretKey::from_slice(&[3; 32]).unwrap())); + let wallets = [wallet_a.as_ref(), wallet_b.as_ref(), wallet_c.as_ref()]; + let mut chain_state = ChainState::new(); + for wallet in wallets { + let coinbase_tx = bitcoin::Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![bitcoin::TxIn { ..Default::default() }], + output: (0..NUM_WALLET_UTXOS) + .map(|_| TxOut { + value: Amount::from_sat(100_000), + script_pubkey: wallet.get_change_script().unwrap(), + }) + .collect(), + }; + for vout in 0..NUM_WALLET_UTXOS { + wallet.add_utxo(coinbase_tx.clone(), vout); + } + chain_state.mine_setup_tx_to_depth(coinbase_tx, ANTI_REORG_DELAY); } let fee_est_a = Arc::new(FuzzEstimator { ret_val: atomic::AtomicU32::new(253) }); @@ -2274,7 +2493,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { let mut nodes = [ HarnessNode::new( 0, - wallet_a, + Arc::clone(&wallet_a), Arc::clone(&fee_est_a), Arc::clone(&broadcast_a), persistence_styles[0], @@ -2285,7 +2504,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { ), HarnessNode::new( 1, - wallet_b, + Arc::clone(&wallet_b), Arc::clone(&fee_est_b), Arc::clone(&broadcast_b), persistence_styles[1], @@ -2296,7 +2515,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { ), HarnessNode::new( 2, - wallet_c, + Arc::clone(&wallet_c), Arc::clone(&fee_est_c), Arc::clone(&broadcast_c), persistence_styles[2], @@ -2306,8 +2525,6 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { chan_type, ), ]; - let mut chain_state = ChainState::new(); - // Connect peers first, then create channels. connect_peers(&nodes[0], &nodes[1]); connect_peers(&nodes[1], &nodes[2]); @@ -2376,7 +2593,34 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { self.bc_link.first_channel_id() } - fn finish(&self) { + // Runs end-of-input cleanup by relaying and mining remaining broadcasts. + // Final invariants should not depend on the input ending with explicit relay + // and mining bytes. + fn finish(&mut self) { + for _ in 0..MAX_FINISH_RELAY_MINE_ROUNDS { + let mut txs = Vec::new(); + for node in &self.nodes { + txs.extend(node.broadcaster.txn_broadcasted.borrow_mut().drain(..)); + } + self.chain_state.relay_transactions(txs); + if self.chain_state.pending_txs.is_empty() { + assert_test_invariants(&self.nodes); + return; + } + if self.mine_blocks(ANTI_REORG_DELAY) == 0 { + // The input ended with pending mempool transactions but no safe + // block left before an HTLC fail-back window. Leave them + // unconfirmed rather than forcing finish cleanup to advance + // the chain past that boundary. + assert_test_invariants(&self.nodes); + return; + } + } + assert!( + !self.nodes.iter().any(|node| !node.broadcaster.txn_broadcasted.borrow().is_empty()) + && self.chain_state.pending_txs.is_empty(), + "finish tx mining loop failed to quiesce", + ); assert_test_invariants(&self.nodes); } @@ -2775,8 +3019,8 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { fn process_events(&mut self, node_idx: usize, fail: bool) -> bool { let nodes = &self.nodes; - let chain_state = &mut self.chain_state; let payments = &mut self.payments; + let chain_state = &self.chain_state; // Multiple HTLCs can resolve for the same payment hash, so deduplicate // claim/fail handling per event batch. let mut claim_set = new_hash_map(); @@ -2814,29 +3058,53 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { unsigned_transaction, .. } => { - let signed_tx = nodes[node_idx].wallet.sign_tx(unsigned_transaction).unwrap(); - match nodes[node_idx].funding_transaction_signed( - &channel_id, - &counterparty_node_id, - signed_tx, - ) { - Ok(()) => {}, - Err(APIError::APIMisuseError { ref err }) - if err.contains("not expecting funding signatures") => + let wallet_script = nodes[node_idx].wallet.get_change_script().unwrap(); + let has_unknown_spent_input = unsigned_transaction.input.iter().any(|input| { + !chain_state.is_unspent(&input.previous_output) + && chain_state.confirmed_output(&input.previous_output).is_none() + }); + assert!( + !has_unknown_spent_input, + "funding transaction referenced an unmodeled input: {:?}", + unsigned_transaction, + ); + let has_spent_wallet_input = unsigned_transaction.input.iter().any(|input| { + !chain_state.is_unspent(&input.previous_output) + && chain_state + .confirmed_output(&input.previous_output) + .map_or(false, |output| output.script_pubkey == wallet_script) + }); + if has_spent_wallet_input { + // A queued RBF signing request can lose the race against a + // transaction confirming with one of its wallet inputs. + match nodes[node_idx] + .cancel_funding_contributed(&channel_id, &counterparty_node_id) { - // A queued signing event can be invalidated by a later `tx_abort` - // before the application handles it. - }, - Err(e) => panic!("{e:?}"), + Ok(()) => {}, + Err(APIError::APIMisuseError { ref err }) + if err.contains("does not have a pending splice negotiation") => {}, + Err(e) => panic!("{e:?}"), + } + } else { + let signed_tx = + nodes[node_idx].wallet.sign_tx(unsigned_transaction).unwrap(); + match nodes[node_idx].funding_transaction_signed( + &channel_id, + &counterparty_node_id, + signed_tx, + ) { + Ok(()) => {}, + Err(APIError::APIMisuseError { ref err }) + if err.contains("not expecting funding signatures") => + { + // A queued signing event can be invalidated by a later `tx_abort` + // before the application handles it. + }, + Err(e) => panic!("{e:?}"), + } } }, - events::Event::SpliceNegotiated { new_funding_txo, .. } => { - let mut txs = nodes[node_idx].broadcaster.txn_broadcasted.borrow_mut(); - assert!(txs.len() >= 1); - let splice_tx = txs.remove(0); - assert_eq!(new_funding_txo.txid, splice_tx.compute_txid()); - chain_state.add_pending_tx(splice_tx); - }, + events::Event::SpliceNegotiated { .. } => {}, events::Event::SpliceNegotiationFailed { .. } => {}, events::Event::DiscardFunding { funding_info: @@ -2935,6 +3203,20 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { self.bc_link.reconnect(&self.nodes); } + // Finds the earliest loaded monitor height for a node. Startup sync uses it + // as ChainMonitor's start height so raw monitors loaded below the manager's + // best block still see every block and transaction they missed. + fn oldest_monitor_height_for_node(&self, node_idx: usize) -> u32 { + let node = &self.nodes[node_idx]; + let mut min_monitor_height = node.manager_height(); + for chan_id in node.monitor.list_monitors() { + if let Ok(mon) = node.monitor.get_monitor(chan_id) { + min_monitor_height = cmp::min(min_monitor_height, mon.current_best_block().height); + } + } + min_monitor_height + } + fn restart_node(&mut self, node_idx: usize, v: u8, router: &'a FuzzRouter) { if !self.nodes[node_idx].deferred { self.nodes[node_idx].checkpoint_manager_persistence(); @@ -2954,6 +3236,21 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { } let loaded_manager_generation = self.nodes[node_idx].reload(v, &self.out, router, self.chan_type); + let monitor_start_height = self.oldest_monitor_height_for_node(node_idx); + let manager_start_height = self.nodes[node_idx].manager_height(); + // Startup sync is part of LDK's deserialization contract. + self.nodes[node_idx].connect_chain_range( + &self.chain_state, + monitor_start_height, + manager_start_height, + self.chain_state.tip_height(), + ); + assert_eq!( + self.nodes[node_idx].manager_height(), + self.chain_state.tip_height(), + "reloaded node {} must sync to the harness tip before normal operation resumes", + node_idx + ); let rolled_back_payment_hashes = self.payments.nodes[node_idx] .sync_pending_with_manager_generation(loaded_manager_generation); for payment_hash in rolled_back_payment_hashes { @@ -2962,6 +3259,11 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { } fn settle_all(&mut self) { + let chain_state = &self.chain_state; + for node in &mut self.nodes { + node.sync_with_chain_state(chain_state, None); + } + // First, make sure peers are all connected to each other self.reconnect_ab(); self.reconnect_bc(); @@ -3036,6 +3338,102 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { } made_progress } + + // Relays one node's broadcasts into the mempool. Per-node relay lets fuzz + // inputs model partial propagation before a block is mined. + fn relay_broadcasts_for_node(&mut self, node_idx: usize) { + let txs = self.nodes[node_idx] + .broadcaster + .txn_broadcasted + .borrow_mut() + .drain(..) + .collect::>(); + self.chain_state.relay_transactions(txs); + } + + fn earliest_pending_htlc_expiry(&self) -> Option { + let mut earliest_expiry: Option = None; + for node in &self.nodes { + for chan in node.list_channels() { + for htlc in &chan.pending_inbound_htlcs { + earliest_expiry = Some( + earliest_expiry + .map_or(htlc.cltv_expiry, |expiry| expiry.min(htlc.cltv_expiry)), + ); + } + for htlc in &chan.pending_outbound_htlcs { + earliest_expiry = Some( + earliest_expiry + .map_or(htlc.cltv_expiry, |expiry| expiry.min(htlc.cltv_expiry)), + ); + } + } + } + earliest_expiry + } + + fn safe_mine_block_count(&self, count: u32) -> u32 { + if let Some(expiry) = self.earliest_pending_htlc_expiry() { + let current_tip = self.chain_state.tip_height(); + // LDK may close to protect a pending HTLC before its raw CLTV + // expiry. Keep mining outside that fail-back window so fuzzed block + // production does not force an on-chain timeout path. + let timeout_deadline = expiry.saturating_sub(channelmonitor::HTLC_FAIL_BACK_BUFFER); + assert!( + current_tip < timeout_deadline, + "pending HTLC with expiry {} and timeout deadline {} is already unsafe at tip {}", + expiry, + timeout_deadline, + current_tip + ); + // Stop before the deadline block itself, since connecting it is + // enough for ChannelMonitor timeout handling to run. + count.min(timeout_deadline - current_tip - 1) + } else { + count + } + } + + // Mines blocks through ChainState, then applies confirmed transactions to + // the wallets and syncs node chain listeners. + fn mine_blocks(&mut self, count: u32) -> u32 { + assert!(count > 0, "mining zero blocks should not be requested"); + + let count = self.safe_mine_block_count(count); + if count == 0 { + return 0; + } + let confirmed_txs = self.chain_state.mine_blocks(count); + let wallets = [ + self.nodes[0].wallet.as_ref(), + self.nodes[1].wallet.as_ref(), + self.nodes[2].wallet.as_ref(), + ]; + for tx in &confirmed_txs { + for wallet in wallets.iter().copied() { + let change_script = wallet.get_change_script().unwrap(); + for input in &tx.input { + // The test wallet is a simple UTXO source. When one of its + // outputs is spent by a confirmed transaction, remove it so + // later funding attempts cannot double-spend it. + wallet.remove_utxo(input.previous_output); + } + for (vout, output) in tx.output.iter().enumerate() { + if output.script_pubkey == change_script { + // Add outputs to whichever test wallet owns the script. + // This lets splice flows recycle wallet change through + // later fuzz commands. + wallet.add_utxo(tx.clone(), vout as u32); + } + } + } + } + let chain_state = &self.chain_state; + for node in &mut self.nodes { + node.sync_with_chain_state(chain_state, None); + } + count + } } #[inline] @@ -3245,32 +3643,14 @@ pub fn do_test(data: &[u8], out: Out) { harness.nodes[2].splice_out(&cp_node_id, &harness.chan_b_id()); }, - // Sync node by 1 block to cover confirmation of a transaction. - 0xa8 => { - harness.chain_state.confirm_pending_txs(); - harness.nodes[0].sync_with_chain_state(&harness.chain_state, Some(1)); - }, - 0xa9 => { - harness.chain_state.confirm_pending_txs(); - harness.nodes[1].sync_with_chain_state(&harness.chain_state, Some(1)); - }, - 0xaa => { - harness.chain_state.confirm_pending_txs(); - harness.nodes[2].sync_with_chain_state(&harness.chain_state, Some(1)); - }, - // Sync node to chain tip to cover confirmation of a transaction post-reorg-risk. - 0xab => { - harness.chain_state.confirm_pending_txs(); - harness.nodes[0].sync_with_chain_state(&harness.chain_state, None); - }, - 0xac => { - harness.chain_state.confirm_pending_txs(); - harness.nodes[1].sync_with_chain_state(&harness.chain_state, None); - }, - 0xad => { - harness.chain_state.confirm_pending_txs(); - harness.nodes[2].sync_with_chain_state(&harness.chain_state, None); - }, + // Sync node by 1 block. + 0xa8 => harness.nodes[0].sync_with_chain_state(&harness.chain_state, Some(1)), + 0xa9 => harness.nodes[1].sync_with_chain_state(&harness.chain_state, Some(1)), + 0xaa => harness.nodes[2].sync_with_chain_state(&harness.chain_state, Some(1)), + // Sync node to chain tip. + 0xab => harness.nodes[0].sync_with_chain_state(&harness.chain_state, None), + 0xac => harness.nodes[1].sync_with_chain_state(&harness.chain_state, None), + 0xad => harness.nodes[2].sync_with_chain_state(&harness.chain_state, None), 0xb0 | 0xb1 | 0xb2 => { // Restart node A, picking among persisted and in-flight `ChannelMonitor` @@ -3395,6 +3775,13 @@ pub fn do_test(data: &[u8], out: Out) { .enable_op_for_all_signers(SignerOp::SignSpliceSharedInput); harness.nodes[2].signer_unblocked(None); }, + 0xd6 => harness.relay_broadcasts_for_node(0), + 0xd7 => harness.relay_broadcasts_for_node(1), + 0xd8 => harness.relay_broadcasts_for_node(2), + 0xd9..=0xe0 => { + let count = MINE_BLOCK_COUNTS[(v - 0xd9) as usize]; + harness.mine_blocks(count); + }, 0xf0 => harness.ab_link.complete_monitor_updates_for_node( 0, From ae62fa377a0a139d937ce2c7d87d3eb1612732af Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 15 Jun 2026 14:05:20 +0000 Subject: [PATCH 476/627] Remove unnecessary (and incorrect) `&mut` cast in net-tokio The owned `Waker` wake method assumed it had the only reference to the sender as the `Waker` is owned at that point, however our `Waker`s can be `clone`d, leaving multiple references to the inner `Sender` (held in an `Arc`). Thus, the `&mut` cast is technically undefined behavior. However, as this patch demonstrates, its only use is in calling an `&self` method which derefs an internal `Arc` in tokio, so its highly unlikely to lead to miscompilation. Reported by Project Loupe. --- lightning-net-tokio/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning-net-tokio/src/lib.rs b/lightning-net-tokio/src/lib.rs index 2e8e568f97e..953fed6f375 100644 --- a/lightning-net-tokio/src/lib.rs +++ b/lightning-net-tokio/src/lib.rs @@ -663,7 +663,7 @@ fn clone_socket_waker(orig_ptr: *const ()) -> task::RawWaker { // sending thread may have already gone away due to a socket close, in which case there's nothing // to wake up anyway. fn wake_socket_waker(orig_ptr: *const ()) { - let sender = unsafe { &mut *(orig_ptr as *mut mpsc::Sender<()>) }; + let sender = unsafe { &*(orig_ptr as *mut mpsc::Sender<()>) }; let _ = sender.try_send(()); drop_socket_waker(orig_ptr); } From b910f8ebb6a4e1ccdaf56a531ff875e99b653467 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 11 Jun 2026 16:51:41 -0500 Subject: [PATCH 477/627] Remove nonce from outbound payment OffersContexts Now that the payer nonce is included in the payer metadata of InvoiceRequest and Refund, Bolt12Invoice verification no longer needs the nonce from the blinded path's OffersContext. Remove it from OffersContext::OutboundPaymentForOffer and OffersContext::OutboundPaymentForRefund, along with enqueue_invoice_request's nonce parameter, which only existed to supply it. The nonce in RetryableInvoiceRequest is no longer used either but is still persisted -- and retained when reading state written by prior versions -- so that such versions can retry the payment and verify the resulting invoice after a downgrade. The payment_id is kept in both variants, however. While no longer needed to confirm the invoice is for an invoice request or refund we created, it is checked against the payment id recovered from a received Bolt12Invoice's payer metadata to ensure the invoice arrived over the blinded path created for that payment. This prevents an attacker from reusing the blinded path of one of our payments to deliver another payment's invoice and correlate the two as ours. Co-Authored-By: Claude --- lightning/src/blinded_path/message.rs | 34 ++++++++++++++------------- lightning/src/ln/channelmanager.rs | 8 +++---- lightning/src/ln/outbound_payment.rs | 8 +++++-- lightning/src/offers/flow.rs | 18 ++++---------- 4 files changed, 33 insertions(+), 35 deletions(-) diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs index 417c66374a9..85cb76b9e72 100644 --- a/lightning/src/blinded_path/message.rs +++ b/lightning/src/blinded_path/message.rs @@ -466,15 +466,17 @@ pub enum OffersContext { OutboundPaymentForRefund { /// Payment ID used when creating a [`Refund`]. /// - /// [`Refund`]: crate::offers::refund::Refund - payment_id: PaymentId, - - /// A nonce used for authenticating that a [`Bolt12Invoice`] is for a valid [`Refund`] and - /// for deriving its signing keys. + /// When a [`Bolt12Invoice`] is received, the payment id recovered from its payer metadata + /// must equal this one, confirming the invoice arrived over the blinded path included in the + /// refund for this payment. Without that check, an attacker holding that path could deliver + /// a different payment's invoice over it, and our paying it would reveal that both payments + /// came from us. That the invoice is for a refund we created is verified by + /// [`Bolt12Invoice::verify_using_metadata`] using its payer metadata. /// - /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice /// [`Refund`]: crate::offers::refund::Refund - nonce: Nonce, + /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice + /// [`Bolt12Invoice::verify_using_metadata`]: crate::offers::invoice::Bolt12Invoice::verify_using_metadata + payment_id: PaymentId, }, /// Context used by a [`BlindedMessagePath`] as a reply path for an [`InvoiceRequest`]. /// @@ -487,15 +489,17 @@ pub enum OffersContext { OutboundPaymentForOffer { /// Payment ID used when creating an [`InvoiceRequest`]. /// - /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest - payment_id: PaymentId, - - /// A nonce used for authenticating that a [`Bolt12Invoice`] is for a valid - /// [`InvoiceRequest`] and for deriving its signing keys. + /// When a [`Bolt12Invoice`] is received, the payment id recovered from its payer metadata + /// must equal this one, confirming the invoice arrived over the reply path created for this + /// payment. Without that check, an attacker holding this reply path could deliver a + /// different payment's invoice over it, and our paying it would reveal that both payments + /// came from us. That the invoice is for an invoice request we created is verified by + /// [`Bolt12Invoice::verify_using_metadata`] using its payer metadata. /// - /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest - nonce: Nonce, + /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice + /// [`Bolt12Invoice::verify_using_metadata`]: crate::offers::invoice::Bolt12Invoice::verify_using_metadata + payment_id: PaymentId, }, /// Context used by a [`BlindedMessagePath`] as a reply path for a [`Bolt12Invoice`]. /// @@ -678,7 +682,6 @@ impl_ser_tlv_based_enum!(OffersContext, }, (1, OutboundPaymentForRefund) => { (0, payment_id, required), - (1, nonce, required), }, (2, InboundPayment) => { (0, payment_hash, required), @@ -690,7 +693,6 @@ impl_ser_tlv_based_enum!(OffersContext, }, (4, OutboundPaymentForOffer) => { (0, payment_id, required), - (1, nonce, required), }, ); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 6398613a762..ff4c0f87411 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -15075,13 +15075,13 @@ impl< let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); self.flow.enqueue_invoice_request( - invoice_request.clone(), payment_id, nonce, + invoice_request.clone(), payment_id, self.get_peers_for_blinded_path() )?; let retryable_invoice_request = RetryableInvoiceRequest { invoice_request: invoice_request.clone(), - nonce, + nonce: Some(nonce), needs_retry: true, }; @@ -17231,11 +17231,11 @@ impl< for (payment_id, retryable_invoice_request) in self.pending_outbound_payments.release_invoice_requests_awaiting_invoice() { - let RetryableInvoiceRequest { invoice_request, nonce, .. } = retryable_invoice_request; + let RetryableInvoiceRequest { invoice_request, .. } = retryable_invoice_request; let peers = self.get_peers_for_blinded_path(); let enqueue_invreq_res = - self.flow.enqueue_invoice_request(invoice_request, payment_id, nonce, peers); + self.flow.enqueue_invoice_request(invoice_request, payment_id, peers); if enqueue_invreq_res.is_err() { log_warn!( self.logger, diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 04e80038cc9..22fdc4722d2 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -173,14 +173,18 @@ pub(crate) enum PendingOutboundPayment { #[derive(Clone)] pub(crate) struct RetryableInvoiceRequest { pub(crate) invoice_request: InvoiceRequest, - pub(crate) nonce: Nonce, + // No longer used, but written so that the payment can be retried after downgrading to a + // version that verifies invoices using the nonce instead of the payer metadata. Set when + // creating an invoice request and otherwise retains the value read from disk, which may have + // been written by such a version. + pub(crate) nonce: Option, pub(super) needs_retry: bool, } impl_ser_tlv_based!(RetryableInvoiceRequest, { (0, invoice_request, required), (1, needs_retry, (default_value, true)), - (2, nonce, required), + (2, nonce, option), }); impl PendingOutboundPayment { diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 7362a2974ea..ade684e5be1 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -503,7 +503,7 @@ impl OffersMessageFlow { None if invoice.is_for_refund_without_paths() => { invoice.verify_using_metadata(expanded_key, secp_ctx) }, - Some(&OffersContext::OutboundPaymentForOffer { payment_id, .. }) => { + Some(&OffersContext::OutboundPaymentForOffer { payment_id }) => { if invoice.is_for_offer() { invoice.verify_using_metadata(expanded_key, secp_ctx).and_then(|extracted| { (extracted == payment_id).then(|| payment_id).ok_or(()) @@ -512,7 +512,7 @@ impl OffersMessageFlow { Err(()) } }, - Some(&OffersContext::OutboundPaymentForRefund { payment_id, .. }) => { + Some(&OffersContext::OutboundPaymentForRefund { payment_id }) => { if invoice.is_for_refund() { invoice.verify_using_metadata(expanded_key, secp_ctx).and_then(|extracted| { (extracted == payment_id).then(|| payment_id).ok_or(()) @@ -693,7 +693,7 @@ impl OffersMessageFlow { let nonce = Nonce::from_entropy_source(entropy); let context = - MessageContext::Offers(OffersContext::OutboundPaymentForRefund { payment_id, nonce }); + MessageContext::Offers(OffersContext::OutboundPaymentForRefund { payment_id }); // Create the base builder with common properties let mut builder = RefundBuilder::deriving_signing_pubkey( @@ -1089,13 +1089,6 @@ impl OffersMessageFlow { /// over those blinded paths, which can be verified against the intended outbound payment, /// ensuring the invoice corresponds to a payment we actually want to make. /// - /// # Nonce - /// The nonce is used to create a unique [`MessageContext`] for the reply paths. - /// These will be used to verify the corresponding [`Bolt12Invoice`] when it is received. - /// - /// Note: The provided [`Nonce`] MUST be the same as the [`Nonce`] used for creating the - /// [`InvoiceRequest`] to ensure correct verification of the corresponding [`Bolt12Invoice`]. - /// /// See [`OffersMessageFlow::create_invoice_request_builder`] for more details. /// /// # Peers @@ -1107,11 +1100,10 @@ impl OffersMessageFlow { /// [`InvoiceError`]: crate::offers::invoice_error::InvoiceError /// [`supports_onion_messages`]: crate::types::features::Features::supports_onion_messages pub fn enqueue_invoice_request( - &self, invoice_request: InvoiceRequest, payment_id: PaymentId, nonce: Nonce, + &self, invoice_request: InvoiceRequest, payment_id: PaymentId, peers: Vec, ) -> Result<(), Bolt12SemanticError> { - let context = - MessageContext::Offers(OffersContext::OutboundPaymentForOffer { payment_id, nonce }); + let context = MessageContext::Offers(OffersContext::OutboundPaymentForOffer { payment_id }); let reply_paths = self .create_blinded_paths(peers, context) .map_err(|_| Bolt12SemanticError::MissingPaths)?; From a1ad1a303c95c1f4d9091c032a6975566dab6329 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 15 Jun 2026 14:43:14 +0000 Subject: [PATCH 478/627] Fix `Borrow`/`Hash` inconsistency on `Payment*` types `Borrow`'d values are required to `Hash` identically to the original object so that a `Borrow`ed key can be used in place of an owned one in a `HashMap` lookup. We'd violated this on our `Payment*` types, which we fix here. Note that changing the `Hash` implementation is generally not considered an API-breaking change and this seems like a useful fix. Reported by Project Loupe. --- lightning-types/src/payment.rs | 32 +++++++++++++++++++++++++----- lightning/src/ln/channelmanager.rs | 31 ++++++++++++++++++++++------- lightning/src/ln/types.rs | 15 +++++++++++--- 3 files changed, 63 insertions(+), 15 deletions(-) diff --git a/lightning-types/src/payment.rs b/lightning-types/src/payment.rs index 0f0fcf7b516..efdab8bbd44 100644 --- a/lightning-types/src/payment.rs +++ b/lightning-types/src/payment.rs @@ -10,15 +10,16 @@ //! Types which describe payments in lightning. use core::borrow::Borrow; +use core::hash::{Hash, Hasher}; -use bitcoin::hashes::{sha256::Hash as Sha256, Hash as _}; +use bitcoin::hashes::{sha256::Hash as Sha256, Hash as CryptoHash}; use bitcoin::hex::display::impl_fmt_traits; /// The payment hash is the hash of the [`PaymentPreimage`] which is the value used to lock funds /// in HTLCs while they transit the lightning network. /// /// This is not exported to bindings users as we just use [u8; 32] directly -#[derive(Hash, Copy, Clone, PartialEq, Eq, Ord, PartialOrd)] +#[derive(Copy, Clone, PartialEq, Eq, Ord, PartialOrd)] pub struct PaymentHash(pub [u8; 32]); impl Borrow<[u8]> for PaymentHash { @@ -27,6 +28,13 @@ impl Borrow<[u8]> for PaymentHash { } } +impl Hash for PaymentHash { + fn hash(&self, state: &mut H) { + let slice: &[u8] = self.borrow(); + Hash::hash(slice, state); + } +} + impl_fmt_traits! { impl fmt_traits for PaymentHash { const LENGTH: usize = 32; @@ -37,7 +45,7 @@ impl_fmt_traits! { /// or in a lightning channel. /// /// This is not exported to bindings users as we just use [u8; 32] directly -#[derive(Hash, Copy, Clone, PartialEq, Eq, Ord, PartialOrd)] +#[derive(Copy, Clone, PartialEq, Eq, Ord, PartialOrd)] pub struct PaymentPreimage(pub [u8; 32]); impl Borrow<[u8]> for PaymentPreimage { @@ -46,6 +54,13 @@ impl Borrow<[u8]> for PaymentPreimage { } } +impl Hash for PaymentPreimage { + fn hash(&self, state: &mut H) { + let slice: &[u8] = self.borrow(); + Hash::hash(slice, state); + } +} + impl_fmt_traits! { impl fmt_traits for PaymentPreimage { const LENGTH: usize = 32; @@ -55,7 +70,7 @@ impl_fmt_traits! { /// Converts a `PaymentPreimage` into a `PaymentHash` by hashing the preimage with SHA256. impl From for PaymentHash { fn from(value: PaymentPreimage) -> Self { - PaymentHash(Sha256::hash(&value.0).to_byte_array()) + PaymentHash(::hash(&value.0).to_byte_array()) } } @@ -63,7 +78,7 @@ impl From for PaymentHash { /// multi-part HTLCs together into a single payment. /// /// This is not exported to bindings users as we just use [u8; 32] directly -#[derive(Hash, Copy, Clone, PartialEq, Eq, Ord, PartialOrd)] +#[derive(Copy, Clone, PartialEq, Eq, Ord, PartialOrd)] pub struct PaymentSecret(pub [u8; 32]); impl Borrow<[u8]> for PaymentSecret { @@ -72,6 +87,13 @@ impl Borrow<[u8]> for PaymentSecret { } } +impl Hash for PaymentSecret { + fn hash(&self, state: &mut H) { + let slice: &[u8] = self.borrow(); + Hash::hash(slice, state); + } +} + impl_fmt_traits! { impl fmt_traits for PaymentSecret { const LENGTH: usize = 32; diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 6398613a762..eac3aefd6e9 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -26,7 +26,7 @@ use bitcoin::transaction::Transaction; use bitcoin::hash_types::{BlockHash, Txid}; use bitcoin::hashes::hmac::Hmac; use bitcoin::hashes::sha256::Hash as Sha256; -use bitcoin::hashes::{Hash, HashEngine, HmacEngine}; +use bitcoin::hashes::{Hash as CryptoHash, HashEngine, HmacEngine}; use bitcoin::secp256k1::Secp256k1; use bitcoin::secp256k1::{PublicKey, SecretKey}; @@ -174,6 +174,7 @@ use crate::ln::script::ShutdownScript; use core::borrow::Borrow; use core::cell::RefCell; use core::convert::Infallible; +use core::hash::{Hash, Hasher}; use core::ops::Deref; use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use core::time::Duration; @@ -619,7 +620,7 @@ impl Ord for ClaimableHTLC { /// a payment and ensure idempotency in LDK. /// /// This is not exported to bindings users as we just use [u8; 32] directly -#[derive(Hash, Copy, Clone, PartialEq, Eq)] +#[derive(Copy, Clone, PartialEq, Eq)] pub struct PaymentId(pub [u8; Self::LENGTH]); impl PaymentId { @@ -651,6 +652,13 @@ impl Borrow<[u8]> for PaymentId { } } +impl Hash for PaymentId { + fn hash(&self, state: &mut H) { + let slice: &[u8] = self.borrow(); + Hash::hash(slice, state); + } +} + impl_fmt_traits! { impl fmt_traits for PaymentId { const LENGTH: usize = 32; @@ -673,7 +681,7 @@ impl Readable for PaymentId { /// An identifier used to uniquely identify an intercepted HTLC to LDK. /// /// This is not exported to bindings users as we just use [u8; 32] directly -#[derive(Hash, Copy, Clone, PartialEq, Eq)] +#[derive(Copy, Clone, PartialEq, Eq)] pub struct InterceptId(pub [u8; 32]); impl InterceptId { @@ -693,6 +701,14 @@ impl Borrow<[u8]> for InterceptId { &self.0[..] } } + +impl Hash for InterceptId { + fn hash(&self, state: &mut H) { + let slice: &[u8] = self.borrow(); + Hash::hash(slice, state); + } +} + impl_fmt_traits! { impl fmt_traits for InterceptId { const LENGTH: usize = 32; @@ -941,7 +957,7 @@ pub use self::fuzzy_channelmanager::*; pub(crate) use self::fuzzy_channelmanager::*; #[allow(clippy::derive_hash_xor_eq)] // Our Hash is faithful to the data, we just don't have SecretKey::hash -impl core::hash::Hash for HTLCSource { +impl Hash for HTLCSource { fn hash(&self, hasher: &mut H) { match self { HTLCSource::PreviousHopData(prev_hop_data) => { @@ -7915,7 +7931,8 @@ impl< Ok(res) => res, Err(onion_utils::OnionDecodeErr::Malformed { err_msg, reason }) => { let sha256_of_onion = - Sha256::hash(&onion_packet.hop_data).to_byte_array(); + ::hash(&onion_packet.hop_data) + .to_byte_array(); // In this scenario, the phantom would have sent us an // `update_fail_malformed_htlc`, meaning here we encrypt the error as // if it came from us (the second-to-last hop) but contains the sha256 @@ -9485,7 +9502,7 @@ impl< } fn claim_payment_internal(&self, payment_preimage: PaymentPreimage, custom_tlvs_known: bool) { - let payment_hash = PaymentHash(Sha256::hash(&payment_preimage.0).to_byte_array()); + let payment_hash: PaymentHash = payment_preimage.into(); let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); @@ -10098,7 +10115,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let derived_key; let session_priv = if path.has_trampoline_hops() { let session_priv_hash = - Sha256::hash(&session_priv.secret_bytes()).to_byte_array(); + ::hash(&session_priv.secret_bytes()).to_byte_array(); derived_key = SecretKey::from_slice(&session_priv_hash[..]).unwrap(); &derived_key } else { diff --git a/lightning/src/ln/types.rs b/lightning/src/ln/types.rs index fd8ccbae382..62ce89bb8d5 100644 --- a/lightning/src/ln/types.rs +++ b/lightning/src/ln/types.rs @@ -20,10 +20,11 @@ use crate::util::ser::{Readable, Writeable, Writer}; #[allow(unused_imports)] use crate::prelude::*; -use bitcoin::hashes::{sha256::Hash as Sha256, Hash as _, HashEngine as _}; +use bitcoin::hashes::{sha256::Hash as Sha256, Hash as CryptoHash, HashEngine as _}; use bitcoin::hex::display::impl_fmt_traits; use core::borrow::Borrow; +use core::hash::{Hash, Hasher}; /// A unique 32-byte identifier for a channel. /// Depending on how the ID is generated, several varieties are distinguished @@ -33,7 +34,7 @@ use core::borrow::Borrow; /// A _temporary_ ID is generated randomly. /// (Later revocation-point-based _v2_ is a possibility.) /// The variety (context) is not stored, it is relevant only at creation. -#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)] pub struct ChannelId(pub [u8; 32]); impl ChannelId { @@ -93,7 +94,8 @@ impl ChannelId { our_revocation_basepoint: &RevocationBasepoint, ) -> Self { let our_revocation_point_bytes = our_revocation_basepoint.0.serialize(); - Self(Sha256::hash(&[[0u8; 33], our_revocation_point_bytes].concat()).to_byte_array()) + let hash_input = &[[0u8; 33], our_revocation_point_bytes].concat(); + Self(::hash(hash_input).to_byte_array()) } /// Indicates whether this is a V2 channel ID for the given local and remote revocation basepoints. @@ -123,6 +125,13 @@ impl Borrow<[u8]> for ChannelId { } } +impl Hash for ChannelId { + fn hash(&self, state: &mut H) { + let slice: &[u8] = self.borrow(); + Hash::hash(slice, state); + } +} + impl_fmt_traits! { impl fmt_traits for ChannelId { const LENGTH: usize = 32; From b48dfa0862c1d90db40c1e1f083b480ad5abe31d Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 15 Jun 2026 15:17:25 +0000 Subject: [PATCH 479/627] (Actually) remove incorrect `*mut` cast in net-tokio In ae62fa377a0a139d937ce2c7d87d3eb1612732af we removed an incorrect `&mut`, but failed to actually resolve the mut aliasing bug - there remained a deref of a `*mut` which is similarly invalid. Here we actually fix the bug and also DRY up code marginally. Reported by Project Loupe. --- lightning-net-tokio/src/lib.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lightning-net-tokio/src/lib.rs b/lightning-net-tokio/src/lib.rs index 953fed6f375..1d853ceb2d1 100644 --- a/lightning-net-tokio/src/lib.rs +++ b/lightning-net-tokio/src/lib.rs @@ -663,8 +663,7 @@ fn clone_socket_waker(orig_ptr: *const ()) -> task::RawWaker { // sending thread may have already gone away due to a socket close, in which case there's nothing // to wake up anyway. fn wake_socket_waker(orig_ptr: *const ()) { - let sender = unsafe { &*(orig_ptr as *mut mpsc::Sender<()>) }; - let _ = sender.try_send(()); + wake_socket_waker_by_ref(orig_ptr); drop_socket_waker(orig_ptr); } fn wake_socket_waker_by_ref(orig_ptr: *const ()) { From 27223fdda7039a01721f7289d218d70a52aabe31 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Tue, 2 Jun 2026 11:48:32 -0700 Subject: [PATCH 480/627] Clear duplicate monitor-pending RAA on signer resend The `chanmon_consistency` fuzz target found a reconnect ordering where `signer_pending_revoke_and_ack` and `monitor_pending_revoke_and_ack` could both describe the same owed `revoke_and_ack`. The channel first received a `commitment_signed` whose monitor update completed, but the signer could not provide the next point or secret, leaving `signer_pending_revoke_and_ack` set. Later, receiving the peer `revoke_and_ack` freed holding-cell HTLCs and produced a held monitor update. While that monitor update was still blocked, `channel_reestablish` saw the peer one state behind and recorded `monitor_pending_revoke_and_ack`, plus the corresponding monitor-pending `commitment_signed`, so the messages could be replayed once monitor updating was restored. If the signer unblocked before the held monitor update was released, `signer_maybe_unblocked` generated and sent the already monitor-safe RAA using `signer_pending_revoke_and_ack`. The monitor-pending flag was not cleared at that point, so `monitor_updating_restored` later generated the same RAA again when the held update completed. The peer had already advanced after accepting the signer-unblocked RAA, so it rejected the duplicate secret as not corresponding to its current pubkey and force-closed. Fix this by clearing `monitor_pending_revoke_and_ack` in the signer-resume path only once a signer-pending RAA is actually being returned. --- lightning/src/ln/async_signer_tests.rs | 207 ++++++++++++++++++++++++- lightning/src/ln/channel.rs | 7 + 2 files changed, 213 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/async_signer_tests.rs b/lightning/src/ln/async_signer_tests.rs index 8edff2094c6..f36c19748f0 100644 --- a/lightning/src/ln/async_signer_tests.rs +++ b/lightning/src/ln/async_signer_tests.rs @@ -18,7 +18,7 @@ use bitcoin::{Amount, TxOut}; use crate::chain::channelmonitor::LATENCY_GRACE_PERIOD_BLOCKS; use crate::chain::ChannelMonitorUpdateStatus; -use crate::events::{ClosureReason, Event}; +use crate::events::{ClosureReason, Event, HTLCHandlingFailureType}; use crate::ln::chan_utils::ClosingTransaction; use crate::ln::channel::DISCONNECT_PEER_AWAITING_RESPONSE_TICKS; use crate::ln::channel_state::{ChannelDetails, ChannelShutdownState}; @@ -498,6 +498,211 @@ fn test_async_raa_peer_disconnect() { do_test_async_raa_peer_disconnect(UnblockSignerAcrossDisconnectCase::BeforeReestablish, false); } +#[test] +fn test_signer_unblocked_clears_monitor_pending_raa_after_reestablish() { + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]); + let mut nodes = create_network(3, &node_cfgs, &node_chanmgrs); + + let node_a_id = nodes[0].node.get_our_node_id(); + let node_b_id = nodes[1].node.get_our_node_id(); + let node_c_id = nodes[2].node.get_our_node_id(); + + create_announced_chan_between_nodes(&nodes, 0, 1); + let chan_bc = create_announced_chan_between_nodes(&nodes, 1, 2); + + // Rebalance so that node C can send a payment back through node B later in the test. + send_payment(&nodes[0], &[&nodes[1], &nodes[2]], 5_000_000); + + // Put the B-C channel into AwaitingRAA by having C fail a payment backwards and retaining C's + // final RAA instead of delivering it to B immediately. + let (_, payment_hash_1, ..) = route_payment(&nodes[0], &[&nodes[1], &nodes[2]], 1_000_000); + nodes[2].node.fail_htlc_backwards(&payment_hash_1); + expect_and_process_pending_htlcs_and_htlc_handling_failed( + &nodes[2], + &[HTLCHandlingFailureType::Receive { payment_hash: payment_hash_1 }], + ); + check_added_monitors(&nodes[2], 1); + + let updates = get_htlc_update_msgs(&nodes[2], &node_b_id); + assert!(updates.update_add_htlcs.is_empty()); + assert_eq!(updates.update_fail_htlcs.len(), 1); + assert!(updates.update_fail_malformed_htlcs.is_empty()); + assert!(updates.update_fee.is_none()); + nodes[1].node.handle_update_fail_htlc(node_c_id, &updates.update_fail_htlcs[0]); + + let pending_c_raa = + commitment_signed_dance_return_raa(&nodes[1], &nodes[2], &updates.commitment_signed, false); + check_added_monitors(&nodes[0], 0); + + // While B is waiting for C's RAA, forward another A-to-C payment. B accepts it on the A-B + // channel, but cannot forward it over B-C yet, so it is held in B's holding cell. + let (route, payment_hash_2, _payment_preimage_2, payment_secret_2) = + get_route_and_payment_hash!(nodes[0], nodes[2], 1_000_000); + let onion_2 = RecipientOnionFields::secret_only(payment_secret_2, 1_000_000); + let id_2 = PaymentId(payment_hash_2.0); + nodes[0].node.send_payment_with_route(route, payment_hash_2, onion_2, id_2).unwrap(); + check_added_monitors(&nodes[0], 1); + + let send_event = SendEvent::from_node(&nodes[0]); + assert_eq!(send_event.node_id, node_b_id); + assert_eq!(send_event.msgs.len(), 1); + nodes[1].node.handle_update_add_htlc(node_a_id, &send_event.msgs[0]); + do_commitment_signed_dance(&nodes[1], &nodes[0], &send_event.commitment_msg, false, false); + + expect_and_process_pending_htlcs(&nodes[1], false); + check_added_monitors(&nodes[1], 0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + + // Now make B owe C an RAA whose monitor update has already completed, but whose RAA cannot be + // constructed because B's signer is unavailable. + let (route, payment_hash_3, _payment_preimage_3, payment_secret_3) = + get_route_and_payment_hash!(nodes[2], nodes[0], 1_000_000); + let onion_3 = RecipientOnionFields::secret_only(payment_secret_3, 1_000_000); + let id_3 = PaymentId(payment_hash_3.0); + nodes[2].node.send_payment_with_route(route, payment_hash_3, onion_3, id_3).unwrap(); + check_added_monitors(&nodes[2], 1); + + let send_event = SendEvent::from_node(&nodes[2]); + assert_eq!(send_event.node_id, node_b_id); + assert_eq!(send_event.msgs.len(), 1); + nodes[1].node.handle_update_add_htlc(node_c_id, &send_event.msgs[0]); + nodes[1].disable_channel_signer_op(&node_c_id, &chan_bc.2, SignerOp::ReleaseCommitmentSecret); + nodes[1].node.handle_commitment_signed_batch_test(node_c_id, &send_event.commitment_msg); + check_added_monitors(&nodes[1], 1); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + + // Deliver C's earlier RAA to B while monitor updating is blocked. This frees B's holding-cell + // HTLC and leaves a monitor update in flight. + chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress); + nodes[1].node.handle_revoke_and_ack(node_c_id, &pending_c_raa); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); + check_added_monitors(&nodes[1], 1); + + nodes[1].node.peer_disconnected(node_c_id); + nodes[2].node.peer_disconnected(node_b_id); + + let init_msg = msgs::Init { + features: nodes[2].node.init_features(), + networks: None, + remote_network_address: None, + }; + nodes[1].node.peer_connected(node_c_id, &init_msg, true).unwrap(); + let bs_reestablish = get_chan_reestablish_msgs!(nodes[1], nodes[2]); + assert_eq!(bs_reestablish.len(), 1); + let init_msg = msgs::Init { + features: nodes[1].node.init_features(), + networks: None, + remote_network_address: None, + }; + nodes[2].node.peer_connected(node_b_id, &init_msg, false).unwrap(); + let cs_reestablish = get_chan_reestablish_msgs!(nodes[2], nodes[1]); + assert_eq!(cs_reestablish.len(), 1); + + nodes[1].node.handle_channel_reestablish(node_c_id, &cs_reestablish[0]); + + // The signer-pending path now generates the owed RAA before the held monitor update + // completes. + nodes[1].enable_channel_signer_op(&node_c_id, &chan_bc.2, SignerOp::ReleaseCommitmentSecret); + nodes[1].node.signer_unblocked(Some((node_c_id, chan_bc.2))); + let (_, signer_revoke_and_ack, signer_commitment_update, _, _, _, _, _) = + handle_chan_reestablish_msgs!(nodes[1], nodes[2]); + assert!(signer_revoke_and_ack.is_some()); + + // Once the held monitor update completes, B must not generate the same RAA a second time via + // the monitor-pending path. + chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); + let (latest_update, _) = nodes[1].chain_monitor.get_latest_mon_update_id(chan_bc.2); + nodes[1].chain_monitor.chain_monitor.force_channel_monitor_updated(chan_bc.2, latest_update); + check_added_monitors(&nodes[1], 0); + let (_, duplicate_revoke_and_ack, monitor_commitment_update, _, _, _, _, _) = + handle_chan_reestablish_msgs!(nodes[1], nodes[2]); + assert!(duplicate_revoke_and_ack.is_none()); + + nodes[2].node.handle_channel_reestablish(node_b_id, &bs_reestablish[0]); + let (_, c_revoke_and_ack, c_commitment_update, _, _, _, _, _) = + handle_chan_reestablish_msgs!(nodes[2], nodes[1]); + assert!(c_revoke_and_ack.is_none()); + assert!(c_commitment_update.is_none()); + + nodes[2].node.handle_revoke_and_ack(node_b_id, &signer_revoke_and_ack.unwrap()); + check_added_monitors(&nodes[2], 1); + + let commitment_update = signer_commitment_update.or(monitor_commitment_update); + if let Some(commitment_update) = commitment_update { + let send_event = SendEvent::from_commitment_update(node_c_id, chan_bc.2, commitment_update); + assert_eq!(send_event.node_id, node_c_id); + for update_add in send_event.msgs { + nodes[2].node.handle_update_add_htlc(node_b_id, &update_add); + } + nodes[2].node.handle_commitment_signed_batch_test(node_b_id, &send_event.commitment_msg); + check_added_monitors(&nodes[2], 1); + let (c_raa, c_commitment_signed) = get_revoke_commit_msgs(&nodes[2], &node_b_id); + nodes[1].node.handle_revoke_and_ack(node_c_id, &c_raa); + check_added_monitors(&nodes[1], 1); + nodes[1].node.handle_commitment_signed_batch_test(node_c_id, &c_commitment_signed); + check_added_monitors(&nodes[1], 1); + let b_raa = get_event_msg!(nodes[1], MessageSendEvent::SendRevokeAndACK, node_c_id); + nodes[2].node.handle_revoke_and_ack(node_b_id, &b_raa); + check_added_monitors(&nodes[2], 1); + } + + let (route, final_payment_hash, _final_payment_preimage, final_payment_secret) = + get_route_and_payment_hash!(nodes[1], nodes[2], 100_000); + let final_payment_id = PaymentId(final_payment_hash.0); + nodes[1] + .node + .send_payment_with_route( + route, + final_payment_hash, + RecipientOnionFields::secret_only(final_payment_secret, 100_000), + final_payment_id, + ) + .unwrap(); + check_added_monitors(&nodes[1], 1); + let final_payment_event = nodes[1].node.get_and_clear_pending_msg_events().remove(0); + match &final_payment_event { + MessageSendEvent::UpdateHTLCs { node_id, .. } => assert_eq!(*node_id, node_c_id), + _ => panic!("Unexpected event"), + } + do_pass_along_path( + PassAlongPathArgs::new( + &nodes[1], + &[&nodes[2]], + 100_000, + final_payment_hash, + final_payment_event, + ) + .with_payment_secret(final_payment_secret) + .without_clearing_recipient_events(), + ); + + let claimable_events = nodes[2].node.get_and_clear_pending_events(); + let final_claimable = claimable_events + .iter() + .find(|event| { + matches!( + event, + Event::PaymentClaimable { payment_hash, .. } if *payment_hash == final_payment_hash + ) + }) + .unwrap(); + check_payment_claimable( + final_claimable, + final_payment_hash, + final_payment_secret, + 100_000, + None, + node_c_id, + ); + expect_htlc_failure_conditions( + nodes[1].node.get_and_clear_pending_events(), + &[HTLCHandlingFailureType::Forward { node_id: Some(node_c_id), channel_id: chan_bc.2 }], + ); +} + fn do_test_async_raa_peer_disconnect( test_case: UnblockSignerAcrossDisconnectCase, raa_blocked_by_commit_point: bool, ) { diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 3df6f5fc436..d0fc940eb62 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -10259,6 +10259,13 @@ where self.context.signer_pending_commitment_update = true; commitment_update = None; } + if revoke_and_ack.is_some() { + // If signer-pending state regenerated an RAA, the monitor update for that RAA was + // already persisted before we set `signer_pending_revoke_and_ack`. Thus, if reconnect + // also marked the same RAA monitor-pending while another monitor update was in flight, + // the RAA we're returning here satisfies that monitor-pending resend. + self.context.monitor_pending_revoke_and_ack = false; + } let (closing_signed, signed_closing_tx, shutdown_result) = if self.context.signer_pending_closing { debug_assert!(self.context.last_sent_closing_fee.is_some()); From 6aea1020a557084748a5b246b001323baf11f95a Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Mon, 15 Jun 2026 13:14:47 -0400 Subject: [PATCH 481/627] Fix: reject fake scids with invalid vout Previously, we would spuriously allow fake scids that had a vout with the high byte set to pass our is_valid_{phantom,intercept,etc}_scid checks, even though our fake vouts only ever set the lowest 3 bits of the 2-byte vout. This can't really be exploited since HTLCs that pass this check would still fail later on in the pipeline, and attackers that want to craft fake scids to pass our checks can still do so after this fix, either via brute force or by reusing a valid fake scid from a previously issued invoice. But at least this makes it harder for them to do so, and makes the check more correct than it was before. Plus invalid fake crafted scids like this could theoretically cause us to generate a spurious HTLCIntercepted event, which wouldn't be ideal. Reported by Project Loupe. --- lightning/src/util/scid_utils.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/lightning/src/util/scid_utils.rs b/lightning/src/util/scid_utils.rs index 342c062d7f1..c5a9182d171 100644 --- a/lightning/src/util/scid_utils.rs +++ b/lightning/src/util/scid_utils.rs @@ -180,7 +180,7 @@ pub(crate) mod fake_scid { let namespace = Namespace::Phantom; let valid_vout = namespace.get_encrypted_vout(block_height, tx_index, fake_scid_rand_bytes); block_height >= segwit_activation_height(chain_hash) - && valid_vout == scid_utils::vout_from_scid(scid) as u8 + && valid_vout as u16 == scid_utils::vout_from_scid(scid) } /// Returns whether the given fake scid falls into the intercept namespace. @@ -192,7 +192,7 @@ pub(crate) mod fake_scid { let namespace = Namespace::Intercept; let valid_vout = namespace.get_encrypted_vout(block_height, tx_index, fake_scid_rand_bytes); block_height >= segwit_activation_height(chain_hash) - && valid_vout == scid_utils::vout_from_scid(scid) as u8 + && valid_vout as u16 == scid_utils::vout_from_scid(scid) } #[cfg(test)] @@ -248,6 +248,15 @@ pub(crate) mod fake_scid { assert!(is_valid_phantom(&fake_scid_rand_bytes, valid_fake_scid, &testnet_genesis)); let invalid_fake_scid = scid_utils::scid_from_parts(1, 0, 12).unwrap(); assert!(!is_valid_phantom(&fake_scid_rand_bytes, invalid_fake_scid, &testnet_genesis)); + // A scid whose low byte matches the namespace value but whose high byte is set must be + // rejected (this was previously broken). + let high_byte_fake_scid = + scid_utils::scid_from_parts(1, 0, valid_encrypted_vout as u64 | 0x0100).unwrap(); + assert!(!is_valid_phantom( + &fake_scid_rand_bytes, + high_byte_fake_scid, + &testnet_genesis + )); } #[test] @@ -265,6 +274,15 @@ pub(crate) mod fake_scid { invalid_fake_scid, &testnet_genesis )); + // A scid whose low byte matches the namespace value but whose high byte is set must be + // rejected (this was previously broken). + let high_byte_fake_scid = + scid_utils::scid_from_parts(1, 0, valid_encrypted_vout as u64 | 0x0100).unwrap(); + assert!(!is_valid_intercept( + &fake_scid_rand_bytes, + high_byte_fake_scid, + &testnet_genesis + )); } #[test] From e560ec170682d36e363361c6e8f09c958edd237b Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Mon, 15 Jun 2026 14:00:42 -0400 Subject: [PATCH 482/627] Fix underflow in blinded path amt_to_forward If we have a high (200%+) proportional fee as an intermediate blinded node combined with a low inbound amount, we previously had some code that calculated the outbound amount of the forward that would've underflowed. This would've caused a panic in debug builds and caused us to relay a payment that should've been rejected (due to being unable to cover our high fee) in release builds. Reported by Project Loupe. --- lightning/src/blinded_path/payment.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs index a5319350d3b..5fd608d6135 100644 --- a/lightning/src/blinded_path/payment.rs +++ b/lightning/src/blinded_path/payment.rs @@ -940,7 +940,7 @@ pub(crate) fn amt_to_forward_msat( (post_base_fee_inbound_amt * 1_000_000 + 1_000_000 + prop - 1) / (prop + 1_000_000); let fee = ((amt_to_forward * prop) / 1_000_000) + base; - if inbound_amt - fee < amt_to_forward { + if inbound_amt.checked_sub(fee)? < amt_to_forward { // Rounding up the forwarded amount resulted in underpaying this node, so take an extra 1 msat // in fee to compensate. amt_to_forward -= 1; @@ -1415,4 +1415,19 @@ mod tests { .unwrap(); assert_eq!(blinded_payinfo.htlc_maximum_msat, 3997); } + + #[test] + fn amt_to_forward_msat_underflow() { + // `amt_to_forward_msat` is documented to return `None` if underflow occurs, but the + // `inbound_amt - fee` subtraction was previously unguarded. With a high proportional fee + // and a small inbound amount, rounding the forwarded amount up leaves `fee` larger than + // `inbound_amt`, so the subtraction underflows (panicking in debug builds and returning a + // nonsensical result in release). Ensure we instead return `None`. + let payment_relay = PaymentRelay { + cltv_expiry_delta: 0, + fee_proportional_millionths: u32::MAX, + fee_base_msat: 1, + }; + assert!(super::amt_to_forward_msat(2, &payment_relay).is_none()); + } } From 3c128ed8eccb1cfadd9615d56b710e67d84a5361 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Tue, 16 Jun 2026 11:59:56 +0200 Subject: [PATCH 483/627] Reject case-varied LSPS5 replay signatures LSPS5 webhook signatures are zbase32 strings, and the verifier accepts case aliases when decoding them. The replay cache compared raw header strings, so a case-only change could bypass immediate replay detection even though it represented the same signature bytes. Canonicalize the verified signature text before cache lookup and storage. Keying the replay cache on decoded signature bytes would be the semantic ideal, but doing that locally would decode once in the validator and again inside message_signing::verify. This keeps the fix local while matching the verifier's identity semantics. Add regression coverage for the case-varied replay. --- lightning-liquidity/src/lsps5/validator.rs | 8 +++++--- lightning-liquidity/tests/lsps5_integration_tests.rs | 10 ++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/lightning-liquidity/src/lsps5/validator.rs b/lightning-liquidity/src/lsps5/validator.rs index 8063ea743b7..50a36ea1d2f 100644 --- a/lightning-liquidity/src/lsps5/validator.rs +++ b/lightning-liquidity/src/lsps5/validator.rs @@ -11,7 +11,6 @@ use super::msgs::LSPS5ClientError; -use crate::alloc::string::ToString; use crate::lsps0::ser::LSPSDateTime; use crate::lsps5::msgs::WebhookNotification; use crate::sync::Mutex; @@ -91,14 +90,17 @@ impl LSPS5Validator { } fn check_for_replay_attack(&self, signature: &str) -> Result<(), LSPS5ClientError> { + // zbase32 decoding accepts case aliases, so canonicalize the cache key + // to match verification semantics without decoding the signature again. + let signature = signature.to_ascii_lowercase(); let mut signatures = self.recent_signatures.lock().unwrap(); - if signatures.contains(&signature.to_string()) { + if signatures.contains(&signature) { return Err(LSPS5ClientError::ReplayAttack); } if signatures.len() == MAX_RECENT_SIGNATURES { signatures.pop_back(); } - signatures.push_front(signature.to_string()); + signatures.push_front(signature); Ok(()) } } diff --git a/lightning-liquidity/tests/lsps5_integration_tests.rs b/lightning-liquidity/tests/lsps5_integration_tests.rs index deed6b2f8b8..e4a0f897536 100644 --- a/lightning-liquidity/tests/lsps5_integration_tests.rs +++ b/lightning-liquidity/tests/lsps5_integration_tests.rs @@ -988,6 +988,16 @@ fn replay_prevention_test() { assert!(replay_result.is_err(), "Immediate replay attack should be detected"); assert_eq!(replay_result.unwrap_err(), LSPS5ClientError::ReplayAttack); + let case_modified_signature = signature.to_ascii_uppercase(); + assert_ne!(case_modified_signature, signature); + let case_modified_replay_result = + validator.validate(service_node_id, ×tamp, &case_modified_signature, &body); + assert!( + case_modified_replay_result.is_err(), + "Immediate replay attack should be detected when the signature case changes" + ); + assert_eq!(case_modified_replay_result.unwrap_err(), LSPS5ClientError::ReplayAttack); + // Fill up the validator's signature cache to push out the original signature. for i in 0..MAX_RECENT_SIGNATURES { // Advance time, allowing for another notification From 1a875a7a6cc8a6491b79112ce5d63a1c73940ae0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 16 Jun 2026 15:48:29 +0200 Subject: [PATCH 484/627] Avoid leaking stale filesystem store temp files Async filesystem writes may be awaited out of order, making older writes stale after their temporary data has already been created. Clean up stale or failed write attempts on a best-effort basis so they do not leave historical plaintext data in *.tmp files, without reporting cleanup failures as persistence failures. Co-Authored-By: HAL 9000 This finding was discovered by Project Loupe --- lightning-persister/src/fs_store/common.rs | 99 +++++++++++++--------- lightning-persister/src/fs_store/v2.rs | 33 ++++++++ 2 files changed, 91 insertions(+), 41 deletions(-) diff --git a/lightning-persister/src/fs_store/common.rs b/lightning-persister/src/fs_store/common.rs index 885f806b344..b591e43bda0 100644 --- a/lightning-persister/src/fs_store/common.rs +++ b/lightning-persister/src/fs_store/common.rs @@ -277,33 +277,43 @@ impl FilesystemStoreInner { let tmp_file_ext = format!("{}.tmp", self.tmp_file_counter.fetch_add(1, Ordering::AcqRel)); tmp_file_path.set_extension(tmp_file_ext); - { - let mut tmp_file = fs::File::create(&tmp_file_path)?; - tmp_file.write_all(&buf)?; - - // If we need to preserve the original mtime (for updates), set it before fsync. - if let Some(mtime) = mtime { - let times = fs::FileTimes::new().set_modified(mtime); - tmp_file.set_times(times)?; - } + let tmp_file_res = match fs::File::create(&tmp_file_path) { + Ok(mut tmp_file) => (|| -> lightning::io::Result<()> { + tmp_file.write_all(&buf)?; + + // If we need to preserve the original mtime (for updates), set it before fsync. + if let Some(mtime) = mtime { + let times = fs::FileTimes::new().set_modified(mtime); + tmp_file.set_times(times)?; + } - tmp_file.sync_all()?; + tmp_file.sync_all()?; + Ok(()) + })(), + Err(e) => return Err(e.into()), + }; + if let Err(e) = tmp_file_res { + let _ = fs::remove_file(&tmp_file_path); + return Err(e); } - self.execute_locked_write(inner_lock_ref, dest_file_path.clone(), version, || { - #[cfg(not(target_os = "windows"))] - { - fs::rename(&tmp_file_path, &dest_file_path)?; - let dir_file = fs::OpenOptions::new().read(true).open(&parent_directory)?; - dir_file.sync_all()?; - Ok(()) - } + let mut tmp_file_needs_cleanup = true; + let write_res = + self.execute_locked_write(inner_lock_ref, dest_file_path.clone(), version, || { + #[cfg(not(target_os = "windows"))] + { + fs::rename(&tmp_file_path, &dest_file_path)?; + tmp_file_needs_cleanup = false; + let dir_file = fs::OpenOptions::new().read(true).open(&parent_directory)?; + dir_file.sync_all()?; + Ok(()) + } - #[cfg(target_os = "windows")] - { - let res = if dest_file_path.exists() { - call!(unsafe { - windows_sys::Win32::Storage::FileSystem::ReplaceFileW( + #[cfg(target_os = "windows")] + { + let res = if dest_file_path.exists() { + call!(unsafe { + windows_sys::Win32::Storage::FileSystem::ReplaceFileW( path_to_windows_str(&dest_file_path).as_ptr(), path_to_windows_str(&tmp_file_path).as_ptr(), std::ptr::null(), @@ -311,30 +321,37 @@ impl FilesystemStoreInner { std::ptr::null_mut() as *const core::ffi::c_void, std::ptr::null_mut() as *const core::ffi::c_void, ) - }) - } else { - call!(unsafe { - windows_sys::Win32::Storage::FileSystem::MoveFileExW( + }) + } else { + call!(unsafe { + windows_sys::Win32::Storage::FileSystem::MoveFileExW( path_to_windows_str(&tmp_file_path).as_ptr(), path_to_windows_str(&dest_file_path).as_ptr(), windows_sys::Win32::Storage::FileSystem::MOVEFILE_WRITE_THROUGH | windows_sys::Win32::Storage::FileSystem::MOVEFILE_REPLACE_EXISTING, ) - }) - }; - - match res { - Ok(()) => { - // We fsync the dest file in hopes this will also flush the metadata to disk. - let dest_file = - fs::OpenOptions::new().read(true).write(true).open(&dest_file_path)?; - dest_file.sync_all()?; - Ok(()) - }, - Err(e) => Err(e.into()), + }) + }; + + match res { + Ok(()) => { + tmp_file_needs_cleanup = false; + // We fsync the dest file in hopes this will also flush the metadata to disk. + let dest_file = fs::OpenOptions::new() + .read(true) + .write(true) + .open(&dest_file_path)?; + dest_file.sync_all()?; + Ok(()) + }, + Err(e) => Err(e.into()), + } } - } - }) + }); + if tmp_file_needs_cleanup { + let _ = fs::remove_file(&tmp_file_path); + } + write_res } fn remove_version( diff --git a/lightning-persister/src/fs_store/v2.rs b/lightning-persister/src/fs_store/v2.rs index fe1fdf60c7a..af0ad4f155c 100644 --- a/lightning-persister/src/fs_store/v2.rs +++ b/lightning-persister/src/fs_store/v2.rs @@ -444,6 +444,39 @@ mod tests { assert_eq!(listed_keys.len(), 0); } + #[cfg(feature = "tokio")] + #[tokio::test] + async fn stale_write_does_not_leak_tmp_file() { + use lightning::util::persist::KVStore; + + let mut temp_path = std::env::temp_dir(); + temp_path.push("test_stale_write_does_not_leak_tmp_file_v2"); + let _ = fs::remove_dir_all(&temp_path); + let fs_store = FilesystemStoreV2::new(temp_path.clone()).unwrap(); + + let data1 = vec![1u8; 32]; + let data2 = vec![2u8; 32]; + + let primary = "testspace"; + let secondary = "testsubspace"; + let key = "testkey"; + + let fut1 = KVStore::write(&fs_store, primary, secondary, key, data1); + let fut2 = KVStore::write(&fs_store, primary, secondary, key, data2); + + fut2.await.unwrap(); + fut1.await.unwrap(); + + let dir = temp_path.join(primary).join(secondary); + let tmp_files: Vec<_> = fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().map_or(false, |ext| ext == "tmp")) + .collect(); + assert!(tmp_files.is_empty(), "Found leaked tmp files: {:?}", tmp_files); + } + #[test] fn test_data_migration() { let mut source_temp_path = std::env::temp_dir(); From ce130957fd99fe0962f985ec22c46ea6d69cecc9 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 16 Jun 2026 13:52:33 +0000 Subject: [PATCH 485/627] Revert "Prevent stale fs-store writes after lock cleanup" This reverts commit 2c09a2610d1231b78704ae3e26d42136e8e89e90, 7106181ad1d7b7a33837efb251f871d44fc5664a, and 7b36bc8beb5809562abe3120bf28188bd1a7190d. The `KVStore` API does not, and can not, provide any ordering guarantees within the runtime of `write` methods. Only after a `write` method returns is there any ordering guarantees provided against future `write` calls. The additional test changes are only noise and likely somewhat brittle against future changes. We also revert the change itself, for simplicity. --- lightning-persister/src/fs_store/common.rs | 88 +--------------------- 1 file changed, 1 insertion(+), 87 deletions(-) diff --git a/lightning-persister/src/fs_store/common.rs b/lightning-persister/src/fs_store/common.rs index 96e58945f84..885f806b344 100644 --- a/lightning-persister/src/fs_store/common.rs +++ b/lightning-persister/src/fs_store/common.rs @@ -11,11 +11,7 @@ use std::collections::HashMap; use std::fs; use std::io::{ErrorKind, Read, Write}; use std::path::{Path, PathBuf}; -#[cfg(test)] -use std::sync::atomic::AtomicBool; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; -#[cfg(test)] -use std::sync::mpsc; use std::sync::{Arc, Mutex, RwLock}; #[cfg(target_os = "windows")] @@ -95,20 +91,14 @@ impl FilesystemStoreState { } fn get_new_version_and_lock_ref(&self, dest_file_path: PathBuf) -> (Arc>, u64) { - let mut outer_lock = self.inner.locks.lock().unwrap(); - - // Allocate the version while holding the lock map mutex so that clean_locks cannot remove the entry after a - // version has been reserved but before its lock reference is cloned. let version = self.next_version.fetch_add(1, Ordering::Relaxed); if version == u64::MAX { panic!("FilesystemStore version counter overflowed"); } - #[cfg(test)] - maybe_pause_after_version_allocation(&self.inner, &dest_file_path); // Get a reference to the inner lock. We do this early so that the arc can double as an in-flight counter for // cleaning up unused locks. - let inner_lock_ref = Arc::clone(&outer_lock.entry(dest_file_path).or_default()); + let inner_lock_ref = self.inner.get_inner_lock_ref(dest_file_path); (inner_lock_ref, version) } @@ -861,79 +851,3 @@ pub(crate) fn get_key_from_dir_entry_path( }, } } - -#[cfg(test)] -struct VersionAllocatedHook { - dest_file_path: PathBuf, - version_allocated: mpsc::Sender<()>, - continue_write: Mutex>, - fired: AtomicBool, -} - -#[cfg(test)] -static VERSION_ALLOCATED_HOOK: Mutex>> = Mutex::new(None); - -#[cfg(test)] -fn maybe_pause_after_version_allocation(inner: &FilesystemStoreInner, dest_file_path: &Path) { - let hook = VERSION_ALLOCATED_HOOK.lock().unwrap().clone(); - if let Some(hook) = hook { - if hook.dest_file_path.as_path() != dest_file_path - || hook.fired.swap(true, Ordering::AcqRel) - { - return; - } - - let version_allocation_holds_lock = inner.locks.try_lock().is_err(); - hook.version_allocated.send(()).unwrap(); - if !version_allocation_holds_lock { - hook.continue_write.lock().unwrap().recv().unwrap(); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use std::sync::Arc; - use std::thread; - - #[test] - fn stale_write_after_lock_cleanup_does_not_overwrite_newer_write() { - let mut temp_path = std::env::temp_dir(); - temp_path.push("test_stale_write_after_lock_cleanup"); - let _ = std::fs::remove_dir_all(&temp_path); - - let state = Arc::new(FilesystemStoreState::new(temp_path.clone())); - let path = - state.get_checked_dest_file_path("ns", "sub", Some("key"), "write", false).unwrap(); - let (version_allocated, wait_for_version) = mpsc::channel(); - let (continue_write, wait_to_continue) = mpsc::channel(); - *VERSION_ALLOCATED_HOOK.lock().unwrap() = Some(Arc::new(VersionAllocatedHook { - dest_file_path: path.clone(), - version_allocated, - continue_write: Mutex::new(wait_to_continue), - fired: AtomicBool::new(false), - })); - - let state_for_thread = Arc::clone(&state); - let path_for_thread = path.clone(); - let stale_write = thread::spawn(move || { - let (inner_lock_ref, version) = - state_for_thread.get_new_version_and_lock_ref(path_for_thread.clone()); - state_for_thread - .inner - .write_version(inner_lock_ref, path_for_thread, b"stale".to_vec(), version, false) - .unwrap(); - }); - - wait_for_version.recv().unwrap(); - state.write_impl("ns", "sub", "key", b"newer".to_vec(), false).unwrap(); - continue_write.send(()).unwrap(); - stale_write.join().unwrap(); - *VERSION_ALLOCATED_HOOK.lock().unwrap() = None; - - assert_eq!(state.read_impl("ns", "sub", "key", false).unwrap(), b"newer"); - let _ = std::fs::remove_dir_all(temp_path); - } -} From b18ad8b39947a3cee360392e7f1e994f18ae34d7 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Tue, 16 Jun 2026 11:02:56 -0400 Subject: [PATCH 486/627] Document that Route::route_params will be required soon This field was always set since 0.0.117, so in the next version we're going to make it officially required. --- lightning/src/routing/router.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 364bd86704e..01889b2ea60 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -683,7 +683,8 @@ pub struct Route { /// /// This is used by `ChannelManager` to track information which may be required for retries. /// - /// Will be `None` for objects serialized with LDK versions prior to 0.0.117. + /// Will be `None` for objects serialized with LDK versions prior to 0.0.117. This field will + /// soon move to being required and must always be set. pub route_params: Option, } From 4636d6c99d6798a7c448ecb2d8d168d0866033d0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Jun 2026 15:56:53 +0200 Subject: [PATCH 487/627] Check Electrum Merkle leaf risk by base size Electrum confirmation checks must reject transactions whose non-witness serialization is 64 bytes, since txids and Merkle leaves are computed from that serialization. Witness padding can otherwise move total_size above 64 without removing the inner-node ambiguity. Co-Authored-By: HAL 9000 This finding was discovered by Project Loupe --- lightning-transaction-sync/src/common.rs | 4 +++ lightning-transaction-sync/src/electrum.rs | 42 ++++++++++++++-------- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/lightning-transaction-sync/src/common.rs b/lightning-transaction-sync/src/common.rs index 88e52de186d..bafc9dc8627 100644 --- a/lightning-transaction-sync/src/common.rs +++ b/lightning-transaction-sync/src/common.rs @@ -133,6 +133,10 @@ impl FilterQueue { } } +pub(crate) fn is_potentially_unsafe_merkle_leaf(tx: &Transaction) -> bool { + tx.base_size() == 64 +} + #[derive(Debug)] pub(crate) struct ConfirmedTx { pub tx: Transaction, diff --git a/lightning-transaction-sync/src/electrum.rs b/lightning-transaction-sync/src/electrum.rs index cb937248f41..540cfc1a248 100644 --- a/lightning-transaction-sync/src/electrum.rs +++ b/lightning-transaction-sync/src/electrum.rs @@ -5,7 +5,7 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. -use crate::common::{ConfirmedTx, FilterQueue, SyncState}; +use crate::common::{is_potentially_unsafe_merkle_leaf, ConfirmedTx, FilterQueue, SyncState}; use crate::error::{InternalError, TxSyncError}; use electrum_client::utils::validate_merkle_proof; @@ -277,14 +277,9 @@ impl ElectrumSyncClient { for txid in &sync_state.watched_transactions { match self.client.transaction_get(&txid) { Ok(tx) => { - // Bitcoin Core's Merkle tree implementation has no way to discern between - // internal and leaf node entries. As a consequence it is susceptible to an - // attacker injecting additional transactions by crafting 64-byte - // transactions matching an inner Merkle node's hash (see - // https://web.archive.org/web/20240329003521/https://bitslog.com/2018/06/09/leaf-node-weakness-in-bitcoin-merkle-tree-design/). - // To protect against this (highly unlikely) attack vector, we check that the - // transaction is at least 65 bytes in length. - if tx.total_size() == 64 { + // Skip before using an arbitrary returned output to look up the + // transaction's script history. + if is_potentially_unsafe_merkle_leaf(&tx) { log_error!(self.logger, "Skipping transaction {} due to retrieving potentially invalid tx data.", txid); continue; } @@ -340,8 +335,9 @@ impl ElectrumSyncClient { continue; } let prob_conf_height = history.height as u32; - let confirmed_tx = self.get_confirmed_tx(tx, prob_conf_height)?; - confirmed_txs.push(confirmed_tx); + if let Some(confirmed_tx) = self.get_confirmed_tx(tx, prob_conf_height)? { + confirmed_txs.push(confirmed_tx); + } } if filtered_history.next().is_some() { log_error!( @@ -384,8 +380,11 @@ impl ElectrumSyncClient { } let prob_conf_height = possible_output_spend.height as u32; - let confirmed_tx = self.get_confirmed_tx(&tx, prob_conf_height)?; - confirmed_txs.push(confirmed_tx); + if let Some(confirmed_tx) = + self.get_confirmed_tx(&tx, prob_conf_height)? + { + confirmed_txs.push(confirmed_tx); + } }, Err(e) => { log_trace!( @@ -450,8 +449,21 @@ impl ElectrumSyncClient { fn get_confirmed_tx( &self, tx: &Transaction, prob_conf_height: u32, - ) -> Result { + ) -> Result, InternalError> { let txid = tx.compute_txid(); + // Bitcoin Core's Merkle tree implementation has no way to discern between internal and + // leaf node entries. As a consequence it is susceptible to an attacker injecting + // additional transactions by crafting 64-byte transactions matching an inner Merkle + // node's hash (see https://web.archive.org/web/20240329003521/https://bitslog.com/2018/06/09/leaf-node-weakness-in-bitcoin-merkle-tree-design/). + if is_potentially_unsafe_merkle_leaf(tx) { + log_error!( + self.logger, + "Skipping transaction {} due to retrieving potentially invalid tx data.", + txid + ); + return Ok(None); + } + match self.client.transaction_get_merkle(&txid, prob_conf_height as usize) { Ok(merkle_res) => { debug_assert_eq!(prob_conf_height, merkle_res.block_height as u32); @@ -473,7 +485,7 @@ impl ElectrumSyncClient { block_height: prob_conf_height, pos, }; - Ok(confirmed_tx) + Ok(Some(confirmed_tx)) }, Err(e) => { log_error!( From bc05d9d99245f53d09bcbf9e394ef0e365b58003 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Jun 2026 15:59:48 +0200 Subject: [PATCH 488/627] Check Esplora Merkle leaf risk by base size Esplora confirmation checks must use the non-witness transaction size for the 64-byte Merkle leaf guard. Witness padding can otherwise raise total_size without changing the serialization hashed into the txid and Merkle tree. Co-Authored-By: HAL 9000 This finding was discovered by Project Loupe --- lightning-transaction-sync/src/esplora.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lightning-transaction-sync/src/esplora.rs b/lightning-transaction-sync/src/esplora.rs index 52cfb394464..07d2ba26219 100644 --- a/lightning-transaction-sync/src/esplora.rs +++ b/lightning-transaction-sync/src/esplora.rs @@ -5,7 +5,7 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. -use crate::common::{ConfirmedTx, FilterQueue, SyncState}; +use crate::common::{is_potentially_unsafe_merkle_leaf, ConfirmedTx, FilterQueue, SyncState}; use crate::error::{InternalError, TxSyncError}; use lightning::chain::WatchedOutput; @@ -393,7 +393,7 @@ impl EsploraSyncClient { // https://web.archive.org/web/20240329003521/https://bitslog.com/2018/06/09/leaf-node-weakness-in-bitcoin-merkle-tree-design/). // To protect against this (highly unlikely) attack vector, we check that the // transaction is at least 65 bytes in length. - if tx.total_size() == 64 { + if is_potentially_unsafe_merkle_leaf(&tx) { log_error!( self.logger, "Skipping transaction {} due to retrieving potentially invalid tx data.", From 1d131506d07798e711841bd4e9a306f31746b8b6 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Jun 2026 16:11:28 +0200 Subject: [PATCH 489/627] Verify Electrum transaction responses before use Electrum confirmations must reject transaction_get responses whose body does not compute the requested txid. Otherwise a malicious server can substitute an unrelated transaction and provide matching Merkle data for the substituted body. Co-Authored-By: HAL 9000 This finding was discovered by Project Loupe --- lightning-transaction-sync/src/electrum.rs | 30 ++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/lightning-transaction-sync/src/electrum.rs b/lightning-transaction-sync/src/electrum.rs index 540cfc1a248..0283b9f00ee 100644 --- a/lightning-transaction-sync/src/electrum.rs +++ b/lightning-transaction-sync/src/electrum.rs @@ -277,6 +277,11 @@ impl ElectrumSyncClient { for txid in &sync_state.watched_transactions { match self.client.transaction_get(&txid) { Ok(tx) => { + if tx.compute_txid() != *txid { + log_error!(self.logger, "Retrieved transaction for txid {} doesn't match expectations. This should not happen. Please verify server integrity.", txid); + return Err(InternalError::Failed); + } + // Skip before using an arbitrary returned output to look up the // transaction's script history. if is_potentially_unsafe_merkle_leaf(&tx) { @@ -365,6 +370,11 @@ impl ElectrumSyncClient { match self.client.transaction_get(&txid) { Ok(tx) => { + if tx.compute_txid() != txid { + log_error!(self.logger, "Retrieved transaction for txid {} doesn't match expectations. This should not happen. Please verify server integrity.", txid); + return Err(InternalError::Failed); + } + let mut is_spend = false; for txin in &tx.input { let watched_outpoint = @@ -529,3 +539,23 @@ impl Filter for ElectrumSyncClient { locked_queue.outputs.insert(output.outpoint.into_bitcoin_outpoint(), output); } } + +#[cfg(test)] +mod tests { + #[test] + fn transaction_get_responses_are_verified_at_call_sites() { + let src = include_str!("electrum.rs"); + let watched_transaction_check = concat!("if tx.compute_", "txid() != *txid"); + let watched_output_spend_check = concat!("if tx.compute_", "txid() != txid"); + + assert!( + src.contains(watched_transaction_check), + "watched transaction_get responses must be verified against the requested txid" + ); + assert!( + src.contains(watched_output_spend_check), + "watched-output spend transaction_get responses must be verified against the \ + requested txid" + ); + } +} From 12815f380bc6339b815b0537bbae084a47e847c4 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 16 Jun 2026 11:28:35 +0200 Subject: [PATCH 490/627] Document LiquidityManager persist result Clarify the return value so callers know it reports whether the forced peer-state write reached the store. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/manager.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index f1b098dbfaa..64f5d974be5 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -618,7 +618,7 @@ where /// Persists the state of the service handlers towards the given [`KVStore`] implementation if /// needed. /// - /// Returns `true` if it persisted sevice handler data. + /// Returns `true` if it persisted service handler data. /// /// This will be regularly called by LDK's background processor if necessary and only needs to /// be called manually if it's not utilized. @@ -1111,7 +1111,7 @@ where /// Persists the state of the service handlers towards the given [`KVStoreSync`] implementation. /// - /// Returns `true` if it persisted sevice handler data. + /// Returns `true` if it persisted service handler data. /// /// Wraps [`LiquidityManager::persist`]. pub fn persist(&self) -> Result { From a1cda953c7614f9dcaa581047783705d9129185f Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 16 Jun 2026 11:28:44 +0200 Subject: [PATCH 491/627] Report LSPS2 fallback persistence When a prunable peer gains state before removal, persist() now reports that the forced peer-state write reached the store. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps2/service.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index b52d12e5168..8bea1009b48 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -1861,6 +1861,7 @@ where did_persist = true; } else { self.persist_peer_state(counterparty_node_id).await?; + did_persist = true; } } From d75719120f8c918cfc798661b0768cd0382c3215 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 16 Jun 2026 11:28:55 +0200 Subject: [PATCH 492/627] Report LSPS5 fallback persistence When a prunable client gains state before removal, persist() now reports that the forced peer-state write reached the store. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps5/service.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/lightning-liquidity/src/lsps5/service.rs b/lightning-liquidity/src/lsps5/service.rs index 7360131a9e9..3d00754f809 100644 --- a/lightning-liquidity/src/lsps5/service.rs +++ b/lightning-liquidity/src/lsps5/service.rs @@ -335,6 +335,7 @@ where did_persist = true; } else { self.persist_peer_state(client_id).await?; + did_persist = true; } } From dce31b727598cec001096a35b4d263a478ef973e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 17 Jun 2026 10:52:23 +0200 Subject: [PATCH 493/627] Avoid re-locking same UTXO future UtxoLookup implementations may cache and return the same async future for repeated requests for a short channel id. When a replacement channel announcement arrives while that future is in-flight, the pending-entry comparison may point back to the future state already held by the async path. Detect that case with Arc::ptr_eq inside check_replace_previous_entry and compare against the held messages instead of taking the mutex again. This keeps duplicate-announcement filtering intact while letting replacement announcements update the pending entry without re-entering the lock. Co-Authored-By: HAL 9000 This finding was discovered by Project Loupe --- lightning/src/routing/utxo.rs | 133 +++++++++++++++++++++++++++------- 1 file changed, 105 insertions(+), 28 deletions(-) diff --git a/lightning/src/routing/utxo.rs b/lightning/src/routing/utxo.rs index 6b2f2963b76..10270364075 100644 --- a/lightning/src/routing/utxo.rs +++ b/lightning/src/routing/utxo.rs @@ -293,11 +293,34 @@ impl PendingChecks { Ok(()) } + fn pending_channel_announcement_matches( + msg: &msgs::UnsignedChannelAnnouncement, full_msg: Option<&msgs::ChannelAnnouncement>, + pending_state: &UtxoMessages, + ) -> bool { + match &pending_state.channel_announce { + Some(ChannelAnnouncement::Full(pending_msg)) => Some(pending_msg) == full_msg, + Some(ChannelAnnouncement::Unsigned(pending_msg)) => pending_msg == msg, + None => { + // This can be reached if `resolve_single_future` has already consumed + // `channel_announce` via `.take()` while the `Arc>` is still + // alive (e.g. held on the stack of `check_resolved_futures`). In that case, + // `complete` should also have been taken. Treat it as non-matching and let the + // new request fly. + debug_assert!( + pending_state.complete.is_none(), + "channel_announce is None but complete is still pending" + ); + false + }, + } + } + fn check_replace_previous_entry( msg: &msgs::UnsignedChannelAnnouncement, full_msg: Option<&msgs::ChannelAnnouncement>, - replacement: Option>>, + replacement: Option<(&Arc>, &UtxoMessages)>, pending_channels: &mut HashMap>>, ) -> Result<(), msgs::LightningError> { + let replacement_state = replacement.map(|(state, _)| state); match pending_channels.entry(msg.short_channel_id) { hash_map::Entry::Occupied(mut e) => { // There's already a pending lookup for the given SCID. Check if the messages @@ -305,30 +328,34 @@ impl PendingChecks { // lookup if we haven't gotten that far yet). match Weak::upgrade(&e.get()) { Some(pending_msgs) => { - // This may be called with the mutex held on a different UtxoMessages - // struct, however in that case we have a global lockorder of new messages - // -> old messages, which makes this safe. - let pending_state = pending_msgs.unsafe_well_ordered_double_lock_self(); - let pending_matches = match &pending_state.channel_announce { - Some(ChannelAnnouncement::Full(pending_msg)) => { - Some(pending_msg) == full_msg + let pending_matches = match replacement { + Some((replacement, replacement_messages)) + if Arc::ptr_eq(&pending_msgs, replacement) => + { + // The pending entry points to the state whose mutex the caller + // already holds. Compare through the held guard instead of locking + // it again. + Self::pending_channel_announcement_matches( + msg, + full_msg, + replacement_messages, + ) }, - Some(ChannelAnnouncement::Unsigned(pending_msg)) => pending_msg == msg, - None => { - // This can be reached if `resolve_single_future` has already - // consumed `channel_announce` via `.take()` while the - // `Arc>` is still alive (e.g. held on - // the stack of `check_resolved_futures`). In that case, - // `complete` should also have been taken. Treat it as - // non-matching and let the new request fly. - debug_assert!( - pending_state.complete.is_none(), - "channel_announce is None but complete is still pending" + _ => { + // This may be called with the mutex held on a different + // UtxoMessages struct, however in that case we have a global + // lockorder of new messages -> old messages, which makes this safe. + let pending_state = + pending_msgs.unsafe_well_ordered_double_lock_self(); + let matches = Self::pending_channel_announcement_matches( + msg, + full_msg, + &pending_state, ); - false + drop(pending_state); + matches }, }; - drop(pending_state); if pending_matches { return Err(LightningError { err: "Channel announcement is already being checked".to_owned(), @@ -340,16 +367,16 @@ impl PendingChecks { // Note that in the replace case whether to replace is somewhat // arbitrary - both results will be handled, we're just updating the // value that will be compared to future lookups with the same SCID. - if let Some(item) = replacement { - *e.get_mut() = item; + if let Some(item) = replacement_state { + *e.get_mut() = Arc::downgrade(item); } } }, None => { // The earlier lookup already resolved. We can't be sure its the same // so just remove/replace it and move on. - if let Some(item) = replacement { - *e.get_mut() = item; + if let Some(item) = replacement_state { + *e.get_mut() = Arc::downgrade(item); } else { e.remove(); } @@ -357,8 +384,8 @@ impl PendingChecks { } }, hash_map::Entry::Vacant(v) => { - if let Some(item) = replacement { - v.insert(item); + if let Some(item) = replacement_state { + v.insert(Arc::downgrade(item)); } }, } @@ -442,7 +469,7 @@ impl PendingChecks { Self::check_replace_previous_entry( msg, full_msg, - Some(Arc::downgrade(&future.state)), + Some((&future.state, &async_messages)), &mut pending_checks.channels, )?; async_messages.channel_announce = Some(if let Some(msg) = full_msg { @@ -1028,6 +1055,56 @@ mod tests { assert!(!is_test_feature_set); } + #[test] + fn test_no_deadlock_same_future_different_announcement() { + // A user's UtxoLookup may return the same UtxoFuture for repeated lookups for a + // given SCID. A different channel_announcement with that SCID should replace the + // pending message without re-locking the already-held future state. + let (valid_announcement, chain_source, network_graph, good_script, ..) = get_test_objects(); + let scid = valid_announcement.contents.short_channel_id; + + let notifier = Arc::new(Notifier::new()); + let future = UtxoFuture::new(Arc::clone(¬ifier)); + *chain_source.utxo_ret.lock().unwrap() = UtxoResult::Async(future.clone()); + + assert_eq!( + network_graph + .update_channel_from_announcement(&valid_announcement, &Some(&chain_source)) + .unwrap_err() + .err, + "Channel being checked async" + ); + assert_eq!(chain_source.get_utxo_call_count.load(Ordering::Relaxed), 1); + + let secp_ctx = Secp256k1::new(); + let replacement_pk_1 = &SecretKey::from_slice(&[99; 32]).unwrap(); + let replacement_pk_2 = &SecretKey::from_slice(&[98; 32]).unwrap(); + let replacement_announcement = get_signed_channel_announcement( + |msg| msg.features.set_unknown_feature_optional(), + replacement_pk_1, + replacement_pk_2, + &secp_ctx, + ); + assert_eq!( + network_graph + .update_channel_from_announcement(&replacement_announcement, &Some(&chain_source)) + .unwrap_err() + .err, + "Channel being checked async" + ); + assert_eq!(chain_source.get_utxo_call_count.load(Ordering::Relaxed), 2); + + future + .resolve(Ok(TxOut { value: Amount::from_sat(1_000_000), script_pubkey: good_script })); + assert!(notifier.notify_pending()); + network_graph.pending_checks.check_resolved_futures(&network_graph); + #[rustfmt::skip] + let is_replacement_feature_set = + network_graph.read_only().channels().get(&scid).unwrap().announcement_message + .as_ref().unwrap().contents.features.supports_unknown_test_feature(); + assert!(is_replacement_feature_set); + } + #[test] fn test_checks_backpressure() { // Test that too_many_checks_pending returns true when there are many checks pending, and From 6997c8886d683b6787645874f9f5f80db26c8164 Mon Sep 17 00:00:00 2001 From: tnull Date: Tue, 2 Jun 2026 14:20:45 +0200 Subject: [PATCH 494/627] Treat replayed LSPS2 HTLCs idempotently Replayed intercepted HTLC events should not duplicate queued payments or panic after restart. Ignore already-queued intercept IDs so persisted queues remain stable across event replay. Co-Authored-By: HAL 9000 --- .../src/lsps2/payment_queue.rs | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/lightning-liquidity/src/lsps2/payment_queue.rs b/lightning-liquidity/src/lsps2/payment_queue.rs index 421e42d7706..600f588716c 100644 --- a/lightning-liquidity/src/lsps2/payment_queue.rs +++ b/lightning-liquidity/src/lsps2/payment_queue.rs @@ -26,21 +26,29 @@ impl PaymentQueue { PaymentQueue { payments: Vec::new() } } + fn payment_status(entry: &PaymentQueueEntry) -> (u64, usize) { + let total_expected_outbound_amount_msat = + entry.htlcs.iter().map(|htlc| htlc.expected_outbound_amount_msat).sum(); + (total_expected_outbound_amount_msat, entry.htlcs.len()) + } + pub(crate) fn add_htlc(&mut self, new_htlc: InterceptedHTLC) -> (u64, usize) { + if let Some(entry) = self + .payments + .iter() + .find(|entry| entry.htlcs.iter().any(|htlc| htlc.intercept_id == new_htlc.intercept_id)) + { + debug_assert_eq!(entry.payment_hash, new_htlc.payment_hash); + return Self::payment_status(entry); + } + let payment = self.payments.iter_mut().find(|entry| entry.payment_hash == new_htlc.payment_hash); if let Some(entry) = payment { // HTLCs within a payment should have the same payment hash. debug_assert!(entry.htlcs.iter().all(|htlc| htlc.payment_hash == entry.payment_hash)); - // The given HTLC should not already be present. - debug_assert!(entry - .htlcs - .iter() - .all(|htlc| htlc.intercept_id != new_htlc.intercept_id)); entry.htlcs.push(new_htlc); - let total_expected_outbound_amount_msat = - entry.htlcs.iter().map(|htlc| htlc.expected_outbound_amount_msat).sum(); - (total_expected_outbound_amount_msat, entry.htlcs.len()) + Self::payment_status(entry) } else { let expected_outbound_amount_msat = new_htlc.expected_outbound_amount_msat; let entry = @@ -127,6 +135,15 @@ mod tests { (500_000_000, 2), ); + assert_eq!( + payment_queue.add_htlc(InterceptedHTLC { + intercept_id: InterceptId([2; 32]), + expected_outbound_amount_msat: 300_000_000, + payment_hash: PaymentHash([100; 32]), + }), + (500_000_000, 2), + ); + let expected_entry = PaymentQueueEntry { payment_hash: PaymentHash([100; 32]), htlcs: vec![ From bab66f621b1c1ed69dae8db6100e29e110ab0d22 Mon Sep 17 00:00:00 2001 From: tnull Date: Tue, 2 Jun 2026 14:21:02 +0200 Subject: [PATCH 495/627] Prune closed LSPS2 terminal channel state Terminal JIT channel state is only useful while the forwarded channel still exists. Drop completed LSPS2 mappings once the channel is gone so persisted service state does not retain stale entries indefinitely. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps2/service.rs | 144 +++++++++++++++++++++++ lightning-liquidity/src/manager.rs | 2 + 2 files changed, 146 insertions(+) diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index 5f318fc077e..467547931c2 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -644,6 +644,26 @@ impl PeerState { }); } + fn remove_terminal_channel_state(&mut self, channel_id: ChannelId) -> Option { + let intercept_scid = self.intercept_scid_by_channel_id.get(&channel_id).copied()?; + let should_remove = self + .outbound_channels_by_intercept_scid + .get(&intercept_scid) + .and_then(|entry| entry.get_channel_id()) + .is_some_and(|existing_channel_id| existing_channel_id == channel_id); + + if !should_remove { + return None; + } + + self.outbound_channels_by_intercept_scid.remove(&intercept_scid); + self.intercept_scid_by_channel_id.remove(&channel_id); + self.intercept_scid_by_user_channel_id.retain(|_, iscid| *iscid != intercept_scid); + self.needs_persist = true; + + Some(intercept_scid) + } + fn pending_requests_and_channels(&self) -> usize { let pending_requests = self.pending_requests.len(); let pending_outbound_channels = self @@ -1252,6 +1272,45 @@ where Ok(()) } + /// Forward [`Event::ChannelClosed`] event parameter into this function. + /// + /// Will prune terminal JIT channel state once the corresponding channel has closed. + /// + /// [`Event::ChannelClosed`]: lightning::events::Event::ChannelClosed + pub async fn channel_closed(&self, channel_id: ChannelId) -> Result<(), APIError> { + let counterparty_node_id = + self.peer_by_channel_id.read().unwrap().get(&channel_id).copied(); + let Some(counterparty_node_id) = counterparty_node_id else { + return Ok(()); + }; + + let removed_intercept_scid = { + let outer_state_lock = self.per_peer_state.read().unwrap(); + match outer_state_lock.get(&counterparty_node_id) { + Some(inner_state_lock) => { + let mut peer_state = inner_state_lock.lock().unwrap(); + peer_state.remove_terminal_channel_state(channel_id) + }, + None => None, + } + }; + + if let Some(intercept_scid) = removed_intercept_scid { + self.peer_by_intercept_scid.write().unwrap().remove(&intercept_scid); + self.peer_by_channel_id.write().unwrap().remove(&channel_id); + self.persist_peer_state(counterparty_node_id).await.map_err(|e| { + APIError::APIMisuseError { + err: format!( + "Failed to persist peer state after channel {} closed: {}", + channel_id, e + ), + } + })?; + } + + Ok(()) + } + /// Abandons a pending JIT‐open flow for `user_channel_id`, removing all local state. /// /// This removes the intercept SCID, any outbound channel state, and associated @@ -2270,6 +2329,25 @@ where } } + /// Forward [`Event::ChannelClosed`] event parameter into this function. + /// + /// Wraps [`LSPS2ServiceHandler::channel_closed`]. + /// + /// [`Event::ChannelClosed`]: lightning::events::Event::ChannelClosed + pub fn channel_closed(&self, channel_id: ChannelId) -> Result<(), APIError> { + let mut fut = pin!(self.inner.channel_closed(channel_id)); + + let mut waker = dummy_waker(); + let mut ctx = task::Context::from_waker(&mut waker); + match fut.as_mut().poll(&mut ctx) { + task::Poll::Ready(result) => result, + task::Poll::Pending => { + // In a sync context, we can't wait for the future to complete. + unreachable!("Should not be pending in a sync context"); + }, + } + } + /// Wraps [`LSPS2ServiceHandler::channel_needs_manual_broadcast`]. pub fn channel_needs_manual_broadcast( &self, user_channel_id: u128, counterparty_node_id: &PublicKey, @@ -2764,6 +2842,72 @@ mod tests { } } + #[test] + fn removes_terminal_state_for_closed_channel() { + let opening_fee_params = LSPS2OpeningFeeParams { + min_fee_msat: 10_000_000, + proportional: 10_000, + valid_until: LSPSDateTime::from_str("2035-05-20T08:30:45Z").unwrap(), + min_lifetime: 4032, + max_client_to_self_delay: 2016, + min_payment_size_msat: 10_000_000, + max_payment_size_msat: 1_000_000_000, + promise: "ignore".to_string(), + }; + let stale_intercept_scid = 42; + let stale_user_channel_id = 43; + let stale_channel_id = ChannelId([44; 32]); + let live_intercept_scid = 45; + let live_user_channel_id = 46; + let live_channel_id = ChannelId([47; 32]); + + let mut stale_jit_channel = + OutboundJITChannel::new(None, opening_fee_params.clone(), stale_user_channel_id, false); + stale_jit_channel.state = + OutboundJITChannelState::PaymentForwarded { channel_id: stale_channel_id }; + let mut live_jit_channel = + OutboundJITChannel::new(None, opening_fee_params, live_user_channel_id, false); + live_jit_channel.state = + OutboundJITChannelState::PaymentForwarded { channel_id: live_channel_id }; + + let mut peer_state = PeerState::new(); + peer_state.insert_outbound_channel(stale_intercept_scid, stale_jit_channel); + peer_state.insert_outbound_channel(live_intercept_scid, live_jit_channel); + peer_state + .intercept_scid_by_user_channel_id + .insert(stale_user_channel_id, stale_intercept_scid); + peer_state + .intercept_scid_by_user_channel_id + .insert(live_user_channel_id, live_intercept_scid); + peer_state.intercept_scid_by_channel_id.insert(stale_channel_id, stale_intercept_scid); + peer_state.intercept_scid_by_channel_id.insert(live_channel_id, live_intercept_scid); + peer_state.needs_persist = false; + + assert_eq!( + peer_state.remove_terminal_channel_state(stale_channel_id), + Some(stale_intercept_scid) + ); + assert!(!peer_state + .outbound_channels_by_intercept_scid + .contains_key(&stale_intercept_scid)); + assert!(peer_state.outbound_channels_by_intercept_scid.contains_key(&live_intercept_scid)); + assert!(!peer_state.intercept_scid_by_user_channel_id.contains_key(&stale_user_channel_id)); + assert_eq!( + peer_state.intercept_scid_by_user_channel_id.get(&live_user_channel_id), + Some(&live_intercept_scid) + ); + assert!(!peer_state.intercept_scid_by_channel_id.contains_key(&stale_channel_id)); + assert_eq!( + peer_state.intercept_scid_by_channel_id.get(&live_channel_id), + Some(&live_intercept_scid) + ); + assert!(peer_state.needs_persist); + + peer_state.needs_persist = false; + assert_eq!(peer_state.remove_terminal_channel_state(stale_channel_id), None); + assert!(!peer_state.needs_persist); + } + #[test] fn broadcast_not_allowed_after_non_paying_fee_payment_claimed() { let min_fee_msat: u64 = 12345; diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index f1b098dbfaa..9accd9e5769 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -256,6 +256,7 @@ where /// - [`Event::ChannelReady`] to [`LSPS2ServiceHandler::channel_ready`] /// - [`Event::HTLCHandlingFailed`] to [`LSPS2ServiceHandler::htlc_handling_failed`] /// - [`Event::PaymentForwarded`] to [`LSPS2ServiceHandler::payment_forwarded`] +/// - [`Event::ChannelClosed`] to [`LSPS2ServiceHandler::channel_closed`] /// /// [`PeerManager`]: lightning::ln::peer_handler::PeerManager /// [`MessageHandler`]: lightning::ln::peer_handler::MessageHandler @@ -263,6 +264,7 @@ where /// [`Event::ChannelReady`]: lightning::events::Event::ChannelReady /// [`Event::HTLCHandlingFailed`]: lightning::events::Event::HTLCHandlingFailed /// [`Event::PaymentForwarded`]: lightning::events::Event::PaymentForwarded +/// [`Event::ChannelClosed`]: lightning::events::Event::ChannelClosed pub struct LiquidityManager< ES: EntropySource + Clone, NS: NodeSigner + Clone, From 68e71c2f7385d70798b5442b0b05553385cb52be Mon Sep 17 00:00:00 2001 From: tnull Date: Tue, 2 Jun 2026 14:20:33 +0200 Subject: [PATCH 496/627] Add LSPS2 replay regression coverage Persisting LSPS2 service state can race with replayed intercepted HTLC events after restart. Cover replaying the same intercepted HTLC after restoring peer state so duplicate queueing is caught. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps2/service.rs | 48 ++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index 467547931c2..4f338a5fd03 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -2439,6 +2439,8 @@ mod tests { use bitcoin::{absolute::LockTime, transaction::Version}; use core::str::FromStr; + use lightning::io::Cursor; + use lightning::util::ser::{Readable, Writeable}; const MAX_VALUE_MSAT: u64 = 21_000_000_0000_0000_000; @@ -2842,6 +2844,52 @@ mod tests { } } + #[test] + fn replayed_intercepted_htlc_after_persist_is_idempotent() { + let payment_size_msat = Some(500_000_000); + let opening_fee_params = LSPS2OpeningFeeParams { + min_fee_msat: 10_000_000, + proportional: 10_000, + valid_until: LSPSDateTime::from_str("2035-05-20T08:30:45Z").unwrap(), + min_lifetime: 4032, + max_client_to_self_delay: 2016, + min_payment_size_msat: 10_000_000, + max_payment_size_msat: 1_000_000_000, + promise: "ignore".to_string(), + }; + let intercept_scid = 42; + let user_channel_id = 43; + let htlc = InterceptedHTLC { + intercept_id: InterceptId([1; 32]), + expected_outbound_amount_msat: 500_000_000, + payment_hash: PaymentHash([2; 32]), + }; + + let mut jit_channel = + OutboundJITChannel::new(payment_size_msat, opening_fee_params, user_channel_id, false); + assert!(matches!( + jit_channel.htlc_intercepted(htlc).unwrap(), + Some(HTLCInterceptedAction::OpenChannel(_)) + )); + + let mut peer_state = PeerState::new(); + peer_state.intercept_scid_by_user_channel_id.insert(user_channel_id, intercept_scid); + peer_state.insert_outbound_channel(intercept_scid, jit_channel); + + let encoded_peer_state = peer_state.encode(); + let mut decoded_peer_state = PeerState::read(&mut Cursor::new(encoded_peer_state)).unwrap(); + let decoded_jit_channel = decoded_peer_state + .outbound_channels_by_intercept_scid + .get_mut(&intercept_scid) + .unwrap(); + + assert!(decoded_jit_channel.htlc_intercepted(htlc).unwrap().is_none()); + + let ForwardPaymentAction(_, fee_payment) = + decoded_jit_channel.channel_ready(ChannelId([3; 32])).unwrap(); + assert_eq!(fee_payment.htlcs, vec![htlc]); + } + #[test] fn removes_terminal_state_for_closed_channel() { let opening_fee_params = LSPS2OpeningFeeParams { From 77ac339b85d76ea1ae11b71c66098fceb979594f Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 17 Jun 2026 09:28:21 -0500 Subject: [PATCH 497/627] Don't panic when a composite sub-handler returns `Ok(None)` A handler built with `composite_custom_message_handler!` routes an incoming message type to the sub-handler whose pattern matches it and assumed the sub-handler would always decode it. But per the `CustomMessageReader` contract a sub-handler returns `Ok(None)` for a type it doesn't recognize, and a sub-handler's pattern -- a range in particular -- can be broader than the types it actually decodes. Since the message type comes from peer input, this let a remote peer panic the message-processing thread with a single custom message whose type falls in a sub-handler's pattern but isn't decoded by it. Report such a message as unknown instead, matching how `wire::do_read` handles an undecoded custom message. Co-Authored-By: Claude Opus 4.8 (1M context) --- lightning-custom-message/src/lib.rs | 72 ++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/lightning-custom-message/src/lib.rs b/lightning-custom-message/src/lib.rs index 0d70ba06385..06e57b47b84 100644 --- a/lightning-custom-message/src/lib.rs +++ b/lightning-custom-message/src/lib.rs @@ -358,7 +358,12 @@ macro_rules! composite_custom_message_handler { match message_type { $( $pattern => match <$type>::read(&self.$field, message_type, buffer)? { - None => unreachable!(), + // A sub-handler returns `None` for a `message_type` it doesn't + // recognize. The composite's pattern can be broader than the types + // the sub-handler decodes (e.g. a range), and `message_type` is + // peer-provided, so report the message as unknown rather than + // treating this as unreachable and panicking. + None => Ok(None), Some(message) => Ok(Some($message::$variant(message))), }, )* @@ -501,6 +506,71 @@ mod tests { } ); + struct ReservedBlockHandler; + impl CustomMessageReader for ReservedBlockHandler { + type CustomMessage = Foo; + fn read( + &self, message_type: u16, _b: &mut R, + ) -> Result, DecodeError> { + // This build defines only the message at 32768; the rest of the block its + // protocol reserved (32768..=32777) is for types future versions may add. + // A not-yet-defined type is unknown to this build, so per the + // `CustomMessageReader` contract it returns `Ok(None)` -- a newer peer can + // send one and this older node will treat it as an unknown message. + match message_type { + 32768 => Ok(Some(Foo)), + _ => Ok(None), + } + } + } + impl CustomMessageHandler for ReservedBlockHandler { + fn handle_custom_message(&self, _msg: Foo, _: PublicKey) -> Result<(), LightningError> { + Ok(()) + } + fn get_and_clear_pending_msg(&self) -> Vec<(PublicKey, Foo)> { + vec![] + } + fn peer_disconnected(&self, _: PublicKey) {} + fn peer_connected(&self, _: PublicKey, _: &Init, _: bool) -> Result<(), ()> { + Ok(()) + } + fn provided_node_features(&self) -> NodeFeatures { + NodeFeatures::empty() + } + fn provided_init_features(&self, _: PublicKey) -> InitFeatures { + InitFeatures::empty() + } + } + + composite_custom_message_handler!( + struct ReservedBlockComposite { + proto: ReservedBlockHandler, + } + + enum ReservedBlockMessage { + Proto(32768..=32777), + } + ); + + #[test] + fn read_treats_a_reserved_in_range_type_as_unknown() { + // A sub-handler may own a block of type ids (declared here as a range) yet only + // decode the subset its build defines, returning `Ok(None)` for reserved or + // not-yet-defined types in the block -- exactly what a node does on receiving a + // newer peer's message. `read` must surface that as an unknown message, not + // panic. + let composite = ReservedBlockComposite { proto: ReservedBlockHandler }; + let mut buffer: &[u8] = &[]; + // The message this build defines decodes to its variant. + assert!(matches!( + composite.read(32768, &mut buffer), + Ok(Some(ReservedBlockMessage::Proto(_))) + )); + // A reserved type from the same block is reported unknown, not panicked + // (pre-fix the matched arm hit `unreachable!()`). + assert!(matches!(composite.read(32770, &mut buffer), Ok(None))); + } + #[test] fn peer_connected_failure_does_not_leak_subhandler_state() { let composite = CompositeHandler { From 4cd3a8a626d5f24fdb61f9a9d975ef6675506fd9 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 16 Jun 2026 16:00:28 +0000 Subject: [PATCH 498/627] Refuse to set features at index higher than `u16::MAX` bytes These aren't serialize-able and clearly bogus. Reported by Project Loupe --- lightning-types/src/features.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lightning-types/src/features.rs b/lightning-types/src/features.rs index 21d59b2b917..a55e811e719 100644 --- a/lightning-types/src/features.rs +++ b/lightning-types/src/features.rs @@ -1268,6 +1268,9 @@ impl Features { fn set_bit(&mut self, bit: usize, custom: bool) -> Result<(), ()> { let byte_offset = bit / 8; let mask = 1 << (bit - 8 * byte_offset); + if byte_offset >= u16::MAX as usize { + return Err(()); + } if byte_offset < T::KNOWN_FEATURE_MASK.len() && custom { if (T::KNOWN_FEATURE_MASK[byte_offset] & mask) != 0 { return Err(()); From 87c8c32637625d17b8715c93e65ff731816bdc21 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Wed, 17 Jun 2026 14:38:06 +0000 Subject: [PATCH 499/627] Correct deserialization of `u16::MAX` byte-Features This shouldn't really matter in practice, but it aligns the serialization and deserialization logic. Reported by Project Loupe. --- lightning/src/ln/features.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/features.rs b/lightning/src/ln/features.rs index a4e7fc15394..a303027b879 100644 --- a/lightning/src/ln/features.rs +++ b/lightning/src/ln/features.rs @@ -40,7 +40,10 @@ macro_rules! impl_feature_len_prefixed_write { } impl Readable for $features { fn read(r: &mut R) -> Result { - Ok(Self::from_be_bytes(Vec::::read(r)?)) + let len: u16 = Readable::read(r)?; + let mut bytes = vec![0u8; len as usize]; + r.read_exact(&mut bytes[..])?; + Ok(Self::from_be_bytes(bytes)) } } }; From 8270c7cdf1edb52addb5114c17ac53b350ef0bad Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 17 Jun 2026 17:00:47 +0200 Subject: [PATCH 500/627] Add pending changelog entry for PR 4656 --- pending_changelog/4656.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 pending_changelog/4656.txt diff --git a/pending_changelog/4656.txt b/pending_changelog/4656.txt new file mode 100644 index 00000000000..b5d5400d9a4 --- /dev/null +++ b/pending_changelog/4656.txt @@ -0,0 +1,2 @@ +## API Updates +* The `LSPS2ServiceHandler` now expects LDK's `ChannelClosed` events to be forwarded to the new `channel_closed` method. (#4656) From 54cdd85fd44bd76a0f8da9b03ff3666561dbeb0d Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Wed, 17 Jun 2026 11:40:43 -0400 Subject: [PATCH 501/627] Fix invalid dummy pubkey in send_to_route If a caller of send_payment_with_route provided a route with either no paths, or where the first path had 0 hops, the method would panic due to attempting to unwrap a dummy pubkey that was initialized with 32 bytes instead of the required 33. Reported by Project Loupe. --- lightning/src/ln/channelmanager.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 2d00b1d1098..49392264709 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5642,7 +5642,7 @@ impl< // Create a dummy route params since they're a required parameter but unused in this case let (payee_node_id, cltv_delta) = route.paths.first() .and_then(|path| path.hops.last().map(|hop| (hop.pubkey, hop.cltv_expiry_delta as u32))) - .unwrap_or_else(|| (PublicKey::from_slice(&[2; 32]).unwrap(), MIN_FINAL_CLTV_EXPIRY_DELTA as u32)); + .unwrap_or_else(|| (PublicKey::from_slice(&[2; 33]).unwrap(), MIN_FINAL_CLTV_EXPIRY_DELTA as u32)); let dummy_payment_params = PaymentParameters::from_node_id(payee_node_id, cltv_delta); RouteParameters::from_payment_params_and_value(dummy_payment_params, route.get_total_amount()) }); From e90ab9d2a7410e4a3738a036690986b0069405cd Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Mon, 15 Jun 2026 10:59:51 -0400 Subject: [PATCH 502/627] Rustfmt send_payment_with_route About to modify this method slightly, so opportunistically format it now. --- lightning/src/ln/channelmanager.rs | 45 ++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 49392264709..2b8b3e87fb9 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5631,30 +5631,51 @@ impl< /// /// LDK will not automatically retry this payment, though it may be manually re-sent after an /// [`Event::PaymentFailed`] is generated. - #[rustfmt::skip] pub fn send_payment_with_route( &self, mut route: Route, payment_hash: PaymentHash, recipient_onion: RecipientOnionFields, - payment_id: PaymentId + payment_id: PaymentId, ) -> Result<(), RetryableSendFailure> { let best_block_height = self.best_block.read().unwrap().height; let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); let route_params = route.route_params.clone().unwrap_or_else(|| { // Create a dummy route params since they're a required parameter but unused in this case - let (payee_node_id, cltv_delta) = route.paths.first() - .and_then(|path| path.hops.last().map(|hop| (hop.pubkey, hop.cltv_expiry_delta as u32))) - .unwrap_or_else(|| (PublicKey::from_slice(&[2; 33]).unwrap(), MIN_FINAL_CLTV_EXPIRY_DELTA as u32)); + let (payee_node_id, cltv_delta) = route + .paths + .first() + .and_then(|path| { + path.hops.last().map(|hop| (hop.pubkey, hop.cltv_expiry_delta as u32)) + }) + .unwrap_or_else(|| { + (PublicKey::from_slice(&[2; 33]).unwrap(), MIN_FINAL_CLTV_EXPIRY_DELTA as u32) + }); let dummy_payment_params = PaymentParameters::from_node_id(payee_node_id, cltv_delta); - RouteParameters::from_payment_params_and_value(dummy_payment_params, route.get_total_amount()) + RouteParameters::from_payment_params_and_value( + dummy_payment_params, + route.get_total_amount(), + ) }); - if route.route_params.is_none() { route.route_params = Some(route_params.clone()); } + if route.route_params.is_none() { + route.route_params = Some(route_params.clone()); + } let router = FixedRouter::new(route); let logger = WithContext::for_payment(&self.logger, None, None, Some(payment_hash), payment_id); - self.pending_outbound_payments - .send_payment(payment_hash, recipient_onion, payment_id, Retry::Attempts(0), - route_params, &&router, self.list_usable_channels(), || self.compute_inflight_htlcs(), - &self.entropy_source, &self.node_signer, best_block_height, - &self.pending_events, |args| self.send_payment_along_path(args), &logger) + self.pending_outbound_payments.send_payment( + payment_hash, + recipient_onion, + payment_id, + Retry::Attempts(0), + route_params, + &&router, + self.list_usable_channels(), + || self.compute_inflight_htlcs(), + &self.entropy_source, + &self.node_signer, + best_block_height, + &self.pending_events, + |args| self.send_payment_along_path(args), + &logger, + ) } /// Sends a payment to the route found using the provided [`RouteParameters`], retrying failed From 41f0809a1017cdf1f6acaaddcffcd75057983d6e Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Mon, 15 Jun 2026 11:00:44 -0400 Subject: [PATCH 503/627] Require Route::route_params This field has always been set since 0.0.117. Since we're making it required here, routes created/serialized prior to 0.0.117 will fail to deserialize on 0.4. --- fuzz/src/chanmon_consistency.rs | 8 +- lightning/src/ln/blinded_payment_tests.rs | 36 +++- lightning/src/ln/chanmon_update_fail_tests.rs | 4 +- lightning/src/ln/channelmanager.rs | 31 +-- lightning/src/ln/functional_test_utils.rs | 4 +- lightning/src/ln/functional_tests.rs | 7 +- .../src/ln/max_payment_path_len_tests.rs | 12 +- lightning/src/ln/onion_utils.rs | 7 +- lightning/src/ln/outbound_payment.rs | 56 +++-- lightning/src/ln/payment_tests.rs | 110 ++++------ lightning/src/routing/router.rs | 200 +++++++++--------- lightning/src/util/test_utils.rs | 2 +- pending_changelog/route-params-required.txt | 4 + 13 files changed, 229 insertions(+), 252 deletions(-) create mode 100644 pending_changelog/route-params-required.txt diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index d8291571884..b0703d8e6ed 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1866,7 +1866,7 @@ impl PaymentTracker { }], blinded_tail: None, }], - route_params: Some(route_params.clone()), + route_params: route_params.clone(), }; let onion = RecipientOnionFields::secret_only(secret, amt); let res = source.send_payment_with_route(route, hash, onion, id); @@ -1946,7 +1946,7 @@ impl PaymentTracker { ], blinded_tail: None, }], - route_params: Some(route_params.clone()), + route_params: route_params.clone(), }; let onion = RecipientOnionFields::secret_only(secret, amt); let res = source.send_payment_with_route(route, hash, onion, id); @@ -2028,7 +2028,7 @@ impl PaymentTracker { PaymentParameters::from_node_id(dest.get_our_node_id(), TEST_FINAL_CLTV), amt, ); - let route = Route { paths, route_params: Some(route_params) }; + let route = Route { paths, route_params }; let onion = RecipientOnionFields::secret_only(secret, amt); let res = source.send_payment_with_route(route, hash, onion, id); let succeeded = match res { @@ -2132,7 +2132,7 @@ impl PaymentTracker { PaymentParameters::from_node_id(dest.get_our_node_id(), TEST_FINAL_CLTV), amt, ); - let route = Route { paths, route_params: Some(route_params) }; + let route = Route { paths, route_params }; let onion = RecipientOnionFields::secret_only(secret, amt); let res = source.send_payment_with_route(route, hash, onion, id); let succeeded = match res { diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index 32c0709ed5c..b4de3791679 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -2093,6 +2093,7 @@ fn test_trampoline_forward_payload_encoded_as_receive() { let bob_carol_scid = nodes[1].node().list_channels().iter().find(|c| c.channel_id == chan_id_bob_carol).unwrap().short_channel_id.unwrap(); let amt_msat = 1000; + let carol_cltv_expiry_delta = 24 + 39; let (payment_preimage, payment_hash, _) = get_payment_preimage_hash(&nodes[2], Some(amt_msat), None); // We need the session priv to construct an invalid onion packet later. @@ -2148,7 +2149,7 @@ fn test_trampoline_forward_payload_encoded_as_receive() { short_channel_id: bob_carol_scid, channel_features: ChannelFeatures::empty(), fee_msat: 0, - cltv_expiry_delta: 24 + 39, + cltv_expiry_delta: carol_cltv_expiry_delta, maybe_announced_channel: false, } ], @@ -2159,7 +2160,7 @@ fn test_trampoline_forward_payload_encoded_as_receive() { pubkey: carol_node_id, node_features: Features::empty(), fee_msat: amt_msat, - cltv_expiry_delta: 24 + 39, + cltv_expiry_delta: carol_cltv_expiry_delta, }, ], hops: carol_blinded_hops, @@ -2168,7 +2169,10 @@ fn test_trampoline_forward_payload_encoded_as_receive() { final_value_msat: amt_msat, }) }], - route_params: None, + route_params: RouteParameters::from_payment_params_and_value( + PaymentParameters::from_node_id(carol_node_id, carol_cltv_expiry_delta), + amt_msat, + ), }; nodes[0].node.send_payment_with_route(route.clone(), payment_hash, RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(payment_hash.0)).unwrap(); @@ -2279,6 +2283,7 @@ fn do_test_trampoline_single_hop_receive(success: bool) { let bob_carol_scid = nodes[1].node().list_channels().iter().find(|c| c.channel_id == chan_id_bob_carol).unwrap().short_channel_id.unwrap(); let amt_msat = 1000; + let carol_cltv_expiry_delta = 104 + 39; let (payment_preimage, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[2], Some(amt_msat), None); // Create a 1-hop blinded path for Carol. @@ -2314,7 +2319,7 @@ fn do_test_trampoline_single_hop_receive(success: bool) { short_channel_id: bob_carol_scid, channel_features: ChannelFeatures::empty(), fee_msat: 0, - cltv_expiry_delta: 104 + 39, + cltv_expiry_delta: carol_cltv_expiry_delta, maybe_announced_channel: false, } ], @@ -2325,7 +2330,7 @@ fn do_test_trampoline_single_hop_receive(success: bool) { pubkey: carol_node_id, node_features: Features::empty(), fee_msat: amt_msat, - cltv_expiry_delta: 104 + 39, + cltv_expiry_delta: carol_cltv_expiry_delta, }, ], hops: blinded_path.blinded_hops().to_vec(), @@ -2334,7 +2339,10 @@ fn do_test_trampoline_single_hop_receive(success: bool) { final_value_msat: amt_msat, }) }], - route_params: None, + route_params: RouteParameters::from_payment_params_and_value( + PaymentParameters::from_node_id(carol_node_id, carol_cltv_expiry_delta), + amt_msat, + ), }; nodes[0].node.send_payment_with_route(route.clone(), payment_hash, RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(payment_hash.0)).unwrap(); @@ -2618,7 +2626,13 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { original_amt_msat, )), }], - route_params: None, + route_params: RouteParameters::from_payment_params_and_value( + PaymentParameters::from_node_id( + carol_node_id, + original_trampoline_cltv + excess_final_cltv, + ), + original_amt_msat, + ), }; nodes[0] @@ -2753,6 +2767,7 @@ fn test_trampoline_forward_rejection() { let bob_carol_scid = nodes[1].node().list_channels().iter().find(|c| c.channel_id == chan_id_bob_carol).unwrap().short_channel_id.unwrap(); let amt_msat = 1000; + let carol_cltv_expiry_delta = 24 + 24 + 39; let (payment_preimage, payment_hash, _) = get_payment_preimage_hash(&nodes[2], Some(amt_msat), None); let route = Route { @@ -2776,7 +2791,7 @@ fn test_trampoline_forward_rejection() { short_channel_id: bob_carol_scid, channel_features: ChannelFeatures::empty(), fee_msat: 0, - cltv_expiry_delta: 24 + 24 + 39, + cltv_expiry_delta: carol_cltv_expiry_delta, maybe_announced_channel: false, } ], @@ -2808,7 +2823,10 @@ fn test_trampoline_forward_rejection() { final_value_msat: amt_msat, }) }], - route_params: None, + route_params: RouteParameters::from_payment_params_and_value( + PaymentParameters::from_node_id(carol_node_id, carol_cltv_expiry_delta), + amt_msat, + ), }; nodes[0].node.send_payment_with_route(route.clone(), payment_hash, RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(payment_hash.0)).unwrap(); diff --git a/lightning/src/ln/chanmon_update_fail_tests.rs b/lightning/src/ln/chanmon_update_fail_tests.rs index 9633800db08..81062ea7cc3 100644 --- a/lightning/src/ln/chanmon_update_fail_tests.rs +++ b/lightning/src/ln/chanmon_update_fail_tests.rs @@ -2315,7 +2315,7 @@ fn test_path_paused_mpp() { route.paths[1].hops[0].pubkey = node_c_id; route.paths[1].hops[0].short_channel_id = chan_2_ann.contents.short_channel_id; route.paths[1].hops[1].short_channel_id = chan_4_id; - route.route_params.as_mut().unwrap().final_value_msat *= 2; + route.route_params.final_value_msat *= 2; // Set it so that the first monitor update (for the path 0 -> 1 -> 3) succeeds, but the second // (for the path 0 -> 2 -> 3) fails. @@ -4315,7 +4315,7 @@ fn do_test_partial_claim_mon_update_compl_actions(reload_a: bool, reload_b: bool route.paths[1].hops[0].pubkey = node_c_id; route.paths[1].hops[0].short_channel_id = chan_2_scid; route.paths[1].hops[1].short_channel_id = chan_4_scid; - route.route_params.as_mut().unwrap().final_value_msat *= 2; + route.route_params.final_value_msat *= 2; let paths = &[&[&nodes[1], &nodes[3]][..], &[&nodes[2], &nodes[3]][..]]; send_along_route_with_secret(&nodes[0], route, paths, 200_000, payment_hash, payment_secret); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 2b8b3e87fb9..5eb2146d7e9 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5632,31 +5632,12 @@ impl< /// LDK will not automatically retry this payment, though it may be manually re-sent after an /// [`Event::PaymentFailed`] is generated. pub fn send_payment_with_route( - &self, mut route: Route, payment_hash: PaymentHash, recipient_onion: RecipientOnionFields, + &self, route: Route, payment_hash: PaymentHash, recipient_onion: RecipientOnionFields, payment_id: PaymentId, ) -> Result<(), RetryableSendFailure> { let best_block_height = self.best_block.read().unwrap().height; let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); - let route_params = route.route_params.clone().unwrap_or_else(|| { - // Create a dummy route params since they're a required parameter but unused in this case - let (payee_node_id, cltv_delta) = route - .paths - .first() - .and_then(|path| { - path.hops.last().map(|hop| (hop.pubkey, hop.cltv_expiry_delta as u32)) - }) - .unwrap_or_else(|| { - (PublicKey::from_slice(&[2; 33]).unwrap(), MIN_FINAL_CLTV_EXPIRY_DELTA as u32) - }); - let dummy_payment_params = PaymentParameters::from_node_id(payee_node_id, cltv_delta); - RouteParameters::from_payment_params_and_value( - dummy_payment_params, - route.get_total_amount(), - ) - }); - if route.route_params.is_none() { - route.route_params = Some(route_params.clone()); - } + let route_params = route.route_params.clone(); let router = FixedRouter::new(route); let logger = WithContext::for_payment(&self.logger, None, None, Some(payment_hash), payment_id); @@ -21131,7 +21112,7 @@ mod tests { // Next, send a keysend payment with the same payment_hash and make sure it fails. nodes[0].node.send_spontaneous_payment( Some(payment_preimage), RecipientOnionFields::spontaneous_empty(100_000), - PaymentId(payment_preimage.0), route.route_params.clone().unwrap(), Retry::Attempts(0) + PaymentId(payment_preimage.0), route.route_params.clone(), Retry::Attempts(0) ).unwrap(); check_added_monitors(&nodes[0], 1); let mut events = nodes[0].node.get_and_clear_pending_msg_events(); @@ -21287,7 +21268,7 @@ mod tests { ).unwrap(); let payment_hash = nodes[0].node.send_spontaneous_payment( Some(payment_preimage), RecipientOnionFields::spontaneous_empty(100_000), - PaymentId(payment_preimage.0), route.route_params.clone().unwrap(), Retry::Attempts(0) + PaymentId(payment_preimage.0), route.route_params.clone(), Retry::Attempts(0) ).unwrap(); check_added_monitors(&nodes[0], 1); let mut events = nodes[0].node.get_and_clear_pending_msg_events(); @@ -21330,7 +21311,7 @@ mod tests { let payment_id_1 = PaymentId([44; 32]); let payment_hash = nodes[0].node.send_spontaneous_payment( Some(payment_preimage), RecipientOnionFields::spontaneous_empty(100_000), payment_id_1, - route.route_params.clone().unwrap(), Retry::Attempts(0) + route.route_params.clone(), Retry::Attempts(0) ).unwrap(); check_added_monitors(&nodes[0], 1); let mut events = nodes[0].node.get_and_clear_pending_msg_events(); @@ -21448,7 +21429,7 @@ mod tests { route.paths[1].hops[0].pubkey = nodes[2].node.get_our_node_id(); route.paths[1].hops[0].short_channel_id = chan_2_id; route.paths[1].hops[1].short_channel_id = chan_4_id; - route.route_params.as_mut().unwrap().final_value_msat *= 2; + route.route_params.final_value_msat *= 2; nodes[0].node.send_payment_with_route(route, payment_hash, RecipientOnionFields::spontaneous_empty(200000), PaymentId(payment_hash.0)).unwrap(); diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 82c1c619e82..6e855c2a184 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -3492,14 +3492,14 @@ pub fn send_along_route_with_secret<'a, 'b, 'c>( recv_value: u64, our_payment_hash: PaymentHash, our_payment_secret: PaymentSecret, ) -> PaymentId { let payment_id = PaymentId(origin_node.keys_manager.backing.get_secure_random_bytes()); - origin_node.router.expect_find_route(route.route_params.clone().unwrap(), Ok(route.clone())); + origin_node.router.expect_find_route(route.route_params.clone(), Ok(route.clone())); origin_node .node .send_payment( our_payment_hash, RecipientOnionFields::secret_only(our_payment_secret, recv_value), payment_id, - route.route_params.unwrap(), + route.route_params, Retry::Attempts(0), ) .unwrap(); diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index 52e2f2e96bf..826b07750fa 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -164,7 +164,7 @@ pub fn fake_network_test() { let route_params = RouteParameters::from_payment_params_and_value(payment_params, 1000000); let route = Route { paths: vec![Path { hops, blinded_tail: None }], - route_params: Some(route_params.clone()), + route_params: route_params.clone(), }; let path: &[_] = &[&nodes[2], &nodes[3], &nodes[1]]; let payment_preimage_1 = send_along_route(&nodes[1], route, path, 1000000).0; @@ -202,8 +202,7 @@ pub fn fake_network_test() { + chan_2.1.contents.fee_proportional_millionths as u64 * hops[2].fee_msat as u64 / 1000000; hops[0].fee_msat = chan_3.1.contents.fee_base_msat as u64 + chan_3.1.contents.fee_proportional_millionths as u64 * hops[1].fee_msat as u64 / 1000000; - let route = - Route { paths: vec![Path { hops, blinded_tail: None }], route_params: Some(route_params) }; + let route = Route { paths: vec![Path { hops, blinded_tail: None }], route_params }; let path: &[_] = &[&nodes[3], &nodes[2], &nodes[1]]; let payment_hash_2 = send_along_route(&nodes[1], route, path, 1000000).1; @@ -7214,7 +7213,7 @@ pub fn test_simple_mpp() { route.paths[1].hops[0].pubkey = node_c_id; route.paths[1].hops[0].short_channel_id = chan_2_id; route.paths[1].hops[1].short_channel_id = chan_4_id; - route.route_params.as_mut().unwrap().final_value_msat = 200_000; + route.route_params.final_value_msat = 200_000; let paths: &[&[_]] = &[&[&nodes[1], &nodes[3]], &[&nodes[2], &nodes[3]]]; send_along_route_with_secret(&nodes[0], route, paths, 200_000, payment_hash, payment_secret); claim_payment_along_route(ClaimAlongRouteArgs::new(&nodes[0], paths, payment_preimage)); diff --git a/lightning/src/ln/max_payment_path_len_tests.rs b/lightning/src/ln/max_payment_path_len_tests.rs index c066f2c6d7b..242ba8000b8 100644 --- a/lightning/src/ln/max_payment_path_len_tests.rs +++ b/lightning/src/ln/max_payment_path_len_tests.rs @@ -110,7 +110,7 @@ fn large_payment_metadata() { custom_tlvs: Vec::new(), total_mpp_amount_msat: amt_msat, }; - let route_params = route_0_1.route_params.clone().unwrap(); + let route_params = route_0_1.route_params.clone(); let id = PaymentId(payment_hash.0); nodes[0] .node @@ -138,14 +138,14 @@ fn large_payment_metadata() { let (payment_hash_2, _, payment_secret_2, encrypted_metadata_2) = get_payment_hash!(nodes[2], payment_metadata.clone()); let (mut route_0_2, ..) = get_route_and_payment_hash!(&nodes[0], &nodes[2], amt_msat); - let mut route_params_0_2 = route_0_2.route_params.clone().unwrap(); + let mut route_params_0_2 = route_0_2.route_params.clone(); route_params_0_2.payment_params.max_path_length = 1; nodes[0].router.expect_find_route_query(route_params_0_2); max_sized_onion.payment_secret = Some(payment_secret_2); max_sized_onion.payment_metadata = Some(encrypted_metadata_2); let id = PaymentId(payment_hash_2.0); - let mut route_params = route_0_2.route_params.clone().unwrap(); + let mut route_params = route_0_2.route_params.clone(); let err = nodes[0] .node .send_payment(payment_hash_2, max_sized_onion.clone(), id, route_params, Retry::Attempts(0)) @@ -184,7 +184,7 @@ fn large_payment_metadata() { _ => panic!(), } - let route_params = route_0_1.route_params.clone().unwrap(); + let route_params = route_0_1.route_params.clone(); let err = nodes[0] .node .send_payment(payment_hash_2, too_large_onion, id, route_params, Retry::Attempts(0)) @@ -202,10 +202,10 @@ fn large_payment_metadata() { custom_tlvs: Vec::new(), total_mpp_amount_msat: amt_msat, }; - let mut route_params_0_2 = route_0_2.route_params.clone().unwrap(); + let mut route_params_0_2 = route_0_2.route_params.clone(); route_params_0_2.payment_params.max_path_length = 2; nodes[0].router.expect_find_route_query(route_params_0_2); - let route_params = route_0_2.route_params.unwrap(); + let route_params = route_0_2.route_params; nodes[0] .node .send_payment(payment_hash_2, onion_allowing_2_hops, id, route_params, Retry::Attempts(0)) diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 234af588eae..d4ee3d36374 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -3034,7 +3034,7 @@ mod tests { use crate::ln::channelmanager::PaymentId; use crate::ln::msgs::{self, UpdateFailHTLC}; use crate::ln::types::ChannelId; - use crate::routing::router::{Path, PaymentParameters, Route, RouteHop}; + use crate::routing::router::{Path, PaymentParameters, Route, RouteHop, RouteParameters}; use crate::types::features::{ChannelFeatures, NodeFeatures}; use crate::types::payment::PaymentHash; use crate::util::ser::{VecWriter, Writeable, Writer}; @@ -3142,7 +3142,10 @@ mod tests { let secp_ctx = Secp256k1::new(); let path = build_test_path(); - let route = Route { paths: vec![path], route_params: None }; + let payment_params = PaymentParameters::from_node_id(path.hops.last().unwrap().pubkey, 0); + let route_params = + RouteParameters::from_payment_params_and_value(payment_params, path.final_value_msat()); + let route = Route { paths: vec![path], route_params }; let onion_keys = super::construct_onion_keys(&secp_ctx, &route.paths[0], &get_test_session_key()); diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 748dfe1e701..6bbe43e0d63 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -985,7 +985,7 @@ impl OutboundPayments { fn validate_found_route( route: &mut Route, route_params: &RouteParameters, logger: &WithContext, ) -> Result<(), ()> { - if route.route_params.as_ref() != Some(route_params) { + if route.route_params != *route_params { debug_assert!( false, "Routers are expected to return a Route which includes the requested RouteParameters. Got {:?}, expected {route_params:?}", @@ -996,7 +996,7 @@ fn validate_found_route( "Routers are expected to return a Route which includes the requested RouteParameters. Got {:?}, expected {route_params:?}", route.route_params ); - route.route_params = Some(route_params.clone()); + route.route_params = route_params.clone(); } route.debug_assert_route_meets_params(logger)?; @@ -1923,7 +1923,20 @@ impl OutboundPayments { })) } - let route = Route { paths: vec![path], route_params: None }; + // `route_params` is a required field, but is unused when sending a probe along a fixed + // path. Construct dummy parameters from the path, leaving the fee budget unset to match + // the previous behavior of not tracking one for probes. + let route_params = { + let last_hop = path.hops.last().unwrap(); + let payment_params = + PaymentParameters::from_node_id(last_hop.pubkey, last_hop.cltv_expiry_delta); + RouteParameters { + payment_params, + final_value_msat: path.final_value_msat(), + max_total_routing_fee_msat: None, + } + }; + let route = Route { paths: vec![path], route_params }; let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret, route.get_total_amount()); let onion_session_privs = self.add_new_pending_payment(payment_hash, @@ -2036,8 +2049,7 @@ impl OutboundPayments { starting_block_height: best_block_height, total_msat: route.get_total_amount(), onion_total_msat: recipient_onion.total_mpp_amount_msat, - remaining_max_total_routing_fee_msat: - route.route_params.as_ref().and_then(|p| p.max_total_routing_fee_msat), + remaining_max_total_routing_fee_msat: route.route_params.max_total_routing_fee_msat, }; for (path, session_priv_bytes) in route.paths.iter().zip(onion_session_privs.iter()) { @@ -2204,19 +2216,17 @@ impl OutboundPayments { results, payment_id, failed_paths_retry: if has_unsent { - if let Some(route_params) = &route.route_params { - let mut route_params = route_params.clone(); - // We calculate the leftover fee budget we're allowed to spend by - // subtracting the used fee from the total fee budget. - route_params.max_total_routing_fee_msat = route_params - .max_total_routing_fee_msat.map(|m| m.saturating_sub(total_ok_fees_msat)); - - // We calculate the remaining target amount by subtracting the succeded - // path values. - route_params.final_value_msat = route_params.final_value_msat - .saturating_sub(total_ok_amt_sent_msat); - Some(route_params) - } else { None } + let mut route_params = route.route_params.clone(); + // We calculate the leftover fee budget we're allowed to spend by + // subtracting the used fee from the total fee budget. + route_params.max_total_routing_fee_msat = route_params + .max_total_routing_fee_msat.map(|m| m.saturating_sub(total_ok_fees_msat)); + + // We calculate the remaining target amount by subtracting the succeded + // path values. + route_params.final_value_msat = route_params.final_value_msat + .saturating_sub(total_ok_amt_sent_msat); + Some(route_params) } else { None }, }) } else if has_err { @@ -2954,7 +2964,7 @@ mod tests { let pending_events = Mutex::new(VecDeque::new()); if on_retry { outbound_payments.add_new_pending_payment(PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(0), - PaymentId([0; 32]), None, &Route { paths: vec![], route_params: None }, + PaymentId([0; 32]), None, &Route { paths: vec![], route_params: expired_route_params.clone() }, Some(Retry::Attempts(1)), Some(expired_route_params.payment_params.clone()), &&keys_manager, 0, None).unwrap(); outbound_payments.find_route_and_send_payment( @@ -3000,7 +3010,7 @@ mod tests { let pending_events = Mutex::new(VecDeque::new()); if on_retry { outbound_payments.add_new_pending_payment(PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(0), - PaymentId([0; 32]), None, &Route { paths: vec![], route_params: None }, + PaymentId([0; 32]), None, &Route { paths: vec![], route_params: route_params.clone() }, Some(Retry::Attempts(1)), Some(route_params.payment_params.clone()), &&keys_manager, 0, None).unwrap(); outbound_payments.find_route_and_send_payment( @@ -3048,13 +3058,13 @@ mod tests { cltv_expiry_delta: 0, maybe_announced_channel: true, }], blinded_tail: None }], - route_params: Some(route_params.clone()), + route_params: route_params.clone(), }; router.expect_find_route(route_params.clone(), Ok(route.clone())); let mut route_params_w_failed_scid = route_params.clone(); route_params_w_failed_scid.payment_params.previously_failed_channels.push(failed_scid); let mut route_w_failed_scid = route.clone(); - route_w_failed_scid.route_params = Some(route_params_w_failed_scid.clone()); + route_w_failed_scid.route_params = route_params_w_failed_scid.clone(); router.expect_find_route(route_params_w_failed_scid, Ok(route_w_failed_scid)); router.expect_find_route(route_params.clone(), Ok(route.clone())); router.expect_find_route(route_params.clone(), Ok(route.clone())); @@ -3418,7 +3428,7 @@ mod tests { blinded_tail: None, } ], - route_params: Some(route_params), + route_params, }) ); diff --git a/lightning/src/ln/payment_tests.rs b/lightning/src/ln/payment_tests.rs index 77684919821..e86245abc60 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -96,7 +96,7 @@ fn mpp_failure() { route.paths[1].hops[0].pubkey = node_c_id; route.paths[1].hops[0].short_channel_id = chan_2_id; route.paths[1].hops[1].short_channel_id = chan_4_id; - route.route_params.as_mut().unwrap().final_value_msat *= 2; + route.route_params.final_value_msat *= 2; let paths: &[&[_]] = &[&[&nodes[1], &nodes[3]], &[&nodes[2], &nodes[3]]]; send_along_route_with_secret(&nodes[0], route, paths, 200_000, payment_hash, payment_secret); @@ -138,11 +138,11 @@ fn mpp_retry() { route.paths[1].hops[0].pubkey = node_c_id; route.paths[1].hops[0].short_channel_id = chan_2_update.contents.short_channel_id; route.paths[1].hops[1].short_channel_id = chan_4_update.contents.short_channel_id; - route.route_params.as_mut().unwrap().final_value_msat *= 2; + route.route_params.final_value_msat *= 2; // Initiate the MPP payment. let id = PaymentId(hash.0); - let mut route_params = route.route_params.clone().unwrap(); + let mut route_params = route.route_params.clone(); nodes[0].router.expect_find_route(route_params.clone(), Ok(route.clone())); let onion = RecipientOnionFields::secret_only(pay_secret, amt_msat * 2); @@ -192,7 +192,7 @@ fn mpp_retry() { // Check the remaining max total routing fee for the second attempt is 50_000 - 1_000 msat fee // used by the first path route_params.max_total_routing_fee_msat = Some(max_fee - 1_000); - route.route_params = Some(route_params.clone()); + route.route_params = route_params.clone(); nodes[0].router.expect_find_route(route_params, Ok(route)); expect_and_process_pending_htlcs(&nodes[0], false); check_added_monitors(&nodes[0], 1); @@ -265,7 +265,7 @@ fn mpp_retry_overpay() { // Initiate the payment. let id = PaymentId(hash.0); - let mut route_params = route.route_params.clone().unwrap(); + let mut route_params = route.route_params.clone(); nodes[0].router.expect_find_route(route_params.clone(), Ok(route.clone())); let onion = RecipientOnionFields::secret_only(pay_secret, amt_msat); @@ -321,7 +321,7 @@ fn mpp_retry_overpay() { // base fee, but not for overpaid value of the first try. route_params.max_total_routing_fee_msat.as_mut().map(|m| *m -= 1000); - route.route_params = Some(route_params.clone()); + route.route_params = route_params.clone(); nodes[0].router.expect_find_route(route_params, Ok(route)); nodes[0].node.process_pending_htlc_forwards(); @@ -373,12 +373,12 @@ fn do_mpp_receive_timeout(send_partial_mpp: bool, keysend: bool) { route.paths[1].hops[0].pubkey = node_c_id; route.paths[1].hops[0].short_channel_id = chan_2_update.contents.short_channel_id; route.paths[1].hops[1].short_channel_id = chan_4_update.contents.short_channel_id; - route.route_params.as_mut().unwrap().final_value_msat *= 2; + route.route_params.final_value_msat *= 2; // Initiate the MPP payment. let onion = RecipientOnionFields::secret_only(payment_secret, 200_000); if keysend { - let route_params = route.route_params.clone().unwrap(); + let route_params = route.route_params.clone(); nodes[0].router.expect_find_route(route_params.clone(), Ok(route.clone())); nodes[0] .node @@ -668,8 +668,8 @@ fn test_reject_mpp_keysend_htlc_mismatching_secret() { route.paths[0].hops[1].short_channel_id = chan_3_id; let payment_id_0 = PaymentId(nodes[0].keys_manager.backing.get_secure_random_bytes()); - nodes[0].router.expect_find_route(route.route_params.clone().unwrap(), Ok(route.clone())); - let params = route.route_params.clone().unwrap(); + nodes[0].router.expect_find_route(route.route_params.clone(), Ok(route.clone())); + let params = route.route_params.clone(); let onion = RecipientOnionFields::spontaneous_empty(amount); let retry = Retry::Attempts(0); nodes[0].node.send_spontaneous_payment(preimage, onion, payment_id_0, params, retry).unwrap(); @@ -716,10 +716,10 @@ fn test_reject_mpp_keysend_htlc_mismatching_secret() { route.paths[0].hops[1].short_channel_id = chan_4_id; let payment_id_1 = PaymentId(nodes[0].keys_manager.backing.get_secure_random_bytes()); - nodes[0].router.expect_find_route(route.route_params.clone().unwrap(), Ok(route.clone())); + nodes[0].router.expect_find_route(route.route_params.clone(), Ok(route.clone())); let onion = RecipientOnionFields::spontaneous_empty(amount); - let params = route.route_params.clone().unwrap(); + let params = route.route_params.clone(); let retry = Retry::Attempts(0); nodes[0].node.send_spontaneous_payment(preimage, onion, payment_id_1, params, retry).unwrap(); check_added_monitors(&nodes[0], 1); @@ -859,7 +859,7 @@ fn do_retry_with_no_persist(confirm_before_reload: bool) { let (payment_preimage_1, payment_hash_1, _, payment_id_1) = send_along_route(&nodes[0], route.clone(), &[&nodes[1], &nodes[2]], 1_000_000); - let route_params = route.route_params.unwrap().clone(); + let route_params = route.route_params.clone(); let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); let id = PaymentId(payment_hash.0); nodes[0].node.send_payment(payment_hash, onion, id, route_params, Retry::Attempts(1)).unwrap(); @@ -2138,7 +2138,7 @@ fn claimed_send_payment_idempotent() { None, RecipientOnionFields::spontaneous_empty(100_000), payment_id, - route.route_params.clone().unwrap(), + route.route_params.clone(), Retry::Attempts(0), ); match send_result { @@ -2221,7 +2221,7 @@ fn abandoned_send_payment_idempotent() { None, RecipientOnionFields::spontaneous_empty(100_000), payment_id, - route.route_params.clone().unwrap(), + route.route_params.clone(), Retry::Attempts(0), ); match send_result { @@ -3224,7 +3224,7 @@ fn auto_retry_partial_failure() { blinded_tail: None, }, ], - route_params: Some(route_params.clone()), + route_params: route_params.clone(), }; nodes[0].router.expect_find_route(route_params.clone(), Ok(send_route)); @@ -3262,7 +3262,7 @@ fn auto_retry_partial_failure() { blinded_tail: None, }, ], - route_params: Some(retry_1_params.clone()), + route_params: retry_1_params.clone(), }; nodes[0].router.expect_find_route(retry_1_params.clone(), Ok(retry_1_route)); @@ -3286,7 +3286,7 @@ fn auto_retry_partial_failure() { }], blinded_tail: None, }], - route_params: Some(retry_2_params.clone()), + route_params: retry_2_params.clone(), }; nodes[0].router.expect_find_route(retry_2_params, Ok(retry_2_route)); @@ -3449,7 +3449,7 @@ fn auto_retry_zero_attempts_send_error() { }], blinded_tail: None, }], - route_params: Some(route_params.clone()), + route_params: route_params.clone(), }; nodes[0].router.expect_find_route(route_params.clone(), Ok(send_route)); @@ -3587,18 +3587,18 @@ fn retry_multi_path_single_failed_payment() { blinded_tail: None, }, ], - route_params: Some(route_params.clone()), + route_params: route_params.clone(), }; nodes[0].router.expect_find_route(route_params.clone(), Ok(route.clone())); // On retry, split the payment across both channels. route.paths[0].hops[0].fee_msat = 50_000_001; route.paths[1].hops[0].fee_msat = 50_000_000; - let mut pay_params = route.route_params.clone().unwrap().payment_params; + let mut pay_params = route.route_params.clone().payment_params; pay_params.previously_failed_channels.push(chans[1].short_channel_id.unwrap()); let mut retry_params = RouteParameters::from_payment_params_and_value(pay_params, 100_000_000); retry_params.max_total_routing_fee_msat = None; - route.route_params = Some(retry_params.clone()); + route.route_params = retry_params.clone(); nodes[0].router.expect_find_route(retry_params, Ok(route.clone())); { @@ -3694,7 +3694,7 @@ fn immediate_retry_on_failure() { }], blinded_tail: None, }], - route_params: Some(route_params.clone()), + route_params: route_params.clone(), }; nodes[0].router.expect_find_route(route_params.clone(), Ok(route.clone())); // On retry, split the payment across both channels. @@ -3705,7 +3705,7 @@ fn immediate_retry_on_failure() { let mut pay_params = route_params.payment_params.clone(); pay_params.previously_failed_channels.push(chans[0].short_channel_id.unwrap()); let retry_params = RouteParameters::from_payment_params_and_value(pay_params, amt_msat); - route.route_params = Some(retry_params.clone()); + route.route_params = retry_params.clone(); nodes[0].router.expect_find_route(retry_params, Ok(route.clone())); let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); @@ -3829,9 +3829,9 @@ fn no_extra_retries_on_back_to_back_fail() { blinded_tail: None, }, ], - route_params: Some(route_params.clone()), + route_params: route_params.clone(), }; - route.route_params.as_mut().unwrap().max_total_routing_fee_msat = None; + route.route_params.max_total_routing_fee_msat = None; nodes[0].router.expect_find_route(route_params.clone(), Ok(route.clone())); let mut second_payment_params = route_params.payment_params.clone(); second_payment_params.previously_failed_channels = vec![chan_2_scid, chan_2_scid]; @@ -3841,7 +3841,7 @@ fn no_extra_retries_on_back_to_back_fail() { let mut retry_params = RouteParameters::from_payment_params_and_value(second_payment_params, amt_msat); retry_params.max_total_routing_fee_msat = None; - route.route_params = Some(retry_params.clone()); + route.route_params = retry_params.clone(); nodes[0].router.expect_find_route(retry_params, Ok(route.clone())); // We can't use the commitment_signed_dance macro helper because in this test we'll be sending @@ -4074,7 +4074,7 @@ fn test_simple_partial_retry() { blinded_tail: None, }, ], - route_params: Some(route_params.clone()), + route_params: route_params.clone(), }; nodes[0].router.expect_find_route(route_params.clone(), Ok(route.clone())); @@ -4086,7 +4086,7 @@ fn test_simple_partial_retry() { let mut retry_params = RouteParameters::from_payment_params_and_value(second_payment_params, amt_msat / 2); retry_params.max_total_routing_fee_msat = None; - route.route_params = Some(retry_params.clone()); + route.route_params = retry_params.clone(); nodes[0].router.expect_find_route(retry_params, Ok(route.clone())); // We can't use the commitment_signed_dance macro helper because in this test we'll be sending @@ -4290,7 +4290,7 @@ fn test_threaded_payment_retries() { blinded_tail: None, }, ], - route_params: Some(route_params.clone()), + route_params: route_params.clone(), }; nodes[0].router.expect_find_route(route_params.clone(), Ok(route.clone())); @@ -4313,7 +4313,7 @@ fn test_threaded_payment_retries() { // from here on out, the retry `RouteParameters` amount will be amt/1000 route_params.final_value_msat /= 1000; - route.route_params = Some(route_params.clone()); + route.route_params = route_params.clone(); route.paths.pop(); let end_time = Instant::now() + Duration::from_secs(1); @@ -4367,7 +4367,7 @@ fn test_threaded_payment_retries() { previously_failed_channels.clone(); new_route_params.max_total_routing_fee_msat.as_mut().map(|m| *m -= 100_000); route.paths[0].hops[1].short_channel_id += 1; - route.route_params = Some(new_route_params.clone()); + route.route_params = new_route_params.clone(); nodes[0].router.expect_find_route(new_route_params, Ok(route.clone())); let bs_fail_updates = get_htlc_update_msgs(&nodes[1], &node_a_id); @@ -4773,7 +4773,7 @@ fn do_test_custom_tlvs(spontaneous: bool, even_tlvs: bool, known_tlvs: bool) { total_mpp_amount_msat: amt_msat, }; if spontaneous { - let params = route.route_params.unwrap(); + let params = route.route_params; let retry = Retry::Attempts(0); nodes[0].node.send_spontaneous_payment(Some(preimage), onion, id, params, retry).unwrap(); } else { @@ -4848,7 +4848,7 @@ fn test_retry_custom_tlvs() { // Initiate the payment let id = PaymentId(hash.0); - let mut route_params = route.route_params.clone().unwrap(); + let mut route_params = route.route_params.clone(); let custom_tlvs = vec![((1 << 16) + 1, vec![0x42u8; 16])]; let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); @@ -4888,7 +4888,7 @@ fn test_retry_custom_tlvs() { // Retry the payment and make sure it succeeds let chan_2_scid = chan_2_update.contents.short_channel_id; route_params.payment_params.previously_failed_channels.push(chan_2_scid); - route.route_params = Some(route_params.clone()); + route.route_params = route_params.clone(); nodes[0].router.expect_find_route(route_params, Ok(route)); nodes[0].node.process_pending_htlc_forwards(); check_added_monitors(&nodes[0], 1); @@ -5603,7 +5603,7 @@ fn remove_pending_outbounds_on_buggy_router() { // Extend the path by itself, essentially simulating route going through same channel twice let cloned_hops = route.paths[0].hops.clone(); route.paths[0].hops.extend_from_slice(&cloned_hops); - let route_params = route.route_params.clone().unwrap(); + let route_params = route.route_params.clone(); nodes[0].router.expect_find_route(route_params.clone(), Ok(route.clone())); // Send the payment with one retry allowed, but the payment should still fail @@ -5662,40 +5662,6 @@ fn remove_pending_outbound_probe_on_buggy_path() { assert!(nodes[0].node.list_recent_payments().is_empty()); } -#[test] -fn pay_route_without_params() { - // Make sure we can use ChannelManager::send_payment_with_route to pay a route where - // Route::route_parameters is None. - let chanmon_cfgs = create_chanmon_cfgs(2); - let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); - let nodes = create_network(2, &node_cfgs, &node_chanmgrs); - - let node_b_id = nodes[1].node.get_our_node_id(); - - create_announced_chan_between_nodes(&nodes, 0, 1); - - let amt_msat = 10_000; - let payment_params = PaymentParameters::from_node_id(node_b_id, TEST_FINAL_CLTV) - .with_bolt11_features(nodes[1].node.bolt11_invoice_features()) - .unwrap(); - let (mut route, hash, preimage, payment_secret) = - get_route_and_payment_hash!(nodes[0], nodes[1], payment_params, amt_msat); - route.route_params.take(); - - let onion = RecipientOnionFields::secret_only(payment_secret, amt_msat); - let id = PaymentId(hash.0); - nodes[0].node.send_payment_with_route(route, hash, onion, id).unwrap(); - - check_added_monitors(&nodes[0], 1); - let mut events = nodes[0].node.get_and_clear_pending_msg_events(); - assert_eq!(events.len(), 1); - let node_1_msgs = remove_first_msg_event_to_node(&node_b_id, &mut events); - let path = &[&nodes[1]]; - pass_along_path(&nodes[0], path, amt_msat, hash, Some(payment_secret), node_1_msgs, true, None); - claim_payment_along_route(ClaimAlongRouteArgs::new(&nodes[0], &[path], preimage)); -} - #[test] fn max_out_mpp_path() { // In this setup, the sender is attempting to route an MPP payment split across the two channels @@ -5984,7 +5950,7 @@ fn bolt11_multi_node_mpp_with_retry() { // First route for A: same path but with fee_msat=0 at C to trigger a forwarding failure let mut first_route = route.clone(); first_route.paths[0].hops[0].fee_msat = 0; - first_route.route_params = Some(route_params.clone()); + first_route.route_params = route_params.clone(); nodes[0].router.expect_find_route(route_params.clone(), Ok(first_route)); // Retry route for A: the natural route with correct fees (will succeed) @@ -5995,7 +5961,7 @@ fn bolt11_multi_node_mpp_with_retry() { payment_params: retry_payment_params, max_total_routing_fee_msat: route_params.max_total_routing_fee_msat, }; - route.route_params = Some(retry_route_params.clone()); + route.route_params = retry_route_params.clone(); nodes[0].router.expect_find_route(retry_route_params, Ok(route)); // Node A pays 60_000 msat (part of the total) with retry enabled diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 01889b2ea60..1c7c1326ede 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -682,10 +682,7 @@ pub struct Route { /// The `route_params` parameter passed to [`find_route`]. /// /// This is used by `ChannelManager` to track information which may be required for retries. - /// - /// Will be `None` for objects serialized with LDK versions prior to 0.0.117. This field will - /// soon move to being required and must always be set. - pub route_params: Option, + pub route_params: RouteParameters, } impl Route { @@ -698,8 +695,8 @@ impl Route { /// [`htlc_minimum_msat`]: https://github.com/lightning/bolts/blob/master/07-routing-gossip.md#the-channel_update-message #[rustfmt::skip] pub fn get_total_fees(&self) -> u64 { - let overpaid_value_msat = self.route_params.as_ref() - .map_or(0, |p| self.get_total_amount().saturating_sub(p.final_value_msat)); + let overpaid_value_msat = + self.get_total_amount().saturating_sub(self.route_params.final_value_msat); overpaid_value_msat + self.paths.iter().map(|path| path.fee_msat()).sum::() } @@ -714,105 +711,102 @@ impl Route { } pub(crate) fn debug_assert_route_meets_params(&self, logger: L) -> Result<(), ()> { - if let Some(route_params) = self.route_params.as_ref() { - // Check that we actually pay less than the max fee we set. - if let Some(max_total_fee) = route_params.max_total_routing_fee_msat { - let total_fee = self.get_total_fees(); - if total_fee > max_total_fee { - let err = format!("Router returned an attempt to pay with a higher fee ({total_fee}msat) than we allowed ({max_total_fee}msat). Your router is critically buggy!"); - debug_assert!(false, "{}", err); - log_error!(logger, "{}", err); - return Err(()); - } + let route_params = &self.route_params; + // Check that we actually pay less than the max fee we set. + if let Some(max_total_fee) = route_params.max_total_routing_fee_msat { + let total_fee = self.get_total_fees(); + if total_fee > max_total_fee { + let err = format!("Router returned an attempt to pay with a higher fee ({total_fee}msat) than we allowed ({max_total_fee}msat). Your router is critically buggy!"); + debug_assert!(false, "{}", err); + log_error!(logger, "{}", err); + return Err(()); } + } + + if self.paths.is_empty() { + let err = "Selected route had no paths. Your router is buggy!"; + debug_assert!(false, "{}", err); + log_error!(logger, "{}", err); + return Err(()); + } - if self.paths.is_empty() { - let err = "Selected route had no paths. Your router is buggy!"; + for path in self.paths.iter() { + if path.hops.is_empty() { + let err = "Unusable path in route (path.hops.len() must be at least 1)"; debug_assert!(false, "{}", err); log_error!(logger, "{}", err); return Err(()); } - for path in self.paths.iter() { - if path.hops.is_empty() { - let err = "Unusable path in route (path.hops.len() must be at least 1)"; + let total_cltv_delta = path.total_cltv_expiry_delta(); + if total_cltv_delta > route_params.payment_params.max_total_cltv_expiry_delta { + let err = format!( + "Path had a total CLTV of {total_cltv_delta} which is greater than the maximum we're allowed {}", + route_params.payment_params.max_total_cltv_expiry_delta, + ); + debug_assert!(false, "{}", err); + log_error!(logger, "{}", err); + return Err(()); + } + + if path.hops.len() > route_params.payment_params.max_path_length.into() { + let err = format!( + "Path had a length of {}, which is greater than the maximum we're allowed ({})", + path.hops.len(), + route_params.payment_params.max_path_length, + ); + #[cfg(any(test, feature = "_test_utils"))] + debug_assert!(false, "{}", err); + log_error!(logger, "{}", err); + // This is a bug, but there's not a material safety risk to making this + // payment, so we don't bother to error here. + } + + if let Some(tail) = &path.blinded_tail { + let trampoline_cltv_sum: u32 = + tail.trampoline_hops.iter().map(|hop| hop.cltv_expiry_delta).sum(); + let last_hop_cltv_delta = path.hops.last().unwrap().cltv_expiry_delta; + if !tail.trampoline_hops.is_empty() && trampoline_cltv_sum != last_hop_cltv_delta { + let err = format!( + "Path had a total trampoline CLTV of {trampoline_cltv_sum}, which is not equal to the total last-hop CLTV delta of {last_hop_cltv_delta}" + ); debug_assert!(false, "{}", err); log_error!(logger, "{}", err); - return Err(()); } - - let total_cltv_delta = path.total_cltv_expiry_delta(); - if total_cltv_delta > route_params.payment_params.max_total_cltv_expiry_delta { + let last_trampoline_cltv_opt = + tail.trampoline_hops.last().map(|h| h.cltv_expiry_delta); + let last_trampoline_cltv = last_trampoline_cltv_opt.unwrap_or(u32::MAX); + if tail.excess_final_cltv_expiry_delta > last_trampoline_cltv { let err = format!( - "Path had a total CLTV of {total_cltv_delta} which is greater than the maximum we're allowed {}", - route_params.payment_params.max_total_cltv_expiry_delta, + "Last trampoline CLTV of {last_trampoline_cltv} is less than the excess blinded path cltv of {}", + tail.excess_final_cltv_expiry_delta ); debug_assert!(false, "{}", err); log_error!(logger, "{}", err); - return Err(()); } - - if path.hops.len() > route_params.payment_params.max_path_length.into() { + if tail.excess_final_cltv_expiry_delta > last_hop_cltv_delta { let err = format!( - "Path had a length of {}, which is greater than the maximum we're allowed ({})", - path.hops.len(), - route_params.payment_params.max_path_length, + "Last path hop CLTV of {last_hop_cltv_delta} is less than the excess blinded path cltv of {}", + tail.excess_final_cltv_expiry_delta ); - #[cfg(any(test, feature = "_test_utils"))] debug_assert!(false, "{}", err); log_error!(logger, "{}", err); - // This is a bug, but there's not a material safety risk to making this - // payment, so we don't bother to error here. - } - - if let Some(tail) = &path.blinded_tail { - let trampoline_cltv_sum: u32 = - tail.trampoline_hops.iter().map(|hop| hop.cltv_expiry_delta).sum(); - let last_hop_cltv_delta = path.hops.last().unwrap().cltv_expiry_delta; - if !tail.trampoline_hops.is_empty() - && trampoline_cltv_sum != last_hop_cltv_delta - { - let err = format!( - "Path had a total trampoline CLTV of {trampoline_cltv_sum}, which is not equal to the total last-hop CLTV delta of {last_hop_cltv_delta}" - ); - debug_assert!(false, "{}", err); - log_error!(logger, "{}", err); - } - let last_trampoline_cltv_opt = - tail.trampoline_hops.last().map(|h| h.cltv_expiry_delta); - let last_trampoline_cltv = last_trampoline_cltv_opt.unwrap_or(u32::MAX); - if tail.excess_final_cltv_expiry_delta > last_trampoline_cltv { - let err = format!( - "Last trampoline CLTV of {last_trampoline_cltv} is less than the excess blinded path cltv of {}", - tail.excess_final_cltv_expiry_delta - ); - debug_assert!(false, "{}", err); - log_error!(logger, "{}", err); - } - if tail.excess_final_cltv_expiry_delta > last_hop_cltv_delta { - let err = format!( - "Last path hop CLTV of {last_hop_cltv_delta} is less than the excess blinded path cltv of {}", - tail.excess_final_cltv_expiry_delta - ); - debug_assert!(false, "{}", err); - log_error!(logger, "{}", err); - } } } + } - // Test that we don't contain any "extra" MPP parts - while we're allowed to overshoot - // the `final_value_msat` specified in the `route_params`, we aren't allowed to have - // any MPP parts which aren't needed to meet `route_params.final_value_msat`. - let min_mpp_part = self.paths.iter().map(|h| h.final_value_msat()).min().unwrap_or(0); - if self.get_total_amount() - min_mpp_part >= route_params.final_value_msat { - let err = format!( - "Router returned an attempt to include more MPP parts than needed. The smallest MPP part ({min_mpp_part}msat) was not needed for a payment of {}msat. Your router is critically buggy!", - route_params.final_value_msat - ); - debug_assert!(false, "{}", err); - log_error!(logger, "{}", err); - return Err(()); - } + // Test that we don't contain any "extra" MPP parts - while we're allowed to overshoot + // the `final_value_msat` specified in the `route_params`, we aren't allowed to have + // any MPP parts which aren't needed to meet `route_params.final_value_msat`. + let min_mpp_part = self.paths.iter().map(|h| h.final_value_msat()).min().unwrap_or(0); + if self.get_total_amount() - min_mpp_part >= route_params.final_value_msat { + let err = format!( + "Router returned an attempt to include more MPP parts than needed. The smallest MPP part ({min_mpp_part}msat) was not needed for a payment of {}msat. Your router is critically buggy!", + route_params.final_value_msat + ); + debug_assert!(false, "{}", err); + log_error!(logger, "{}", err); + return Err(()); } Ok(()) @@ -850,12 +844,10 @@ impl Writeable for Route { } else if !blinded_tails.is_empty() { blinded_tails.push(None); } } write_tlv_fields!(writer, { - // For compatibility with LDK versions prior to 0.0.117, we take the individual - // RouteParameters' fields and reconstruct them on read. - (1, self.route_params.as_ref().map(|p| &p.payment_params), option), + (1, self.route_params.payment_params, required), (2, blinded_tails, optional_vec), - (3, self.route_params.as_ref().map(|p| p.final_value_msat), option), - (5, self.route_params.as_ref().and_then(|p| p.max_total_routing_fee_msat), option), + (3, self.route_params.final_value_msat, required), + (5, self.route_params.max_total_routing_fee_msat, option), }); Ok(()) } @@ -881,9 +873,9 @@ impl Readable for Route { paths.push(Path { hops, blinded_tail: None }); } _init_and_read_len_prefixed_tlv_fields!(reader, { - (1, payment_params, (option: ReadableArgs, min_final_cltv_expiry_delta)), + (1, payment_params, (required: ReadableArgs, min_final_cltv_expiry_delta)), (2, blinded_tails, optional_vec), - (3, final_value_msat, option), + (3, final_value_msat, required), (5, max_total_routing_fee_msat, option) }); let blinded_tails = blinded_tails.unwrap_or(Vec::new()); @@ -894,12 +886,10 @@ impl Readable for Route { } } - // If we previously wrote the corresponding fields, reconstruct RouteParameters. - let route_params = match (payment_params, final_value_msat) { - (Some(payment_params), Some(final_value_msat)) => { - Some(RouteParameters { payment_params, final_value_msat, max_total_routing_fee_msat }) - } - _ => None, + let route_params = RouteParameters { + payment_params: payment_params.0.unwrap(), + final_value_msat: final_value_msat.0.unwrap(), + max_total_routing_fee_msat, }; Ok(Route { paths, route_params }) @@ -3908,7 +3898,7 @@ pub(crate) fn get_route( } } - let route = Route { paths, route_params: Some(route_params.clone()) }; + let route = Route { paths, route_params: route_params.clone() }; // Make sure we would never create a route whose total fees exceed max_total_routing_fee_msat. if let Some(max_total_routing_fee_msat) = route_params.max_total_routing_fee_msat { @@ -7504,7 +7494,7 @@ mod tests { short_channel_id: 0, fee_msat: 225, cltv_expiry_delta: 0, maybe_announced_channel: true, }, ], blinded_tail: None }], - route_params: None, + route_params: RouteParameters::from_payment_params_and_value(PaymentParameters::from_node_id(ln_test_utils::pubkey(42), 0), 225), }; assert_eq!(route.get_total_fees(), 250); @@ -7537,7 +7527,7 @@ mod tests { short_channel_id: 0, fee_msat: 150, cltv_expiry_delta: 0, maybe_announced_channel: true, }, ], blinded_tail: None }], - route_params: None, + route_params: RouteParameters::from_payment_params_and_value(PaymentParameters::from_node_id(ln_test_utils::pubkey(42), 0), 300), }; assert_eq!(route.get_total_fees(), 200); @@ -7549,7 +7539,13 @@ mod tests { // In an earlier version of `Route::get_total_fees` and `Route::get_total_amount`, they // would both panic if the route was completely empty. We test to ensure they return 0 // here, even though its somewhat nonsensical as a route. - let route = Route { paths: Vec::new(), route_params: None }; + let route = Route { + paths: Vec::new(), + route_params: RouteParameters::from_payment_params_and_value( + PaymentParameters::from_node_id(ln_test_utils::pubkey(42), 0), + 0, + ), + }; assert_eq!(route.get_total_fees(), 0); assert_eq!(route.get_total_amount(), 0); @@ -8144,7 +8140,7 @@ mod tests { cltv_expiry_delta: 0, maybe_announced_channel: true, }], blinded_tail: None }], - route_params: None, + route_params: RouteParameters::from_payment_params_and_value(PaymentParameters::from_node_id(ln_test_utils::pubkey(42), 0), 200), }; let encoded_route = route.encode(); let decoded_route: Route = Readable::read(&mut Cursor::new(&encoded_route[..])).unwrap(); @@ -8340,7 +8336,7 @@ mod tests { excess_final_cltv_expiry_delta: 0, final_value_msat: 200, }), - }], route_params: None}; + }], route_params: RouteParameters::from_payment_params_and_value(PaymentParameters::from_node_id(ln_test_utils::pubkey(42), 0), 200)}; let payment_params = PaymentParameters::from_node_id(ln_test_utils::pubkey(47), 18); let (_, network_graph, _, _, _) = build_line_graph(); diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 892c9f4169d..7af41c19586 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -240,7 +240,7 @@ impl<'a> Router for TestRouter<'a> { assert_eq!(find_route_query, *params); if let Some(res) = find_route_res { if let Ok(ref route) = res { - assert_eq!(route.route_params, Some(find_route_query)); + assert_eq!(route.route_params, find_route_query); let scorer = self.scorer.read().unwrap(); let scorer = ScorerAccountingForInFlightHtlcs::new(scorer, &inflight_htlcs); for path in &route.paths { diff --git a/pending_changelog/route-params-required.txt b/pending_changelog/route-params-required.txt new file mode 100644 index 00000000000..ea47deb1ad6 --- /dev/null +++ b/pending_changelog/route-params-required.txt @@ -0,0 +1,4 @@ +# Backwards Compatibility + * `Route`s serialized by LDK versions prior to 0.0.117 can no longer be + deserialized, as parts of the now-required `Route::route_params` were not + written by those versions. From d5d502a9ddaa42f5f3c58d5a57a6b966a57757b7 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Mon, 15 Jun 2026 16:08:10 -0400 Subject: [PATCH 504/627] Remove now-redundant PaymentParameters params In the last commit, we made Route::route_params required instead of an Option. After this change, we can modify a few methods that took a Route in addition to a separate PaymentParameters argument, since the pay params can always be retrieved from the Route now. --- lightning/src/ln/outbound_payment.rs | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 6bbe43e0d63..105ee355a9e 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -1203,14 +1203,13 @@ impl OutboundPayments { }, }; - let payment_params = Some(route_params.payment_params.clone()); let mut outbounds = self.pending_outbound_payments.lock().unwrap(); let onion_session_privs = match outbounds.entry(payment_id) { hash_map::Entry::Occupied(entry) => match entry.get() { PendingOutboundPayment::InvoiceReceived { .. } => { let (retryable_payment, onion_session_privs) = Self::create_pending_payment( payment_hash, recipient_onion.clone(), keysend_preimage, None, Some(bolt12_invoice.clone()), &route, - Some(retry_strategy), payment_params, entropy_source, best_block_height, + Some(retry_strategy), entropy_source, best_block_height, ); *entry.into_mut() = retryable_payment; onion_session_privs @@ -1221,7 +1220,7 @@ impl OutboundPayments { } else { unreachable!() }; let (retryable_payment, onion_session_privs) = Self::create_pending_payment( payment_hash, recipient_onion.clone(), keysend_preimage, Some(invreq), Some(bolt12_invoice.clone()), &route, - Some(retry_strategy), payment_params, entropy_source, best_block_height + Some(retry_strategy), entropy_source, best_block_height ); outbounds.insert(payment_id, retryable_payment); onion_session_privs @@ -1617,7 +1616,7 @@ impl OutboundPayments { let onion_session_privs = self.add_new_pending_payment(payment_hash, recipient_onion.clone(), payment_id, keysend_preimage, &route, Some(retry_strategy), - Some(route_params.payment_params.clone()), entropy_source, best_block_height, None) + entropy_source, best_block_height, None) .map_err(|_| { log_error!(logger, "Payment with id {} is already pending. New payment had payment hash {}", payment_id, payment_hash); @@ -1940,7 +1939,7 @@ impl OutboundPayments { let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret, route.get_total_amount()); let onion_session_privs = self.add_new_pending_payment(payment_hash, - recipient_onion_fields.clone(), payment_id, None, &route, None, None, + recipient_onion_fields.clone(), payment_id, None, &route, None, entropy_source, best_block_height, None ).map_err(|e| { debug_assert!(matches!(e, PaymentSendFailure::DuplicatePayment)); @@ -1996,14 +1995,14 @@ impl OutboundPayments { &self, payment_hash: PaymentHash, recipient_onion: RecipientOnionFields, payment_id: PaymentId, route: &Route, retry_strategy: Option, entropy_source: &ES, best_block_height: u32 ) -> Result, PaymentSendFailure> { - self.add_new_pending_payment(payment_hash, recipient_onion, payment_id, None, route, retry_strategy, None, entropy_source, best_block_height, None) + self.add_new_pending_payment(payment_hash, recipient_onion, payment_id, None, route, retry_strategy, entropy_source, best_block_height, None) } #[rustfmt::skip] pub(super) fn add_new_pending_payment( &self, payment_hash: PaymentHash, recipient_onion: RecipientOnionFields, payment_id: PaymentId, keysend_preimage: Option, route: &Route, retry_strategy: Option, - payment_params: Option, entropy_source: &ES, best_block_height: u32, + entropy_source: &ES, best_block_height: u32, bolt12_invoice: Option ) -> Result, PaymentSendFailure> { let mut pending_outbounds = self.pending_outbound_payments.lock().unwrap(); @@ -2012,7 +2011,7 @@ impl OutboundPayments { hash_map::Entry::Vacant(entry) => { let (payment, onion_session_privs) = Self::create_pending_payment( payment_hash, recipient_onion, keysend_preimage, None, bolt12_invoice, route, retry_strategy, - payment_params, entropy_source, best_block_height + entropy_source, best_block_height ); entry.insert(payment); Ok(onion_session_privs) @@ -2025,7 +2024,7 @@ impl OutboundPayments { payment_hash: PaymentHash, recipient_onion: RecipientOnionFields, keysend_preimage: Option, invoice_request: Option, bolt12_invoice: Option, route: &Route, retry_strategy: Option, - payment_params: Option, entropy_source: &ES, best_block_height: u32 + entropy_source: &ES, best_block_height: u32 ) -> (PendingOutboundPayment, Vec<[u8; 32]>) { let mut onion_session_privs = Vec::with_capacity(route.paths.len()); for _ in 0..route.paths.len() { @@ -2035,7 +2034,7 @@ impl OutboundPayments { let mut payment = PendingOutboundPayment::Retryable { retry_strategy, attempts: PaymentAttempts::new(), - payment_params, + payment_params: Some(route.route_params.payment_params.clone()), session_privs: new_hash_set(), pending_amt_msat: 0, pending_fee_msat: Some(0), @@ -2965,7 +2964,7 @@ mod tests { if on_retry { outbound_payments.add_new_pending_payment(PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(0), PaymentId([0; 32]), None, &Route { paths: vec![], route_params: expired_route_params.clone() }, - Some(Retry::Attempts(1)), Some(expired_route_params.payment_params.clone()), + Some(Retry::Attempts(1)), &&keys_manager, 0, None).unwrap(); outbound_payments.find_route_and_send_payment( PaymentHash([0; 32]), PaymentId([0; 32]), expired_route_params, &&router, vec![], @@ -3011,7 +3010,7 @@ mod tests { if on_retry { outbound_payments.add_new_pending_payment(PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(0), PaymentId([0; 32]), None, &Route { paths: vec![], route_params: route_params.clone() }, - Some(Retry::Attempts(1)), Some(route_params.payment_params.clone()), + Some(Retry::Attempts(1)), &&keys_manager, 0, None).unwrap(); outbound_payments.find_route_and_send_payment( PaymentHash([0; 32]), PaymentId([0; 32]), route_params, &&router, vec![], From ccc8b55f54930319e47676152ecd5bad727cafd9 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Thu, 18 Jun 2026 08:16:30 +0200 Subject: [PATCH 505/627] Fail held HTLCs on LSPS2 abandon Drain queued intercepted HTLCs before removing pending LSPS2 JIT channel state in channel_open_abandoned. Add a real interception regression test that verifies the held HTLC is no longer pending after the abandon call. --- lightning-liquidity/src/lsps2/service.rs | 36 +++--- .../tests/lsps2_integration_tests.rs | 119 ++++++++++++++++++ 2 files changed, 139 insertions(+), 16 deletions(-) diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index b4ae2db96c1..5987756be47 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -1316,6 +1316,8 @@ where /// This removes the intercept SCID, any outbound channel state, and associated /// channel‐ID mappings for the specified `user_channel_id`, but only while no payment /// has been forwarded yet and no channel has been opened on-chain. + /// Any held HTLCs for the pending flow are failed backwards before the local state + /// is removed. /// /// Returns an error if: /// - there is no channel matching `user_channel_id`, or @@ -1351,25 +1353,27 @@ where let jit_channel = peer_state .outbound_channels_by_intercept_scid - .get(&intercept_scid) + .get_mut(&intercept_scid) .ok_or_else(|| APIError::APIMisuseError { - err: format!( - "Failed to map intercept_scid {} for user_channel_id {} to a channel.", - intercept_scid, user_channel_id, - ), - })?; + err: format!( + "Failed to map intercept_scid {} for user_channel_id {} to a channel.", + intercept_scid, user_channel_id, + ), + })?; - let is_pending = matches!( - jit_channel.state, - OutboundJITChannelState::PendingInitialPayment { .. } - | OutboundJITChannelState::PendingChannelOpen { .. } - ); + let intercepted_htlcs = match &mut jit_channel.state { + OutboundJITChannelState::PendingInitialPayment { payment_queue } + | OutboundJITChannelState::PendingChannelOpen { payment_queue, .. } => payment_queue.clear(), + _ => { + return Err(APIError::APIMisuseError { + err: "Cannot abandon channel open after channel creation or payment forwarding" + .to_string(), + }); + }, + }; - if !is_pending { - return Err(APIError::APIMisuseError { - err: "Cannot abandon channel open after channel creation or payment forwarding" - .to_string(), - }); + for htlc in intercepted_htlcs { + let _ = self.channel_manager.get_cm().fail_intercepted_htlc(htlc.intercept_id); } peer_state.intercept_scid_by_user_channel_id.remove(&user_channel_id); diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 6ebf176e12d..241fabe5a72 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -682,6 +682,125 @@ fn channel_open_abandoned() { assert!(result.is_err()); } +#[test] +fn channel_open_abandoned_releases_intercepted_htlcs() { + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let mut service_node_config = test_default_channel_config(); + service_node_config.htlc_interception_flags = HTLCInterceptionFlags::ToInterceptSCIDs as u8; + + let mut client_node_config = test_default_channel_config(); + client_node_config.channel_config.accept_underpaying_htlcs = true; + + let node_chanmgrs = create_node_chanmgrs( + 3, + &node_cfgs, + &[Some(service_node_config), Some(client_node_config), None], + ); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, promise_secret) = setup_test_lsps2_nodes_with_payer(nodes); + let LSPSNodesWithPayer { ref service_node, ref client_node, ref payer_node } = lsps_nodes; + + let payer_node_id = payer_node.node.get_our_node_id(); + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + + let service_handler = service_node.liquidity_manager.lsps2_service_handler().unwrap(); + create_chan_between_nodes_with_value(&payer_node, &service_node.inner, 2_000_000, 100_000); + + let intercept_scid = service_node.node.get_intercept_scid(); + let user_channel_id = 42u128; + let cltv_expiry_delta: u32 = 144; + let payment_size_msat = Some(1_000_000); + let fee_base_msat: u64 = 1_000; + + execute_lsps2_dance( + &lsps_nodes, + intercept_scid, + user_channel_id, + cltv_expiry_delta, + promise_secret, + payment_size_msat, + fee_base_msat, + ); + + let invoice = create_jit_invoice( + &client_node, + service_node_id, + intercept_scid, + cltv_expiry_delta, + payment_size_msat, + "channel-open-abandoned-cleanup", + 3600, + ) + .unwrap(); + + payer_node + .node + .pay_for_bolt11_invoice( + &invoice, + PaymentId(invoice.payment_hash().0), + None, + OptionalBolt11PaymentParams::default(), + ) + .unwrap(); + + check_added_monitors(&payer_node, 1); + let events = payer_node.node.get_and_clear_pending_msg_events(); + let ev = SendEvent::from_event(events[0].clone()); + service_node.inner.node.handle_update_add_htlc(payer_node_id, &ev.msgs[0]); + do_commitment_signed_dance(&service_node.inner, &payer_node, &ev.commitment_msg, false, true); + service_node.inner.node.process_pending_htlc_forwards(); + + let events = service_node.inner.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + let intercept_id = match &events[0] { + Event::HTLCIntercepted { + intercept_id, + requested_next_hop_scid, + payment_hash, + expected_outbound_amount_msat, + .. + } => { + assert_eq!(*requested_next_hop_scid, intercept_scid); + service_handler + .htlc_intercepted( + *requested_next_hop_scid, + *intercept_id, + *expected_outbound_amount_msat, + *payment_hash, + ) + .unwrap(); + *intercept_id + }, + other => panic!("Expected HTLCIntercepted, got {:?}", other), + }; + + match service_node.liquidity_manager.next_event().unwrap() { + LiquidityEvent::LSPS2Service(LSPS2ServiceEvent::OpenChannel { .. }) => {}, + other => panic!("Unexpected event: {:?}", other), + }; + + service_handler.channel_open_abandoned(&client_node_id, user_channel_id).unwrap(); + + let res = service_node.inner.node.fail_intercepted_htlc(intercept_id); + assert!( + res.is_err(), + "channel_open_abandoned must release the intercepted HTLC via fail_intercepted_htlc, but the entry is still pending: {:?}", + res, + ); + + let events = service_node.inner.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match &events[0] { + Event::HTLCHandlingFailed { + failure_type: HTLCHandlingFailureType::InvalidForward { requested_forward_scid }, + .. + } => assert_eq!(*requested_forward_scid, intercept_scid), + other => panic!("Expected HTLCHandlingFailed, got {:?}", other), + } +} + #[test] fn channel_open_abandoned_nonexistent_channel() { let chanmon_cfgs = create_chanmon_cfgs(2); From 5b4626fa716b5a2dbd4eff9a83301f55867ee43e Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Wed, 1 Apr 2026 23:50:34 +0000 Subject: [PATCH 506/627] Avoid over-allocating when reading corrupted lengths for `HashMap`s Luckily this was only used in `ChannelManager` and scorer deserialization, though we anticipate occasionally fetching the second from an only semi-trusted source. --- lightning/src/util/ser.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs index b93be6446ce..4defe29fe34 100644 --- a/lightning/src/util/ser.rs +++ b/lightning/src/util/ser.rs @@ -960,7 +960,9 @@ macro_rules! impl_for_map { #[inline] fn read(r: &mut R) -> Result { let len: CollectionLength = Readable::read(r)?; - let mut ret = $constr(len.0 as usize); + let entry_size = ::core::mem::size_of::() + ::core::mem::size_of::(); + let max_alloc = MAX_BUF_SIZE / (entry_size + 1); + let mut ret = $constr(cmp::min(len.0 as usize, max_alloc)); for _ in 0..len.0 { let k = K::read(r)?; let v_opt = V::read(r)?; From 7a89362c4ae3eb97a4b3e7138146da7a67a7fc38 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Sun, 12 Apr 2026 21:07:19 +0000 Subject: [PATCH 507/627] Reject RGS snapshots that leave our graph absurdly-sized If an RGS server sends snapshots that are absurdly-sized, they can bloat a client's network graph, eventually leading to an OOM. While we generally consider RGS servers to be semi-trusted (at least in the sense that they can often simply not respond and leave a client unable to find paths) we should still avoid allowing them to OOM a client. Thus, here, we naively start ignoring new channels from an RGS server if they leave our graph 10x larger than we expect. This at least avoids the OOM even if we end up not being able to make payments. Reported by Jordan Mecom of Block's Security Team --- lightning-rapid-gossip-sync/src/lib.rs | 12 +++++++ lightning-rapid-gossip-sync/src/processing.rs | 34 ++++++++++++++++--- lightning/src/routing/gossip.rs | 5 +-- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/lightning-rapid-gossip-sync/src/lib.rs b/lightning-rapid-gossip-sync/src/lib.rs index a9653754655..70a2a79b618 100644 --- a/lightning-rapid-gossip-sync/src/lib.rs +++ b/lightning-rapid-gossip-sync/src/lib.rs @@ -147,6 +147,10 @@ impl>, L: Logger> RapidGossipSync { /// Sync gossip data from a file. /// Returns the last sync timestamp to be used the next time rapid sync data is queried. /// + /// You should consider the gossip data source as semi-trusted. It is generally the case that it + /// can DoS the client either by omitting data which leads to pathfinding failure or by bloating + /// the graph such that it leads to eventual OOM on the client. + /// /// `network_graph`: The network graph to apply the updates to /// /// `sync_path`: Path to the file where the gossip update data is located @@ -166,6 +170,10 @@ impl>, L: Logger> RapidGossipSync { /// Update network graph from binary data. /// Returns the last sync timestamp to be used the next time rapid sync data is queried. /// + /// You should consider the gossip data source as semi-trusted. It is generally the case that it + /// can DoS the client either by omitting data which leads to pathfinding failure or by bloating + /// the graph such that it leads to eventual OOM on the client. + /// /// `update_data`: `&[u8]` binary stream that comprises the update data #[cfg(feature = "std")] pub fn update_network_graph(&self, update_data: &[u8]) -> Result { @@ -176,6 +184,10 @@ impl>, L: Logger> RapidGossipSync { /// Update network graph from binary data. /// Returns the last sync timestamp to be used the next time rapid sync data is queried. /// + /// You should consider the gossip data source as semi-trusted. It is generally the case that it + /// can DoS the client either by omitting data which leads to pathfinding failure or by bloating + /// the graph such that it leads to eventual OOM on the client. + /// /// `update_data`: `&[u8]` binary stream that comprises the update data /// `current_time_unix`: `Option` optional current timestamp to verify data age pub fn update_network_graph_no_std( diff --git a/lightning-rapid-gossip-sync/src/processing.rs b/lightning-rapid-gossip-sync/src/processing.rs index cce3dc29a59..45aa1a84486 100644 --- a/lightning-rapid-gossip-sync/src/processing.rs +++ b/lightning-rapid-gossip-sync/src/processing.rs @@ -9,7 +9,9 @@ use lightning::ln::msgs::{ DecodeError, ErrorAction, LightningError, SocketAddress, UnsignedChannelUpdate, UnsignedNodeAnnouncement, }; -use lightning::routing::gossip::{NetworkGraph, NodeAlias, NodeId}; +use lightning::routing::gossip::{ + NetworkGraph, NodeAlias, NodeId, CHAN_COUNT_ESTIMATE, NODE_COUNT_ESTIMATE, +}; use lightning::util::logger::Logger; use lightning::util::ser::{BigSize, FixedLengthReader, Readable}; use lightning::{log_debug, log_given_level, log_gossip, log_trace, log_warn}; @@ -112,17 +114,27 @@ impl>, L: Logger> RapidGossipSync { } }; + const MAX_NODE_COUNT: u32 = (NODE_COUNT_ESTIMATE as u32) * 10; + const MAX_CHANNEL_COUNT: u64 = (CHAN_COUNT_ESTIMATE as u64) * 10; + let node_id_count: u32 = Readable::read(read_cursor)?; + if node_id_count > MAX_NODE_COUNT { + return Err(LightningError { + err: "RGS data contained nonsense number of nodes to update".to_owned(), + action: ErrorAction::IgnoreError, + } + .into()); + } let mut node_ids: Vec = Vec::with_capacity(core::cmp::min( node_id_count, MAX_INITIAL_NODE_ID_VECTOR_CAPACITY, ) as usize); - let network_graph = &self.network_graph; let mut node_modifications: Vec = Vec::new(); + let read_only_network_graph = network_graph.read_only(); + if parse_node_details { - let read_only_network_graph = network_graph.read_only(); for _ in 0..node_id_count { let mut pubkey_bytes = [0u8; 33]; read_cursor.read_exact(&mut pubkey_bytes)?; @@ -234,9 +246,12 @@ impl>, L: Logger> RapidGossipSync { } } + let original_graph_channel_count = read_only_network_graph.channels().len() as u32; + core::mem::drop(read_only_network_graph); + let mut previous_scid: u64 = 0; let announcement_count: u32 = Readable::read(read_cursor)?; - for _ in 0..announcement_count { + for i in 0..announcement_count { let features = Readable::read(read_cursor)?; // handle SCID @@ -281,6 +296,10 @@ impl>, L: Logger> RapidGossipSync { } } + if (original_graph_channel_count as u64) + (i as u64) > MAX_CHANNEL_COUNT { + continue; + } + let announcement_result = network_graph.add_channel_from_partial_announcement( short_channel_id, funding_sats, @@ -326,6 +345,13 @@ impl>, L: Logger> RapidGossipSync { previous_scid = 0; let update_count: u32 = Readable::read(read_cursor)?; + if update_count as u64 > MAX_CHANNEL_COUNT { + return Err(LightningError { + err: "RGS data contained nonsense number of channels to update".to_owned(), + action: ErrorAction::IgnoreError, + } + .into()); + } log_debug!(self.logger, "Processing RGS update from {} with {} nodes, {} channel announcements and {} channel updates.", latest_seen_timestamp, node_id_count, announcement_count, update_count); if update_count == 0 { diff --git a/lightning/src/routing/gossip.rs b/lightning/src/routing/gossip.rs index 71e96ea879e..6eb583e57f6 100644 --- a/lightning/src/routing/gossip.rs +++ b/lightning/src/routing/gossip.rs @@ -1765,12 +1765,13 @@ impl PartialEq for NetworkGraph { /// /// We over-allocate by a bit because ~15% more is better than the double we get if we're slightly /// too low. -const CHAN_COUNT_ESTIMATE: usize = 63_000; +pub const CHAN_COUNT_ESTIMATE: usize = 63_000; + /// In Jan, 2026 there were about 17K nodes /// /// We over-allocate by a bit because 15% more is better than the double we get if we're slightly /// too low. -const NODE_COUNT_ESTIMATE: usize = 20_000; +pub const NODE_COUNT_ESTIMATE: usize = 20_000; impl NetworkGraph { /// Creates a new, empty, network graph. From 06393eba2d2f12f13ff7a79149ec76b9895db787 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Jun 2026 14:42:54 +0200 Subject: [PATCH 508/627] Use BOLT11 invoice payee keys for payment params Payment parameters should use the canonical payee key from BOLT11 invoices. When an invoice includes an n field, using that key avoids attempting signature recovery that may legitimately be unavailable. Co-Authored-By: HAL 9000 This finding was discovered by Project Loupe --- lightning-invoice/src/lib.rs | 56 ++++++++++++++++++++++++++++--- lightning/src/ln/invoice_utils.rs | 4 +-- lightning/src/routing/router.rs | 54 +++++++++++++++++++++++++++-- 3 files changed, 105 insertions(+), 9 deletions(-) diff --git a/lightning-invoice/src/lib.rs b/lightning-invoice/src/lib.rs index 6c18e600b55..e6150cde9af 100644 --- a/lightning-invoice/src/lib.rs +++ b/lightning-invoice/src/lib.rs @@ -1498,17 +1498,22 @@ impl Bolt11Invoice { self.signed_invoice.features() } - /// Recover the payee's public key (only to be used if none was included in the invoice) + /// Get the invoice's payee public key. + /// + /// This uses the explicitly included payee public key, if present, otherwise it recovers the + /// payee public key from the signature. Prefer [`Self::get_payee_pub_key`] for clarity. pub fn recover_payee_pub_key(&self) -> PublicKey { - self.signed_invoice.recover_payee_pub_key().expect("was checked by constructor").0 + self.get_payee_pub_key() } - /// Recover the payee's public key if one was included in the invoice, otherwise return the - /// recovered public key from the signature + /// Get the invoice's payee public key, preferring an explicitly included payee public key and + /// falling back to recovering the key from the signature. pub fn get_payee_pub_key(&self) -> PublicKey { match self.payee_pub_key() { Some(pk) => *pk, - None => self.recover_payee_pub_key(), + None => { + self.signed_invoice.recover_payee_pub_key().expect("was checked by constructor").0 + }, } } @@ -2057,6 +2062,47 @@ mod test { assert!(new_signed.check_signature()); } + #[test] + fn recover_payee_pub_key_uses_included_payee_pub_key() { + use crate::{ + Bolt11Invoice, Bolt11InvoiceSignature, Currency, InvoiceBuilder, PaymentHash, + PaymentSecret, SignedRawBolt11Invoice, + }; + use bitcoin::secp256k1::ecdsa::{RecoverableSignature, RecoveryId}; + use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + use core::time::Duration; + + let secp_ctx = Secp256k1::new(); + let private_key = SecretKey::from_slice(&[42; 32]).unwrap(); + let public_key = PublicKey::from_secret_key(&secp_ctx, &private_key); + + let invoice = InvoiceBuilder::new(Currency::Bitcoin) + .description("Test".to_string()) + .payment_hash(PaymentHash([0; 32])) + .payment_secret(PaymentSecret([21; 32])) + .payee_pub_key(public_key) + .min_final_cltv_expiry_delta(144) + .duration_since_epoch(Duration::from_secs(1234567)) + .build_signed(|hash| secp_ctx.sign_ecdsa_recoverable(hash, &private_key)) + .unwrap(); + + let signed_raw = invoice.into_signed_raw(); + let (raw_invoice, hash, signature) = signed_raw.into_parts(); + let (_orig_rid, sig_bytes) = signature.0.serialize_compact(); + let bad_rid = RecoveryId::from_i32(2).unwrap(); + let bad_sig = RecoverableSignature::from_compact(&sig_bytes, bad_rid).unwrap(); + let bad_signed_raw = SignedRawBolt11Invoice { + raw_invoice, + hash, + signature: Bolt11InvoiceSignature(bad_sig), + }; + let bad_invoice = Bolt11Invoice::from_signed(bad_signed_raw).unwrap(); + + assert_eq!(bad_invoice.payee_pub_key(), Some(&public_key)); + assert_eq!(bad_invoice.recover_payee_pub_key(), public_key); + assert_eq!(bad_invoice.get_payee_pub_key(), public_key); + } + #[test] fn test_check_feature_bits() { use crate::TaggedField::*; diff --git a/lightning/src/ln/invoice_utils.rs b/lightning/src/ln/invoice_utils.rs index 98996fa28bb..10cda068b68 100644 --- a/lightning/src/ln/invoice_utils.rs +++ b/lightning/src/ln/invoice_utils.rs @@ -1281,7 +1281,7 @@ mod test { assert!(!invoice.features().unwrap().supports_basic_mpp()); let payment_params = PaymentParameters::from_node_id( - invoice.recover_payee_pub_key(), + invoice.get_payee_pub_key(), invoice.min_final_cltv_expiry_delta() as u32, ) .with_bolt11_features(invoice.features().unwrap().clone()) @@ -1347,7 +1347,7 @@ mod test { payment_secret, payment_amt, payment_preimage_opt, - invoice.recover_payee_pub_key(), + invoice.get_payee_pub_key(), ); do_claim_payment_along_route(ClaimAlongRouteArgs::new( &nodes[0], diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 364bd86704e..99d9623cefb 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -1177,7 +1177,7 @@ impl PaymentParameters { /// [`PaymentParameters::expiry_time`]. pub fn from_bolt11_invoice(invoice: &Bolt11Invoice) -> Self { let mut payment_params = Self::from_node_id( - invoice.recover_payee_pub_key(), + invoice.get_payee_pub_key(), invoice.min_final_cltv_expiry_delta() as u32, ) .with_route_hints(invoice.route_hints()) @@ -4094,7 +4094,7 @@ mod tests { use crate::routing::gossip::{EffectiveCapacity, NetworkGraph, NodeId, P2PGossipSync}; use crate::routing::router::{ add_random_cltv_offset, build_route_from_hops_internal, default_node_features, get_route, - BlindedPathCandidate, BlindedTail, CandidateRouteHop, InFlightHtlcs, Path, + BlindedPathCandidate, BlindedTail, CandidateRouteHop, InFlightHtlcs, Path, Payee, PaymentParameters, PublicHopCandidate, Route, RouteHint, RouteHintHop, RouteHop, RouteParameters, RoutingFees, ScorerAccountingForInFlightHtlcs, DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA, MAX_PATH_LENGTH_ESTIMATE, @@ -4113,6 +4113,8 @@ mod tests { use crate::util::test_utils as ln_test_utils; use bitcoin::amount::Amount; + use bitcoin::bech32::primitives::decode::CheckedHrpstring; + use bitcoin::bech32::{ByteIterExt, Fe32IterExt}; use bitcoin::constants::ChainHash; use bitcoin::hashes::Hash; use bitcoin::hex::FromHex; @@ -4124,10 +4126,58 @@ mod tests { use bitcoin::transaction::TxOut; use chacha20_poly1305::chacha20::ChaCha20; use chacha20_poly1305::{Key, Nonce}; + use lightning_invoice::{Bolt11Bech32, Bolt11Invoice, Currency, InvoiceBuilder}; use crate::io::Cursor; use crate::prelude::*; use crate::sync::{Arc, Mutex}; + use crate::types::payment::{PaymentHash, PaymentSecret}; + + fn invoice_with_included_payee_pub_key_and_bad_recovery_id() -> (Bolt11Invoice, PublicKey) { + let secp_ctx = Secp256k1::new(); + let private_key = SecretKey::from_slice(&[42; 32]).unwrap(); + let public_key = PublicKey::from_secret_key(&secp_ctx, &private_key); + + let invoice = InvoiceBuilder::new(Currency::Bitcoin) + .description("Test".to_string()) + .amount_milli_satoshis(1000) + .payment_hash(PaymentHash([0; 32])) + .payment_secret(PaymentSecret([21; 32])) + .payee_pub_key(public_key) + .min_final_cltv_expiry_delta(144) + .duration_since_epoch(core::time::Duration::from_secs(1234567)) + .build_signed(|hash| secp_ctx.sign_ecdsa_recoverable(hash, &private_key)) + .unwrap(); + + let invoice_string = invoice.to_string(); + let parsed = CheckedHrpstring::new::(&invoice_string).unwrap(); + let hrp = parsed.hrp(); + let mut data: Vec<_> = parsed.fe32_iter::<&mut dyn Iterator>().collect(); + let signature_start = data.len() - 104; + let mut signature_bytes: Vec = + data[signature_start..].iter().copied().fes_to_bytes().collect(); + signature_bytes[64] = 2; + let signature_data: Vec<_> = signature_bytes.into_iter().bytes_to_fes().collect(); + data.splice(signature_start.., signature_data); + + let bad_invoice_string = data + .into_iter() + .with_checksum::(&hrp) + .chars() + .collect::(); + (bad_invoice_string.parse().unwrap(), public_key) + } + + #[test] + fn payment_params_from_bolt11_invoice_uses_included_payee_pub_key() { + let (invoice, public_key) = invoice_with_included_payee_pub_key_and_bad_recovery_id(); + let payment_params = PaymentParameters::from_bolt11_invoice(&invoice); + + match payment_params.payee { + Payee::Clear { node_id, .. } => assert_eq!(node_id, public_key), + Payee::Blinded { .. } => panic!("BOLT11 invoice should create a clear payee"), + } + } #[rustfmt::skip] fn get_channel_details(short_channel_id: Option, node_id: PublicKey, From 851d03f3441122e32ec42ca61ef26b528b191fda Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Jun 2026 15:07:43 +0200 Subject: [PATCH 509/627] Return optional recovered BOLT11 payee keys Recovering a BOLT11 payee key can fail even when an invoice includes a valid n field. Return the recovery result as an option and document get_payee_pub_key as the canonical accessor. Co-Authored-By: HAL 9000 This finding was discovered by Project Loupe --- lightning-invoice/src/lib.rs | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/lightning-invoice/src/lib.rs b/lightning-invoice/src/lib.rs index e6150cde9af..3826adc0e3f 100644 --- a/lightning-invoice/src/lib.rs +++ b/lightning-invoice/src/lib.rs @@ -1478,7 +1478,7 @@ impl Bolt11Invoice { unreachable!("ensured by constructor"); } - /// Get the payee's public key if one was included in the invoice + /// Get the payee's public key if one was explicitly included in the invoice's `n` field. pub fn payee_pub_key(&self) -> Option<&PublicKey> { self.signed_invoice.payee_pub_key().map(|x| &x.0) } @@ -1498,12 +1498,13 @@ impl Bolt11Invoice { self.signed_invoice.features() } - /// Get the invoice's payee public key. + /// Recover the payee's public key from the invoice signature. /// - /// This uses the explicitly included payee public key, if present, otherwise it recovers the - /// payee public key from the signature. Prefer [`Self::get_payee_pub_key`] for clarity. - pub fn recover_payee_pub_key(&self) -> PublicKey { - self.get_payee_pub_key() + /// This attempts signature recovery regardless of whether a payee public key was explicitly + /// included in the invoice's `n` field. Recovery can fail for a valid invoice with an included + /// `n` field, so [`Self::get_payee_pub_key`] should be used to obtain the invoice's payee key. + pub fn recover_payee_pub_key(&self) -> Option { + self.signed_invoice.recover_payee_pub_key().ok().map(|p| p.0) } /// Get the invoice's payee public key, preferring an explicitly included payee public key and @@ -1511,9 +1512,7 @@ impl Bolt11Invoice { pub fn get_payee_pub_key(&self) -> PublicKey { match self.payee_pub_key() { Some(pk) => *pk, - None => { - self.signed_invoice.recover_payee_pub_key().expect("was checked by constructor").0 - }, + None => self.recover_payee_pub_key().expect("was checked by constructor"), } } @@ -2063,7 +2062,7 @@ mod test { } #[test] - fn recover_payee_pub_key_uses_included_payee_pub_key() { + fn recover_payee_pub_key_returns_signature_recovery_result() { use crate::{ Bolt11Invoice, Bolt11InvoiceSignature, Currency, InvoiceBuilder, PaymentHash, PaymentSecret, SignedRawBolt11Invoice, @@ -2076,17 +2075,28 @@ mod test { let private_key = SecretKey::from_slice(&[42; 32]).unwrap(); let public_key = PublicKey::from_secret_key(&secp_ctx, &private_key); - let invoice = InvoiceBuilder::new(Currency::Bitcoin) + let invoice_without_payee_pub_key = InvoiceBuilder::new(Currency::Bitcoin) .description("Test".to_string()) .payment_hash(PaymentHash([0; 32])) .payment_secret(PaymentSecret([21; 32])) + .min_final_cltv_expiry_delta(144) + .duration_since_epoch(Duration::from_secs(1234567)) + .build_signed(|hash| secp_ctx.sign_ecdsa_recoverable(hash, &private_key)) + .unwrap(); + assert_eq!(invoice_without_payee_pub_key.recover_payee_pub_key(), Some(public_key)); + assert_eq!(invoice_without_payee_pub_key.get_payee_pub_key(), public_key); + + let invoice_with_payee_pub_key = InvoiceBuilder::new(Currency::Bitcoin) + .description("Test".to_string()) + .payment_hash(PaymentHash([1; 32])) + .payment_secret(PaymentSecret([21; 32])) .payee_pub_key(public_key) .min_final_cltv_expiry_delta(144) .duration_since_epoch(Duration::from_secs(1234567)) .build_signed(|hash| secp_ctx.sign_ecdsa_recoverable(hash, &private_key)) .unwrap(); - let signed_raw = invoice.into_signed_raw(); + let signed_raw = invoice_with_payee_pub_key.into_signed_raw(); let (raw_invoice, hash, signature) = signed_raw.into_parts(); let (_orig_rid, sig_bytes) = signature.0.serialize_compact(); let bad_rid = RecoveryId::from_i32(2).unwrap(); @@ -2099,7 +2109,7 @@ mod test { let bad_invoice = Bolt11Invoice::from_signed(bad_signed_raw).unwrap(); assert_eq!(bad_invoice.payee_pub_key(), Some(&public_key)); - assert_eq!(bad_invoice.recover_payee_pub_key(), public_key); + assert_eq!(bad_invoice.recover_payee_pub_key(), None); assert_eq!(bad_invoice.get_payee_pub_key(), public_key); } From 837763a6179a31fad25f4f33984b0da837ee8bd4 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 5 May 2026 20:48:43 +0200 Subject: [PATCH 510/627] Reject pre-epoch `LSPSDateTime` at parse time `LSPSDateTime::is_past` coerced `chrono`'s `i64` timestamp into a `u64` via `try_into().expect(...)`. Because `LSPSDateTime` is parsed from peer-controlled RFC 3339 strings (which can be pre-1970 and so yield negative timestamps), this could be triggered remotely: an attacker-supplied `valid_until` / `expires_at` field of e.g. `"1900-01-01T00:00:00Z"` would parse successfully, land in LSPS state before any HMAC / promise check, and panic the LSP thread on the next `prune_pending_requests` sweep. Concretely reachable today via LSPS2 `opening_fee_params.valid_until` (in the buy request) and the LSPS1 expiry fields. Make `LSPSDateTime::from_str` reject pre-epoch datetimes, and route serde deserialization through it: `#[serde(transparent)]` was delegating Deserialize directly to `chrono`'s impl and bypassing our parser, so peer JSON had to be guarded separately. With both paths funnelled through one parser, no `LSPSDateTime` value with a negative inner timestamp can be constructed and `is_past` is safe by construction. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps0/ser.rs | 32 +++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/lightning-liquidity/src/lsps0/ser.rs b/lightning-liquidity/src/lsps0/ser.rs index d28bba75e52..1ac900b88fe 100644 --- a/lightning-liquidity/src/lsps0/ser.rs +++ b/lightning-liquidity/src/lsps0/ser.rs @@ -234,7 +234,7 @@ impl Readable for LSPSRequestId { } /// An object representing datetimes as described in bLIP-50 / LSPS0. -#[derive(Clone, Debug, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)] +#[derive(Clone, Debug, Copy, PartialEq, Eq, Hash, Serialize)] #[serde(transparent)] pub struct LSPSDateTime(pub chrono::DateTime); @@ -275,8 +275,23 @@ impl LSPSDateTime { impl FromStr for LSPSDateTime { type Err = (); fn from_str(s: &str) -> Result { - let datetime = chrono::DateTime::parse_from_rfc3339(s).map_err(|_| ())?; - Ok(Self(datetime.into())) + let datetime: chrono::DateTime = + chrono::DateTime::parse_from_rfc3339(s).map_err(|_| ())?.into(); + // Reject pre-epoch datetimes here so peer-controlled `valid_until` / + // `expires_at` fields can never produce an `LSPSDateTime` with a negative + // UNIX timestamp, which would otherwise panic the `i64 -> u64` cast in + // `is_past`. + if datetime.timestamp() < 0 { + return Err(()); + } + Ok(Self(datetime)) + } +} + +impl<'de> Deserialize<'de> for LSPSDateTime { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::from_str(&s).map_err(|()| de::Error::custom("invalid LSPSDateTime")) } } @@ -996,4 +1011,15 @@ mod tests { assert_eq!(later.duration_since(&earlier), Duration::from_secs(60)); assert_eq!(earlier.duration_since(&later), Duration::ZERO); } + + #[test] + fn is_past_handles_pre_epoch_datetime() { + // A peer-controlled RFC3339 datetime before 1970 must be rejected at parse + // time, so it can never reach `is_past` (or any other consumer) and panic. + assert!(LSPSDateTime::from_str("1900-01-01T00:00:00Z").is_err()); + + // JSON deserialization (the path peer messages take) must reject it too. + let json = "\"1900-01-01T00:00:00Z\""; + assert!(serde_json::from_str::(json).is_err()); + } } From e2f611e91b3f6edb4345e59656affef8a3a303d0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 19 Mar 2026 13:07:04 +0100 Subject: [PATCH 511/627] Truncate logged peer message strings Counterparty-provided strings in network messages (Error, Warning, TxAbort) were logged without length limits, allowing a malicious peer to bloat log files. Some logging sites also lacked the same sanitization used for other untrusted strings. Add a `DebugMsg` struct and `log_msg!` macro that consistently truncate messages to 512 characters while preserving `PrintableString` sanitization. Replace all bare `msg.data` and ad hoc `PrintableString(&msg.data)` usages at the 7 relevant logging sites in `peer_handler.rs` and `channel.rs`. Co-Authored-By: HAL 9000 --- lightning/src/ln/channel.rs | 9 ++-- lightning/src/ln/peer_handler.rs | 15 +++--- lightning/src/util/macro_logger.rs | 85 ++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 12 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index d0fc940eb62..ab9c964e5cb 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2070,9 +2070,12 @@ where let tx_abort = should_ack.then(|| { let logger = WithChannelContext::from(logger, &self.context(), None); - let reason = - types::string::UntrustedString(String::from_utf8_lossy(&msg.data).to_string()); - log_info!(logger, "Counterparty failed interactive transaction negotiation: {reason}"); + let reason = String::from_utf8_lossy(&msg.data); + log_info!( + logger, + "Counterparty failed interactive transaction negotiation: {}", + log_msg!(reason) + ); msgs::TxAbort { channel_id: msg.channel_id, data: "Acknowledged tx_abort".to_string().into_bytes(), diff --git a/lightning/src/ln/peer_handler.rs b/lightning/src/ln/peer_handler.rs index 69d0815e8f0..2cc2b9c843b 100644 --- a/lightning/src/ln/peer_handler.rs +++ b/lightning/src/ln/peer_handler.rs @@ -45,7 +45,6 @@ use crate::onion_message::packet::OnionMessageContents; use crate::routing::gossip::{NodeAlias, NodeId}; use crate::sign::{NodeSigner, Recipient}; use crate::types::features::{InitFeatures, NodeFeatures}; -use crate::types::string::PrintableString; use crate::util::atomic_counter::AtomicCounter; use crate::util::logger::{Level, Logger, WithContext}; use crate::util::ser::{VecWriter, Writeable, Writer}; @@ -2384,7 +2383,7 @@ impl< logger, "Got Err message from {}: {}", their_node_id, - PrintableString(&msg.data) + log_msg!(msg.data) ); self.message_handler.chan_handler.handle_error(their_node_id, &msg); if msg.channel_id.is_zero() { @@ -2392,7 +2391,7 @@ impl< } }, Message::Warning(msg) => { - log_debug!(logger, "Got warning message: {}", PrintableString(&msg.data)); + log_debug!(logger, "Got warning message: {}", log_msg!(msg.data)); }, Message::Ping(msg) => { @@ -3246,7 +3245,7 @@ impl< msgs::ErrorAction::DisconnectPeer { msg } => { if let Some(msg) = msg.as_ref() { log_trace!(logger, "Handling DisconnectPeer HandleError event in peer_handler with message {}", - msg.data); + log_msg!(msg.data)); } else { log_trace!(logger, "Handling DisconnectPeer HandleError event in peer_handler", ); @@ -3260,7 +3259,7 @@ impl< }, msgs::ErrorAction::DisconnectPeerWithWarning { msg } => { log_trace!(logger, "Handling DisconnectPeer HandleError event in peer_handler with message {}", - msg.data); + log_msg!(msg.data)); // We do not have the peers write lock, so we just store that we're // about to disconnect the peer and do it after we finish // processing most messages. @@ -3283,8 +3282,7 @@ impl< }, msgs::ErrorAction::SendErrorMessage { msg } => { log_trace!(logger, "Handling SendErrorMessage HandleError event in peer_handler with message {}", - - msg.data); + log_msg!(msg.data)); let msg = Message::Error(msg); self.enqueue_message( &mut *get_peer_for_forwarding!(&node_id)?, @@ -3293,8 +3291,7 @@ impl< }, msgs::ErrorAction::SendWarningMessage { msg, ref log_level } => { log_given_level!(logger, *log_level, "Handling SendWarningMessage HandleError event in peer_handler with message {}", - - msg.data); + log_msg!(msg.data)); let msg = Message::Warning(msg); self.enqueue_message( &mut *get_peer_for_forwarding!(&node_id)?, diff --git a/lightning/src/util/macro_logger.rs b/lightning/src/util/macro_logger.rs index 12f4f67962e..92f6d9767dc 100644 --- a/lightning/src/util/macro_logger.rs +++ b/lightning/src/util/macro_logger.rs @@ -169,6 +169,33 @@ macro_rules! log_spendable { }; } +/// The maximum number of characters to display in a network message log entry. +pub(crate) const LOG_MSG_MAX_LEN: usize = 512; + +/// Wraps a string slice for Display, truncating to [`LOG_MSG_MAX_LEN`] characters and +/// delegating sanitization to [`crate::types::string::PrintableString`]. +/// Useful for logging counterparty-provided messages. +pub(crate) struct DebugMsg<'a>(pub &'a str); +impl<'a> core::fmt::Display for DebugMsg<'a> { + fn fmt(&self, f: &mut core::fmt::Formatter) -> Result<(), core::fmt::Error> { + let (msg, was_truncated) = match self.0.char_indices().nth(LOG_MSG_MAX_LEN) { + Some((idx, _)) => (&self.0[..idx], true), + None => (self.0, false), + }; + core::fmt::Display::fmt(&crate::types::string::PrintableString(msg), f)?; + if was_truncated { + f.write_str("...")?; + } + Ok(()) + } +} + +macro_rules! log_msg { + ($obj: expr) => { + $crate::util::macro_logger::DebugMsg(&$obj) + }; +} + /// Create a new Record and log it. You probably don't want to use this macro directly, /// but it needs to be exported so `log_trace` etc can use it in external crates. #[doc(hidden)] @@ -226,3 +253,61 @@ macro_rules! log_gossip { $crate::log_given_level!($logger, $crate::util::logger::Level::Gossip, $($arg)*); ) } + +#[cfg(test)] +mod tests { + use super::*; + use alloc::string::ToString; + + #[test] + fn debug_msg_short_string() { + let s = "hello world"; + assert_eq!(DebugMsg(s).to_string(), "hello world"); + } + + #[test] + fn debug_msg_truncates_at_limit() { + let s: String = core::iter::repeat('a').take(LOG_MSG_MAX_LEN + 100).collect(); + let result = DebugMsg(&s).to_string(); + // Should be exactly LOG_MSG_MAX_LEN 'a's followed by "..." + assert_eq!(result.len(), LOG_MSG_MAX_LEN + 3); + assert!(result.ends_with("...")); + } + + #[test] + fn debug_msg_no_truncation_at_exact_limit() { + let s: String = core::iter::repeat('a').take(LOG_MSG_MAX_LEN).collect(); + let result = DebugMsg(&s).to_string(); + assert_eq!(result.len(), LOG_MSG_MAX_LEN); + assert!(!result.ends_with("...")); + } + + #[test] + fn debug_msg_replaces_control_characters() { + let s = "hello\x00world\nfoo"; + let result = DebugMsg(s).to_string(); + assert_eq!(result, "hello\u{FFFD}world\u{FFFD}foo"); + } + + #[test] + fn debug_msg_uses_printable_string_sanitization() { + let s = "safe\u{202E}cipsxe.exe"; + assert_eq!(DebugMsg(s).to_string(), crate::types::string::PrintableString(s).to_string()); + } + + #[test] + fn debug_msg_multibyte_unicode() { + // Each emoji is multiple bytes but one character + let s: String = core::iter::repeat('\u{1F600}').take(LOG_MSG_MAX_LEN + 10).collect(); + let result = DebugMsg(&s).to_string(); + let char_count: usize = result.chars().count(); + // LOG_MSG_MAX_LEN emoji chars + 3 chars for "..." + assert_eq!(char_count, LOG_MSG_MAX_LEN + 3); + assert!(result.ends_with("...")); + } + + #[test] + fn debug_msg_empty_string() { + assert_eq!(DebugMsg("").to_string(), ""); + } +} From b7c9935be7d59290c11b27967021c486b65b046d Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 16 Jun 2026 01:02:34 +0000 Subject: [PATCH 512/627] Get real rand in `possiblyrandom` on supported platforms w/o feat It turns out that conditionally-enabling a dependency via `target` in `Cargo.toml` does not enable the corresponding dependency `feature` when compiling the code. As a result, only when building `possiblyrandom` with an explicit `getrandom` feature did we ever actually return random values. This fixes this by matching the `target` cfg in `Cargo.toml` to the cfg in `lib.rs`. Reported by Project Loupe --- possiblyrandom/src/lib.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/possiblyrandom/src/lib.rs b/possiblyrandom/src/lib.rs index 9cbbad7f13d..6ddbc6de1a2 100644 --- a/possiblyrandom/src/lib.rs +++ b/possiblyrandom/src/lib.rs @@ -20,16 +20,19 @@ #![no_std] -#[cfg(feature = "getrandom")] +#[cfg(any( + feature = "getrandom", + not(any(target_os = "unknown", target_os = "none")) +))] extern crate getrandom; /// Possibly fills `dest` with random data. May fill it with zeros. #[inline] pub fn getpossiblyrandom(dest: &mut [u8]) { - #[cfg(feature = "getrandom")] - if getrandom::getrandom(dest).is_err() { - dest.fill(0); - } - #[cfg(not(feature = "getrandom"))] dest.fill(0); + #[cfg(any( + feature = "getrandom", + not(any(target_os = "unknown", target_os = "none")) + ))] + let _ = getrandom::getrandom(dest); } From ae852b58a7fa0026a042f35a66990363cb97bce4 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Sat, 11 Apr 2026 11:22:08 +0000 Subject: [PATCH 513/627] Fix string slicing in TXT record validation Rust's panicy string slicing behavior has always been a sharp edge and here it finally caught up with us. Ensure we don't slice into a string provided in an onion message until we're sure the index is a character boundary. Reported by Jordan Mecom of Block's Security Team --- lightning/src/onion_message/dns_resolution.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lightning/src/onion_message/dns_resolution.rs b/lightning/src/onion_message/dns_resolution.rs index 5f68fa732d9..67d91bc99eb 100644 --- a/lightning/src/onion_message/dns_resolution.rs +++ b/lightning/src/onion_message/dns_resolution.rs @@ -537,7 +537,8 @@ impl OMNameResolver { .filter_map(|data| String::from_utf8(data).ok()) .filter(|data_string| data_string.len() > URI_PREFIX.len()) .filter(|data_string| { - data_string[..URI_PREFIX.len()].eq_ignore_ascii_case(URI_PREFIX) + let pfx = &data_string.as_bytes()[..URI_PREFIX.len()]; + pfx.eq_ignore_ascii_case(URI_PREFIX.as_bytes()) }); // Check that there is exactly one TXT record that begins with // bitcoin: as required by BIP 353 (and is valid UTF-8). From bc01a548c7611350fdd718fc2b213b9a0facf1b1 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Fri, 19 Jun 2026 13:13:22 +0200 Subject: [PATCH 514/627] Format possiblyrandom cfg attributes Run rustfmt on the possiblyrandom crate so its cfg attributes match the current formatting rules. --- possiblyrandom/src/lib.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/possiblyrandom/src/lib.rs b/possiblyrandom/src/lib.rs index 6ddbc6de1a2..f27788d03fa 100644 --- a/possiblyrandom/src/lib.rs +++ b/possiblyrandom/src/lib.rs @@ -20,19 +20,13 @@ #![no_std] -#[cfg(any( - feature = "getrandom", - not(any(target_os = "unknown", target_os = "none")) -))] +#[cfg(any(feature = "getrandom", not(any(target_os = "unknown", target_os = "none"))))] extern crate getrandom; /// Possibly fills `dest` with random data. May fill it with zeros. #[inline] pub fn getpossiblyrandom(dest: &mut [u8]) { dest.fill(0); - #[cfg(any( - feature = "getrandom", - not(any(target_os = "unknown", target_os = "none")) - ))] + #[cfg(any(feature = "getrandom", not(any(target_os = "unknown", target_os = "none"))))] let _ = getrandom::getrandom(dest); } From e9e3060b596f3be2cdbb26d42ebbf952d901f7fa Mon Sep 17 00:00:00 2001 From: Abeeujah Date: Mon, 22 Jun 2026 12:54:34 +0100 Subject: [PATCH 515/627] Simplify ChannelUnavailable APIError handling with let-else Refactor the nested match statement used during error construction into a more idiomatic let-else construct (stabilised in Rust 1.65). The previous implementation required verbose, nested matching to navigate around borrow checker limitations. By leveraging let-else alongside chaining unwrap_err on handle_error Result, we achieve the same teardown logic (dropping state locks) and error mapping with significantly less boilerplate and nesting. --- lightning/src/ln/channelmanager.rs | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 49392264709..123d26d2eae 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -11465,26 +11465,11 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }, }; - // We have to match below instead of map_err on the above as in the map_err closure the borrow checker - // would consider peer_state moved even though we would bail out with the `?` operator. - let (channel_id, mut channel, message_send_event) = match res { - Ok(res) => res, - Err(err) => { - mem::drop(peer_state_lock); - mem::drop(per_peer_state); - // TODO(dunxen): Find/make less icky way to do this. - match self.handle_error( - Result::<(), MsgHandleErrInternal>::Err(err), - *counterparty_node_id, - ) { - Ok(_) => { - unreachable!("`handle_error` only returns Err as we've passed in an Err") - }, - Err(e) => { - return Err(APIError::ChannelUnavailable { err: e.err }); - }, - } - }, + let Ok((channel_id, mut channel, message_send_event)) = res else { + mem::drop(peer_state_lock); + mem::drop(per_peer_state); + let e = self.handle_error::<()>(res.map(|_| ()), *counterparty_node_id).unwrap_err(); + return Err(APIError::ChannelUnavailable { err: e.err }); }; if trusted_channel_features.is_some_and(|f| f.is_0conf()) { From b4f74165e3a292f0d68890a75a5d5faf9ba699a2 Mon Sep 17 00:00:00 2001 From: Abeeujah Date: Tue, 23 Jun 2026 17:36:50 +0100 Subject: [PATCH 516/627] Drop duplicate Hasher import --- lightning/src/ln/channelmanager.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 123d26d2eae..2d7370bb15e 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -19829,7 +19829,7 @@ impl< #[cfg(test)] let reconstruct_manager_from_monitors = args.reconstruct_manager_from_monitors.unwrap_or_else(|| { - use core::hash::{BuildHasher, Hasher}; + use core::hash::BuildHasher; match std::env::var("LDK_TEST_REBUILD_MGR_FROM_MONITORS") { Ok(val) => match val.as_str() { From 999ab2dbac2207938cfa86d15cbdcd657bdc7fab Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Fri, 19 Jun 2026 14:21:06 +0200 Subject: [PATCH 517/627] Fix stable clippy string repeat lint Use str::repeat in DebugMsg tests to satisfy current stable clippy. --- lightning/src/util/macro_logger.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lightning/src/util/macro_logger.rs b/lightning/src/util/macro_logger.rs index 92f6d9767dc..66b6720292b 100644 --- a/lightning/src/util/macro_logger.rs +++ b/lightning/src/util/macro_logger.rs @@ -267,7 +267,7 @@ mod tests { #[test] fn debug_msg_truncates_at_limit() { - let s: String = core::iter::repeat('a').take(LOG_MSG_MAX_LEN + 100).collect(); + let s = "a".repeat(LOG_MSG_MAX_LEN + 100); let result = DebugMsg(&s).to_string(); // Should be exactly LOG_MSG_MAX_LEN 'a's followed by "..." assert_eq!(result.len(), LOG_MSG_MAX_LEN + 3); @@ -276,7 +276,7 @@ mod tests { #[test] fn debug_msg_no_truncation_at_exact_limit() { - let s: String = core::iter::repeat('a').take(LOG_MSG_MAX_LEN).collect(); + let s = "a".repeat(LOG_MSG_MAX_LEN); let result = DebugMsg(&s).to_string(); assert_eq!(result.len(), LOG_MSG_MAX_LEN); assert!(!result.ends_with("...")); @@ -298,7 +298,7 @@ mod tests { #[test] fn debug_msg_multibyte_unicode() { // Each emoji is multiple bytes but one character - let s: String = core::iter::repeat('\u{1F600}').take(LOG_MSG_MAX_LEN + 10).collect(); + let s = "\u{1F600}".repeat(LOG_MSG_MAX_LEN + 10); let result = DebugMsg(&s).to_string(); let char_count: usize = result.chars().count(); // LOG_MSG_MAX_LEN emoji chars + 3 chars for "..." From 87ea15c3ab807f27e6fb68b7489dce49d0dd5924 Mon Sep 17 00:00:00 2001 From: elnosh Date: Tue, 23 Jun 2026 16:47:04 -0400 Subject: [PATCH 518/627] Check metadata length for bolt11 invoices Check that the metadata when creating a bolt11 invoice is below the bolt11 limit of 639 bytes for tagged fields. --- lightning-invoice/src/lib.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/lightning-invoice/src/lib.rs b/lightning-invoice/src/lib.rs index 3826adc0e3f..2dfd752bc81 100644 --- a/lightning-invoice/src/lib.rs +++ b/lightning-invoice/src/lib.rs @@ -159,6 +159,10 @@ pub const DEFAULT_MIN_FINAL_CLTV_EXPIRY_DELTA: u64 = 18; /// consistency is more important. pub const MAX_LENGTH: usize = 7089; +/// The maximum length of a tagged field in a BOLT11 invoice. This is 1023 * 5 bits (i.e., 639 +/// bytes). +pub const MAX_TAGGED_FIELD_DATA_BYTES: usize = 639; + /// The [`bech32::Bech32`] checksum algorithm, with extended max length suitable /// for BOLT11 invoices. pub enum Bolt11Bech32 {} @@ -886,7 +890,11 @@ impl pub fn optional_payment_metadata( mut self, payment_metadata: Vec, ) -> InvoiceBuilder { - self.tagged_fields.push(TaggedField::PaymentMetadata(payment_metadata)); + if payment_metadata.len() > MAX_TAGGED_FIELD_DATA_BYTES { + self.error = Some(CreationError::PaymentMetadataTooLong); + } else { + self.tagged_fields.push(TaggedField::PaymentMetadata(payment_metadata)); + } let mut found_features = false; for field in self.tagged_fields.iter_mut() { if let TaggedField::Features(f) = field { @@ -1676,12 +1684,12 @@ impl TaggedField { } impl Description { - /// Creates a new `Description` if `description` is at most 1023 * 5 bits (i.e., 639 bytes) + /// Creates a new `Description` if `description` is at most [`MAX_TAGGED_FIELD_DATA_BYTES`] /// long, and returns [`CreationError::DescriptionTooLong`] otherwise. /// /// Please note that single characters may use more than one byte due to UTF8 encoding. pub fn new(description: String) -> Result { - if description.len() > 639 { + if description.len() > MAX_TAGGED_FIELD_DATA_BYTES { Err(CreationError::DescriptionTooLong) } else { Ok(Description(UntrustedString(description))) @@ -1798,6 +1806,9 @@ pub enum CreationError { /// The supplied description string was longer than 639 __bytes__ (see [`Description::new`]) DescriptionTooLong, + /// The supplied payment metadata was longer than 639 __bytes__ + PaymentMetadataTooLong, + /// The specified route has too many hops and can't be encoded RouteTooLong, @@ -1820,6 +1831,7 @@ impl Display for CreationError { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match self { CreationError::DescriptionTooLong => f.write_str("The supplied description string was longer than 639 bytes"), + CreationError::PaymentMetadataTooLong => f.write_str("The supplied payment metadata was longer than 639 bytes"), CreationError::RouteTooLong => f.write_str("The specified route has too many hops and can't be encoded"), CreationError::TimestampOutOfBounds => f.write_str("The Unix timestamp of the supplied date is less than zero or greater than 35-bits"), CreationError::InvalidAmount => f.write_str("The supplied millisatoshi amount was greater than the total bitcoin supply"), @@ -2276,6 +2288,10 @@ mod test { let long_desc_res = builder.clone().description(too_long_string).build_raw(); assert_eq!(long_desc_res, Err(CreationError::DescriptionTooLong)); + let long_metadata_res = + builder.clone().description("Test".into()).payment_metadata(vec![0u8; 640]).build_raw(); + assert_eq!(long_metadata_res, Err(CreationError::PaymentMetadataTooLong)); + let route_hop = RouteHintHop { src_node_id: PublicKey::from_slice( &[ From deee085c2b5a95befe65b6a7cecf6353189306c4 Mon Sep 17 00:00:00 2001 From: Abeeujah Date: Wed, 24 Jun 2026 15:06:16 +0100 Subject: [PATCH 519/627] Deserialize consensus objects using Read type The BufReader wrapping is no longer needed after the rust-bitcoin `0.32.4` release which contains the standardisation of the trait bounds for deserialization to `Read` instead of `BufReader`. --- lightning/src/util/ser.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs index 4defe29fe34..de6c929d0eb 100644 --- a/lightning/src/util/ser.rs +++ b/lightning/src/util/ser.rs @@ -1625,8 +1625,7 @@ macro_rules! impl_consensus_ser { impl Readable for $bitcoin_type { fn read(r: &mut R) -> Result { - let mut reader = BufReader::<_>::new(r); - match consensus::encode::Decodable::consensus_decode(&mut reader) { + match consensus::encode::Decodable::consensus_decode(r) { Ok(t) => Ok(t), Err(consensus::encode::Error::Io(ref e)) if e.kind() == io::ErrorKind::UnexpectedEof => From b582ce15ae3a7f37a64ebfdd428297ac781d5fb5 Mon Sep 17 00:00:00 2001 From: Abeeujah Date: Wed, 24 Jun 2026 15:17:39 +0100 Subject: [PATCH 520/627] Drop the BufReader wrapper Post `0.32.4` deserialization of consensus objects now use `Read` as the trait bounds, making the BufReader no longer needed for deserialization. --- lightning/src/util/ser.rs | 68 +-------------------------------------- 1 file changed, 1 insertion(+), 67 deletions(-) diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs index de6c929d0eb..1411ba8dcc6 100644 --- a/lightning/src/util/ser.rs +++ b/lightning/src/util/ser.rs @@ -13,7 +13,7 @@ //! [`ChannelManager`]: crate::ln::channelmanager::ChannelManager //! [`ChannelMonitor`]: crate::chain::channelmonitor::ChannelMonitor -use crate::io::{self, BufRead, Read, Write}; +use crate::io::{self, Read, Write}; use crate::io_extras::{copy, sink}; use crate::ln::interactivetxs::{TxInMetadata, TxOutMetadata}; use crate::ln::onion_utils::{HMAC_COUNT, HMAC_LEN, HOLD_TIME_LEN, MAX_HOPS}; @@ -77,72 +77,6 @@ impl Writer for W { } } -// TODO: Drop this entirely if rust-bitcoin releases a version bump with https://github.com/rust-bitcoin/rust-bitcoin/pull/3173 -/// Wrap buffering support for implementations of Read. -/// A [`Read`]er which keeps an internal buffer to avoid hitting the underlying stream directly for -/// every read, implementing [`BufRead`]. -/// -/// In order to avoid reading bytes past the first object, and those bytes then ending up getting -/// dropped, this BufReader operates in one-byte-increments. -struct BufReader<'a, R: Read> { - inner: &'a mut R, - buf: [u8; 1], - is_consumed: bool, -} - -impl<'a, R: Read> BufReader<'a, R> { - /// Creates a [`BufReader`] which will read from the given `inner`. - pub fn new(inner: &'a mut R) -> Self { - BufReader { inner, buf: [0; 1], is_consumed: true } - } -} - -impl<'a, R: Read> Read for BufReader<'a, R> { - #[inline] - fn read(&mut self, output: &mut [u8]) -> io::Result { - if output.is_empty() { - return Ok(0); - } - let mut offset = 0; - if !self.is_consumed { - output[0] = self.buf[0]; - self.is_consumed = true; - offset = 1; - } - self.inner.read(&mut output[offset..]).map(|len| len + offset) - } -} - -impl<'a, R: Read> BufRead for BufReader<'a, R> { - #[inline] - fn fill_buf(&mut self) -> io::Result<&[u8]> { - debug_assert!(false, "rust-bitcoin doesn't actually use this"); - if self.is_consumed { - let count = self.inner.read(&mut self.buf[..])?; - debug_assert!(count <= 1, "read gave us a garbage length"); - - // upon hitting EOF, assume the byte is already consumed - self.is_consumed = count == 0; - } - - if self.is_consumed { - Ok(&[]) - } else { - Ok(&self.buf[..]) - } - } - - #[inline] - fn consume(&mut self, amount: usize) { - debug_assert!(false, "rust-bitcoin doesn't actually use this"); - if amount >= 1 { - debug_assert_eq!(amount, 1, "Can only consume one byte"); - debug_assert!(!self.is_consumed, "Cannot consume more than had been read"); - self.is_consumed = true; - } - } -} - pub(crate) struct WriterWriteAdaptor<'a, W: Writer + 'a>(pub &'a mut W); impl<'a, W: Writer + 'a> Write for WriterWriteAdaptor<'a, W> { #[inline] From a8a4767f5688af6a338760a19db09120ea92de63 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Wed, 24 Jun 2026 19:26:22 +0200 Subject: [PATCH 521/627] Pin zeroize for old Rust CI zeroize 1.9.0 uses Rust 2024 metadata, which Cargo 1.75 cannot parse. Pin it to 1.8.2 for older toolchains so the transaction sync HTTPS feature check keeps passing on the MSRV job. --- ci/ci-tests-common.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ci/ci-tests-common.sh b/ci/ci-tests-common.sh index 9631689fcdd..a14928d3a35 100755 --- a/ci/ci-tests-common.sh +++ b/ci/ci-tests-common.sh @@ -23,4 +23,7 @@ PIN_RELEASE_DEPS # pin the release dependencies in our main workspace # Starting with version 0.27.8, the `hyper-rustls` crate has an MSRV of rustc 1.85.0. [ "$RUSTC_MINOR_VERSION" -lt 85 ] && cargo update -p hyper-rustls --precise "0.27.7" --quiet +# Starting with version 1.9.0, the `zeroize` crate uses Rust 2024. +[ "$RUSTC_MINOR_VERSION" -lt 85 ] && cargo update -p zeroize --precise "1.8.2" --quiet + export RUST_BACKTRACE=1 From 3cad6af6979fb7b72379050c3e6a46329994b1eb Mon Sep 17 00:00:00 2001 From: Abeeujah Date: Sat, 20 Jun 2026 21:04:37 +0100 Subject: [PATCH 522/627] Avoid heap-allocating background processor futures Replace Box::pin with core::pin::pin! in process_events_async now that MSRV is 1.75. This eliminates a heap allocation per task on every loop iteration by pinning the futures directly to the stack. To satisfy lifetime and Joiner bounds, the loop logic was refactored to run synchronous timer checks first, using flags to conditionally execute the stack-pinned futures. Existing eager polling and early-break semantics are preserved. --- lightning-background-processor/src/lib.rs | 171 ++++++++++++---------- 1 file changed, 90 insertions(+), 81 deletions(-) diff --git a/lightning-background-processor/src/lib.rs b/lightning-background-processor/src/lib.rs index 8ab20d5a1f3..f2b3cdd1831 100644 --- a/lightning-background-processor/src/lib.rs +++ b/lightning-background-processor/src/lib.rs @@ -1120,21 +1120,14 @@ where None => {}, } - // We capture pending_operation_count inside the persistence branch to - // avoid a race: ChannelManager handlers queue deferred monitor ops - // before the persistence flag is set. Capturing outside would let us - // observe pending ops while the flag is still unset, causing us to - // flush monitor writes without persisting the ChannelManager. - // Declared before futures so it outlives the Joiner (drop order). - let pending_monitor_writes; - let mut futures = Joiner::new(); - if channel_manager.get_cm().get_and_clear_needs_persistence() { - pending_monitor_writes = chain_monitor.get_cm().pending_operation_count(); - log_trace!(logger, "Persisting ChannelManager..."); + let needs_cm_persist = channel_manager.get_cm().get_and_clear_needs_persistence(); + let mut cm_fut = core::pin::pin!(async { + if needs_cm_persist { + // Capture the monitor operations pending before we persist the ChannelManager. + let pending_monitor_writes = chain_monitor.get_cm().pending_operation_count(); - let fut = async { kv_store .write( CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, @@ -1147,22 +1140,24 @@ where // Flush monitor operations that were pending before we persisted. New updates // that arrived after are left for the next iteration. chain_monitor.get_cm().flush(pending_monitor_writes, &logger); - Ok(()) - }; - // TODO: Once our MSRV is 1.68 we should be able to drop the Box - let mut fut = Box::pin(fut); - - // Because persisting the ChannelManager is important to avoid accidental - // force-closures, go ahead and poll the future once before we do slightly more - // CPU-intensive tasks in the form of NetworkGraph pruning or scorer time-stepping - // below. This will get it moving but won't block us for too long if the underlying - // future is actually async. + } + Ok(()) + }); + + // Because persisting the ChannelManager is important to avoid accidental force-closures, + // go ahead and poll the future once before we do slightly more CPU-intensive tasks in the + // form of NetworkGraph pruning or scorer time-stepping below. This will get it moving but + // won't block us for too long if the underlying future is actually async. We stash the + // outcome and feed it into the `Joiner` once it is constructed. + if needs_cm_persist { + log_trace!(logger, "Persisting ChannelManager..."); + use core::future::Future; let mut waker = dummy_waker(); let mut ctx = task::Context::from_waker(&mut waker); - match core::pin::Pin::new(&mut fut).poll(&mut ctx) { + match cm_fut.as_mut().poll(&mut ctx) { task::Poll::Ready(res) => futures.set_a_res(res), - task::Poll::Pending => futures.set_a(fut), + task::Poll::Pending => futures.set_a(cm_fut), } log_trace!(logger, "Done persisting ChannelManager."); @@ -1210,7 +1205,8 @@ where GossipSync::Rapid(_) => !have_pruned || prune_timer_elapsed, _ => prune_timer_elapsed, }; - if should_prune { + + let network_graph_to_persist = if should_prune { // The network graph must not be pruned while rapid sync completion is pending if let Some(network_graph) = gossip_sync.prunable_network_graph() { if let Some(duration_since_epoch) = fetch_time() { @@ -1222,28 +1218,15 @@ where log_warn!(logger, "Not pruning network graph, consider implementing the fetch_time argument or calling remove_stale_channels_and_tracking_with_time manually."); log_trace!(logger, "Persisting network graph."); } - let fut = async { - if let Err(e) = kv_store - .write( - NETWORK_GRAPH_PERSISTENCE_PRIMARY_NAMESPACE, - NETWORK_GRAPH_PERSISTENCE_SECONDARY_NAMESPACE, - NETWORK_GRAPH_PERSISTENCE_KEY, - network_graph.encode(), - ) - .await - { - log_error!(logger, "Error: Failed to persist network graph, check your disk and permissions {}",e); - } - - Ok(()) - }; - - // TODO: Once our MSRV is 1.68 we should be able to drop the Box - futures.set_b(Box::pin(fut)); have_pruned = true; + Some(network_graph) + } else { + None } - } + } else { + None + }; if !have_decayed_scorer { if let Some(ref scorer) = scorer { if let Some(duration_since_epoch) = fetch_time() { @@ -1253,7 +1236,9 @@ where } have_decayed_scorer = true; } - match check_and_reset_sleeper(&mut last_scorer_persist_call, || { + // Step the scorer forward synchronously here, deferring the actual write to the + // future built below. + let persist_scorer = match check_and_reset_sleeper(&mut last_scorer_persist_call, || { sleeper(SCORER_PERSIST_TIMER) }) { Some(false) => { @@ -1264,7 +1249,46 @@ where } else { log_trace!(logger, "Persisting scorer"); } - let fut = async { + true + } else { + false + } + }, + Some(true) => break, + None => false, + }; + let persist_sweeper = + match check_and_reset_sleeper(&mut last_sweeper_call, || sleeper(SWEEPER_TIMER)) { + Some(false) => { + log_trace!(logger, "Regenerating sweeper spends if necessary"); + true + }, + Some(true) => break, + None => false, + }; + + let network_graph_fut = core::pin::pin!(async { + if let Some(network_graph) = network_graph_to_persist { + if let Err(e) = kv_store + .write( + NETWORK_GRAPH_PERSISTENCE_PRIMARY_NAMESPACE, + NETWORK_GRAPH_PERSISTENCE_SECONDARY_NAMESPACE, + NETWORK_GRAPH_PERSISTENCE_KEY, + network_graph.encode(), + ) + .await + { + log_error!(logger, "Error: Failed to persist network graph, check your disk and permissions {}",e); + } + } + Ok(()) + }); + futures.set_b(network_graph_fut); + + let scorer_fut = + core::pin::pin!(async { + if persist_scorer { + if let Some(ref scorer) = scorer { if let Err(e) = kv_store .write( SCORER_PERSISTENCE_PRIMARY_NAMESPACE, @@ -1274,43 +1298,26 @@ where ) .await { - log_error!( - logger, - "Error: Failed to persist scorer, check your disk and permissions {}", - e - ); + log_error!(logger, "Error: Failed to persist scorer, check your disk and permissions {}", e); } - - Ok(()) - }; - - // TODO: Once our MSRV is 1.68 we should be able to drop the Box - futures.set_c(Box::pin(fut)); + } } - }, - Some(true) => break, - None => {}, - } - match check_and_reset_sleeper(&mut last_sweeper_call, || sleeper(SWEEPER_TIMER)) { - Some(false) => { - log_trace!(logger, "Regenerating sweeper spends if necessary"); - if let Some(ref sweeper) = sweeper { - let fut = async { - let _ = sweeper.regenerate_and_broadcast_spend_if_necessary().await; - - Ok(()) - }; + Ok(()) + }); + futures.set_c(scorer_fut); - // TODO: Once our MSRV is 1.68 we should be able to drop the Box - futures.set_d(Box::pin(fut)); + let sweeper_fut = core::pin::pin!(async { + if persist_sweeper { + if let Some(ref sweeper) = sweeper { + let _ = sweeper.regenerate_and_broadcast_spend_if_necessary().await; } - }, - Some(true) => break, - None => {}, - } + } + Ok(()) + }); + futures.set_d(sweeper_fut); - if let Some(liquidity_manager) = liquidity_manager.as_ref() { - let fut = async { + let lm_fut = core::pin::pin!(async { + if let Some(liquidity_manager) = liquidity_manager.as_ref() { liquidity_manager .get_lm() .persist() @@ -1324,9 +1331,11 @@ where log_error!(logger, "Persisting LiquidityManager failed: {}", e); e }) - }; - futures.set_e(Box::pin(fut)); - } + } else { + Ok(()) + } + }); + futures.set_e(lm_fut); // Run persistence tasks in parallel and exit if any of them returns an error. for res in futures.await { From b3e2dc8d1a1493eaad0d9c5ce339d30c2a63e49c Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Thu, 25 Jun 2026 10:04:52 -0700 Subject: [PATCH 523/627] Ignore stale splice initial commitment_signed After we complete a splice negotiation and see a `FundingTransactionReadyForSigning` event, the counterparty may already have sent its initial `commitment_signed` for the splice funding transaction. If we then cancel the funding contribution, our local channel state no longer tracks the pending splice attempt and queues `tx_abort`, but the in-flight `commitment_signed` can still arrive first. Handling that message against the post-abort channel state attempts to validate a signature for the now-stale splice funding transaction and can force-close the still-live channel. We fix this by checking the optional `funding_txid` (which we expect all implementations to always include by default) included in `commitment_signed` before validating the commitment signature. If it does not match the channel's locked funding txid, we can safely ignore the stale message. --- lightning/src/ln/channel.rs | 9 ++++ lightning/src/ln/splicing_tests.rs | 72 ++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index ab9c964e5cb..9353dbe9431 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -8614,6 +8614,15 @@ where "Got a single commitment_signed message when expecting a batch".to_owned(), )); } + if let Some(funding_txid) = msg.funding_txid { + let locked_funding_txid = + self.funding.get_funding_txid().expect("funded channel must have known txid"); + if funding_txid != locked_funding_txid { + return Err(ChannelError::Ignore(format!( + "Ignoring commitment_signed for stale funding txid {funding_txid}" + ))); + } + } let transaction_number = self.holder_commitment_point.next_transaction_number(); let commitment_point = self.holder_commitment_point.next_point(); diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index f480c4e9bc0..1140e6b7028 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -4176,6 +4176,78 @@ fn do_cancel_funding_contributed_before_funding_transaction_signed(state: u8) { do_commitment_signed_dance(acceptor, initiator, &update.commitment_signed, false, false); } +#[test] +fn cancel_funding_contributed_then_inflight_commitment_signed_does_not_close_channel() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let initiator = &nodes[0]; + let acceptor = &nodes[1]; + + let node_id_initiator = initiator.node.get_our_node_id(); + let node_id_acceptor = acceptor.node.get_our_node_id(); + + let initial_channel_capacity = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0); + + let outputs = vec![TxOut { + value: Amount::from_sat(1_000), + script_pubkey: initiator.wallet_source.get_change_script().unwrap(), + }]; + let funding_contribution = + initiate_splice_out(initiator, acceptor, channel_id, outputs).unwrap(); + let new_funding_script = complete_splice_handshake(initiator, acceptor); + complete_interactive_funding_negotiation( + initiator, + acceptor, + channel_id, + funding_contribution.clone(), + new_funding_script, + ); + + // Both peers completed the interactive transaction exchange. Since only the + // initiator contributed splice funds, the initiator must still surface the + // unsigned funding transaction before it may send its initial + // `commitment_signed`. + let _ = get_event!(initiator, Event::FundingTransactionReadyForSigning); + assert!(acceptor.node.get_and_clear_pending_events().is_empty()); + assert!(initiator.node.get_and_clear_pending_msg_events().is_empty()); + + // The acceptor has no funding contribution, so it can send its initial + // `commitment_signed` immediately. Hold that message to model it racing with + // the local caller's decision to cancel instead of sign. + let acceptor_commit_sig = get_htlc_update_msgs(acceptor, &node_id_initiator); + assert_eq!(acceptor_commit_sig.commitment_signed.len(), 1); + + // Cancel before signing. This is a valid API flow: local contribution is + // discarded, the splice negotiation fails locally, and LDK queues a + // `tx_abort` for the peer. + initiator.node.cancel_funding_contributed(&channel_id, &node_id_acceptor).unwrap(); + let reason = NegotiationFailureReason::LocallyCanceled; + expect_splice_failed_events(initiator, &channel_id, funding_contribution, reason); + + // Keep our `tx_abort` queued. The fuzz failure has this exact ordering: our + // abort is outbound, but the acceptor's earlier `commitment_signed` reaches + // us first. + let tx_abort = get_event_msg!(initiator, MessageSendEvent::SendTxAbort, node_id_acceptor); + assert_eq!(tx_abort.channel_id, channel_id); + + initiator + .node + .handle_commitment_signed(node_id_acceptor, &acceptor_commit_sig.commitment_signed[0]); + + // The delayed `commitment_signed` belonged to the splice we just aborted. It + // should not be validated against the post-abort channel state and should + // not force-close the live channel as an invalid commitment signature. + assert!(initiator.node.get_and_clear_pending_events().is_empty()); + assert!(acceptor.node.get_and_clear_pending_events().is_empty()); + assert!(initiator.node.get_and_clear_pending_msg_events().is_empty()); + assert!(acceptor.node.get_and_clear_pending_msg_events().is_empty()); +} + #[test] fn cannot_cancel_funding_contributed_after_funding_transaction_signed() { let chanmon_cfgs = create_chanmon_cfgs(2); From fab95959886014a12bedfb0c7e48a61e3ec63323 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Thu, 25 Jun 2026 15:55:22 -0700 Subject: [PATCH 524/627] Remove expectance of interactive-tx commitment_signed during reestablish The use of `expecting_peer_commitment_signed` was being used as a way to signal that we must disconnect if the message is not sent in a timely manner. This isn't necessary, as we're already quiescent within this flow, and can disconnect via that signal instead. --- lightning/src/ln/channel.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index ab9c964e5cb..ef8b8a4df2b 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -10645,10 +10645,6 @@ where ))); } - if !session.has_received_commitment_signed() { - self.context.expecting_peer_commitment_signed = true; - } - if !session.has_holder_witnesses() { log_debug!(logger, "Waiting for funding transaction signatures to be provided"); } else { From b8a76c17e6488da45ee3be9f7c6303f2e4898593 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Wed, 24 Jun 2026 16:31:31 -0700 Subject: [PATCH 525/627] Lower strictness of pending monitor update while awaiting tx_signatures We previously assumed that no monitor update should ever be pending when receiving `tx_signatures` while quiescent, with the exception of the `RenegotiatedFunding` variant. This was a bit too strict, as we did not consider that if an HTLC was sent via the same channel, its preimage could be received from upstream leading to a monitor update to durably persist it. This commit ensures that if the recipient of a `tx_signatures` has not yet echoed theirs back, and it is awaiting a monitor update completion, then the pending monitor update must be of the `RenegotiatedFunding` variant. If the pending monitor update is of another variant, then we must remain quiescent with no pending updates available to send until after the `tx_signatures` exchange. --- lightning/src/ln/channel.rs | 10 +- lightning/src/ln/splicing_tests.rs | 168 +++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 4 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index ef8b8a4df2b..d609f8912d6 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -9530,8 +9530,10 @@ where &mut self, funding_tx_signed: &mut FundingTxSigned, funding_tx: Transaction, best_block_height: u32, logger: &WithChannelContext<'a, L>, ) { - debug_assert!(!self.context.channel_state.is_monitor_update_in_progress()); - debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke()); + debug_assert!( + !self.is_awaiting_monitor_update() || !self.context.monitor_pending_tx_signatures + ); + debug_assert!(!self.context.is_waiting_on_peer_pending_channel_update()); if let Some(pending_splice) = self.pending_splice.as_mut() { if let Some(FundingNegotiation::AwaitingSignatures { @@ -9684,11 +9686,11 @@ where splice_negotiated: None, splice_locked: None, }; - if self.is_awaiting_monitor_update() { + if self.is_awaiting_monitor_update() && self.context.monitor_pending_tx_signatures { // Although the user may have already provided our `tx_signatures`, we must not send // them if we're waiting for the monitor to durably persist the counterparty's signature // for our initial commitment post-splice. - debug_assert!(self.context.monitor_pending_tx_signatures); + debug_assert!(holder_tx_signatures.is_some()); log_debug!( logger, "Waiting for async monitor update to complete prior to releasing our tx_signatures" diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index f480c4e9bc0..21b5303486b 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -34,6 +34,7 @@ use crate::types::string::UntrustedString; use crate::util::config::UserConfig; use crate::util::errors::APIError; use crate::util::ser::Writeable; +use crate::util::test_channel_signer::SignerOp; use crate::util::wallet_utils::{ CoinSelection, CoinSelectionSourceSync, ConfirmedUtxo, Input, WalletSourceSync, WalletSync, }; @@ -10248,3 +10249,170 @@ fn test_splice_out_maximum_includes_pending_claimed_inbound_htlc() { assert!(nodes[1].node.splice_channel(&channel_id, &node_id_0).is_ok()); } + +#[test] +fn test_async_splice_receives_tx_signatures_while_unrelated_monitor_update_pending() { + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + + let channel_id = create_announced_chan_between_nodes(&nodes, 0, 1).2; + create_announced_chan_between_nodes(&nodes, 1, 2); + + let (initiator, acceptor) = (&nodes[0], &nodes[1]); + let initiator_node_id = initiator.node.get_our_node_id(); + let acceptor_node_id = acceptor.node.get_our_node_id(); + let final_node_id = nodes[2].node.get_our_node_id(); + + // Leave a forwarded HTLC across A-B and B-C. Later, C will reveal the + // preimage so B has to persist an unrelated preimage update on A-B while the + // delayed splice `tx_signatures` are still in flight. + let (payment_preimage, payment_hash, ..) = + route_payment(initiator, &[acceptor, &nodes[2]], 1_000_000); + + // Keep the A-B splice from completing immediately at B. The disabled + // counterparty-commitment signer forces B to wait for both the signer and + // the splice monitor update before it can send `tx_signatures`. + acceptor.disable_channel_signer_op( + &initiator_node_id, + &channel_id, + SignerOp::SignCounterpartyCommitment, + ); + + let outputs = vec![TxOut { + value: Amount::from_sat(1_000), + script_pubkey: initiator.wallet_source.get_change_script().unwrap(), + }]; + let contribution = initiate_splice_out(initiator, acceptor, channel_id, outputs).unwrap(); + negotiate_splice_tx(initiator, acceptor, channel_id, contribution); + + let event = get_event!(initiator, Event::FundingTransactionReadyForSigning); + if let Event::FundingTransactionReadyForSigning { unsigned_transaction, .. } = event { + let partially_signed_tx = initiator.wallet_source.sign_tx(unsigned_transaction).unwrap(); + initiator + .node + .funding_transaction_signed(&channel_id, &acceptor_node_id, partially_signed_tx) + .unwrap(); + } + + let initiator_commit_sig = get_htlc_update_msgs(initiator, &acceptor_node_id); + + // B accepts A's splice commitment, but the monitor update remains pending. + // This is the async-signing window that normally guards emission of B's + // `tx_signatures`. + chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress); + acceptor + .node + .handle_commitment_signed(initiator_node_id, &initiator_commit_sig.commitment_signed[0]); + check_added_monitors(acceptor, 1); + assert!(acceptor.node.get_and_clear_pending_msg_events().is_empty()); + + // Unblock only the signer side first. B can now produce its splice + // `commitment_signed`, but still must not send `tx_signatures` until the + // monitor update above completes. + acceptor.enable_channel_signer_op( + &initiator_node_id, + &channel_id, + SignerOp::SignCounterpartyCommitment, + ); + acceptor.node.signer_unblocked(None); + + let msg_events = acceptor.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + if let MessageSendEvent::UpdateHTLCs { updates, .. } = &msg_events[0] { + initiator.node.handle_commitment_signed(acceptor_node_id, &updates.commitment_signed[0]); + check_added_monitors(initiator, 1); + } else { + panic!("Unexpected event"); + } + + // Completing B's splice monitor update releases B's `tx_signatures`. This + // is the update for which `monitor_pending_tx_signatures` is expected to be + // set. + acceptor.chain_monitor.complete_sole_pending_chan_update(&channel_id); + + let acceptor_tx_signatures = + get_event_msg!(acceptor, MessageSendEvent::SendTxSignatures, initiator_node_id); + initiator.node.handle_tx_signatures(acceptor_node_id, &acceptor_tx_signatures); + + // A can now fully sign and broadcast the splice transaction. Save A's + // reciprocal `tx_signatures` instead of delivering them to B, so B later + // sees an old splice message after another monitor update has started. + let delayed_initiator_tx_signatures = + get_event_msg!(initiator, MessageSendEvent::SendTxSignatures, acceptor_node_id); + let mut broadcasted = initiator.tx_broadcaster.txn_broadcast(); + assert_eq!(broadcasted.len(), 1, "{broadcasted:?}"); + let splice_tx = broadcasted.pop().unwrap(); + + // Confirm the splice on both A and B before B receives A's delayed + // `tx_signatures`. This mirrors the fuzz timeline where one side's + // broadcast can reach chain before the reciprocal message reaches its peer. + mine_transaction(initiator, &splice_tx); + mine_transaction(acceptor, &splice_tx); + let _ = get_event!(initiator, Event::SpliceNegotiated); + + // Claiming the forwarded payment at C creates an HTLC fulfill that B must + // propagate backward over the same A-B channel that is being spliced. + nodes[2].node.claim_funds(payment_preimage); + check_added_monitors(&nodes[2], 1); + expect_payment_claimed!(nodes[2], payment_hash, 1_000_000); + + let mut commitment_update = get_htlc_update_msgs(&nodes[2], &acceptor_node_id); + assert_eq!(commitment_update.update_fulfill_htlcs.len(), 1); + // Deliver only the fulfill to B and make B's A-B monitor update stay + // in-flight. This monitor update is unrelated to splice tx signatures: it + // durably records the payment preimage so B can safely settle the incoming + // HTLC from A. + chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress); + acceptor.node.handle_update_fulfill_htlc( + final_node_id, + commitment_update.update_fulfill_htlcs.remove(0), + ); + check_added_monitors(acceptor, 1); + assert!(acceptor.node.get_and_clear_pending_msg_events().is_empty()); + + // Deliver A's delayed splice `tx_signatures` while B is waiting on the unrelated HTLC-preimage + // monitor update. B's `tx_signatures` was already released, so there's no message to send and + // we should expect the splice negotiation to complete. + acceptor.node.handle_tx_signatures(initiator_node_id, &delayed_initiator_tx_signatures); + expect_splice_pending_event(acceptor, &initiator_node_id); + + // Finally, drive the state machines to completion. + acceptor.chain_monitor.complete_sole_pending_chan_update(&channel_id); + let mut update_fulfill = get_htlc_update_msgs(acceptor, &initiator_node_id); + check_added_monitors(acceptor, 1); + let payment_forwarded = get_event!(acceptor, Event::PaymentForwarded); + expect_payment_forwarded( + payment_forwarded, + acceptor, + initiator, + &nodes[2], + Some(1000), + None, + false, + false, + false, + ); + + do_commitment_signed_dance( + acceptor, + &nodes[2], + &commitment_update.commitment_signed, + false, + false, + ); + + initiator.node.handle_update_fulfill_htlc( + acceptor_node_id, + update_fulfill.update_fulfill_htlcs.remove(0), + ); + do_commitment_signed_dance( + initiator, + acceptor, + &update_fulfill.commitment_signed, + false, + false, + ); + expect_payment_sent(initiator, payment_preimage, None, true, true); +} From cc4312a736b94db5cced6bdf16f59b1cd0fb38fb Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Fri, 26 Jun 2026 10:44:17 -0400 Subject: [PATCH 526/627] Fuzz: remove unnecessary route_params clones --- fuzz/src/chanmon_consistency.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index b0703d8e6ed..273af2a021f 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1866,7 +1866,7 @@ impl PaymentTracker { }], blinded_tail: None, }], - route_params: route_params.clone(), + route_params, }; let onion = RecipientOnionFields::secret_only(secret, amt); let res = source.send_payment_with_route(route, hash, onion, id); @@ -1946,7 +1946,7 @@ impl PaymentTracker { ], blinded_tail: None, }], - route_params: route_params.clone(), + route_params, }; let onion = RecipientOnionFields::secret_only(secret, amt); let res = source.send_payment_with_route(route, hash, onion, id); From 47f58f34af4e53ec6c8f3598ee6d6198d73194e7 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Fri, 26 Jun 2026 10:44:17 -0400 Subject: [PATCH 527/627] Set max fee in route params for probes May as well, and allows removing a comment that explains why we were previously leaving it as None. --- lightning/src/ln/outbound_payment.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 105ee355a9e..20b594a1e83 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -1922,9 +1922,6 @@ impl OutboundPayments { })) } - // `route_params` is a required field, but is unused when sending a probe along a fixed - // path. Construct dummy parameters from the path, leaving the fee budget unset to match - // the previous behavior of not tracking one for probes. let route_params = { let last_hop = path.hops.last().unwrap(); let payment_params = @@ -1932,7 +1929,7 @@ impl OutboundPayments { RouteParameters { payment_params, final_value_msat: path.final_value_msat(), - max_total_routing_fee_msat: None, + max_total_routing_fee_msat: Some(path.fee_msat()), } }; let route = Route { paths: vec![path], route_params }; From 93ac580261c4db28ff6701c225e0cffd62dae63d Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Fri, 26 Jun 2026 23:55:23 +0000 Subject: [PATCH 528/627] Add Forgejo Actions workflows mirroring GitHub CI Port the GitHub Actions workflows under .github/workflows/ to Forgejo Actions under .forgejo/workflows/, targeting the instance at git.rust-bitcoin.org and the lightningdevkit/rust-lightning repo. All jobs run on the debian-trixie worker label; the build matrix restores windows/macos legs (no runners provisioned for those yet). Forgejo-specific adaptations: - Fold the fuzz corpus push into the fuzz job, since Forgejo supports neither the workflow_run trigger nor cross-run artifact access. The push still targets the GitHub corpus repo and is best-effort so a push hiccup cannot cascade to dependent jobs. - Report build failures and stale-unicode notices via the fj CLI against Forgejo instead of the gh CLI against GitHub. - Run cargo audit directly in place of the rustsec/audit-check action, which is not on Forgejo's default action registry. Co-Authored-By: Claude Opus 4.8 (1M context) --- .forgejo/workflows/audit.yml | 24 ++ .forgejo/workflows/build.yml | 441 +++++++++++++++++++++++++++ .forgejo/workflows/check_commits.yml | 33 ++ .forgejo/workflows/check_unicode.yml | 35 +++ .forgejo/workflows/ci-build.yml | 80 +++++ .forgejo/workflows/semver.yml | 27 ++ 6 files changed, 640 insertions(+) create mode 100644 .forgejo/workflows/audit.yml create mode 100644 .forgejo/workflows/build.yml create mode 100644 .forgejo/workflows/check_commits.yml create mode 100644 .forgejo/workflows/check_unicode.yml create mode 100644 .forgejo/workflows/ci-build.yml create mode 100644 .forgejo/workflows/semver.yml diff --git a/.forgejo/workflows/audit.yml b/.forgejo/workflows/audit.yml new file mode 100644 index 00000000000..56516c0d621 --- /dev/null +++ b/.forgejo/workflows/audit.yml @@ -0,0 +1,24 @@ +name: Security Audit +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * *' + +jobs: + audit: + runs-on: debian-trixie + steps: + - name: Checkout source code + uses: actions/checkout@v4 + - name: Install Rust stable toolchain + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain stable + - name: Install cargo-audit + run: cargo install cargo-audit --locked + - name: Run cargo audit + # RUSTSEC-2021-0145 pertains `atty`, which is a depencency of + # `criterion`. While the latter removed the depencency in its + # newest version, it would also require a higher `rustc`. We + # therefore avoid bumping it to allow benchmarking with our + # `rustc` 1.63 MSRV. + run: cargo audit --ignore RUSTSEC-2021-0145 diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml new file mode 100644 index 00000000000..e55a9518fd4 --- /dev/null +++ b/.forgejo/workflows/build.yml @@ -0,0 +1,441 @@ +name: Continuous Integration Checks + +on: + push: + branches-ignore: + - master + pull_request: + branches-ignore: + - master + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + ext-test: + runs-on: debian-trixie + steps: + - name: Checkout source code + uses: actions/checkout@v4 + - name: Install Rust stable toolchain + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain stable + - name: Run externalized tests + run: | + cd ext-functional-test-demo + cargo test --verbose --color always + cargo test --verbose --color always --features test-broken + + build-workspace: + uses: ./.forgejo/workflows/ci-build.yml + with: + script: ci/ci-tests-workspace.sh + + build-features: + uses: ./.forgejo/workflows/ci-build.yml + with: + script: ci/ci-tests-features.sh + + build-bindings: + uses: ./.forgejo/workflows/ci-build.yml + with: + script: ci/ci-tests-bindings.sh + + build-nostd: + uses: ./.forgejo/workflows/ci-build.yml + with: + script: ci/ci-tests-nostd.sh + + build-cfg-flags: + uses: ./.forgejo/workflows/ci-build.yml + with: + script: ci/ci-tests-cfg-flags.sh + + build-sync: + uses: ./.forgejo/workflows/ci-build.yml + with: + script: ci/ci-tests-sync.sh + + coverage: + needs: fuzz + strategy: + fail-fast: false + runs-on: debian-trixie + steps: + - name: Checkout source code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install Rust stable toolchain + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal + - name: Run tests with coverage generation + run: | + cargo install cargo-llvm-cov + export RUSTFLAGS="-Coverflow-checks=off" + cargo llvm-cov --features rest-client,rpc-client,tokio,serde --codecov --hide-instantiations --output-path=target/codecov.json + curl --verbose -O https://cli.codecov.io/latest/linux/codecov + chmod +x codecov + # Could you use this to fake the coverage report for your PR? Sure. + # Will anyone be impressed by your amazing coverage? No + # Maybe if codecov wasn't broken we wouldn't need to do this... + ./codecov --verbose upload-process --disable-search --fail-on-error -f target/codecov.json -t "f421b687-4dc2-4387-ac3d-dc3b2528af57" -F 'tests' + cargo clean + - name: Clone fuzzing corpus + run: git clone --depth=1 https://github.com/lightningdevkit/ldk-fuzzing-corpus.git fuzz/ldk-fuzzing-corpus + - name: Symlink corpus into hfuzz_workspace + run: | + set -eu + cd fuzz + for D in ldk-fuzzing-corpus/rust-lightning/*/; do + NAME=$(basename "$D") + mkdir -p "hfuzz_workspace/${NAME}_target" + cp -r "ldk-fuzzing-corpus/rust-lightning/${NAME}" "hfuzz_workspace/${NAME}_target/input" + done + - name: Run fuzz coverage generation + run: | + ./contrib/generate_fuzz_coverage.sh --output-dir `pwd` --output-codecov-json + # Could you use this to fake the coverage report for your PR? Sure. + # Will anyone be impressed by your amazing coverage? No + # Maybe if codecov wasn't broken we wouldn't need to do this... + ./codecov --verbose upload-process --disable-search --fail-on-error -f fuzz-fake-hashes-codecov.json -t "f421b687-4dc2-4387-ac3d-dc3b2528af57" -F 'fuzzing-fake-hashes' + ./codecov --verbose upload-process --disable-search --fail-on-error -f fuzz-real-hashes-codecov.json -t "f421b687-4dc2-4387-ac3d-dc3b2528af57" -F 'fuzzing-real-hashes' + + benchmark: + runs-on: debian-trixie + env: + TOOLCHAIN: stable + steps: + - name: Checkout source code + uses: actions/checkout@v4 + - name: Install Rust ${{ env.TOOLCHAIN }} toolchain + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ env.TOOLCHAIN }} + - name: Cache routing graph snapshot + id: cache-graph + uses: actions/cache@v4 + with: + path: lightning/net_graph-2023-12-10.bin + key: ldk-net_graph-v0.0.118-2023-12-10.bin + - name: Fetch routing graph snapshot + if: steps.cache-graph.outputs.cache-hit != 'true' + run: | + curl --verbose -L -o lightning/net_graph-2023-12-10.bin https://bitcoin.ninja/ldk-net_graph-v0.0.118-2023-12-10.bin + echo "Sha sum: $(sha256sum lightning/net_graph-2023-12-10.bin | awk '{ print $1 }')" + if [ "$(sha256sum lightning/net_graph-2023-12-10.bin | awk '{ print $1 }')" != "${EXPECTED_ROUTING_GRAPH_SNAPSHOT_SHASUM}" ]; then + echo "Bad hash" + exit 1 + fi + env: + EXPECTED_ROUTING_GRAPH_SNAPSHOT_SHASUM: e94b38ef4b3ce683893bf6a3ee28d60cb37c73b059403ff77b7e7458157968c2 + - name: Cache scorer snapshot + id: cache-scorer + uses: actions/cache@v4 + with: + path: lightning/scorer-2023-12-10.bin + key: ldk-scorer-v0.0.118-2023-12-10.bin + - name: Fetch scorer snapshot + if: steps.cache-scorer.outputs.cache-hit != 'true' + run: | + curl --verbose -L -o lightning/scorer-2023-12-10.bin https://bitcoin.ninja/ldk-scorer-v0.0.118-2023-12-10.bin + echo "Sha sum: $(sha256sum lightning/scorer-2023-12-10.bin | awk '{ print $1 }')" + if [ "$(sha256sum lightning/scorer-2023-12-10.bin | awk '{ print $1 }')" != "${EXPECTED_SCORER_SNAPSHOT_SHASUM}" ]; then + echo "Bad hash" + exit 1 + fi + env: + EXPECTED_SCORER_SNAPSHOT_SHASUM: 570a26bb28870fe1da7e392cdec9fb794718826b04c43ca053d71a8a9bb9be69 + - name: Fetch rapid graph sync reference input + run: | + curl --verbose -L -o lightning-rapid-gossip-sync/res/full_graph.lngossip https://bitcoin.ninja/ldk-compressed_graph-285cb27df79-2022-07-21.bin + echo "Sha sum: $(sha256sum lightning-rapid-gossip-sync/res/full_graph.lngossip | awk '{ print $1 }')" + if [ "$(sha256sum lightning-rapid-gossip-sync/res/full_graph.lngossip | awk '{ print $1 }')" != "${EXPECTED_RAPID_GOSSIP_SHASUM}" ]; then + echo "Bad hash" + exit 1 + fi + env: + EXPECTED_RAPID_GOSSIP_SHASUM: e0f5d11641c11896d7af3a2246d3d6c3f1720b7d2d17aab321ecce82e6b7deb8 + - name: Test with Network Graph on Rust ${{ matrix.toolchain }} + run: | + cd lightning + RUSTFLAGS="--cfg=require_route_graph_test" cargo test + cd .. + - name: Run benchmarks on Rust ${{ matrix.toolchain }} + run: | + cd bench + RUSTFLAGS="--cfg=ldk_bench --cfg=require_route_graph_test" cargo bench + + check_release: + runs-on: debian-trixie + env: + TOOLCHAIN: stable + steps: + - name: Checkout source code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install Rust ${{ env.TOOLCHAIN }} toolchain + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ env.TOOLCHAIN }} + - name: Run cargo check for release build. + run: | + cargo check --release + cargo check --no-default-features --features=std --release + cargo doc --release + - name: Run cargo check for Taproot build. + run: | + cargo check --release + cargo check --no-default-features --release + cargo check --no-default-features --features=std --release + cargo doc --release + cargo doc --no-default-features --release + env: + RUSTFLAGS: '--cfg=taproot' + RUSTDOCFLAGS: '--cfg=taproot' + + check_docs: + runs-on: debian-trixie + env: + # While docs.rs builds using a nightly compiler (and we use some nightly features), + # nightly ends up randomly breaking builds occasionally, so we instead use beta + # and set RUSTC_BOOTSTRAP in check-docsrs.sh + TOOLCHAIN: beta + steps: + - name: Checkout source code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install Rust ${{ env.TOOLCHAIN }} toolchain + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ env.TOOLCHAIN }} + - name: Simulate docs.rs build + run: ci/check-docsrs.sh + + fuzz_sanity: + runs-on: debian-trixie + env: + TOOLCHAIN: 1.75 + steps: + - name: Checkout source code + uses: actions/checkout@v4 + - name: Install Rust ${{ env.TOOLCHAIN }} toolchain + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ env.TOOLCHAIN }} + - name: Sanity check fuzz targets on Rust ${{ env.TOOLCHAIN }} + run: | + cd fuzz + RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz --cfg=chacha20_poly1305_fuzz" cargo test --quiet --color always --lib -j8 + RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz --cfg=chacha20_poly1305_fuzz" cargo test --manifest-path fuzz-fake-hashes/Cargo.toml --quiet --color always --bins -j8 + RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=chacha20_poly1305_fuzz" cargo test --manifest-path fuzz-real-hashes/Cargo.toml --quiet --color always --bins -j8 + + fuzz: + runs-on: debian-trixie + env: + TOOLCHAIN: 1.75 + steps: + - name: Checkout source code + uses: actions/checkout@v4 + - name: Install Rust ${{ env.TOOLCHAIN }} toolchain + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ env.TOOLCHAIN }} + - name: Clone fuzzing corpus + run: git clone --depth=1 https://github.com/lightningdevkit/ldk-fuzzing-corpus.git fuzz/ldk-fuzzing-corpus + - name: Symlink corpus into hfuzz_workspace + run: | + set -eu + cd fuzz + for D in ldk-fuzzing-corpus/rust-lightning/*/; do + NAME=$(basename "$D") + mkdir -p "hfuzz_workspace/${NAME}_target" + ln -sfn "../../ldk-fuzzing-corpus/rust-lightning/${NAME}" \ + "hfuzz_workspace/${NAME}_target/input" + done + - name: Run fuzzers + run: cd fuzz && ./ci-fuzz.sh && cd .. + env: + FUZZ_MINIMIZE: ${{ contains(github.event.pull_request.labels.*.name, 'fuzz-minimize') }} + - name: Open PR with new corpus entries + # Forgejo supports neither the `workflow_run` trigger nor reading + # artifacts from another workflow run, so the corpus push that used to + # live in its own workflow is folded in here. New fuzzer inputs are + # written straight into the corpus checkout (the input dirs are + # symlinked into it above), so they show up as untracked files. + # + # The push still targets the GitHub corpus repo. On Forgejo, secrets + # are empty for `pull_request` events from forks, so CORPUS_PUSH_TOKEN + # is unset there and this step safely skips the push. + # + # A push hiccup must not fail the fuzz job (and cascade to the jobs that + # depend on it), so this step is best-effort. + if: success() || failure() + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.CORPUS_PUSH_TOKEN }} + SOURCE_SHA: ${{ github.sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }} + RUN_ID: ${{ github.run_id }} + run: | + set -eu + cd fuzz/ldk-fuzzing-corpus + if [ -z "$(git status --porcelain)" ]; then + echo "No new corpus entries to contribute." + exit 0 + fi + if [ -z "${GH_TOKEN:-}" ]; then + echo "Found new corpus entries but CORPUS_PUSH_TOKEN is unset; skipping PR." + git status --short + exit 0 + fi + BRANCH="ci/new-corpus-${RUN_ID}" + git config user.email "ldk-ci@users.noreply.github.com" + git config user.name "LDK CI" + git checkout -b "$BRANCH" + git add rust-lightning + git commit \ + -m "Add corpus entries from rust-lightning CI" \ + -m "Source commit: ${SOURCE_SHA}" \ + -m "Run: ${RUN_URL}" + REMOTE=$(git config --get remote.origin.url) + PUSH_URL="https://x-access-token:${GH_TOKEN}@${REMOTE#https://}" + git push "$PUSH_URL" "HEAD:$BRANCH" + gh pr create \ + --title "New corpus entries from rust-lightning CI run ${RUN_ID}" \ + --body "Discovered while running fuzz CI against \`${SOURCE_SHA}\`. Source: ${RUN_URL}" \ + --head "$BRANCH" \ + --base master + + linting: + runs-on: debian-trixie + env: + TOOLCHAIN: stable + steps: + - name: Checkout source code + uses: actions/checkout@v4 + - name: Install Rust ${{ env.TOOLCHAIN }} toolchain + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ env.TOOLCHAIN }} + - name: Install clippy + run: | + rustup component add clippy + - name: shellcheck the CI and `contrib` scripts + run: | + shellcheck ci/*.sh -aP ci + shellcheck contrib/*.sh -aP contrib + - name: Run default clippy linting + run: | + ./ci/check-lint.sh + + rustfmt: + runs-on: debian-trixie + env: + TOOLCHAIN: 1.75.0 + steps: + - name: Checkout source code + uses: actions/checkout@v4 + - name: Install Rust ${{ env.TOOLCHAIN }} toolchain + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ env.TOOLCHAIN }} + - name: Install rustfmt + run: | + rustup component add rustfmt + - name: Run rustfmt checks + run: cargo fmt --check + - name: Run rustfmt checks on lightning-tests + run: cd lightning-tests && cargo fmt --check + - name: Run rustfmt checks on fuzz + run: cd fuzz && cargo fmt --check + tor-connect: + runs-on: debian-trixie + env: + TOOLCHAIN: 1.75.0 + steps: + - name: Checkout source code + uses: actions/checkout@v4 + - name: Install tor + run: | + sudo apt install -y tor + - name: Install Rust ${{ env.TOOLCHAIN }} toolchain + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ env.TOOLCHAIN }} + - name: Test tor connections using lightning-net-tokio + run: | + TOR_PROXY="127.0.0.1:9050" RUSTFLAGS="--cfg=tor" cargo test --verbose --color always -p lightning-net-tokio + + notify-failure: + needs: [build-workspace, build-features, build-bindings, build-nostd, build-cfg-flags, build-sync, fuzz_sanity, fuzz, linting, rustfmt, check_release, check_docs, benchmark, ext-test, tor-connect, coverage] + if: failure() && github.ref == 'refs/heads/main' + runs-on: debian-trixie + steps: + - name: Configure fj credentials + # `fj` reads its token from keys.json; it has no token environment + # variable, so write the automatic Actions token there. + env: + FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} + FORGEJO_USER: ${{ github.actor }} + run: | + install -d -m 700 "$HOME/.local/share/forgejo-cli" + printf '{"hosts":{"git.rust-bitcoin.org":{"type":"Application","name":"%s","token":"%s"}}}' "$FORGEJO_USER" "$FORGEJO_TOKEN" > "$HOME/.local/share/forgejo-cli/keys.json" + chmod 600 "$HOME/.local/share/forgejo-cli/keys.json" + - name: Create or update failure issue + # Deduplicate by label (like the GitHub job): comment on the top open + # issue carrying the "build failed" label, otherwise open a new one. + # fj handles search/create/comment; the raw API is used only to attach + # the label after creating, since fj cannot set or create labels. The + # automatic token has write access to this repo for non-fork events. + env: + HOST: git.rust-bitcoin.org + API: ${{ github.server_url }}/api/v1 + REPO: ${{ github.repository }} + FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} + LABEL: build failed + run: | + set -eu + AUTH="Authorization: token ${FORGEJO_TOKEN}" + + TITLE="Failed build: ${{ github.workflow }}" + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}" + REPO_URL="${{ github.server_url }}/${{ github.repository }}" + COMMITTER="${{ github.event.head_commit.author.username }}" + BODY="Forgejo Actions workflow [${{ github.workflow }} #${{ github.run_number }}](${RUN_URL}) failed." + BODY="${BODY}"$'\n\n'"Event: ${{ github.event_name }}" + BRANCH="${{ github.ref_name }}" + BODY="${BODY}"$'\n'"Branch: [${BRANCH}](${REPO_URL}/src/branch/${BRANCH})" + BODY="${BODY}"$'\n'"Commit: [${{ github.sha }}](${REPO_URL}/commit/${{ github.sha }})" + if [ -n "$COMMITTER" ]; then + BODY="${BODY}"$'\n'"Committer: @${COMMITTER}" + fi + + # Find the top open issue carrying the label. With `--style minimal`, + # `fj issue search` prints a totals line, then one + # "#: (by <author>)" line per match; take the first. + NUM="$(fj -H "$HOST" --style minimal issue search --repo "$REPO" --labels "$LABEL" --state open \ + | head -n2 | tail -n1 | awk '/^#[0-9]/ { print $1 }' | tr -d '#:')" + + if [ -n "$NUM" ]; then + fj -H "$HOST" issue comment "${REPO}#${NUM}" "$BODY" + else + # Create with fj, then parse the new number from "created issue #N:". + if ! CREATED="$(fj -H "$HOST" --style minimal issue create "$TITLE" --body "$BODY" --repo "$REPO" 2>&1)"; then + echo "fj issue create failed:"; echo "$CREATED"; exit 1 + fi + echo "$CREATED" + NUM="$(printf '%s\n' "$CREATED" | grep -oE '#[0-9]+' | head -n1 | tr -d '#')" + + # Attach the label via the raw API (fj cannot set or create labels): + # resolve the label id, creating the label if it does not exist yet. + if [ -n "$NUM" ]; then + LABEL_ID="$(curl -fsS -H "$AUTH" "$API/repos/$REPO/labels" \ + | jq -r --arg n "$LABEL" 'map(select(.name == $n)) | .[0].id // empty')" + if [ -z "$LABEL_ID" ]; then + LABEL_ID="$(curl -fsS -H "$AUTH" -H 'Content-Type: application/json' \ + -X POST "$API/repos/$REPO/labels" \ + -d "$(jq -n --arg n "$LABEL" '{name: $n, color: "#e11d21"}')" | jq -r '.id')" + fi + curl -fsS -H "$AUTH" -H 'Content-Type: application/json' \ + -X POST "$API/repos/$REPO/issues/$NUM/labels" \ + -d "$(jq -n --argjson l "[$LABEL_ID]" '{labels: $l}')" >/dev/null + else + echo "Could not parse the new issue number; label not attached." >&2 + fi + fi diff --git a/.forgejo/workflows/check_commits.yml b/.forgejo/workflows/check_commits.yml new file mode 100644 index 00000000000..d7cb8743bd7 --- /dev/null +++ b/.forgejo/workflows/check_commits.yml @@ -0,0 +1,33 @@ +name: CI check_commits + +on: + pull_request: + branches-ignore: + - master + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check_commits: + runs-on: debian-trixie + env: + TOOLCHAIN: stable + steps: + - name: Checkout source code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install Rust ${{ env.TOOLCHAIN }} toolchain + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ env.TOOLCHAIN }} + - name: Fetch full tree and rebase on upstream + run: | + git remote add upstream https://git.rust-bitcoin.org/lightningdevkit/rust-lightning + git fetch upstream + export GIT_COMMITTER_EMAIL="rl-ci@example.com" + export GIT_COMMITTER_NAME="RL CI" + git rebase upstream/${{ github.base_ref }} + - name: For each commit, run cargo check (including in fuzz) + run: ci/check-each-commit.sh upstream/${{ github.base_ref }} diff --git a/.forgejo/workflows/check_unicode.yml b/.forgejo/workflows/check_unicode.yml new file mode 100644 index 00000000000..26426965a97 --- /dev/null +++ b/.forgejo/workflows/check_unicode.yml @@ -0,0 +1,35 @@ +name: Unicode listing up to date +on: + workflow_dispatch: + schedule: + - cron: '42 3 * * *' + +jobs: + check-unicode: + runs-on: debian-trixie + steps: + - name: Checkout source code + uses: actions/checkout@v4 + - name: Configure fj credentials + # `fj` reads its token from keys.json; it has no token environment + # variable, so write the automatic Actions token there for the API call. + env: + FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} + FORGEJO_USER: ${{ github.actor }} + run: | + install -d -m 700 "$HOME/.local/share/forgejo-cli" + printf '{"hosts":{"git.rust-bitcoin.org":{"type":"Application","name":"%s","token":"%s"}}}' "$FORGEJO_USER" "$FORGEJO_TOKEN" > "$HOME/.local/share/forgejo-cli/keys.json" + chmod 600 "$HOME/.local/share/forgejo-cli/keys.json" + - name: Check unicode file state + env: + HOST: git.rust-bitcoin.org + REPO: ${{ github.repository }} + run: | + curl --proto '=https' --tlsv1.2 -fsSL -o /tmp/UnicodeData.txt https://www.unicode.org/Public/UCD/latest/ucd/UnicodeData.txt + contrib/gen_unicode_general_category.py /tmp/UnicodeData.txt -o /tmp/unicode.rs + if ! diff -u lightning-types/src/unicode.rs /tmp/unicode.rs; then + TITLE="Unicode listing out of date: ${{ github.workflow }}" + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}" + BODY="The unicode character listing is out of date, see $RUN_URL" + fj -H "$HOST" issue create "$TITLE" --body "$BODY" --repo "$REPO" + fi diff --git a/.forgejo/workflows/ci-build.yml b/.forgejo/workflows/ci-build.yml new file mode 100644 index 00000000000..e691d59f39d --- /dev/null +++ b/.forgejo/workflows/ci-build.yml @@ -0,0 +1,80 @@ +name: CI Build Job + +on: + workflow_call: + inputs: + script: + description: CI script to run (relative to repo root) + required: true + type: string + +jobs: + build: + strategy: + fail-fast: false + matrix: + platform: >- + ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' + && fromJSON('["debian-trixie","windows","macos"]') + || fromJSON('["debian-trixie"]') }} + toolchain: >- + ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' + && fromJSON('["stable","beta","1.75.0"]') + || fromJSON('["1.75.0"]') }} + exclude: + - platform: windows + toolchain: 1.75.0 + - platform: windows + toolchain: beta + - platform: macos + toolchain: beta + runs-on: ${{ matrix.platform }} + steps: + - name: Checkout source code + uses: actions/checkout@v4 + - name: Install Rust ${{ matrix.toolchain }} toolchain + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ matrix.toolchain }} + - name: Use rust-lld linker on Windows + if: matrix.platform == 'windows' + shell: bash + run: echo "RUSTFLAGS=-C linker=rust-lld" >> "$GITHUB_ENV" + - name: Set RUSTFLAGS to deny warnings + if: "matrix.toolchain == '1.75.0'" + run: echo "RUSTFLAGS=-D warnings" >> "$GITHUB_ENV" + - name: Install no-std-check dependencies for ARM Embedded + if: matrix.platform == 'debian-trixie' + run: | + rustup target add thumbv7m-none-eabi + - name: Enable caching for bitcoind + if: matrix.platform != 'windows' + id: cache-bitcoind + uses: actions/cache@v4 + with: + path: bin/bitcoind-${{ runner.os }}-${{ runner.arch }} + key: bitcoind-${{ runner.os }}-${{ runner.arch }} + - name: Enable caching for electrs + if: matrix.platform != 'windows' + id: cache-electrs + uses: actions/cache@v4 + with: + path: bin/electrs-${{ runner.os }}-${{ runner.arch }} + key: electrs-${{ runner.os }}-${{ runner.arch }} + - name: Download bitcoind/electrs + if: >- + matrix.platform != 'windows' + && (steps.cache-bitcoind.outputs.cache-hit != 'true' + || steps.cache-electrs.outputs.cache-hit != 'true') + run: | + source ./contrib/download_bitcoind_electrs.sh + mkdir bin + mv "$BITCOIND_EXE" bin/bitcoind-${{ runner.os }}-${{ runner.arch }} + mv "$ELECTRS_EXE" bin/electrs-${{ runner.os }}-${{ runner.arch }} + - name: Set bitcoind/electrs environment variables + if: matrix.platform != 'windows' + run: | + echo "BITCOIND_EXE=$( pwd )/bin/bitcoind-${{ runner.os }}-${{ runner.arch }}" >> "$GITHUB_ENV" + echo "ELECTRS_EXE=$( pwd )/bin/electrs-${{ runner.os }}-${{ runner.arch }}" >> "$GITHUB_ENV" + - name: Run CI script + shell: bash + run: CI_ENV=1 CI_MINIMIZE_DISK_USAGE=1 ./${{ inputs.script }} diff --git a/.forgejo/workflows/semver.yml b/.forgejo/workflows/semver.yml new file mode 100644 index 00000000000..c2001306994 --- /dev/null +++ b/.forgejo/workflows/semver.yml @@ -0,0 +1,27 @@ +name: SemVer checks +on: + push: + branches-ignore: + - master + pull_request: + branches-ignore: + - master + +jobs: + semver-checks: + runs-on: debian-trixie + steps: + - name: Checkout source code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install Rust stable toolchain + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain stable + rustup override set stable + - name: Install SemVer Checker + run: cargo install cargo-semver-checks --locked + - name: Check SemVer with all features + run: cargo semver-checks + - name: Check SemVer without any non-default features + run: cargo semver-checks --only-explicit-features From 7c2068959825068ca0a0ad339194b07ba30e9790 Mon Sep 17 00:00:00 2001 From: Matt Corallo <git+claude@bluematt.me> Date: Sat, 27 Jun 2026 01:11:53 +0000 Subject: [PATCH 529/627] Run tor in the background for the tor-connect job The runner image ships the tor package but has no sudo to start the system service, so the previous `sudo apt install -y tor` step cannot work. Instead, start tor in the background within the test step, wait for it to fully bootstrap (the test routes real traffic, including to a .onion address, through the proxy), run the test, and kill tor on exit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .forgejo/workflows/build.yml | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index e55a9518fd4..0374580e4ee 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -352,14 +352,33 @@ jobs: steps: - name: Checkout source code uses: actions/checkout@v4 - - name: Install tor - run: | - sudo apt install -y tor - name: Install Rust ${{ env.TOOLCHAIN }} toolchain run: | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ env.TOOLCHAIN }} - name: Test tor connections using lightning-net-tokio run: | + set -eu + # tor is preinstalled in the runner image, but we have no sudo to + # start the system service, so run it in the background for this step. + # The test routes real traffic (including to a .onion address) through + # the proxy, so we must wait until tor is fully bootstrapped. + TOR_DATA="$(mktemp -d)" + tor --SocksPort 9050 --DataDirectory "$TOR_DATA" \ + --Log "notice file $TOR_DATA/tor.log" & + TOR_PID=$! + trap 'kill "$TOR_PID" 2>/dev/null || true' EXIT + for _ in $(seq 1 90); do + if grep -q "Bootstrapped 100%" "$TOR_DATA/tor.log" 2>/dev/null; then + break + fi + if ! kill -0 "$TOR_PID" 2>/dev/null; then + echo "tor exited before bootstrapping:"; cat "$TOR_DATA/tor.log"; exit 1 + fi + sleep 2 + done + if ! grep -q "Bootstrapped 100%" "$TOR_DATA/tor.log"; then + echo "tor failed to bootstrap within timeout:"; cat "$TOR_DATA/tor.log"; exit 1 + fi TOR_PROXY="127.0.0.1:9050" RUSTFLAGS="--cfg=tor" cargo test --verbose --color always -p lightning-net-tokio notify-failure: From c897a448be3a4c0fd388082696ba89c32a17397b Mon Sep 17 00:00:00 2001 From: Matt Corallo <git+claude@bluematt.me> Date: Sat, 27 Jun 2026 01:14:21 +0000 Subject: [PATCH 530/627] Use preinstalled rustup instead of curling the installer rustup is already present in the runner image, so replace every `curl https://sh.rustup.rs | sh ...` toolchain install with a plain `rustup default <toolchain>`, which installs the toolchain if needed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .forgejo/workflows/audit.yml | 2 +- .forgejo/workflows/build.yml | 20 ++++++++++---------- .forgejo/workflows/check_commits.yml | 2 +- .forgejo/workflows/ci-build.yml | 2 +- .forgejo/workflows/semver.yml | 2 +- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.forgejo/workflows/audit.yml b/.forgejo/workflows/audit.yml index 56516c0d621..45b083e1b0f 100644 --- a/.forgejo/workflows/audit.yml +++ b/.forgejo/workflows/audit.yml @@ -12,7 +12,7 @@ jobs: uses: actions/checkout@v4 - name: Install Rust stable toolchain run: | - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain stable + rustup default stable - name: Install cargo-audit run: cargo install cargo-audit --locked - name: Run cargo audit diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 0374580e4ee..d2013ea4392 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -20,7 +20,7 @@ jobs: uses: actions/checkout@v4 - name: Install Rust stable toolchain run: | - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain stable + rustup default stable - name: Run externalized tests run: | cd ext-functional-test-demo @@ -69,7 +69,7 @@ jobs: fetch-depth: 0 - name: Install Rust stable toolchain run: | - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal + rustup default stable - name: Run tests with coverage generation run: | cargo install cargo-llvm-cov @@ -111,7 +111,7 @@ jobs: uses: actions/checkout@v4 - name: Install Rust ${{ env.TOOLCHAIN }} toolchain run: | - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ env.TOOLCHAIN }} + rustup default ${{ env.TOOLCHAIN }} - name: Cache routing graph snapshot id: cache-graph uses: actions/cache@v4 @@ -177,7 +177,7 @@ jobs: fetch-depth: 0 - name: Install Rust ${{ env.TOOLCHAIN }} toolchain run: | - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ env.TOOLCHAIN }} + rustup default ${{ env.TOOLCHAIN }} - name: Run cargo check for release build. run: | cargo check --release @@ -208,7 +208,7 @@ jobs: fetch-depth: 0 - name: Install Rust ${{ env.TOOLCHAIN }} toolchain run: | - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ env.TOOLCHAIN }} + rustup default ${{ env.TOOLCHAIN }} - name: Simulate docs.rs build run: ci/check-docsrs.sh @@ -221,7 +221,7 @@ jobs: uses: actions/checkout@v4 - name: Install Rust ${{ env.TOOLCHAIN }} toolchain run: | - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ env.TOOLCHAIN }} + rustup default ${{ env.TOOLCHAIN }} - name: Sanity check fuzz targets on Rust ${{ env.TOOLCHAIN }} run: | cd fuzz @@ -238,7 +238,7 @@ jobs: uses: actions/checkout@v4 - name: Install Rust ${{ env.TOOLCHAIN }} toolchain run: | - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ env.TOOLCHAIN }} + rustup default ${{ env.TOOLCHAIN }} - name: Clone fuzzing corpus run: git clone --depth=1 https://github.com/lightningdevkit/ldk-fuzzing-corpus.git fuzz/ldk-fuzzing-corpus - name: Symlink corpus into hfuzz_workspace @@ -314,7 +314,7 @@ jobs: uses: actions/checkout@v4 - name: Install Rust ${{ env.TOOLCHAIN }} toolchain run: | - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ env.TOOLCHAIN }} + rustup default ${{ env.TOOLCHAIN }} - name: Install clippy run: | rustup component add clippy @@ -335,7 +335,7 @@ jobs: uses: actions/checkout@v4 - name: Install Rust ${{ env.TOOLCHAIN }} toolchain run: | - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ env.TOOLCHAIN }} + rustup default ${{ env.TOOLCHAIN }} - name: Install rustfmt run: | rustup component add rustfmt @@ -354,7 +354,7 @@ jobs: uses: actions/checkout@v4 - name: Install Rust ${{ env.TOOLCHAIN }} toolchain run: | - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ env.TOOLCHAIN }} + rustup default ${{ env.TOOLCHAIN }} - name: Test tor connections using lightning-net-tokio run: | set -eu diff --git a/.forgejo/workflows/check_commits.yml b/.forgejo/workflows/check_commits.yml index d7cb8743bd7..969ebafb650 100644 --- a/.forgejo/workflows/check_commits.yml +++ b/.forgejo/workflows/check_commits.yml @@ -21,7 +21,7 @@ jobs: fetch-depth: 0 - name: Install Rust ${{ env.TOOLCHAIN }} toolchain run: | - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ env.TOOLCHAIN }} + rustup default ${{ env.TOOLCHAIN }} - name: Fetch full tree and rebase on upstream run: | git remote add upstream https://git.rust-bitcoin.org/lightningdevkit/rust-lightning diff --git a/.forgejo/workflows/ci-build.yml b/.forgejo/workflows/ci-build.yml index e691d59f39d..ec6f281eac3 100644 --- a/.forgejo/workflows/ci-build.yml +++ b/.forgejo/workflows/ci-build.yml @@ -34,7 +34,7 @@ jobs: uses: actions/checkout@v4 - name: Install Rust ${{ matrix.toolchain }} toolchain run: | - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ matrix.toolchain }} + rustup default ${{ matrix.toolchain }} - name: Use rust-lld linker on Windows if: matrix.platform == 'windows' shell: bash diff --git a/.forgejo/workflows/semver.yml b/.forgejo/workflows/semver.yml index c2001306994..479517d583f 100644 --- a/.forgejo/workflows/semver.yml +++ b/.forgejo/workflows/semver.yml @@ -17,7 +17,7 @@ jobs: fetch-depth: 0 - name: Install Rust stable toolchain run: | - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain stable + rustup default stable rustup override set stable - name: Install SemVer Checker run: cargo install cargo-semver-checks --locked From 3a56fcc2557dff2177a407e85589927e984b8f53 Mon Sep 17 00:00:00 2001 From: Matt Corallo <git+claude@bluematt.me> Date: Sat, 27 Jun 2026 01:15:06 +0000 Subject: [PATCH 531/627] Pin actions/checkout + actions/cache to a full URL and commit hash Reference the checkout action by its explicit data.forgejo.org URL pinned to a commit hash (v6) rather than the bare `actions/checkout@v4` short form. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .forgejo/workflows/audit.yml | 2 +- .forgejo/workflows/build.yml | 24 ++++++++++++------------ .forgejo/workflows/check_commits.yml | 2 +- .forgejo/workflows/check_unicode.yml | 2 +- .forgejo/workflows/ci-build.yml | 6 +++--- .forgejo/workflows/semver.yml | 2 +- 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.forgejo/workflows/audit.yml b/.forgejo/workflows/audit.yml index 45b083e1b0f..65d702e70aa 100644 --- a/.forgejo/workflows/audit.yml +++ b/.forgejo/workflows/audit.yml @@ -9,7 +9,7 @@ jobs: runs-on: debian-trixie steps: - name: Checkout source code - uses: actions/checkout@v4 + uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install Rust stable toolchain run: | rustup default stable diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index d2013ea4392..7e0e518e63b 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -17,7 +17,7 @@ jobs: runs-on: debian-trixie steps: - name: Checkout source code - uses: actions/checkout@v4 + uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install Rust stable toolchain run: | rustup default stable @@ -64,7 +64,7 @@ jobs: runs-on: debian-trixie steps: - name: Checkout source code - uses: actions/checkout@v4 + uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 - name: Install Rust stable toolchain @@ -108,13 +108,13 @@ jobs: TOOLCHAIN: stable steps: - name: Checkout source code - uses: actions/checkout@v4 + uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install Rust ${{ env.TOOLCHAIN }} toolchain run: | rustup default ${{ env.TOOLCHAIN }} - name: Cache routing graph snapshot id: cache-graph - uses: actions/cache@v4 + uses: https://data.forgejo.org/actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: lightning/net_graph-2023-12-10.bin key: ldk-net_graph-v0.0.118-2023-12-10.bin @@ -131,7 +131,7 @@ jobs: EXPECTED_ROUTING_GRAPH_SNAPSHOT_SHASUM: e94b38ef4b3ce683893bf6a3ee28d60cb37c73b059403ff77b7e7458157968c2 - name: Cache scorer snapshot id: cache-scorer - uses: actions/cache@v4 + uses: https://data.forgejo.org/actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: lightning/scorer-2023-12-10.bin key: ldk-scorer-v0.0.118-2023-12-10.bin @@ -172,7 +172,7 @@ jobs: TOOLCHAIN: stable steps: - name: Checkout source code - uses: actions/checkout@v4 + uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 - name: Install Rust ${{ env.TOOLCHAIN }} toolchain @@ -203,7 +203,7 @@ jobs: TOOLCHAIN: beta steps: - name: Checkout source code - uses: actions/checkout@v4 + uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 - name: Install Rust ${{ env.TOOLCHAIN }} toolchain @@ -218,7 +218,7 @@ jobs: TOOLCHAIN: 1.75 steps: - name: Checkout source code - uses: actions/checkout@v4 + uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install Rust ${{ env.TOOLCHAIN }} toolchain run: | rustup default ${{ env.TOOLCHAIN }} @@ -235,7 +235,7 @@ jobs: TOOLCHAIN: 1.75 steps: - name: Checkout source code - uses: actions/checkout@v4 + uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install Rust ${{ env.TOOLCHAIN }} toolchain run: | rustup default ${{ env.TOOLCHAIN }} @@ -311,7 +311,7 @@ jobs: TOOLCHAIN: stable steps: - name: Checkout source code - uses: actions/checkout@v4 + uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install Rust ${{ env.TOOLCHAIN }} toolchain run: | rustup default ${{ env.TOOLCHAIN }} @@ -332,7 +332,7 @@ jobs: TOOLCHAIN: 1.75.0 steps: - name: Checkout source code - uses: actions/checkout@v4 + uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install Rust ${{ env.TOOLCHAIN }} toolchain run: | rustup default ${{ env.TOOLCHAIN }} @@ -351,7 +351,7 @@ jobs: TOOLCHAIN: 1.75.0 steps: - name: Checkout source code - uses: actions/checkout@v4 + uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install Rust ${{ env.TOOLCHAIN }} toolchain run: | rustup default ${{ env.TOOLCHAIN }} diff --git a/.forgejo/workflows/check_commits.yml b/.forgejo/workflows/check_commits.yml index 969ebafb650..8502bc2640c 100644 --- a/.forgejo/workflows/check_commits.yml +++ b/.forgejo/workflows/check_commits.yml @@ -16,7 +16,7 @@ jobs: TOOLCHAIN: stable steps: - name: Checkout source code - uses: actions/checkout@v4 + uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 - name: Install Rust ${{ env.TOOLCHAIN }} toolchain diff --git a/.forgejo/workflows/check_unicode.yml b/.forgejo/workflows/check_unicode.yml index 26426965a97..e13c0776a48 100644 --- a/.forgejo/workflows/check_unicode.yml +++ b/.forgejo/workflows/check_unicode.yml @@ -9,7 +9,7 @@ jobs: runs-on: debian-trixie steps: - name: Checkout source code - uses: actions/checkout@v4 + uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Configure fj credentials # `fj` reads its token from keys.json; it has no token environment # variable, so write the automatic Actions token there for the API call. diff --git a/.forgejo/workflows/ci-build.yml b/.forgejo/workflows/ci-build.yml index ec6f281eac3..3f734c4b16d 100644 --- a/.forgejo/workflows/ci-build.yml +++ b/.forgejo/workflows/ci-build.yml @@ -31,7 +31,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Checkout source code - uses: actions/checkout@v4 + uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install Rust ${{ matrix.toolchain }} toolchain run: | rustup default ${{ matrix.toolchain }} @@ -49,14 +49,14 @@ jobs: - name: Enable caching for bitcoind if: matrix.platform != 'windows' id: cache-bitcoind - uses: actions/cache@v4 + uses: https://data.forgejo.org/actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: bin/bitcoind-${{ runner.os }}-${{ runner.arch }} key: bitcoind-${{ runner.os }}-${{ runner.arch }} - name: Enable caching for electrs if: matrix.platform != 'windows' id: cache-electrs - uses: actions/cache@v4 + uses: https://data.forgejo.org/actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: bin/electrs-${{ runner.os }}-${{ runner.arch }} key: electrs-${{ runner.os }}-${{ runner.arch }} diff --git a/.forgejo/workflows/semver.yml b/.forgejo/workflows/semver.yml index 479517d583f..3322de850b3 100644 --- a/.forgejo/workflows/semver.yml +++ b/.forgejo/workflows/semver.yml @@ -12,7 +12,7 @@ jobs: runs-on: debian-trixie steps: - name: Checkout source code - uses: actions/checkout@v4 + uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 - name: Install Rust stable toolchain From 4f884af2e925b0ad50afa25d6fccff3cf859bbeb Mon Sep 17 00:00:00 2001 From: Matt Corallo <git+claude@bluematt.me> Date: Sat, 27 Jun 2026 12:55:26 +0000 Subject: [PATCH 532/627] Pass commit context to codecov explicitly in coverage job Codecov auto-detects only a fixed set of CI providers and cannot detect the Forgejo Actions environment, so it fails to determine the pull request (and commit/branch) for uploads. Pass --slug, --sha, --branch, and --pr to the codecov CLI explicitly, derived from the workflow context, with --pr omitted on non-pull_request events. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .forgejo/workflows/build.yml | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 7e0e518e63b..7a7453cb0bb 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -62,6 +62,14 @@ jobs: strategy: fail-fast: false runs-on: debian-trixie + # Codecov auto-detects only a fixed set of CI providers (not Forgejo), so the + # commit/branch/PR context is passed to the CLI explicitly in the steps below. + # CODECOV_PR is empty on non-pull_request events and is then omitted. + env: + CODECOV_SLUG: ${{ github.repository }} + CODECOV_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + CODECOV_BRANCH: ${{ github.head_ref || github.ref_name }} + CODECOV_PR: ${{ github.event.pull_request.number }} steps: - name: Checkout source code uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -77,10 +85,13 @@ jobs: cargo llvm-cov --features rest-client,rpc-client,tokio,serde --codecov --hide-instantiations --output-path=target/codecov.json curl --verbose -O https://cli.codecov.io/latest/linux/codecov chmod +x codecov + # Pass the commit context manually since codecov can't detect Forgejo. + CC="--git-service github --slug $CODECOV_SLUG --sha $CODECOV_SHA --branch $CODECOV_BRANCH" + if [ -n "${CODECOV_PR:-}" ]; then CC="$CC --pr $CODECOV_PR"; fi # Could you use this to fake the coverage report for your PR? Sure. # Will anyone be impressed by your amazing coverage? No # Maybe if codecov wasn't broken we wouldn't need to do this... - ./codecov --verbose upload-process --disable-search --fail-on-error -f target/codecov.json -t "f421b687-4dc2-4387-ac3d-dc3b2528af57" -F 'tests' + ./codecov --verbose upload-process --disable-search --fail-on-error $CC -f target/codecov.json -t "f421b687-4dc2-4387-ac3d-dc3b2528af57" -F 'tests' cargo clean - name: Clone fuzzing corpus run: git clone --depth=1 https://github.com/lightningdevkit/ldk-fuzzing-corpus.git fuzz/ldk-fuzzing-corpus @@ -96,11 +107,14 @@ jobs: - name: Run fuzz coverage generation run: | ./contrib/generate_fuzz_coverage.sh --output-dir `pwd` --output-codecov-json + # Pass the commit context manually since codecov can't detect Forgejo. + CC="--git-service github --slug $CODECOV_SLUG --sha $CODECOV_SHA --branch $CODECOV_BRANCH" + if [ -n "${CODECOV_PR:-}" ]; then CC="$CC --pr $CODECOV_PR"; fi # Could you use this to fake the coverage report for your PR? Sure. # Will anyone be impressed by your amazing coverage? No # Maybe if codecov wasn't broken we wouldn't need to do this... - ./codecov --verbose upload-process --disable-search --fail-on-error -f fuzz-fake-hashes-codecov.json -t "f421b687-4dc2-4387-ac3d-dc3b2528af57" -F 'fuzzing-fake-hashes' - ./codecov --verbose upload-process --disable-search --fail-on-error -f fuzz-real-hashes-codecov.json -t "f421b687-4dc2-4387-ac3d-dc3b2528af57" -F 'fuzzing-real-hashes' + ./codecov --verbose upload-process --disable-search --fail-on-error $CC -f fuzz-fake-hashes-codecov.json -t "f421b687-4dc2-4387-ac3d-dc3b2528af57" -F 'fuzzing-fake-hashes' + ./codecov --verbose upload-process --disable-search --fail-on-error $CC -f fuzz-real-hashes-codecov.json -t "f421b687-4dc2-4387-ac3d-dc3b2528af57" -F 'fuzzing-real-hashes' benchmark: runs-on: debian-trixie From 51d0a4f879b5a0e3d5a0f98cedc78cc1de76021f Mon Sep 17 00:00:00 2001 From: Matt Corallo <git+claude@bluematt.me> Date: Sat, 27 Jun 2026 21:33:11 +0000 Subject: [PATCH 533/627] Comment the codecov report link on PRs Codecov's own PR comment depends on CI environment detection, which does not work under Forgejo, so post a link to the commit's coverage report from the coverage job instead. A hidden marker keeps the comment sticky (updated in place rather than re-posted on every push), and the step only runs for pull requests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .forgejo/workflows/build.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 7a7453cb0bb..fcc80d7ff08 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -115,6 +115,35 @@ jobs: # Maybe if codecov wasn't broken we wouldn't need to do this... ./codecov --verbose upload-process --disable-search --fail-on-error $CC -f fuzz-fake-hashes-codecov.json -t "f421b687-4dc2-4387-ac3d-dc3b2528af57" -F 'fuzzing-fake-hashes' ./codecov --verbose upload-process --disable-search --fail-on-error $CC -f fuzz-real-hashes-codecov.json -t "f421b687-4dc2-4387-ac3d-dc3b2528af57" -F 'fuzzing-real-hashes' + - name: Comment the codecov report link on the PR + # Codecov's own PR comment relies on CI environment detection (broken + # under Forgejo), so post a link to the commit's report ourselves. A + # hidden marker makes the comment sticky: update it instead of piling up + # a new comment on every push. Only runs for pull requests. + if: github.event.pull_request.number + env: + API: ${{ github.server_url }}/api/v1 + REPO: ${{ github.repository }} + FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} + run: | + set -eu + AUTH="Authorization: token ${FORGEJO_TOKEN}" + URL="https://app.codecov.io/github/${REPO}/commit/${CODECOV_SHA}" + MARKER="<!-- codecov-report-link -->" + BODY="${MARKER}"$'\n'"[Coverage report for this commit on Codecov](${URL})" + + # Update an existing sticky comment if present, otherwise create one. + CID="$(curl -fsS -H "$AUTH" "$API/repos/$REPO/issues/$CODECOV_PR/comments?limit=50" \ + | jq -r --arg m "$MARKER" 'map(select((.body // "") | contains($m))) | .[0].id // empty')" + if [ -n "$CID" ]; then + curl -fsS -H "$AUTH" -H 'Content-Type: application/json' \ + -X PATCH "$API/repos/$REPO/issues/comments/$CID" \ + -d "$(jq -n --arg b "$BODY" '{body: $b}')" >/dev/null + else + curl -fsS -H "$AUTH" -H 'Content-Type: application/json' \ + -X POST "$API/repos/$REPO/issues/$CODECOV_PR/comments" \ + -d "$(jq -n --arg b "$BODY" '{body: $b}')" >/dev/null + fi benchmark: runs-on: debian-trixie From 311a74cf46732d9a2ab9955f1d3f9bc63062f4ac Mon Sep 17 00:00:00 2001 From: Matt Corallo <git+claude@bluematt.me> Date: Sat, 27 Jun 2026 20:00:54 +0000 Subject: [PATCH 534/627] Add workflow to assign a random reviewer on new PRs Forgejo has no built-in random/round-robin reviewer assignment, so add a small Forgejo Actions workflow that, on pull request open, picks a random developer from the maintainer pool (excluding the author) and requests their review via the API. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .forgejo/workflows/assign-reviewer.yml | 46 ++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .forgejo/workflows/assign-reviewer.yml diff --git a/.forgejo/workflows/assign-reviewer.yml b/.forgejo/workflows/assign-reviewer.yml new file mode 100644 index 00000000000..f4e4f86be52 --- /dev/null +++ b/.forgejo/workflows/assign-reviewer.yml @@ -0,0 +1,46 @@ +name: Assign a random reviewer + +# Forgejo has no built-in random/round-robin reviewer assignment (only +# path-based CODEOWNERS), so pick a random developer for each newly opened +# pull request and request their review via the API. + +on: + pull_request_target: + types: [opened] + +jobs: + assign: + runs-on: debian-trixie + steps: + - name: Request review from a random developer + # This never checks out or runs any PR code -- it only makes an API + # call -- so running in the base-repo context (pull_request_target, + # which is what grants the token write access even for fork PRs) is safe. + env: + API: ${{ github.server_url }}/api/v1 + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + AUTHOR: ${{ github.event.pull_request.user.login }} + FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} + # Space-separated pool of candidate reviewers. + REVIEWERS: "matt val wpaulino joost_spiral jkczyz benthecarman tankyleo tnull" + run: | + set -eu + AUTH="Authorization: token ${FORGEJO_TOKEN}" + + # Build the candidate pool, excluding the PR author. + POOL="" + for d in $REVIEWERS; do + [ "$d" = "$AUTHOR" ] || POOL="$POOL $d" + done + + REVIEWER="$(printf '%s\n' $POOL | shuf -n1)" + if [ -z "$REVIEWER" ]; then + echo "No eligible reviewer (author is the only candidate); skipping." + exit 0 + fi + + echo "Requesting review from $REVIEWER on PR #$PR" + curl -fsS -H "$AUTH" -H 'Content-Type: application/json' \ + -X POST "$API/repos/$REPO/pulls/$PR/requested_reviewers" \ + -d "$(jq -n --arg r "$REVIEWER" '{reviewers: [$r]}')" >/dev/null From b86d2b33195ed6e2af27e582a7d684f98b87eccf Mon Sep 17 00:00:00 2001 From: Matt Corallo <git@bluematt.me> Date: Sat, 27 Jun 2026 10:45:40 +0000 Subject: [PATCH 535/627] For now disable all windows + macos CI runs until we have runners --- .forgejo/workflows/ci-build.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.forgejo/workflows/ci-build.yml b/.forgejo/workflows/ci-build.yml index 3f734c4b16d..5cf45de4c52 100644 --- a/.forgejo/workflows/ci-build.yml +++ b/.forgejo/workflows/ci-build.yml @@ -23,11 +23,7 @@ jobs: || fromJSON('["1.75.0"]') }} exclude: - platform: windows - toolchain: 1.75.0 - - platform: windows - toolchain: beta - platform: macos - toolchain: beta runs-on: ${{ matrix.platform }} steps: - name: Checkout source code From fbc22051d9cb4fcc8429e864f9d379c7874d6718 Mon Sep 17 00:00:00 2001 From: Daniel Roberts <ademan555@gmail.com> Date: Mon, 29 Jun 2026 13:30:46 -0500 Subject: [PATCH 536/627] Fix lightning-invoice bitcoin dependency version `lightning-invoice` since 743f43fcfd5acba55242792ed1e9337f2ab52858 will not build against `rust-bitcoin` older than v0.32.7. --- lightning-invoice/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning-invoice/Cargo.toml b/lightning-invoice/Cargo.toml index 2b5d570f43f..8efe3833351 100644 --- a/lightning-invoice/Cargo.toml +++ b/lightning-invoice/Cargo.toml @@ -22,7 +22,7 @@ std = [] bech32 = { version = "0.11.0", default-features = false } lightning-types = { version = "0.4.0", path = "../lightning-types", default-features = false } serde = { version = "1.0", optional = true, default-features = false, features = ["alloc"] } -bitcoin = { version = "0.32.4", default-features = false, features = ["secp-recovery"] } +bitcoin = { version = "0.32.7", default-features = false, features = ["secp-recovery"] } [dev-dependencies] serde_json = { version = "1"} From 693c478a03185c740a4b22a7475cc9aa4d2203b8 Mon Sep 17 00:00:00 2001 From: Matt Corallo <git@bluematt.me> Date: Mon, 29 Jun 2026 19:20:39 +0000 Subject: [PATCH 537/627] Tweak default merge message somewhat I hate how long the title ends up being when you include the full branch name in it, so move it to the next line. --- .forgejo/default_merge_message/MERGE_TEMPLATE.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .forgejo/default_merge_message/MERGE_TEMPLATE.md diff --git a/.forgejo/default_merge_message/MERGE_TEMPLATE.md b/.forgejo/default_merge_message/MERGE_TEMPLATE.md new file mode 100644 index 00000000000..669632fc772 --- /dev/null +++ b/.forgejo/default_merge_message/MERGE_TEMPLATE.md @@ -0,0 +1,5 @@ +Merge PR '${PullRequestTitle}' (${PullRequestReference}) +from ${HeadBranch} into ${BaseBranch} + +${ReviewedOn} +${ReviewedBy} From 05c9ef04831317313921ed4593428a8ecdc1ac63 Mon Sep 17 00:00:00 2001 From: Matt Corallo <git@bluematt.me> Date: Mon, 29 Jun 2026 19:28:51 +0000 Subject: [PATCH 538/627] Explicitly state rust toolchain in forgejo actions Forgejo doesn't expand env/matrix arguments in the names it prints for steps in actions, so we have to be explicit. We also have a lot of jobs where we really don't need an env indirection for two lines that reference it. --- .forgejo/workflows/build.yml | 56 ++++++++++------------------ .forgejo/workflows/check_commits.yml | 6 +-- .forgejo/workflows/ci-build.yml | 2 +- 3 files changed, 23 insertions(+), 41 deletions(-) diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index fcc80d7ff08..1278a385f8f 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -147,14 +147,12 @@ jobs: benchmark: runs-on: debian-trixie - env: - TOOLCHAIN: stable steps: - name: Checkout source code uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - name: Install Rust ${{ env.TOOLCHAIN }} toolchain + - name: Install Rust stable toolchain run: | - rustup default ${{ env.TOOLCHAIN }} + rustup default stable - name: Cache routing graph snapshot id: cache-graph uses: https://data.forgejo.org/actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 @@ -211,16 +209,14 @@ jobs: check_release: runs-on: debian-trixie - env: - TOOLCHAIN: stable steps: - name: Checkout source code uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 - - name: Install Rust ${{ env.TOOLCHAIN }} toolchain + - name: Install Rust stable toolchain run: | - rustup default ${{ env.TOOLCHAIN }} + rustup default stable - name: Run cargo check for release build. run: | cargo check --release @@ -239,33 +235,29 @@ jobs: check_docs: runs-on: debian-trixie - env: - # While docs.rs builds using a nightly compiler (and we use some nightly features), - # nightly ends up randomly breaking builds occasionally, so we instead use beta - # and set RUSTC_BOOTSTRAP in check-docsrs.sh - TOOLCHAIN: beta steps: - name: Checkout source code uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 - - name: Install Rust ${{ env.TOOLCHAIN }} toolchain + # While docs.rs builds using a nightly compiler (and we use some nightly features), + # nightly ends up randomly breaking builds occasionally, so we instead use beta + # and set RUSTC_BOOTSTRAP in check-docsrs.sh + - name: Install Rust beta toolchain run: | - rustup default ${{ env.TOOLCHAIN }} + rustup default beta - name: Simulate docs.rs build run: ci/check-docsrs.sh fuzz_sanity: runs-on: debian-trixie - env: - TOOLCHAIN: 1.75 steps: - name: Checkout source code uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - name: Install Rust ${{ env.TOOLCHAIN }} toolchain + - name: Install Rust 1.75 toolchain run: | - rustup default ${{ env.TOOLCHAIN }} - - name: Sanity check fuzz targets on Rust ${{ env.TOOLCHAIN }} + rustup default 1.75 + - name: Sanity check fuzz targets on Rust 1.75 run: | cd fuzz RUSTFLAGS="--cfg=fuzzing --cfg=secp256k1_fuzz --cfg=hashes_fuzz --cfg=chacha20_poly1305_fuzz" cargo test --quiet --color always --lib -j8 @@ -274,14 +266,12 @@ jobs: fuzz: runs-on: debian-trixie - env: - TOOLCHAIN: 1.75 steps: - name: Checkout source code uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - name: Install Rust ${{ env.TOOLCHAIN }} toolchain + - name: Install Rust 1.75 toolchain run: | - rustup default ${{ env.TOOLCHAIN }} + rustup default 1.75 - name: Clone fuzzing corpus run: git clone --depth=1 https://github.com/lightningdevkit/ldk-fuzzing-corpus.git fuzz/ldk-fuzzing-corpus - name: Symlink corpus into hfuzz_workspace @@ -350,14 +340,12 @@ jobs: linting: runs-on: debian-trixie - env: - TOOLCHAIN: stable steps: - name: Checkout source code uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - name: Install Rust ${{ env.TOOLCHAIN }} toolchain + - name: Install Rust stable toolchain run: | - rustup default ${{ env.TOOLCHAIN }} + rustup default stable - name: Install clippy run: | rustup component add clippy @@ -371,14 +359,12 @@ jobs: rustfmt: runs-on: debian-trixie - env: - TOOLCHAIN: 1.75.0 steps: - name: Checkout source code uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - name: Install Rust ${{ env.TOOLCHAIN }} toolchain + - name: Install Rust 1.75 toolchain run: | - rustup default ${{ env.TOOLCHAIN }} + rustup default 1.75 - name: Install rustfmt run: | rustup component add rustfmt @@ -390,14 +376,12 @@ jobs: run: cd fuzz && cargo fmt --check tor-connect: runs-on: debian-trixie - env: - TOOLCHAIN: 1.75.0 steps: - name: Checkout source code uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - name: Install Rust ${{ env.TOOLCHAIN }} toolchain + - name: Install Rust 1.75 toolchain run: | - rustup default ${{ env.TOOLCHAIN }} + rustup default 1.75 - name: Test tor connections using lightning-net-tokio run: | set -eu diff --git a/.forgejo/workflows/check_commits.yml b/.forgejo/workflows/check_commits.yml index 8502bc2640c..4778bd53ff2 100644 --- a/.forgejo/workflows/check_commits.yml +++ b/.forgejo/workflows/check_commits.yml @@ -12,16 +12,14 @@ concurrency: jobs: check_commits: runs-on: debian-trixie - env: - TOOLCHAIN: stable steps: - name: Checkout source code uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 - - name: Install Rust ${{ env.TOOLCHAIN }} toolchain + - name: Install Rust stable toolchain run: | - rustup default ${{ env.TOOLCHAIN }} + rustup default stable - name: Fetch full tree and rebase on upstream run: | git remote add upstream https://git.rust-bitcoin.org/lightningdevkit/rust-lightning diff --git a/.forgejo/workflows/ci-build.yml b/.forgejo/workflows/ci-build.yml index 5cf45de4c52..d9a0329cf42 100644 --- a/.forgejo/workflows/ci-build.yml +++ b/.forgejo/workflows/ci-build.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Checkout source code uses: https://data.forgejo.org/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - name: Install Rust ${{ matrix.toolchain }} toolchain + - name: Select Rust toolchain run: | rustup default ${{ matrix.toolchain }} - name: Use rust-lld linker on Windows From 6ae2634e34af2f011cc3851152219be65ce33b05 Mon Sep 17 00:00:00 2001 From: Joost Jager <joost.jager@gmail.com> Date: Thu, 4 Jun 2026 12:17:35 +0200 Subject: [PATCH 539/627] fuzz: add chanmon holder signer fuzz ops Allow chanmon consistency fuzz inputs to block holder-side signer operations and retry monitor-driven claim signing. The new commands extend the existing signer-op blocking machinery to the holder commitment and holder HTLC transaction paths. --- fuzz/src/chanmon_consistency.rs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 273af2a021f..c65e86de314 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -880,13 +880,16 @@ impl SignerProvider for KeyProvider { } } -// Since this fuzzer is only concerned with live-channel operations, we don't need to worry about -// any signer operations that come after a force close. -const SUPPORTED_SIGNER_OPS: [SignerOp; 4] = [ +// These signer operations can be blocked by fuzz bytes. The first four cover +// live-channel and splice signing, while the holder-side operations cover local +// on-chain claim signing after LDK has moved a channel to chain handling. +const SUPPORTED_SIGNER_OPS: [SignerOp; 6] = [ SignerOp::SignCounterpartyCommitment, SignerOp::GetPerCommitmentPoint, SignerOp::ReleaseCommitmentSecret, SignerOp::SignSpliceSharedInput, + SignerOp::SignHolderCommitment, + SignerOp::SignHolderHtlcTransaction, ]; impl KeyProvider { @@ -1242,6 +1245,15 @@ impl<'a> HarnessNode<'a> { self.node.timer_tick_occurred(); } + // Re-enables holder claim signing and asks the chain monitor to retry + // pending claim transactions. Different on-chain claim paths use + // SignHolderCommitment or SignHolderHtlcTransaction for force-closed channels. + fn enable_holder_signer_ops(&self) { + self.keys_manager.enable_op_for_all_signers(SignerOp::SignHolderCommitment); + self.keys_manager.enable_op_for_all_signers(SignerOp::SignHolderHtlcTransaction); + self.monitor.signer_unblocked(None); + } + fn current_feerate_sat_per_kw(&self) -> FeeRate { self.fee_estimator.feerate_sat_per_kw() } @@ -3273,9 +3285,14 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { self.nodes[1].keys_manager.enable_op_for_all_signers(op); self.nodes[2].keys_manager.enable_op_for_all_signers(op); } + // Live-channel signer work retries through the manager, while + // on-chain holder claims retry through the chain monitor. self.nodes[0].signer_unblocked(None); self.nodes[1].signer_unblocked(None); self.nodes[2].signer_unblocked(None); + self.nodes[0].monitor.signer_unblocked(None); + self.nodes[1].monitor.signer_unblocked(None); + self.nodes[2].monitor.signer_unblocked(None); self.process_all_events(); @@ -3775,6 +3792,12 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) { .enable_op_for_all_signers(SignerOp::SignSpliceSharedInput); harness.nodes[2].signer_unblocked(None); }, + // The harness toggles signer availability at node granularity, not + // per channel, so each byte re-enables both holder claim ops and + // asks that node's monitors to retry. + 0xd3 => harness.nodes[0].enable_holder_signer_ops(), + 0xd4 => harness.nodes[1].enable_holder_signer_ops(), + 0xd5 => harness.nodes[2].enable_holder_signer_ops(), 0xd6 => harness.relay_broadcasts_for_node(0), 0xd7 => harness.relay_broadcasts_for_node(1), 0xd8 => harness.relay_broadcasts_for_node(2), From a4d8301f756379a144628559dedf7bc8e4bb2b88 Mon Sep 17 00:00:00 2001 From: Joost Jager <joost.jager@gmail.com> Date: Fri, 5 Jun 2026 16:43:32 +0200 Subject: [PATCH 540/627] fuzz: factor chanmon finish cleanup helper Move the finish-time relay and mining loop into a helper so the harness has a single cleanup path for relayed transactions. --- fuzz/src/chanmon_consistency.rs | 49 +++++++++++++++++---------------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index c65e86de314..9bcd9754143 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -2609,30 +2609,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { // Final invariants should not depend on the input ending with explicit relay // and mining bytes. fn finish(&mut self) { - for _ in 0..MAX_FINISH_RELAY_MINE_ROUNDS { - let mut txs = Vec::new(); - for node in &self.nodes { - txs.extend(node.broadcaster.txn_broadcasted.borrow_mut().drain(..)); - } - self.chain_state.relay_transactions(txs); - if self.chain_state.pending_txs.is_empty() { - assert_test_invariants(&self.nodes); - return; - } - if self.mine_blocks(ANTI_REORG_DELAY) == 0 { - // The input ended with pending mempool transactions but no safe - // block left before an HTLC fail-back window. Leave them - // unconfirmed rather than forcing finish cleanup to advance - // the chain past that boundary. - assert_test_invariants(&self.nodes); - return; - } - } - assert!( - !self.nodes.iter().any(|node| !node.broadcaster.txn_broadcasted.borrow().is_empty()) - && self.chain_state.pending_txs.is_empty(), - "finish tx mining loop failed to quiesce", - ); + self.mine_relayed_txs_until_quiet(); assert_test_invariants(&self.nodes); } @@ -3451,6 +3428,30 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { } count } + + fn mine_relayed_txs_until_quiet(&mut self) { + for _ in 0..MAX_FINISH_RELAY_MINE_ROUNDS { + let mut txs = Vec::new(); + for node in &self.nodes { + txs.extend(node.broadcaster.txn_broadcasted.borrow_mut().drain(..)); + } + self.chain_state.relay_transactions(txs); + if self.chain_state.pending_txs.is_empty() { + return; + } + if self.mine_blocks(ANTI_REORG_DELAY) == 0 { + // Pending mempool transactions remain, but no safe block is + // left before an HTLC fail-back window. Leave them unconfirmed + // rather than advancing the chain past that boundary. + return; + } + } + assert!( + !self.nodes.iter().any(|node| !node.broadcaster.txn_broadcasted.borrow().is_empty()) + && self.chain_state.pending_txs.is_empty(), + "tx mining loop failed to quiesce", + ); + } } #[inline] From c468bb8221c4116a3a5b48e28aa8cb7351c30d3e Mon Sep 17 00:00:00 2001 From: Joost Jager <joost.jager@gmail.com> Date: Mon, 29 Jun 2026 17:23:55 +0200 Subject: [PATCH 541/627] fuzz: inline chanmon finish invariants Move the final chanmon consistency invariant checks into Harness::finish so the checks can read harness state directly. --- fuzz/src/chanmon_consistency.rs | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 9bcd9754143..6f45250502d 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -2232,17 +2232,6 @@ fn build_node_config(chan_type: ChanType) -> UserConfig { config } -fn assert_test_invariants(nodes: &[HarnessNode<'_>; 3]) { - assert_eq!(nodes[0].list_channels().len(), 3); - assert_eq!(nodes[1].list_channels().len(), 6); - assert_eq!(nodes[2].list_channels().len(), 3); - - // All broadcasters should be empty. Broadcast transactions are handled explicitly. - assert!(nodes[0].broadcaster.txn_broadcasted.borrow().is_empty()); - assert!(nodes[1].broadcaster.txn_broadcasted.borrow().is_empty()); - assert!(nodes[2].broadcaster.txn_broadcasted.borrow().is_empty()); -} - fn connect_peers(source: &ChanMan<'_>, dest: &ChanMan<'_>) { let init_dest = Init { features: dest.init_features(), networks: None, remote_network_address: None }; @@ -2610,7 +2599,14 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { // and mining bytes. fn finish(&mut self) { self.mine_relayed_txs_until_quiet(); - assert_test_invariants(&self.nodes); + assert_eq!(self.nodes[0].list_channels().len(), 3); + assert_eq!(self.nodes[1].list_channels().len(), 6); + assert_eq!(self.nodes[2].list_channels().len(), 3); + + // All broadcasters should be empty. Broadcast transactions are handled explicitly. + for node in &self.nodes { + assert!(node.broadcaster.txn_broadcasted.borrow().is_empty()); + } } fn link_between(&self, source_idx: usize, dest_idx: usize) -> &PeerLink { From 2ede721275b8f613fdd89bfc34177670c97ddb79 Mon Sep 17 00:00:00 2001 From: Joost Jager <joost.jager@gmail.com> Date: Mon, 29 Jun 2026 17:25:10 +0200 Subject: [PATCH 542/627] fuzz: add explicit local force-close ops Add a small HTLC-free force-close slice to the chanmon consistency harness. The new opcodes close one known channel on each peer link and track which channels are expected to close. Use the close tracker to reject untracked channel loss while accepting stale post-close errors and cleanup generated by explicitly closed channels. Later harness API calls skip tracked-closed channels so normal channel APIs are not called after modeling a local close. --- fuzz/src/chanmon_consistency.rs | 422 ++++++++++++++++++++++++++------ 1 file changed, 341 insertions(+), 81 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 6f45250502d..94111ed2ea4 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -935,18 +935,104 @@ type ChanMan<'a> = ChannelManager< >; #[inline] -fn assert_disconnect_action(action: &msgs::ErrorAction) -> (&msgs::WarningMessage, bool) { - // Since sending/receiving messages may be delayed, `timer_tick_occurred` may cause a node to - // disconnect their counterparty if they're expecting a timely response. - if let msgs::ErrorAction::DisconnectPeerWithWarning { ref msg } = action { - let is_quiescent_msg = msg.data.contains("already sent splice_locked, cannot RBF"); - if !msg.data.contains("Disconnecting due to timeout awaiting response") && !is_quiescent_msg - { - panic!("Unexpected disconnect case: {}", msg.data); - } - (msg, is_quiescent_msg) - } else { - panic!("Expected disconnect, got: {:?}", action); +fn assert_disconnect_action<'a>( + action: &'a msgs::ErrorAction, close_tracker: &ChannelCloseTracker, +) -> ExpectedControlAction<'a> { + match action { + msgs::ErrorAction::DisconnectPeerWithWarning { ref msg } => { + // Since sending/receiving messages may be delayed, `timer_tick_occurred` may cause + // a node to disconnect their counterparty if they're expecting a timely response. + let is_quiescent_msg = msg.data.contains("already sent splice_locked, cannot RBF"); + assert!( + msg.data.contains("Disconnecting due to timeout awaiting response") + || is_quiescent_msg, + "Unexpected disconnect case: {}", + msg.data, + ); + ExpectedControlAction::Warning(msg, is_quiescent_msg) + }, + msgs::ErrorAction::SendErrorMessage { ref msg } => { + assert!( + close_tracker.is_expected_closed_channel_error_msg(msg), + "Expected closed-channel error, got: {:?}", + msg, + ); + ExpectedControlAction::Error(msg) + }, + _ => panic!("Expected harness control error, got: {:?}", action), + } +} + +enum ExpectedControlAction<'a> { + Warning(&'a msgs::WarningMessage, bool), + Error(&'a msgs::ErrorMessage), +} + +struct ChannelCloseTracker { + // Channels this input explicitly requested to close, with the error reason + // passed to `force_close_broadcasting_latest_txn`. + closed_channels: HashMap<ChannelId, String>, +} + +impl ChannelCloseTracker { + fn new() -> Self { + Self { closed_channels: new_hash_map() } + } + + fn is_closed_or_closing(&self, channel_id: &ChannelId) -> bool { + self.closed_channels.contains_key(channel_id) + } + + fn is_open(&self, channel_id: &ChannelId) -> bool { + !self.is_closed_or_closing(channel_id) + } + + fn open_channels(&self, channel_ids: &[ChannelId]) -> Vec<ChannelId> { + channel_ids.iter().copied().filter(|channel_id| self.is_open(channel_id)).collect() + } + + fn has_closed_channels(&self) -> bool { + !self.closed_channels.is_empty() + } + + fn expect_channel_close(&mut self, channel_id: ChannelId, reason: String) { + assert!( + self.closed_channels.insert(channel_id, reason).is_none(), + "Channel {:?} close was already tracked", + channel_id, + ); + } + + fn verify_channel_closed_event( + &mut self, channel_id: ChannelId, reason: &events::ClosureReason, + ) { + assert!( + self.closed_channels.contains_key(&channel_id), + "Channel {:?} closed without an explicit force-close: {:?}", + channel_id, + reason, + ); + } + + fn is_expected_closed_channel_error_msg(&self, msg: &msgs::ErrorMessage) -> bool { + let expected_reason = match self.closed_channels.get(&msg.channel_id) { + Some(reason) => reason, + None => return false, + }; + msg.data == *expected_reason + || msg.data + == "Channel closed because commitment or closing transaction was confirmed on chain." + // Messages queued before the close can be delivered + // after the counterparty has removed the channel. + || msg.data.starts_with( + "Got a message for a channel from the wrong node! No such channel_id", + ) + // A stale channel message may already have been delivered before + // the harness observes the close. If it errors against the same + // tracked channel, the result is part of explicit-close cleanup. + || msg.data + == "Peer sent an invalid channel_reestablish to force close in a non-standard way" + || msg.data.contains("when we needed a channel_reestablish") } } @@ -1495,6 +1581,7 @@ impl EventQueues { fn route_from_middle<'a, I: IntoIterator<Item = MessageSendEvent>>( &mut self, excess_events: I, expect_drop_node: Option<usize>, nodes: &[HarnessNode<'a>; 3], + close_tracker: &ChannelCloseTracker, ) { // Push any events from Node B onto queues.ba and queues.bc. let a_id = nodes[0].get_our_node_id(); @@ -1526,7 +1613,7 @@ impl EventQueues { *node_id == a_id }, MessageSendEvent::HandleError { ref action, ref node_id } => { - assert_disconnect_action(action); + assert_disconnect_action(action, close_tracker); if Some(*node_id) == expect_drop_id { panic!( "peer_disconnected should drop msgs bound for the disconnected peer" @@ -1561,7 +1648,10 @@ impl EventQueues { } } - fn drain_on_disconnect(&mut self, edge_node: usize, nodes: &[HarnessNode<'_>; 3]) { + fn drain_on_disconnect( + &mut self, edge_node: usize, nodes: &[HarnessNode<'_>; 3], + close_tracker: &ChannelCloseTracker, + ) { match edge_node { 0 => { for event in nodes[0].get_and_clear_pending_msg_events() { @@ -1575,12 +1665,17 @@ impl EventQueues { MessageSendEvent::BroadcastChannelUpdate { .. } => {}, MessageSendEvent::SendChannelUpdate { .. } => {}, MessageSendEvent::HandleError { ref action, .. } => { - assert_disconnect_action(action); + assert_disconnect_action(action, close_tracker); }, _ => panic!("Unhandled message event"), } } - self.route_from_middle(nodes[1].get_and_clear_pending_msg_events(), Some(0), nodes); + self.route_from_middle( + nodes[1].get_and_clear_pending_msg_events(), + Some(0), + nodes, + close_tracker, + ); }, 2 => { for event in nodes[2].get_and_clear_pending_msg_events() { @@ -1594,12 +1689,17 @@ impl EventQueues { MessageSendEvent::BroadcastChannelUpdate { .. } => {}, MessageSendEvent::SendChannelUpdate { .. } => {}, MessageSendEvent::HandleError { ref action, .. } => { - assert_disconnect_action(action); + assert_disconnect_action(action, close_tracker); }, _ => panic!("Unhandled message event"), } } - self.route_from_middle(nodes[1].get_and_clear_pending_msg_events(), Some(2), nodes); + self.route_from_middle( + nodes[1].get_and_clear_pending_msg_events(), + Some(2), + nodes, + close_tracker, + ); }, _ => panic!("unsupported disconnected edge"), } @@ -1649,7 +1749,34 @@ impl PeerLink { } } - fn disconnect(&mut self, nodes: &[HarnessNode<'_>; 3], queues: &mut EventQueues) { + fn assert_no_unexpected_disappeared_channels( + &self, nodes: &[HarnessNode<'_>; 3], close_tracker: &ChannelCloseTracker, + ) { + let node_a_channels = nodes[self.node_a].list_channels(); + let node_b_channels = nodes[self.node_b].list_channels(); + for channel_id in &self.channel_ids { + if close_tracker.is_closed_or_closing(channel_id) { + continue; + } + assert!( + node_a_channels.iter().any(|chan| chan.channel_id == *channel_id), + "Node {} no longer lists channel {:?} without an explicit force-close", + self.node_a, + channel_id, + ); + assert!( + node_b_channels.iter().any(|chan| chan.channel_id == *channel_id), + "Node {} no longer lists channel {:?} without an explicit force-close", + self.node_b, + channel_id, + ); + } + } + + fn disconnect( + &mut self, nodes: &[HarnessNode<'_>; 3], queues: &mut EventQueues, + close_tracker: &ChannelCloseTracker, + ) { if self.disconnected { return; } @@ -1665,7 +1792,7 @@ impl PeerLink { } else { panic!("unsupported link topology") }; - queues.drain_on_disconnect(edge_node, nodes); + queues.drain_on_disconnect(edge_node, nodes, close_tracker); queues.clear_link(self); } @@ -1692,6 +1819,7 @@ impl PeerLink { fn disconnect_for_reload( &mut self, restarted_node: usize, nodes: &[HarnessNode<'_>; 3], queues: &mut EventQueues, + close_tracker: &ChannelCloseTracker, ) { if self.disconnected { return; @@ -1708,6 +1836,7 @@ impl PeerLink { nodes[1].get_and_clear_pending_msg_events(), Some(restarted_node), nodes, + close_tracker, ); } else { nodes[remaining_node].get_and_clear_pending_msg_events(); @@ -2208,6 +2337,7 @@ struct Harness<'a, Out: Output + MaybeSend + MaybeSync> { bc_link: PeerLink, queues: EventQueues, payments: PaymentTracker, + close_tracker: ChannelCloseTracker, } fn build_node_config(chan_type: ChanType) -> UserConfig { @@ -2583,6 +2713,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { bc_link: PeerLink::new(1, 2, chan_bc_ids), queues: EventQueues::new(), payments: PaymentTracker::new(), + close_tracker: ChannelCloseTracker::new(), } } @@ -2599,9 +2730,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { // and mining bytes. fn finish(&mut self) { self.mine_relayed_txs_until_quiet(); - assert_eq!(self.nodes[0].list_channels().len(), 3); - assert_eq!(self.nodes[1].list_channels().len(), 6); - assert_eq!(self.nodes[2].list_channels().len(), 3); + self.assert_only_expected_channel_closes(); // All broadcasters should be empty. Broadcast transactions are handled explicitly. for node in &self.nodes { @@ -2609,6 +2738,13 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { } } + fn assert_only_expected_channel_closes(&self) { + // A close may show up first as a missing list_channels entry rather + // than as an already-drained ChannelClosed event. + self.ab_link.assert_no_unexpected_disappeared_channels(&self.nodes, &self.close_tracker); + self.bc_link.assert_no_unexpected_disappeared_channels(&self.nodes, &self.close_tracker); + } + fn link_between(&self, source_idx: usize, dest_idx: usize) -> &PeerLink { if self.ab_link.connects(source_idx, dest_idx) { &self.ab_link @@ -2630,17 +2766,29 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { fn send_on_channel( &mut self, source_idx: usize, dest_idx: usize, dest_chan_id: ChannelId, amt: u64, ) -> bool { + if !self.close_tracker.is_open(&dest_chan_id) { + return false; + } self.payments.send(&self.nodes, source_idx, dest_idx, dest_chan_id, amt) } fn send(&mut self, source_idx: usize, dest_idx: usize, amt: u64) { - let dest_chan_id = self.first_channel_id_between(source_idx, dest_idx); + let chan_ids = self.channel_ids_between(source_idx, dest_idx); + let dest_chan_id = match self.close_tracker.open_channels(&chan_ids).first().copied() { + Some(chan_id) => chan_id, + None => return, + }; self.payments.send_noret(&self.nodes, source_idx, dest_idx, dest_chan_id, amt); } fn send_hop(&mut self, source_idx: usize, middle_idx: usize, dest_idx: usize, amt: u64) { let middle_chan_id = self.first_channel_id_between(source_idx, middle_idx); let dest_chan_id = self.first_channel_id_between(middle_idx, dest_idx); + if !self.close_tracker.is_open(&middle_chan_id) + || !self.close_tracker.is_open(&dest_chan_id) + { + return; + } self.payments.send_hop( &self.nodes, source_idx, @@ -2657,17 +2805,22 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { ) { match channels { MppDirectChannels::All => { - let dest_chan_ids = self.channel_ids_between(source_idx, dest_idx); + let dest_chan_ids = self + .close_tracker + .open_channels(&self.channel_ids_between(source_idx, dest_idx)); self.payments.send_mpp_direct( &self.nodes, source_idx, dest_idx, - &dest_chan_ids, + &dest_chan_ids[..], amt, ); }, MppDirectChannels::RepeatedFirst => { let dest_chan_id = self.first_channel_id_between(source_idx, dest_idx); + if !self.close_tracker.is_open(&dest_chan_id) { + return; + } let dest_chan_ids = [dest_chan_id, dest_chan_id, dest_chan_id]; self.payments.send_mpp_direct( &self.nodes, @@ -2690,29 +2843,39 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { let dest_first_chan_id = dest_chan_ids[0]; match channels { MppHopChannels::FirstHop => { + let middle_chan_ids = self.close_tracker.open_channels(&middle_chan_ids); + if !self.close_tracker.is_open(&dest_first_chan_id) { + return; + } let dest_chan_ids = [dest_first_chan_id]; self.payments.send_mpp_hop( &self.nodes, source_idx, middle_idx, - &middle_chan_ids, + &middle_chan_ids[..], dest_idx, &dest_chan_ids, amt, ); }, MppHopChannels::BothHops => { + let middle_chan_ids = self.close_tracker.open_channels(&middle_chan_ids); + let dest_chan_ids = self.close_tracker.open_channels(&dest_chan_ids); self.payments.send_mpp_hop( &self.nodes, source_idx, middle_idx, - &middle_chan_ids, + &middle_chan_ids[..], dest_idx, - &dest_chan_ids, + &dest_chan_ids[..], amt, ); }, MppHopChannels::SecondHop => { + if !self.close_tracker.is_open(&middle_first_chan_id) { + return; + } + let dest_chan_ids = self.close_tracker.open_channels(&dest_chan_ids); let middle_chan_ids = [middle_first_chan_id]; self.payments.send_mpp_hop( &self.nodes, @@ -2720,7 +2883,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { middle_idx, &middle_chan_ids, dest_idx, - &dest_chan_ids, + &dest_chan_ids[..], amt, ); }, @@ -2836,7 +2999,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { fn process_msg_event<Out: Output + MaybeSend + MaybeSync>( node_idx: usize, source_node_id: PublicKey, event: MessageSendEvent, corrupt_forward: bool, limit_events: ProcessMessages, nodes: &[HarnessNode<'_>; 3], - out: &Out, + close_tracker: &ChannelCloseTracker, out: &Out, ) -> Option<MessageSendEvent> { match event { MessageSendEvent::UpdateHTLCs { node_id, channel_id, updates } => { @@ -2859,6 +3022,12 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { None }, MessageSendEvent::SendChannelReestablish { ref node_id, ref msg } => { + if close_tracker.is_closed_or_closing(&msg.channel_id) { + // A reestablish generated before an explicit close is stale once that + // close is tracked. Delivering it can keep generating closed-channel + // error messages and prevent settle_all from quiescing. + return None; + } let dest_idx = log_peer_message(node_idx, node_id, nodes, out, "channel_reestablish"); nodes[dest_idx].handle_channel_reestablish(source_node_id, msg); @@ -2932,14 +3101,26 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { None }, MessageSendEvent::HandleError { ref action, ref node_id, .. } => { - let (msg, is_quiescent) = assert_disconnect_action(action); - let dest_idx = log_peer_message(node_idx, node_id, nodes, out, "warning"); - if is_quiescent { - nodes[node_idx].node.exit_quiescence(node_id, &msg.channel_id).unwrap(); - nodes[dest_idx] - .node - .exit_quiescence(&source_node_id, &msg.channel_id) - .unwrap(); + match assert_disconnect_action(action, close_tracker) { + ExpectedControlAction::Warning(msg, is_quiescent) => { + let dest_idx = + log_peer_message(node_idx, node_id, nodes, out, "warning"); + if is_quiescent && !close_tracker.is_closed_or_closing(&msg.channel_id) + { + nodes[node_idx] + .node + .exit_quiescence(node_id, &msg.channel_id) + .unwrap(); + nodes[dest_idx] + .node + .exit_quiescence(&source_node_id, &msg.channel_id) + .unwrap(); + } + }, + ExpectedControlAction::Error(msg) => { + let dest_idx = log_peer_message(node_idx, node_id, nodes, out, "error"); + nodes[dest_idx].handle_error(source_node_id, msg); + }, } None }, @@ -2959,6 +3140,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { } let nodes = &self.nodes; + let close_tracker = &self.close_tracker; let out = &self.out; let queues = &mut self.queues; let mut events = queues.take_for_node(node_idx); @@ -2979,6 +3161,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { corrupt_forward, limit_events, nodes, + close_tracker, out, ); if limit_events != ProcessMessages::AllMessages { @@ -2987,7 +3170,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { } if node_idx == 1 { let remaining = extra_ev.into_iter().chain(events_iter).collect::<Vec<_>>(); - queues.route_from_middle(remaining, None, nodes); + queues.route_from_middle(remaining, None, nodes, close_tracker); } else if node_idx == 0 { if let Some(ev) = extra_ev { queues.push_for_node(0, ev); @@ -3006,6 +3189,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { let nodes = &self.nodes; let payments = &mut self.payments; let chain_state = &self.chain_state; + let close_tracker = &mut self.close_tracker; // Multiple HTLCs can resolve for the same payment hash, so deduplicate // claim/fail handling per event batch. let mut claim_set = new_hash_map(); @@ -3043,6 +3227,11 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { unsigned_transaction, .. } => { + if close_tracker.is_closed_or_closing(&channel_id) { + // The signing event was queued before an explicit close. + // Do not call splice funding APIs for a tracked-closed channel. + continue; + } let wallet_script = nodes[node_idx].wallet.get_change_script().unwrap(); let has_unknown_spent_input = unsigned_transaction.input.iter().any(|input| { !chain_state.is_unspent(&input.previous_output) @@ -3091,11 +3280,21 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { }, events::Event::SpliceNegotiated { .. } => {}, events::Event::SpliceNegotiationFailed { .. } => {}, + events::Event::ChannelClosed { channel_id, reason, .. } => { + close_tracker.verify_channel_closed_event(channel_id, &reason); + }, events::Event::DiscardFunding { funding_info: events::FundingInfo::Contribution { .. } | events::FundingInfo::Tx { .. }, .. } => {}, + events::Event::SpendableOutputs { .. } => { + // The harness does not model an external sweeper wallet. + }, + events::Event::BumpTransaction(_) => { + // Fee bumping is not modeled; broadcasts are relayed through + // the harness mempool directly. + }, _ => panic!("Unhandled event: {:?}", event), } } @@ -3173,11 +3372,11 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { } fn disconnect_ab(&mut self) { - self.ab_link.disconnect(&self.nodes, &mut self.queues); + self.ab_link.disconnect(&self.nodes, &mut self.queues, &self.close_tracker); } fn disconnect_bc(&mut self) { - self.bc_link.disconnect(&self.nodes, &mut self.queues); + self.bc_link.disconnect(&self.nodes, &mut self.queues, &self.close_tracker); } fn reconnect_ab(&mut self) { @@ -3188,6 +3387,54 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { self.bc_link.reconnect(&self.nodes); } + fn has_pending_htlcs(&self) -> bool { + self.nodes.iter().any(|node| { + node.list_channels().iter().any(|chan| { + !chan.pending_inbound_htlcs.is_empty() || !chan.pending_outbound_htlcs.is_empty() + }) + }) + } + + fn force_close(&mut self, closer_idx: usize, channel_id: ChannelId, counterparty_idx: usize) { + if self.close_tracker.is_closed_or_closing(&channel_id) || self.has_pending_htlcs() { + // This opcode only models HTLC-free local closes. Leave it as a no-op + // while any channel has pending HTLCs, rather than mixing local + // force-close coverage with HTLC settlement. + return; + } + assert!( + self.nodes[closer_idx].list_channels().iter().any(|chan| chan.channel_id == channel_id), + "force-close target channel {:?} missing before explicit close", + channel_id, + ); + let reason = + format!("chanmon harness force-close by node {} on {:?}", closer_idx, channel_id); + match self.nodes[closer_idx].node.force_close_broadcasting_latest_txn( + &channel_id, + &self.nodes[counterparty_idx].get_our_node_id(), + reason.clone(), + ) { + Ok(()) => self.close_tracker.expect_channel_close(channel_id, reason), + Err(e) => panic!("{e:?}"), + } + } + + fn splice_in(&self, node_idx: usize, channel_id: ChannelId, counterparty_idx: usize) { + if self.close_tracker.is_closed_or_closing(&channel_id) { + return; + } + let cp_node_id = self.nodes[counterparty_idx].get_our_node_id(); + self.nodes[node_idx].splice_in(&cp_node_id, &channel_id); + } + + fn splice_out(&self, node_idx: usize, channel_id: ChannelId, counterparty_idx: usize) { + if self.close_tracker.is_closed_or_closing(&channel_id) { + return; + } + let cp_node_id = self.nodes[counterparty_idx].get_our_node_id(); + self.nodes[node_idx].splice_out(&cp_node_id, &channel_id); + } + // Finds the earliest loaded monitor height for a node. Startup sync uses it // as ChainMonitor's start height so raw monitors loaded below the manager's // best block still see every block and transaction they missed. @@ -3208,14 +3455,34 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { } match node_idx { 0 => { - self.ab_link.disconnect_for_reload(0, &self.nodes, &mut self.queues); + self.ab_link.disconnect_for_reload( + 0, + &self.nodes, + &mut self.queues, + &self.close_tracker, + ); }, 1 => { - self.ab_link.disconnect_for_reload(1, &self.nodes, &mut self.queues); - self.bc_link.disconnect_for_reload(1, &self.nodes, &mut self.queues); + self.ab_link.disconnect_for_reload( + 1, + &self.nodes, + &mut self.queues, + &self.close_tracker, + ); + self.bc_link.disconnect_for_reload( + 1, + &self.nodes, + &mut self.queues, + &self.close_tracker, + ); }, 2 => { - self.bc_link.disconnect_for_reload(2, &self.nodes, &mut self.queues); + self.bc_link.disconnect_for_reload( + 2, + &self.nodes, + &mut self.queues, + &self.close_tracker, + ); }, _ => panic!("invalid node index"), } @@ -3277,6 +3544,13 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { } self.process_all_events(); + if self.close_tracker.has_closed_channels() { + // Explicit closes broadcast commitment transactions. Mine the + // modeled mempool so later invariants see the post-close state. + self.mine_relayed_txs_until_quiet(); + self.process_all_events(); + } + // Verify no payments are stuck - all should have resolved self.payments.assert_all_resolved(); // Verify that every payment claimed by a receiver resulted in a @@ -3286,6 +3560,9 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { // All HTLCs should have been claimed or failed once we reach quiescence. for (idx, node) in self.nodes.iter().enumerate() { for chan in node.list_channels() { + if !self.close_tracker.is_open(&chan.channel_id) { + continue; + } assert!( chan.pending_inbound_htlcs.is_empty() && chan.pending_outbound_htlcs.is_empty(), "Node {} channel {:?} has stuck HTLCs after settling all state: \ @@ -3300,16 +3577,19 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { } } - // Finally, make sure that at least one end of each channel can make a substantial payment. + self.assert_only_expected_channel_closes(); + + // Finally, make sure that at least one end of each live channel can make + // a substantial payment. let chan_ab_ids = self.ab_link.channel_ids().clone(); let chan_bc_ids = self.bc_link.channel_ids().clone(); - for chan_id in chan_ab_ids { + for chan_id in self.close_tracker.open_channels(&chan_ab_ids) { assert!( self.send_on_channel(0, 1, chan_id, 10_000_000) || self.send_on_channel(1, 0, chan_id, 10_000_000) ); } - for chan_id in chan_bc_ids { + for chan_id in self.close_tracker.open_channels(&chan_bc_ids) { assert!( self.send_on_channel(1, 2, chan_id, 10_000_000) || self.send_on_channel(2, 1, chan_id, 10_000_000) @@ -3623,39 +3903,15 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) { harness.nodes[2].checkpoint_manager_persistence(); }, - 0xa0 => { - let cp_node_id = harness.nodes[1].get_our_node_id(); - harness.nodes[0].splice_in(&cp_node_id, &harness.chan_a_id()); - }, - 0xa1 => { - let cp_node_id = harness.nodes[0].get_our_node_id(); - harness.nodes[1].splice_in(&cp_node_id, &harness.chan_a_id()); - }, - 0xa2 => { - let cp_node_id = harness.nodes[2].get_our_node_id(); - harness.nodes[1].splice_in(&cp_node_id, &harness.chan_b_id()); - }, - 0xa3 => { - let cp_node_id = harness.nodes[1].get_our_node_id(); - harness.nodes[2].splice_in(&cp_node_id, &harness.chan_b_id()); - }, + 0xa0 => harness.splice_in(0, harness.chan_a_id(), 1), + 0xa1 => harness.splice_in(1, harness.chan_a_id(), 0), + 0xa2 => harness.splice_in(1, harness.chan_b_id(), 2), + 0xa3 => harness.splice_in(2, harness.chan_b_id(), 1), - 0xa4 => { - let cp_node_id = harness.nodes[1].get_our_node_id(); - harness.nodes[0].splice_out(&cp_node_id, &harness.chan_a_id()); - }, - 0xa5 => { - let cp_node_id = harness.nodes[0].get_our_node_id(); - harness.nodes[1].splice_out(&cp_node_id, &harness.chan_a_id()); - }, - 0xa6 => { - let cp_node_id = harness.nodes[2].get_our_node_id(); - harness.nodes[1].splice_out(&cp_node_id, &harness.chan_b_id()); - }, - 0xa7 => { - let cp_node_id = harness.nodes[1].get_our_node_id(); - harness.nodes[2].splice_out(&cp_node_id, &harness.chan_b_id()); - }, + 0xa4 => harness.splice_out(0, harness.chan_a_id(), 1), + 0xa5 => harness.splice_out(1, harness.chan_a_id(), 0), + 0xa6 => harness.splice_out(1, harness.chan_b_id(), 2), + 0xa7 => harness.splice_out(2, harness.chan_b_id(), 1), // Sync node by 1 block. 0xa8 => harness.nodes[0].sync_with_chain_state(&harness.chain_state, Some(1)), @@ -3802,6 +4058,10 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) { let count = MINE_BLOCK_COUNTS[(v - 0xd9) as usize]; harness.mine_blocks(count); }, + 0xe1 => harness.force_close(0, harness.chan_a_id(), 1), + 0xe2 => harness.force_close(1, harness.chan_b_id(), 2), + 0xe3 => harness.force_close(1, harness.chan_a_id(), 0), + 0xe4 => harness.force_close(2, harness.chan_b_id(), 1), 0xf0 => harness.ab_link.complete_monitor_updates_for_node( 0, From 773c08ac5ce65933181ddc3d01dad886b6b5b730 Mon Sep 17 00:00:00 2001 From: Elias Rohrer <dev@tnull.de> Date: Thu, 2 Jul 2026 11:41:40 +0200 Subject: [PATCH 543/627] Reduce LSPS5 reset cooldown Allow LSPS5 peer lifecycle events to clear webhook notification cooldowns again after 100ms. This keeps rapid reconnect churn throttled while avoiding multi-second delays between legitimate wake-up opportunities. Preserve subsecond precision in LSPSDateTime elapsed-time calculation so the new interval is enforced as configured. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps0/ser.rs | 10 ++++------ lightning-liquidity/src/lsps5/service.rs | 12 ++++++++---- lightning-liquidity/tests/lsps5_integration_tests.rs | 7 ++++++- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/lightning-liquidity/src/lsps0/ser.rs b/lightning-liquidity/src/lsps0/ser.rs index 1ac900b88fe..bbd3100e4ed 100644 --- a/lightning-liquidity/src/lsps0/ser.rs +++ b/lightning-liquidity/src/lsps0/ser.rs @@ -258,12 +258,7 @@ impl LSPSDateTime { /// Returns the elapsed duration from `other` to `self`, or zero if `other` is later. pub fn duration_since(&self, other: &Self) -> Duration { - let diff_secs = self.0.timestamp().saturating_sub(other.0.timestamp()); - if diff_secs <= 0 { - Duration::ZERO - } else { - Duration::from_secs(diff_secs as u64) - } + self.0.signed_duration_since(other.0).to_std().unwrap_or(Duration::ZERO) } /// Returns the time in seconds since the unix epoch. @@ -1007,8 +1002,11 @@ mod tests { fn datetime_duration_since_is_directional() { let earlier = LSPSDateTime::new_from_duration_since_epoch(Duration::from_secs(30)); let later = LSPSDateTime::new_from_duration_since_epoch(Duration::from_secs(90)); + let later_with_millis = + LSPSDateTime::new_from_duration_since_epoch(Duration::from_millis(90_100)); assert_eq!(later.duration_since(&earlier), Duration::from_secs(60)); + assert_eq!(later_with_millis.duration_since(&later), Duration::from_millis(100)); assert_eq!(earlier.duration_since(&later), Duration::ZERO); } diff --git a/lightning-liquidity/src/lsps5/service.rs b/lightning-liquidity/src/lsps5/service.rs index acc77efcb0b..babed1c7e66 100644 --- a/lightning-liquidity/src/lsps5/service.rs +++ b/lightning-liquidity/src/lsps5/service.rs @@ -90,7 +90,7 @@ pub const NOTIFICATION_COOLDOWN_TIME: Duration = Duration::from_secs(60); // 1 m /// This is distinct from [`NOTIFICATION_COOLDOWN_TIME`]: that cooldown protects the client from /// repeated spammy wake-ups, while this reset throttle protects registered notification URLs from /// amplification via rapid peer connect/disconnect churn. -const NOTIFICATION_COOLDOWN_RESET_INTERVAL: Duration = Duration::from_secs(10); +const NOTIFICATION_COOLDOWN_RESET_INTERVAL: Duration = Duration::from_millis(100); // Default configuration for LSPS5 service. impl Default for LSPS5ServiceConfig { @@ -878,6 +878,10 @@ mod tests { LSPSDateTime::new_from_duration_since_epoch(Duration::from_secs(seconds)) } + fn lsps_datetime_millis(milliseconds: u64) -> LSPSDateTime { + LSPSDateTime::new_from_duration_since_epoch(Duration::from_millis(milliseconds)) + } + fn test_webhook(last_notification_sent: Option<LSPSDateTime>) -> (LSPS5AppName, Webhook) { let app_name = LSPS5AppName::new("test_app".to_string()).unwrap(); let url = LSPS5WebhookUrl::new("https://example.com/webhook".to_string()).unwrap(); @@ -913,8 +917,8 @@ mod tests { assert!(peer_state.needs_persist); peer_state.needs_persist = false; - let skipped_reset = lsps_datetime(2_009); - let recent_notification = lsps_datetime(2_009); + let skipped_reset = lsps_datetime_millis(2_000_099); + let recent_notification = skipped_reset; peer_state.webhooks_mut()[0].1.last_notification_sent = Some(recent_notification); peer_state.needs_persist = false; @@ -923,7 +927,7 @@ mod tests { assert_eq!(peer_state.last_notification_cooldown_reset, Some(first_reset)); assert!(!peer_state.needs_persist); - let allowed_reset = lsps_datetime(2_010); + let allowed_reset = lsps_datetime_millis(2_000_100); peer_state.reset_notification_cooldown(allowed_reset); assert_eq!(peer_state.webhooks()[0].1.last_notification_sent, None); assert_eq!(peer_state.last_notification_cooldown_reset, Some(allowed_reset)); diff --git a/lightning-liquidity/tests/lsps5_integration_tests.rs b/lightning-liquidity/tests/lsps5_integration_tests.rs index 7a77b97fbec..07e0351ac09 100644 --- a/lightning-liquidity/tests/lsps5_integration_tests.rs +++ b/lightning-liquidity/tests/lsps5_integration_tests.rs @@ -274,6 +274,11 @@ impl MockTimeProvider { let mut time = self.current_time.write().unwrap(); *time += Duration::from_secs(seconds); } + + fn advance_time_millis(&self, milliseconds: u64) { + let mut time = self.current_time.write().unwrap(); + *time += Duration::from_millis(milliseconds); + } } impl TimeProvider for MockTimeProvider { @@ -1409,7 +1414,7 @@ fn test_notifications_and_peer_connected_reset_is_throttled() { ); // 7. Once the reset throttle has elapsed, peer_connected can reset the cooldown again. - mock_time_provider.advance_time(11); + mock_time_provider.advance_time_millis(100); service_node.liquidity_manager.peer_connected(client_node_id, &init_msg, false).unwrap(); let _ = service_handler.notify_payment_incoming(client_node_id); let event = service_node.liquidity_manager.next_event().unwrap(); From 57c84bca94c89968d9462f9ab8c20cd290166ae7 Mon Sep 17 00:00:00 2001 From: Elias Rohrer <dev@tnull.de> Date: Thu, 2 Jul 2026 15:36:55 +0200 Subject: [PATCH 544/627] Use Forgejo OIDC for review requests Request a local Authorized Integration JWT in the reviewer workflow. Use bearer authorization for the reviewer request API call. This avoids a long-lived user token. The workflow still gets the missing reviewer-request capability. Co-Authored-By: HAL 9000 --- .forgejo/workflows/assign-reviewer.yml | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/.forgejo/workflows/assign-reviewer.yml b/.forgejo/workflows/assign-reviewer.yml index f4e4f86be52..f9dee100615 100644 --- a/.forgejo/workflows/assign-reviewer.yml +++ b/.forgejo/workflows/assign-reviewer.yml @@ -8,25 +8,34 @@ on: pull_request_target: types: [opened] +enable-openid-connect: true + jobs: assign: runs-on: debian-trixie + permissions: + id-token: write steps: + - name: Fetch Authorized Integration token + id: jwt + run: | + set -eu + jwt="$(curl -fsS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL" | jq -r '.value')" + echo "::add-mask::$jwt" + echo "jwt=$jwt" >> "$FORGEJO_OUTPUT" - name: Request review from a random developer # This never checks out or runs any PR code -- it only makes an API - # call -- so running in the base-repo context (pull_request_target, - # which is what grants the token write access even for fork PRs) is safe. + # call with a local Authorized Integration token. env: - API: ${{ github.server_url }}/api/v1 - REPO: ${{ github.repository }} - PR: ${{ github.event.pull_request.number }} - AUTHOR: ${{ github.event.pull_request.user.login }} - FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} + API: ${{ forgejo.server_url }}/api/v1 + REPO: ${{ forgejo.event.repository.full_name }} + PR: ${{ forgejo.event.pull_request.number }} + AUTHOR: ${{ forgejo.event.pull_request.user.login }} # Space-separated pool of candidate reviewers. REVIEWERS: "matt val wpaulino joost_spiral jkczyz benthecarman tankyleo tnull" run: | set -eu - AUTH="Authorization: token ${FORGEJO_TOKEN}" + AUTH="Authorization: bearer ${{ steps.jwt.outputs.jwt }}" # Build the candidate pool, excluding the PR author. POOL="" From ade3b6bd440f099af19d7bbce6262dacc28f2e3f Mon Sep 17 00:00:00 2001 From: Elias Rohrer <dev@tnull.de> Date: Thu, 2 Jul 2026 13:14:15 +0200 Subject: [PATCH 545/627] Fix Joost's Forgejo username Use the account name present on this Forgejo instance so Joost stays in the reviewer rotation. Co-Authored-By: HAL 9000 --- .forgejo/workflows/assign-reviewer.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/assign-reviewer.yml b/.forgejo/workflows/assign-reviewer.yml index f9dee100615..f743c4cd621 100644 --- a/.forgejo/workflows/assign-reviewer.yml +++ b/.forgejo/workflows/assign-reviewer.yml @@ -32,7 +32,7 @@ jobs: PR: ${{ forgejo.event.pull_request.number }} AUTHOR: ${{ forgejo.event.pull_request.user.login }} # Space-separated pool of candidate reviewers. - REVIEWERS: "matt val wpaulino joost_spiral jkczyz benthecarman tankyleo tnull" + REVIEWERS: "matt val wpaulino joostjager jkczyz benthecarman tankyleo tnull" run: | set -eu AUTH="Authorization: bearer ${{ steps.jwt.outputs.jwt }}" From 53ce559020adddabb8e095ebd824c831588ce122 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Tue, 14 Apr 2026 09:37:06 -0400 Subject: [PATCH 546/627] ln/refactor: use amount_msat and counterparty_skimmed_fee_msat vars Followup from prefactor PR. --- lightning/src/ln/channelmanager.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index e3335291bf6..939e0046e0f 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8445,13 +8445,8 @@ impl< receiver_node_id: Some(receiver_node_id), payment_hash, purpose, - amount_msat: claimable_payment - .htlcs - .iter() - .map(|htlc| htlc.mpp_part.value) - .sum(), - counterparty_skimmed_fee_msat: claimable_payment - .total_counterparty_skimmed_msat(), + amount_msat, + counterparty_skimmed_fee_msat, receiving_channel_ids: claimable_payment.receiving_channel_ids(), claim_deadline, onion_fields: Some(claimable_payment.onion_fields.clone()), From 816b866dda0eb10a3534497f78f7fa8948d5770a Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Thu, 12 Mar 2026 11:28:42 -0400 Subject: [PATCH 547/627] ln: remove incoming trampoline secret from HTLCSource We don't need to track a single trampoline secret in our HTLCSource because this is already tracked in each of our previous hops contained in the source. This field was unnecessarily added under the belief that each inner trampoline onion we receive for inbound MPP trampoline would have the same session key. It can be removed with breaking changes to persistence because we currently refuse to decode trampoline forwards, and will not read HTLCSource::Trampoline to prevent downgrades. --- lightning/src/ln/channelmanager.rs | 32 ++++++++++-------------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 939e0046e0f..b822b9aad54 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -873,7 +873,6 @@ mod fuzzy_channelmanager { /// We might be forwarding an incoming payment that was received over MPP, and therefore /// need to store the vector of corresponding `HTLCPreviousHopData` values. previous_hop_data: Vec<HTLCPreviousHopData>, - incoming_trampoline_shared_secret: [u8; 32], /// Track outbound payment details once the payment has been dispatched, will be `None` /// when waiting for incoming MPP to accumulate. outbound_payment: Option<TrampolineDispatch>, @@ -978,14 +977,9 @@ impl Hash for HTLCSource { first_hop_htlc_msat.hash(hasher); bolt12_invoice.hash(hasher); }, - HTLCSource::TrampolineForward { - previous_hop_data, - incoming_trampoline_shared_secret, - outbound_payment, - } => { + HTLCSource::TrampolineForward { previous_hop_data, outbound_payment } => { 2u8.hash(hasher); previous_hop_data.hash(hasher); - incoming_trampoline_shared_secret.hash(hasher); if let Some(payment) = outbound_payment { payment.payment_id.hash(hasher); payment.path.hash(hasher); @@ -9402,11 +9396,7 @@ impl< None, )); }, - HTLCSource::TrampolineForward { - previous_hop_data, - incoming_trampoline_shared_secret, - .. - } => { + HTLCSource::TrampolineForward { previous_hop_data, .. } => { let decoded_onion_failure = onion_error.decode_onion_failure(&self.secp_ctx, &self.logger, &source); log_trace!( @@ -9418,8 +9408,6 @@ impl< "unknown channel".to_string() }, ); - let incoming_trampoline_shared_secret = Some(*incoming_trampoline_shared_secret); - // TODO: when we receive a failure from a single outgoing trampoline HTLC, we don't // necessarily want to fail all of our incoming HTLCs back yet. We may have other // outgoing HTLCs that need to resolve first. This will be tracked in our @@ -9431,6 +9419,7 @@ impl< incoming_packet_shared_secret, blinded_failure, channel_id, + trampoline_shared_secret, .. } = current_hop_data; log_trace!( @@ -9442,13 +9431,17 @@ impl< LocalHTLCFailureReason::TemporaryTrampolineFailure, Vec::new(), ); + debug_assert!( + trampoline_shared_secret.is_some(), + "trampoline hop should have secret" + ); push_forward_htlcs_failure( *prev_outbound_scid_alias, get_htlc_forward_failure( blinded_failure, &onion_error, incoming_packet_shared_secret, - &incoming_trampoline_shared_secret, + &trampoline_shared_secret, &None, *htlc_id, ), @@ -18153,16 +18146,11 @@ impl Writeable for HTLCSource { 1u8.write(writer)?; field.write(writer)?; }, - HTLCSource::TrampolineForward { - ref previous_hop_data, - incoming_trampoline_shared_secret, - ref outbound_payment, - } => { + HTLCSource::TrampolineForward { ref previous_hop_data, ref outbound_payment } => { 2u8.write(writer)?; write_tlv_fields!(writer, { (1, *previous_hop_data, required_vec), - (3, incoming_trampoline_shared_secret, required), - (5, outbound_payment, option), + (3, outbound_payment, option), }); }, } From a1260ed1e34d3a3d26323dc6e8c43e7e15ad6a91 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Tue, 27 Jan 2026 13:49:35 -0500 Subject: [PATCH 548/627] ln: store incoming mpp data in PendingHTLCRouting When we receive a trampoline forward, we need to wait for MPP parts to arrive at our node before we can forward the outgoing payment onwards. This commit threads this information through to our pending htlc struct which we'll use to validate the parts we receive. --- lightning/src/ln/channelmanager.rs | 3 +++ lightning/src/ln/onion_payment.rs | 14 +++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index b822b9aad54..7582321b93c 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -237,6 +237,8 @@ pub enum PendingHTLCRouting { blinded: Option<BlindedForward>, /// The absolute CLTV of the inbound HTLC incoming_cltv_expiry: u32, + /// MPP data for accumulating incoming HTLCs before dispatching an outbound payment. + incoming_multipath_data: Option<msgs::FinalOnionHopData>, }, /// The onion indicates that this is a payment for an invoice (supposedly) generated by us. /// @@ -17890,6 +17892,7 @@ impl_ser_tlv_based_enum!(PendingHTLCRouting, (4, blinded, option), (6, node_id, required), (8, incoming_cltv_expiry, required), + (10, incoming_multipath_data, option), } ); diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index e8ff9788f3c..4c31b63e865 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -111,6 +111,7 @@ enum RoutingInfo { next_hop_hmac: [u8; 32], shared_secret: SharedSecret, current_path_key: Option<PublicKey>, + incoming_multipath_data: Option<msgs::FinalOnionHopData>, }, } @@ -167,14 +168,15 @@ pub(super) fn create_fwd_pending_htlc_info( reason: LocalHTLCFailureReason::InvalidOnionPayload, err_data: Vec::new(), }), - onion_utils::Hop::TrampolineForward { next_trampoline_hop_data, next_trampoline_hop_hmac, new_trampoline_packet_bytes, trampoline_shared_secret, .. } => { + onion_utils::Hop::TrampolineForward { outer_hop_data, next_trampoline_hop_data, next_trampoline_hop_hmac, new_trampoline_packet_bytes, trampoline_shared_secret, .. } => { ( RoutingInfo::Trampoline { next_trampoline: next_trampoline_hop_data.next_trampoline, new_packet_bytes: new_trampoline_packet_bytes, next_hop_hmac: next_trampoline_hop_hmac, shared_secret: trampoline_shared_secret, - current_path_key: None + current_path_key: None, + incoming_multipath_data: outer_hop_data.multipath_trampoline_data, }, next_trampoline_hop_data.amt_to_forward, next_trampoline_hop_data.outgoing_cltv_value, @@ -200,7 +202,8 @@ pub(super) fn create_fwd_pending_htlc_info( new_packet_bytes: new_trampoline_packet_bytes, next_hop_hmac: next_trampoline_hop_hmac, shared_secret: trampoline_shared_secret, - current_path_key: outer_hop_data.current_path_key + current_path_key: outer_hop_data.current_path_key, + incoming_multipath_data: outer_hop_data.multipath_trampoline_data, }, amt_to_forward, outgoing_cltv_value, @@ -233,7 +236,7 @@ pub(super) fn create_fwd_pending_htlc_info( }), } } - RoutingInfo::Trampoline { next_trampoline, new_packet_bytes, next_hop_hmac, shared_secret, current_path_key } => { + RoutingInfo::Trampoline { next_trampoline, new_packet_bytes, next_hop_hmac, shared_secret, current_path_key, incoming_multipath_data } => { let next_trampoline_packet_pubkey = match next_packet_pubkey_opt { Some(Ok(pubkey)) => pubkey, _ => return Err(InboundHTLCErr { @@ -260,7 +263,8 @@ pub(super) fn create_fwd_pending_htlc_info( failure: intro_node_blinding_point .map(|_| BlindedFailure::FromIntroductionNode) .unwrap_or(BlindedFailure::FromBlindedNode), - }) + }), + incoming_multipath_data, } } }; From 3e162db9b72bbc76faee97a4846326b20ab2a4dd Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Wed, 25 Feb 2026 13:51:10 +0200 Subject: [PATCH 549/627] ln: use total_msat to calculate the amount for our next trampoline For regular blinded forwards, it's okay to use the amount in our update_add_htlc to calculate the amount that we need to foward onwards because we're only expecting on HTLC in and one HTLC out. For blinded trampoline forwards, it's possible that we have multiple incoming HTLCs that need to accumulate at our node that make our total incoming amount from which we'll calculate the amount that we need to forward onwards to the next trampoline. This commit updates our next trampoline amount calculation to use the total intended incoming amount for the payment so we can correctly calculate our next trampoline's amount. `decode_incoming_update_add_htlc_onion` is left unchanged because the call to `check_blinded` will be removed in upcoming commits. --- lightning/src/ln/onion_payment.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index 4c31b63e865..4fe44ac8190 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -185,8 +185,12 @@ pub(super) fn create_fwd_pending_htlc_info( ) }, onion_utils::Hop::TrampolineBlindedForward { outer_hop_data, next_trampoline_hop_data, next_trampoline_hop_hmac, new_trampoline_packet_bytes, trampoline_shared_secret, .. } => { + // The blinded path's payment_relay and payment_constraints apply to the aggregate + // amount that the trampoline node will forward onward, not the individual amount that + // arrives in a single (incoming MPP) HTLC. We used the desired total amount to + // calculate our outbound values. let (amt_to_forward, outgoing_cltv_value) = check_blinded_forward( - msg.amount_msat, msg.cltv_expiry, &next_trampoline_hop_data.payment_relay, &next_trampoline_hop_data.payment_constraints, &next_trampoline_hop_data.features + outer_hop_data.multipath_trampoline_data.as_ref().map(|f| f.total_msat).unwrap_or(msg.amount_msat), msg.cltv_expiry, &next_trampoline_hop_data.payment_relay, &next_trampoline_hop_data.payment_constraints, &next_trampoline_hop_data.features ).map_err(|()| { // We should be returning malformed here if `msg.blinding_point` is set, but this is // unreachable right now since we checked it in `decode_update_add_htlc_onion`. From c58d1d8368320108bd4461c53e967334becaf813 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Tue, 12 May 2026 13:55:39 -0400 Subject: [PATCH 550/627] ln: use outer onion values in PendingHTLCInfo for trampoline When we are a trampoline node receiving an incoming HTLC, we need access to our outer onion's amount_to_forward to check that we have been forwarded the correct amount. We can't use the amount in the inner onion, because that contains our fee budget - somebody could forward us less than we were intended to receive, and provided it is within the trampoline fee budget we wouldn't know. In this commit we set our outer onion values in PendingHTLCInfo to perform this validation properly. In the commit that follows, we'll start tracking our expected trampoline values in trampoline-specific routing info. --- lightning/src/ln/onion_payment.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index 4fe44ac8190..df32d50f5c1 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -178,8 +178,8 @@ pub(super) fn create_fwd_pending_htlc_info( current_path_key: None, incoming_multipath_data: outer_hop_data.multipath_trampoline_data, }, - next_trampoline_hop_data.amt_to_forward, - next_trampoline_hop_data.outgoing_cltv_value, + outer_hop_data.amt_to_forward, + outer_hop_data.outgoing_cltv_value, None, None ) @@ -189,7 +189,7 @@ pub(super) fn create_fwd_pending_htlc_info( // amount that the trampoline node will forward onward, not the individual amount that // arrives in a single (incoming MPP) HTLC. We used the desired total amount to // calculate our outbound values. - let (amt_to_forward, outgoing_cltv_value) = check_blinded_forward( + let (_next_hop_amount, _next_hop_cltv) = check_blinded_forward( outer_hop_data.multipath_trampoline_data.as_ref().map(|f| f.total_msat).unwrap_or(msg.amount_msat), msg.cltv_expiry, &next_trampoline_hop_data.payment_relay, &next_trampoline_hop_data.payment_constraints, &next_trampoline_hop_data.features ).map_err(|()| { // We should be returning malformed here if `msg.blinding_point` is set, but this is @@ -209,8 +209,8 @@ pub(super) fn create_fwd_pending_htlc_info( current_path_key: outer_hop_data.current_path_key, incoming_multipath_data: outer_hop_data.multipath_trampoline_data, }, - amt_to_forward, - outgoing_cltv_value, + outer_hop_data.amt_to_forward, + outer_hop_data.outgoing_cltv_value, next_trampoline_hop_data.intro_node_blinding_point, next_trampoline_hop_data.next_blinding_override ) From e14a28dd6fc0ac944c006c6da2e0c1f982e6f634 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Tue, 12 May 2026 13:56:58 -0400 Subject: [PATCH 551/627] ln: store next trampoline amount and cltv in PendingHTLCRouting When we're forwarding a trampoline payment, we need to remember the amount and CLTV that the next trampoline is expecting. --- lightning/src/ln/channelmanager.rs | 6 ++++++ lightning/src/ln/onion_payment.rs | 13 +++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 7582321b93c..030348300ec 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -239,6 +239,10 @@ pub enum PendingHTLCRouting { incoming_cltv_expiry: u32, /// MPP data for accumulating incoming HTLCs before dispatching an outbound payment. incoming_multipath_data: Option<msgs::FinalOnionHopData>, + /// The amount that the next trampoline is expecting to receive. + next_trampoline_amt_msat: u64, + /// The CLTV expiry height that the next trampoline is expecting to receive. + next_trampoline_cltv_expiry: u32, }, /// The onion indicates that this is a payment for an invoice (supposedly) generated by us. /// @@ -17893,6 +17897,8 @@ impl_ser_tlv_based_enum!(PendingHTLCRouting, (6, node_id, required), (8, incoming_cltv_expiry, required), (10, incoming_multipath_data, option), + (12, next_trampoline_amt_msat, required), + (14, next_trampoline_cltv_expiry, required), } ); diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index df32d50f5c1..36270ebb5e0 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -112,6 +112,8 @@ enum RoutingInfo { shared_secret: SharedSecret, current_path_key: Option<PublicKey>, incoming_multipath_data: Option<msgs::FinalOnionHopData>, + next_trampoline_amt_msat: u64, + next_trampoline_cltv: u32, }, } @@ -177,6 +179,8 @@ pub(super) fn create_fwd_pending_htlc_info( shared_secret: trampoline_shared_secret, current_path_key: None, incoming_multipath_data: outer_hop_data.multipath_trampoline_data, + next_trampoline_amt_msat: next_trampoline_hop_data.amt_to_forward, + next_trampoline_cltv: next_trampoline_hop_data.outgoing_cltv_value, }, outer_hop_data.amt_to_forward, outer_hop_data.outgoing_cltv_value, @@ -189,7 +193,7 @@ pub(super) fn create_fwd_pending_htlc_info( // amount that the trampoline node will forward onward, not the individual amount that // arrives in a single (incoming MPP) HTLC. We used the desired total amount to // calculate our outbound values. - let (_next_hop_amount, _next_hop_cltv) = check_blinded_forward( + let (next_hop_amount, next_hop_cltv) = check_blinded_forward( outer_hop_data.multipath_trampoline_data.as_ref().map(|f| f.total_msat).unwrap_or(msg.amount_msat), msg.cltv_expiry, &next_trampoline_hop_data.payment_relay, &next_trampoline_hop_data.payment_constraints, &next_trampoline_hop_data.features ).map_err(|()| { // We should be returning malformed here if `msg.blinding_point` is set, but this is @@ -208,6 +212,8 @@ pub(super) fn create_fwd_pending_htlc_info( shared_secret: trampoline_shared_secret, current_path_key: outer_hop_data.current_path_key, incoming_multipath_data: outer_hop_data.multipath_trampoline_data, + next_trampoline_amt_msat: next_hop_amount, + next_trampoline_cltv: next_hop_cltv, }, outer_hop_data.amt_to_forward, outer_hop_data.outgoing_cltv_value, @@ -240,7 +246,7 @@ pub(super) fn create_fwd_pending_htlc_info( }), } } - RoutingInfo::Trampoline { next_trampoline, new_packet_bytes, next_hop_hmac, shared_secret, current_path_key, incoming_multipath_data } => { + RoutingInfo::Trampoline { next_trampoline, new_packet_bytes, next_hop_hmac, shared_secret, current_path_key, incoming_multipath_data, next_trampoline_amt_msat, next_trampoline_cltv } => { let next_trampoline_packet_pubkey = match next_packet_pubkey_opt { Some(Ok(pubkey)) => pubkey, _ => return Err(InboundHTLCErr { @@ -269,6 +275,9 @@ pub(super) fn create_fwd_pending_htlc_info( .unwrap_or(BlindedFailure::FromBlindedNode), }), incoming_multipath_data, + next_trampoline_amt_msat, + next_trampoline_cltv_expiry: next_trampoline_cltv, + } } }; From 52f2394afcd4c056905b9c12bc365c3e6659e502 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Thu, 12 Feb 2026 12:34:15 +0200 Subject: [PATCH 552/627] ln: use outer onion values for trampoline NextPacketDetails When we receive trampoline payments, we first want to validate the values in our outer onion to ensure that we've been given the amount/ expiry that the sender was intending us to receive to make sure that forwarding nodes haven't sent us less than they should. --- lightning/src/ln/onion_payment.rs | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index 36270ebb5e0..3dbb274b8e6 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -700,33 +700,24 @@ pub(super) fn decode_incoming_update_add_htlc_onion<NS: NodeSigner, L: Logger, T Some(NextPacketDetails { next_packet_pubkey, outgoing_connector: HopConnector::Dummy, outgoing_amt_msat: amt_to_forward, outgoing_cltv_value }) } - onion_utils::Hop::TrampolineForward { next_trampoline_hop_data: msgs::InboundTrampolineForwardPayload { amt_to_forward, outgoing_cltv_value, next_trampoline }, trampoline_shared_secret, incoming_trampoline_public_key, .. } => { + onion_utils::Hop::TrampolineForward { next_trampoline_hop_data: msgs::InboundTrampolineForwardPayload { next_trampoline, .. }, ref outer_hop_data, trampoline_shared_secret, incoming_trampoline_public_key, .. } => { let next_trampoline_packet_pubkey = onion_utils::next_hop_pubkey(secp_ctx, incoming_trampoline_public_key, &trampoline_shared_secret.secret_bytes()); Some(NextPacketDetails { next_packet_pubkey: next_trampoline_packet_pubkey, outgoing_connector: HopConnector::Trampoline(next_trampoline), - outgoing_amt_msat: amt_to_forward, - outgoing_cltv_value, + outgoing_amt_msat: outer_hop_data.amt_to_forward, + outgoing_cltv_value: outer_hop_data.outgoing_cltv_value, }) } - onion_utils::Hop::TrampolineBlindedForward { next_trampoline_hop_data: msgs::InboundTrampolineBlindedForwardPayload { next_trampoline, ref payment_relay, ref payment_constraints, ref features, .. }, outer_shared_secret, trampoline_shared_secret, incoming_trampoline_public_key, .. } => { - let (amt_to_forward, outgoing_cltv_value) = match check_blinded_forward( - msg.amount_msat, msg.cltv_expiry, &payment_relay, &payment_constraints, &features - ) { - Ok((amt, cltv)) => (amt, cltv), - Err(()) => { - return encode_relay_error("Underflow calculating outbound amount or cltv value for blinded trampoline forward", - LocalHTLCFailureReason::InvalidOnionBlinding, outer_shared_secret.secret_bytes(), Some(trampoline_shared_secret.secret_bytes()), &[0; 32]); - } - }; + onion_utils::Hop::TrampolineBlindedForward { next_trampoline_hop_data: msgs::InboundTrampolineBlindedForwardPayload { next_trampoline, .. }, ref outer_hop_data, trampoline_shared_secret, incoming_trampoline_public_key, .. } => { let next_trampoline_packet_pubkey = onion_utils::next_hop_pubkey(secp_ctx, incoming_trampoline_public_key, &trampoline_shared_secret.secret_bytes()); Some(NextPacketDetails { next_packet_pubkey: next_trampoline_packet_pubkey, outgoing_connector: HopConnector::Trampoline(next_trampoline), - outgoing_amt_msat: amt_to_forward, - outgoing_cltv_value, + outgoing_amt_msat: outer_hop_data.amt_to_forward, + outgoing_cltv_value: outer_hop_data.outgoing_cltv_value, }) } _ => None From bfb4acb8f1fe7bcbc9f898f434a254dfddfe42dc Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Mon, 30 Mar 2026 14:16:44 -0400 Subject: [PATCH 553/627] ln: add awaiting_trampoline_forwards to accumulate inbound MPP When we are a trampoline router, we need to accumulate incoming HTLCs (if MPP is used) before forwarding the trampoline-routed outgoing HTLC(s). This commit adds a new map in channel manager, and mimics the handling done for claimable_payments. We will rely on our pending_outbound_payments (which will contain a payment for trampoline forwards) for completing MPP claims, not want to surface `PaymentClaimable` events for trampoline, so do not need to have pending_claiming_payments like we have for MPP receives. This map is not persisted, as we're currently working on refactoring restart logic to depend on channel monitors. We should not use this accumulation map in production yet, as we can hit a force close if: - We are used as a trampoline, despite not supporting the feature - A trampoline MPP part arrives and is committed to the inbound channel and added to `awaiting_trampoline_forwards` - We restart and the MPP part is not re-added to `awaiting_trampoline_forwards` In this scenario, we will not hit our MPP timeout logic for this HTLC because we have "forgotten" about it. It will be up to our counterparty to force close the channel on us, because we're not failing it back after we hit MPP timeout. Likewise, even if other MPP parts arrive, we won't consider the inbound accumulation to be complete so we'll fail them back but forget about the HTLC that came before the restart. We currently reject trampoline HTLCs earlier in the lifecycle, so we are not at risk of producing a state that could trigger such a force close. In the commits that follow, we'll allow forwarding of trampoline HTLC for tests so that we can start to cover this code. --- lightning/src/ln/channelmanager.rs | 63 ++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 030348300ec..3cacbdc1926 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -1313,6 +1313,12 @@ fn check_mpp_timeout<'a>( timed_out } +/// Tracks trampoline HTLCs being accumulated before forwarding. +struct TrampolinePayment { + onion_fields: RecipientOnionFields, + htlcs: Vec<MppPart>, +} + /// Represent the channel funding transaction type. enum FundingType { /// This variant is useful when we want LDK to validate the funding transaction and @@ -2894,6 +2900,16 @@ pub struct ChannelManager< /// [`ClaimablePayments`]' individual field docs for more info. claimable_payments: Mutex<ClaimablePayments>, + /// The sets of trampoline payments which are in the process of being accumulated on inbound + /// channel(s). + /// + /// Note that this map is currently not persisted, as there is ongoing work to refactor our + /// reload from disk depending only on channel managers. Until proper restart logic is added + /// we will "forget" about any HTLCs that are pending in this map on restart waiting for MPP + /// timeout. For this reason, we should not forward any trampoline HTLCs until properly + /// implemented. + awaiting_trampoline_forwards: Mutex<HashMap<PaymentHash, TrampolinePayment>>, + /// The set of outbound SCID aliases across all our channels, including unconfirmed channels /// and some closed channels which reached a usable state prior to being closed. This is used /// only to avoid duplicates, and is not persisted explicitly to disk, but rebuilt from the @@ -3741,6 +3757,7 @@ impl< forward_htlcs: Mutex::new(new_hash_map()), decode_update_add_htlcs: Mutex::new(new_hash_map()), claimable_payments: Mutex::new(ClaimablePayments { claimable_payments: new_hash_map(), pending_claiming_payments: new_hash_map() }), + awaiting_trampoline_forwards: Mutex::new(new_hash_map()), pending_intercepted_htlcs: Mutex::new(new_hash_map()), short_to_chan_info: FairRwLock::new(new_hash_map()), @@ -9098,6 +9115,26 @@ impl< }, ); + self.awaiting_trampoline_forwards.lock().unwrap().retain(|payment_hash, payment| { + if payment.htlcs.is_empty() { + debug_assert!(false); + return false; + } + let mpp_timeout = + check_mpp_timeout(payment.htlcs.iter_mut(), &payment.onion_fields); + if mpp_timeout { + let previous_hop_data = + payment.htlcs.drain(..).map(|claimable| claimable.prev_hop).collect(); + + timed_out_mpp_htlcs.push(( + HTLCSource::TrampolineForward { previous_hop_data, outbound_payment: None }, + *payment_hash, + HTLCHandlingFailureType::TrampolineForward {}, + )); + } + !mpp_timeout + }); + for (htlc_source, payment_hash, failure_type) in timed_out_mpp_htlcs.drain(..) { let failure_reason = LocalHTLCFailureReason::MPPTimeout; let reason = HTLCFailReason::from_failure_code(failure_reason); @@ -16586,6 +16623,31 @@ impl< }, ); + self.awaiting_trampoline_forwards.lock().unwrap().retain(|payment_hash, payment| { + if payment.htlcs.is_empty() { + debug_assert!(false); + return false; + } + let htlc_timed_out = + payment.htlcs.iter().any(|htlc| htlc.check_onchain_timeout(height)); + if htlc_timed_out { + let previous_hop_data = + payment.htlcs.drain(..).map(|claimable| claimable.prev_hop).collect(); + + let failure_reason = LocalHTLCFailureReason::CLTVExpiryTooSoon; + timed_out_htlcs.push(( + HTLCSource::TrampolineForward { previous_hop_data, outbound_payment: None }, + *payment_hash, + HTLCFailReason::reason( + failure_reason, + self.get_htlc_inbound_temp_fail_data(failure_reason), + ), + HTLCHandlingFailureType::TrampolineForward {}, + )); + } + !htlc_timed_out + }); + let mut intercepted_htlcs = self.pending_intercepted_htlcs.lock().unwrap(); intercepted_htlcs.retain(|_, htlc| { if height >= htlc.forward_info.outgoing_cltv_value - HTLC_FAIL_BACK_BUFFER { @@ -20484,6 +20546,7 @@ impl< claimable_payments, pending_claiming_payments, }), + awaiting_trampoline_forwards: Mutex::new(new_hash_map()), outbound_scid_aliases: Mutex::new(outbound_scid_aliases), short_to_chan_info: FairRwLock::new(short_to_chan_info), fake_scid_rand_bytes: fake_scid_rand_bytes.unwrap(), From 2334a2003902552ab848e2d927fc68aa5a63983b Mon Sep 17 00:00:00 2001 From: Wilmer Paulino <wilmer@wilmerpaulino.com> Date: Wed, 17 Jun 2026 11:32:01 -0700 Subject: [PATCH 554/627] Only emit Event::SpliceNegotiated when contributing There's no need to inform users of negotiated splices when they're not contributing as it just produces noise. Once they do start contributing, they cannot stop, so we always emit the event going forward. Note that we still emit `Event::ChannelReady` with the new locked funding outpoint for each locked splice, so users can still learn that a splice occurred that way. --- lightning/src/events/mod.rs | 14 ++-- lightning/src/ln/async_signer_tests.rs | 6 +- lightning/src/ln/channel.rs | 10 +++ lightning/src/ln/channelmanager.rs | 106 ++++++++++++++----------- lightning/src/ln/splicing_tests.rs | 68 ++++++++-------- 5 files changed, 113 insertions(+), 91 deletions(-) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index ec0ad6ccd9b..2e56d35c887 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -1647,8 +1647,12 @@ pub enum Event { /// [`ChainMonitor::get_claimable_balances`]: crate::chain::chainmonitor::ChainMonitor::get_claimable_balances last_local_balance_msat: Option<u64>, }, - /// Used to indicate that a splice for the given `channel_id` has been negotiated and its - /// funding transaction has been broadcast. + /// Used to indicate that a splice for the given `channel_id` has been negotiated, its + /// funding transaction has been broadcast, and local inputs or outputs were contributed to + /// it. + /// + /// This event is not emitted if the counterparty negotiated a splice without using a local + /// contribution. /// /// The splice is then considered pending until both parties have seen enough confirmations to /// consider the funding locked. Once this occurs, an [`Event::ChannelReady`] will be emitted. @@ -1679,9 +1683,9 @@ pub enum Event { }, /// Used to indicate that a splice negotiation round for the given `channel_id` has failed. /// - /// Each splice attempt (initial or RBF) resolves to either [`Event::SpliceNegotiated`] on - /// success or this event on failure. Prior successfully negotiated splice transactions are - /// unaffected. + /// Each splice attempt (initial or RBF) resolves to this event on failure. On success, + /// [`Event::SpliceNegotiated`] is emitted if the negotiated transaction includes local + /// inputs or outputs. Prior successfully negotiated splice transactions are unaffected. /// /// Any UTXOs contributed to the failed round that are not committed to a prior negotiated /// splice transaction will be returned via a preceding [`Event::DiscardFunding`]. diff --git a/lightning/src/ln/async_signer_tests.rs b/lightning/src/ln/async_signer_tests.rs index f36c19748f0..f60e63a87e9 100644 --- a/lightning/src/ln/async_signer_tests.rs +++ b/lightning/src/ln/async_signer_tests.rs @@ -1853,7 +1853,7 @@ fn test_async_splice_initial_commit_sig() { acceptor.node.handle_tx_signatures(initiator_node_id, &tx_signatures); let _ = get_event!(initiator, Event::SpliceNegotiated); - let _ = get_event!(acceptor, Event::SpliceNegotiated); + assert!(acceptor.node.get_and_clear_pending_events().is_empty()); } #[test] @@ -1945,7 +1945,7 @@ fn test_async_splice_initial_commit_sig_waits_for_monitor_before_tx_signatures() acceptor.node.handle_tx_signatures(initiator_node_id, &tx_signatures); let _ = get_event!(initiator, Event::SpliceNegotiated); - let _ = get_event!(acceptor, Event::SpliceNegotiated); + assert!(acceptor.node.get_and_clear_pending_events().is_empty()); } #[test] @@ -2022,5 +2022,5 @@ fn test_async_splice_shared_input_signature_released_on_unblock() { acceptor.node.handle_tx_signatures(initiator_node_id, &tx_signatures); let _ = get_event!(initiator, Event::SpliceNegotiated); - let _ = get_event!(acceptor, Event::SpliceNegotiated); + assert!(acceptor.node.get_and_clear_pending_events().is_empty()); } diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 51a6795afdc..9fab47fdfc1 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -7185,6 +7185,9 @@ pub struct SpliceFundingNegotiated { /// The outpoint of the channel's splice funding transaction. pub funding_txo: bitcoin::OutPoint, + /// Whether the holder contributed local inputs or outputs to the negotiated splice. + pub has_local_contribution: bool, + /// The features that this channel will operate with. pub channel_type: ChannelTypeFeatures, @@ -9559,11 +9562,18 @@ where funding.get_funding_txo().expect("funding outpoint should be set"); let channel_type = funding.get_channel_type().clone(); let funding_redeem_script = funding.get_funding_redeemscript(); + let has_local_contribution = self + .context + .interactive_tx_signing_session + .as_ref() + .map(|signing_session| signing_session.has_local_contribution()) + .unwrap_or(false); pending_splice.negotiated_candidates.push(funding); let splice_negotiated = SpliceFundingNegotiated { funding_txo: funding_txo.into_bitcoin_outpoint(), + has_local_contribution, channel_type, funding_redeem_script, }; diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index e3335291bf6..2667d5fa872 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -6783,8 +6783,9 @@ impl< /// /// Calling this method will commence the process of creating a new funding transaction for the /// channel. Once the funding transaction has been constructed, an [`Event::SpliceNegotiated`] - /// will be emitted. At this point, any inputs contributed to the splice can only be re-spent - /// if an [`Event::DiscardFunding`] is seen. + /// will be emitted if the negotiated transaction includes local inputs or outputs. At this + /// point, any inputs contributed to the splice can only be re-spent if an + /// [`Event::DiscardFunding`] is seen. /// /// If any failures occur while negotiating the funding transaction, an /// [`Event::SpliceNegotiationFailed`] will be emitted. Any contributed inputs no longer used @@ -7007,18 +7008,20 @@ impl< ); } if let Some(splice_negotiated) = splice_negotiated { - self.pending_events.lock().unwrap().push_back(( - events::Event::SpliceNegotiated { - channel_id: *channel_id, - counterparty_node_id: *counterparty_node_id, - user_channel_id: chan.context().get_user_id(), - new_funding_txo: splice_negotiated.funding_txo, - channel_type: splice_negotiated.channel_type, - new_funding_redeem_script: splice_negotiated - .funding_redeem_script, - }, - None, - )); + if splice_negotiated.has_local_contribution { + self.pending_events.lock().unwrap().push_back(( + events::Event::SpliceNegotiated { + channel_id: *channel_id, + counterparty_node_id: *counterparty_node_id, + user_channel_id: chan.context().get_user_id(), + new_funding_txo: splice_negotiated.funding_txo, + channel_type: splice_negotiated.channel_type, + new_funding_redeem_script: splice_negotiated + .funding_redeem_script, + }, + None, + )); + } } if chan.context().is_connected() { @@ -11201,17 +11204,19 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ .as_mut() .and_then(|v| v.splice_negotiated.take()) { - pending_events.push_back(( - events::Event::SpliceNegotiated { - channel_id: channel.context.channel_id(), - counterparty_node_id, - user_channel_id: channel.context.get_user_id(), - new_funding_txo: splice_negotiated.funding_txo, - channel_type: splice_negotiated.channel_type, - new_funding_redeem_script: splice_negotiated.funding_redeem_script, - }, - None, - )); + if splice_negotiated.has_local_contribution { + pending_events.push_back(( + events::Event::SpliceNegotiated { + channel_id: channel.context.channel_id(), + counterparty_node_id, + user_channel_id: channel.context.get_user_id(), + new_funding_txo: splice_negotiated.funding_txo, + channel_type: splice_negotiated.channel_type, + new_funding_redeem_script: splice_negotiated.funding_redeem_script, + }, + None, + )); + } } } @@ -12286,18 +12291,20 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ // which also terminates quiescence. let needs_holding_cell_release = splice_negotiated.is_some(); if let Some(splice_negotiated) = splice_negotiated { - self.pending_events.lock().unwrap().push_back(( - events::Event::SpliceNegotiated { - channel_id: msg.channel_id, - counterparty_node_id: *counterparty_node_id, - user_channel_id: chan.context.get_user_id(), - new_funding_txo: splice_negotiated.funding_txo, - channel_type: splice_negotiated.channel_type, - new_funding_redeem_script: splice_negotiated - .funding_redeem_script, - }, - None, - )); + if splice_negotiated.has_local_contribution { + self.pending_events.lock().unwrap().push_back(( + events::Event::SpliceNegotiated { + channel_id: msg.channel_id, + counterparty_node_id: *counterparty_node_id, + user_channel_id: chan.context.get_user_id(), + new_funding_txo: splice_negotiated.funding_txo, + channel_type: splice_negotiated.channel_type, + new_funding_redeem_script: splice_negotiated + .funding_redeem_script, + }, + None, + )); + } } let holding_cell_res = if needs_holding_cell_release { self.check_free_peer_holding_cells(peer_state) @@ -14148,17 +14155,20 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ .and_then(|funding_tx_signed| funding_tx_signed.splice_negotiated.take()) { *needs_holding_cell_release = true; - self.pending_events.lock().unwrap().push_back(( - events::Event::SpliceNegotiated { - channel_id, - counterparty_node_id: node_id, - user_channel_id: funded_chan.context.get_user_id(), - new_funding_txo: splice_negotiated.funding_txo, - channel_type: splice_negotiated.channel_type, - new_funding_redeem_script: splice_negotiated.funding_redeem_script, - }, - None, - )); + if splice_negotiated.has_local_contribution { + self.pending_events.lock().unwrap().push_back(( + events::Event::SpliceNegotiated { + channel_id, + counterparty_node_id: node_id, + user_channel_id: funded_chan.context.get_user_id(), + new_funding_txo: splice_negotiated.funding_txo, + channel_type: splice_negotiated.channel_type, + new_funding_redeem_script: splice_negotiated + .funding_redeem_script, + }, + None, + )); + } } if let Some(broadcast_tx) = msgs.signed_closing_tx { log_info!(logger, "Broadcasting closing tx {}", log_tx!(broadcast_tx)); diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 5fff8dcd6b8..b8a6e2b4daf 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -703,7 +703,6 @@ pub fn splice_channel<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, funding_contribution: FundingContribution, ) -> (Transaction, ScriptBuf) { - let node_id_initiator = initiator.node.get_our_node_id(); let node_id_acceptor = acceptor.node.get_our_node_id(); let new_funding_script = complete_splice_handshake(initiator, acceptor); @@ -719,7 +718,7 @@ pub fn splice_channel<'a, 'b, 'c, 'd>( assert!(splice_locked.is_none()); expect_splice_pending_event(initiator, &node_id_acceptor); - expect_splice_pending_event(acceptor, &node_id_initiator); + assert!(acceptor.node.get_and_clear_pending_events().is_empty()); (splice_tx, new_funding_script) } @@ -1750,7 +1749,7 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_1_id); - expect_splice_pending_event(&nodes[1], &node_0_id); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); // Now that the splice is pending, another splice may be initiated. assert!(nodes[0].node.splice_channel(&channel_id, &node_1_id).is_ok()); @@ -2024,7 +2023,7 @@ fn do_test_splice_tiebreak( assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); mine_transaction(&nodes[0], &tx); mine_transaction(&nodes[1], &tx); @@ -2071,7 +2070,7 @@ fn do_test_splice_tiebreak( assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[1], &node_id_0); - expect_splice_pending_event(&nodes[0], &node_id_1); + assert!(nodes[0].node.get_and_clear_pending_events().is_empty()); mine_transaction(&nodes[1], &new_splice_tx); mine_transaction(&nodes[0], &new_splice_tx); @@ -2537,7 +2536,7 @@ fn do_test_splice_reestablish(reload: bool, async_monitor_update: bool) { reconnect_nodes!(|reconnect_args: &mut ReconnectArgs| { reconnect_args.send_interactive_tx_sigs = (false, true); }); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); // Reestablish the channel again to make sure node 0 doesn't retransmit `tx_signatures` // unnecessarily as it was delivered in the previous reestablishment. @@ -2931,7 +2930,7 @@ fn test_splice_reestablish_waits_for_holder_tx_signatures_before_commitment_sign nodes[1].node.handle_tx_signatures(node_id_0, &initiator_tx_signatures); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); } #[test] @@ -3035,7 +3034,7 @@ fn test_splice_reestablish_sends_commitment_signed_before_tx_signatures() { nodes[1].node.handle_tx_signatures(node_id_0, &initiator_tx_signatures); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); } #[test] @@ -4024,7 +4023,7 @@ fn acceptor_can_cancel_queued_funding_contributed_during_counterparty_splice() { let (splice_tx, splice_locked) = sign_interactive_funding_tx(initiator, acceptor, false, None); assert!(splice_locked.is_none()); expect_splice_pending_event(initiator, &node_id_acceptor); - expect_splice_pending_event(acceptor, &node_id_initiator); + assert!(acceptor.node.get_and_clear_pending_events().is_empty()); mine_transaction(initiator, &splice_tx); mine_transaction(acceptor, &splice_tx); @@ -4495,7 +4494,7 @@ fn free_holding_cell_on_tx_signatures_quiescence_exit() { } expect_splice_pending_event(initiator, &node_id_acceptor); - expect_splice_pending_event(acceptor, &node_id_initiator); + assert!(acceptor.node.get_and_clear_pending_events().is_empty()); } #[test] @@ -4975,7 +4974,7 @@ fn test_splice_buffer_commitment_signed_until_funding_tx_signed() { } expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); // Both nodes should broadcast the splice transaction. let splice_tx = { @@ -5217,7 +5216,7 @@ fn do_splice_waits_for_initial_commitment_monitor_update_before_releasing_tx_sig expect_splice_pending_event(&nodes[0], &node_id_1); if !complete_update_while_disconnected { - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); } } @@ -6140,7 +6139,7 @@ fn test_splice_rbf_acceptor_basic() { let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); - let node_id_0 = nodes[0].node.get_our_node_id(); + let _node_id_0 = nodes[0].node.get_our_node_id(); let node_id_1 = nodes[1].node.get_our_node_id(); let initial_channel_value_sat = 100_000; @@ -6189,7 +6188,7 @@ fn test_splice_rbf_acceptor_basic() { assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); // Step 11: Mine, lock, and verify DiscardFunding for the replaced splice candidate. let result = lock_rbf_splice_after_blocks( @@ -6221,7 +6220,7 @@ fn test_splice_rbf_discard_unique_contribution() { let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); - let node_id_0 = nodes[0].node.get_our_node_id(); + let _node_id_0 = nodes[0].node.get_our_node_id(); let node_id_1 = nodes[1].node.get_our_node_id(); let initial_channel_value_sat = 100_000; @@ -6290,7 +6289,7 @@ fn test_splice_rbf_discard_unique_contribution() { assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); let result = lock_rbf_splice_after_blocks( &nodes[0], @@ -6322,7 +6321,7 @@ fn test_splice_rbf_at_high_feerate() { let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); - let node_id_0 = nodes[0].node.get_our_node_id(); + let _node_id_0 = nodes[0].node.get_our_node_id(); let node_id_1 = nodes[1].node.get_our_node_id(); let initial_channel_value_sat = 100_000; @@ -6357,7 +6356,7 @@ fn test_splice_rbf_at_high_feerate() { ); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); // Step 3: RBF again using the template's min_rbf_feerate. The counterparty must accept it. provide_utxo_reserves(&nodes, 2, added_value * 2); @@ -6378,7 +6377,7 @@ fn test_splice_rbf_at_high_feerate() { sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(rbf_tx_1.compute_txid())); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); } #[test] @@ -6579,7 +6578,7 @@ fn test_splice_rbf_insufficient_feerate_high() { sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(splice_tx.compute_txid())); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); // prev=1000: flat increment gives 1000+25=1025, 25/24 rule gives 1000*25/24=1041. // Feerate 1025 satisfies the flat increment but not 25/24 — rejected. @@ -7291,7 +7290,7 @@ pub fn do_test_splice_rbf_tiebreak( assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); // Mine, lock, and verify DiscardFunding for the replaced splice candidate. // Node 1's QuiescentAction was preserved, so after splice_locked it re-initiates @@ -7354,7 +7353,7 @@ pub fn do_test_splice_rbf_tiebreak( assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[1], &node_id_0); - expect_splice_pending_event(&nodes[0], &node_id_1); + assert!(nodes[0].node.get_and_clear_pending_events().is_empty()); // Mine and lock. mine_transaction(&nodes[1], &new_splice_tx); @@ -7853,7 +7852,7 @@ fn test_splice_rbf_sequential() { let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); - let node_id_0 = nodes[0].node.get_our_node_id(); + let _node_id_0 = nodes[0].node.get_our_node_id(); let node_id_1 = nodes[1].node.get_our_node_id(); let initial_channel_value_sat = 100_000; @@ -7891,7 +7890,7 @@ fn test_splice_rbf_sequential() { sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(splice_tx_0.compute_txid())); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); // --- Round 2: RBF #2 at feerate 303. --- provide_utxo_reserves(&nodes, 2, added_value * 2); @@ -7912,7 +7911,7 @@ fn test_splice_rbf_sequential() { sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(splice_tx_1.compute_txid())); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); // --- Mine and lock the final RBF, verifying DiscardFunding for both replaced candidates. --- let splice_tx_0_txid = splice_tx_0.compute_txid(); @@ -7938,7 +7937,7 @@ fn test_splice_rbf_amends_prior_net_positive_contribution_request() { let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); - let node_id_0 = nodes[0].node.get_our_node_id(); + let _node_id_0 = nodes[0].node.get_our_node_id(); let node_id_1 = nodes[1].node.get_our_node_id(); let (_, _, channel_id, _) = @@ -7981,7 +7980,7 @@ fn test_splice_rbf_amends_prior_net_positive_contribution_request() { sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(replaced_txid)); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); tx }; @@ -8070,7 +8069,7 @@ fn test_splice_rbf_amends_prior_net_negative_contribution_request() { let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); - let node_id_0 = nodes[0].node.get_our_node_id(); + let _node_id_0 = nodes[0].node.get_our_node_id(); let node_id_1 = nodes[1].node.get_our_node_id(); let (_, _, channel_id, _) = @@ -8115,7 +8114,7 @@ fn test_splice_rbf_amends_prior_net_negative_contribution_request() { sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(replaced_txid)); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); tx }; @@ -9161,7 +9160,7 @@ fn test_splice_rbf_rejects_low_feerate_after_several_attempts() { ); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); prev_feerate = feerate; prev_splice_tx = rbf_tx; } @@ -9193,7 +9192,7 @@ fn test_splice_rbf_rejects_own_low_feerate_after_several_attempts() { let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); - let node_id_0 = nodes[0].node.get_our_node_id(); + let _node_id_0 = nodes[0].node.get_our_node_id(); let node_id_1 = nodes[1].node.get_our_node_id(); let initial_channel_value_sat = 100_000; @@ -9236,7 +9235,7 @@ fn test_splice_rbf_rejects_own_low_feerate_after_several_attempts() { ); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); prev_feerate = feerate; prev_splice_tx = rbf_tx; } @@ -9306,10 +9305,10 @@ fn test_no_disconnect_after_splice_completes() { let (_, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false, None); assert!(splice_locked.is_none()); - let node_id_0 = nodes[0].node.get_our_node_id(); + let _node_id_0 = nodes[0].node.get_our_node_id(); let node_id_1 = nodes[1].node.get_our_node_id(); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); // Fire enough ticks to trigger a disconnect if the timer wasn't properly cleared. for _ in 0..DISCONNECT_PEER_AWAITING_RESPONSE_TICKS { @@ -10448,7 +10447,6 @@ fn test_async_splice_receives_tx_signatures_while_unrelated_monitor_update_pendi // monitor update. B's `tx_signatures` was already released, so there's no message to send and // we should expect the splice negotiation to complete. acceptor.node.handle_tx_signatures(initiator_node_id, &delayed_initiator_tx_signatures); - expect_splice_pending_event(acceptor, &initiator_node_id); // Finally, drive the state machines to completion. acceptor.chain_monitor.complete_sole_pending_chan_update(&channel_id); From 367c657ab73a8a4b71cd5f7e292678ab3076d25f Mon Sep 17 00:00:00 2001 From: Wilmer Paulino <wilmer@wilmerpaulino.com> Date: Tue, 16 Jun 2026 12:00:35 -0700 Subject: [PATCH 555/627] Always emit SpliceNegotiationFailed when contributing Previously, this could result in an acceptor not receiving a `Event::SpliceNegotiationFailed` for a splice in which they reused the same contribution (except for the feerate change). Our API should guarantee that users should always see `SpliceNegotiated` and `SpliceNegotiationFailed` events for splices that they contribute to. --- lightning/src/ln/channel.rs | 47 +++++++----------------------- lightning/src/ln/splicing_tests.rs | 18 +++--------- 2 files changed, 15 insertions(+), 50 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 9fab47fdfc1..26325c06d1e 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -7227,8 +7227,7 @@ impl SpliceFundingFailed { } macro_rules! splice_funding_failed_for { - ($self: expr, $is_initiator: expr, $contribution: expr, - $contributed_inputs: ident, $contributed_outputs: ident) => {{ + ($self: expr, $contribution: expr, $contributed_inputs: ident, $contributed_outputs: ident) => {{ let contribution = $contribution; let existing_inputs = $self.pending_splice.as_ref().into_iter().flat_map(|ps| ps.$contributed_inputs()); @@ -7237,17 +7236,16 @@ macro_rules! splice_funding_failed_for { let filtered = contribution.clone().into_unique_contributions(existing_inputs, existing_outputs); match filtered { - None if !$is_initiator => None, - None => Some(SpliceFundingFailed { + None => SpliceFundingFailed { contributed_inputs: vec![], contributed_outputs: vec![], contribution: Some(contribution), - }), - Some((contributed_inputs, contributed_outputs)) => Some(SpliceFundingFailed { + }, + Some((contributed_inputs, contributed_outputs)) => SpliceFundingFailed { contributed_inputs, contributed_outputs, contribution: Some(contribution), - }), + }, } }}; } @@ -7280,14 +7278,7 @@ where fn splice_funding_failed_for(&self, contribution: FundingContribution) -> SpliceFundingFailed { // The contribution was never pushed to `contributions`, so `contributed_inputs()` and // `contributed_outputs()` return only prior rounds' entries for filtering. - splice_funding_failed_for!( - self, - true, - contribution, - contributed_inputs, - contributed_outputs - ) - .expect("is_initiator is true so this always returns Some") + splice_funding_failed_for!(self, contribution, contributed_inputs, contributed_outputs) } fn abandon_quiescent_action(&mut self) -> Option<SpliceFundingFailed> { @@ -7429,11 +7420,7 @@ where pending_splice.funding_negotiation.is_some(), "reset_pending_splice_state requires an active funding negotiation" ); - let is_initiator = pending_splice - .funding_negotiation - .take() - .map(|negotiation| negotiation.is_initiator()) - .unwrap_or(false); + pending_splice.funding_negotiation.take(); let contribution = pending_splice.contributions.pop(); if let Some(ref contribution) = contribution { debug_assert!( @@ -7447,14 +7434,8 @@ where // After pop, `contributed_inputs()` / `contributed_outputs()` return only prior // rounds for filtering. - let splice_funding_failed = contribution.and_then(|contribution| { - splice_funding_failed_for!( - self, - is_initiator, - contribution, - contributed_inputs, - contributed_outputs - ) + let splice_funding_failed = contribution.map(|contribution| { + splice_funding_failed_for!(self, contribution, contributed_inputs, contributed_outputs) }); if self.pending_funding().is_empty() { @@ -7479,19 +7460,13 @@ where pending_splice.funding_negotiation.is_some(), "maybe_splice_funding_failed requires an active funding negotiation" ); - let is_initiator = pending_splice - .funding_negotiation - .as_ref() - .map(|negotiation| negotiation.is_initiator()) - .unwrap_or(false); let contribution = pending_splice.contributions.last().cloned()?; - splice_funding_failed_for!( + Some(splice_funding_failed_for!( self, - is_initiator, contribution, prior_contributed_inputs, prior_contributed_outputs - ) + )) } #[rustfmt::skip] diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index b8a6e2b4daf..a612451db60 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -8300,23 +8300,13 @@ fn test_splice_rbf_acceptor_contributes_then_disconnects() { // The initiator re-used the same UTXOs as round 0. Since those UTXOs are still committed // to round 0's splice, they are filtered and no DiscardFunding is emitted. - let events = nodes[0].node.get_and_clear_pending_events(); - assert_eq!(events.len(), 1, "{events:?}"); - match &events[0] { - Event::SpliceNegotiationFailed { channel_id: cid, reason, contribution, .. } => { - assert_eq!(*cid, channel_id); - assert_eq!(*reason, NegotiationFailureReason::PeerDisconnected); - assert!(contribution.is_some()); - }, - other => panic!("Expected SpliceNegotiationFailed, got {:?}", other), - } + let _ = get_event!(&nodes[0], Event::SpliceNegotiationFailed); // The acceptor re-contributed the same UTXOs as round 0 (via prior contribution // adjustment). Since those UTXOs are still committed to round 0's splice, they are - // filtered and no DiscardFunding is emitted. With all inputs/outputs filtered, no events - // are emitted for the acceptor. - let events = nodes[1].node.get_and_clear_pending_events(); - assert_eq!(events.len(), 0, "{events:?}"); + // filtered and no DiscardFunding is emitted. The contribution still fails and needs a + // SpliceNegotiationFailed event so the wallet can resume funding. + let _ = get_event!(&nodes[1], Event::SpliceNegotiationFailed); // Reconnect. let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); From 68c1f21a5e241fa28ab3898e18ed0a9b57c12142 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino <wilmer@wilmerpaulino.com> Date: Wed, 17 Jun 2026 09:34:15 -0700 Subject: [PATCH 556/627] Allow invalid contribution error upon quiescence While a contribution may be valid at the time the splice is requested, quiescence still needs to happen, which can affect the balances of the channel as it fully settles all pending state. After doing so, it's possible that the contribution is no longer valid. Since quiescence itself doesn't have a terminal message, we see a `WarnAndDisconnect` event happen. --- fuzz/src/chanmon_consistency.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 94111ed2ea4..3450faf0e9a 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -942,7 +942,8 @@ fn assert_disconnect_action<'a>( msgs::ErrorAction::DisconnectPeerWithWarning { ref msg } => { // Since sending/receiving messages may be delayed, `timer_tick_occurred` may cause // a node to disconnect their counterparty if they're expecting a timely response. - let is_quiescent_msg = msg.data.contains("already sent splice_locked, cannot RBF"); + let is_quiescent_msg = msg.data.contains("already sent splice_locked, cannot RBF") + || msg.data.contains("contribution no longer valid at quiescence"); assert!( msg.data.contains("Disconnecting due to timeout awaiting response") || is_quiescent_msg, From a5d599275231dab22765f2eba749bb651ea8223a Mon Sep 17 00:00:00 2001 From: Wilmer Paulino <wilmer@wilmerpaulino.com> Date: Tue, 16 Jun 2026 14:40:10 -0700 Subject: [PATCH 557/627] Prefer tx_abort over disconnection for splice negotiation errors We keep some `WarnAndDisconnect` cases as mandated by the spec, but otherwise prefer sending `tx_abort` to terminate quiescence and avoid reconnection loops. --- lightning/src/ln/channel.rs | 95 ++++++++++++++--------------- lightning/src/ln/channelmanager.rs | 2 +- lightning/src/ln/interactivetxs.rs | 7 ++- lightning/src/ln/splicing_tests.rs | 98 ++++++++++++++---------------- 4 files changed, 99 insertions(+), 103 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 26325c06d1e..f24008a12cc 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -13143,11 +13143,13 @@ where /// Checks during handling splice_init pub fn validate_splice_init(&self, msg: &msgs::SpliceInit) -> Result<(), ChannelError> { - if self.holder_commitment_point.current_point().is_none() { - return Err(ChannelError::WarnAndDisconnect(format!( - "Channel {} commitment point needs to be advanced once before spliced", - self.context.channel_id(), - ))); + // - If it has received shutdown: + // MUST send a warning and close the connection or send an error + // and fail the channel. + if !self.context.is_live() { + return Err(ChannelError::WarnAndDisconnect( + "Splicing requested on a channel that is not live".to_owned(), + )); } if !self.context.channel_state.is_quiescent() { @@ -13162,15 +13164,6 @@ where ))); } - // - If it has received shutdown: - // MUST send a warning and close the connection or send an error - // and fail the channel. - if !self.context.is_live() { - return Err(ChannelError::WarnAndDisconnect( - "Splicing requested on a channel that is not live".to_owned(), - )); - } - let their_funding_contribution = SignedAmount::from_sat(msg.funding_contribution_satoshis); if their_funding_contribution == SignedAmount::ZERO { return Err(ChannelError::WarnAndDisconnect(format!( @@ -13179,6 +13172,12 @@ where ))); } + if self.holder_commitment_point.current_point().is_none() { + return Err(ChannelError::Abort(AbortReason::InternalError( + "Commitment point needs to be advanced once before spliced".into(), + ))); + } + Ok(()) } @@ -13195,13 +13194,10 @@ where counterparty_funding_pubkey, our_new_holder_keys, min_funding_satoshis, - ) - .map_err(|e| format!("Channel {} cannot be spliced; {}", self.context.channel_id(), e))?; + )?; let (post_splice_holder_balance, post_splice_counterparty_balance) = - self.get_holder_counterparty_balances_floor_incl_fee(&candidate_scope).map_err( - |e| format!("Channel {} cannot be spliced; {}", self.context.channel_id(), e), - )?; + self.get_holder_counterparty_balances_floor_incl_fee(&candidate_scope)?; let holder_selected_channel_reserve = Amount::from_sat(candidate_scope.holder_selected_channel_reserve_satoshis); @@ -13211,25 +13207,23 @@ where // We allow parties to draw from their previous reserve, as long as they satisfy their v2 reserve if our_funding_contribution != SignedAmount::ZERO { - post_splice_holder_balance.checked_sub(counterparty_selected_channel_reserve) - .ok_or(format!( - "Channel {} cannot be {}; our post-splice channel balance {} is smaller than their selected v2 reserve {}", - self.context.channel_id(), - if our_funding_contribution.is_positive() { "spliced in" } else { "spliced out" }, - post_splice_holder_balance, - counterparty_selected_channel_reserve, - ))?; + post_splice_holder_balance.checked_sub(counterparty_selected_channel_reserve).ok_or( + format!( + "Our post-splice channel balance {} is smaller than their selected v2 reserve {}", + post_splice_holder_balance, + counterparty_selected_channel_reserve, + ), + )?; } if their_funding_contribution != SignedAmount::ZERO { - post_splice_counterparty_balance.checked_sub(holder_selected_channel_reserve) - .ok_or(format!( - "Channel {} cannot be {}; their post-splice channel balance {} is smaller than our selected v2 reserve {}", - self.context.channel_id(), - if their_funding_contribution.is_positive() { "spliced in" } else { "spliced out" }, - post_splice_counterparty_balance, - holder_selected_channel_reserve, - ))?; + post_splice_counterparty_balance.checked_sub(holder_selected_channel_reserve).ok_or( + format!( + "Their post-splice channel balance {} is smaller than our selected v2 reserve {}", + post_splice_counterparty_balance, + holder_selected_channel_reserve, + ), + )?; } #[cfg(debug_assertions)] @@ -13340,7 +13334,11 @@ where holder_pubkeys, min_funding_satoshis, ) - .map_err(|e| self.quiescent_negotiation_err(ChannelError::WarnAndDisconnect(e)))?; + .map_err(|e| { + self.quiescent_negotiation_err(ChannelError::Abort( + AbortReason::InvalidContribution(e), + )) + })?; // Adjust for the feerate and clone so we can store it for future RBF re-use. let (adjusted_contribution, our_funding_inputs, our_funding_outputs) = @@ -13399,17 +13397,16 @@ where fn validate_tx_init_rbf<F: FeeEstimator>( &self, msg: &msgs::TxInitRbf, fee_estimator: &LowerBoundedFeeEstimator<F>, ) -> Result<(ChannelPublicKeys, PublicKey), ChannelError> { - if self.holder_commitment_point.current_point().is_none() { - return Err(ChannelError::WarnAndDisconnect(format!( - "Channel {} commitment point needs to be advanced once before RBF", - self.context.channel_id(), - ))); - } - if !self.context.channel_state.is_quiescent() { return Err(ChannelError::WarnAndDisconnect("Quiescence needed for RBF".to_owned())); } + if self.holder_commitment_point.current_point().is_none() { + return Err(ChannelError::Abort(AbortReason::InternalError( + "Commitment point needs to be advanced once before RBF".into(), + ))); + } + self.is_rbf_compatible().map_err(|msg| ChannelError::WarnAndDisconnect(msg))?; let pending_splice = match &self.pending_splice { @@ -13523,7 +13520,11 @@ where holder_pubkeys, min_funding_satoshis, ) - .map_err(|e| self.quiescent_negotiation_err(ChannelError::WarnAndDisconnect(e)))?; + .map_err(|e| { + self.quiescent_negotiation_err(ChannelError::Abort( + AbortReason::InvalidContribution(e), + )) + })?; // Consume the appropriate contribution source. let (our_funding_inputs, our_funding_outputs) = if queued_net_value.is_some() { @@ -13623,7 +13624,7 @@ where holder_pubkeys, min_funding_satoshis, ) - .map_err(|e| ChannelError::WarnAndDisconnect(e))?; + .map_err(|e| ChannelError::Abort(AbortReason::InvalidContribution(e)))?; Ok(new_funding) } @@ -13700,8 +13701,6 @@ where fn validate_splice_ack( &self, msg: &msgs::SpliceAck, min_funding_satoshis: u64, ) -> Result<FundingScope, ChannelError> { - // TODO(splicing): Add check that we are the splice (quiescence) initiator - let pending_splice = self .pending_splice .as_ref() @@ -13724,7 +13723,7 @@ where new_keys, min_funding_satoshis, ) - .map_err(|e| ChannelError::WarnAndDisconnect(e))?; + .map_err(|e| ChannelError::Abort(AbortReason::InvalidContribution(e)))?; Ok(new_funding) } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 2667d5fa872..88c9b7bf8cf 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -1147,7 +1147,7 @@ impl MsgHandleErrInternal { fn from_chan_no_close(err: ChannelError, channel_id: ChannelId) -> Self { let tx_abort = match &err { - &ChannelError::Abort(reason) => Some(reason.into_tx_abort_msg(channel_id)), + ChannelError::Abort(reason) => Some(reason.clone().into_tx_abort_msg(channel_id)), _ => None, }; let err = match err { diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index dfb702a2657..a0e325abf35 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -91,7 +91,7 @@ impl SerialIdExt for SerialId { } } -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, PartialEq)] pub(crate) enum AbortReason { InvalidStateTransition, UnexpectedCounterpartyMessage, @@ -142,6 +142,8 @@ pub(crate) enum AbortReason { /// /// [`ChannelManager::cancel_funding_contributed`]: crate::ln::channelmanager::ChannelManager::cancel_funding_contributed ManualIntervention, + /// The contribution is not valid given the current balances of the channel. + InvalidContribution(String), /// Internal error InternalError(&'static str), } @@ -209,6 +211,9 @@ impl Display for AbortReason { f.write_str("The initiator's feerate exceeds our maximum") }, AbortReason::ManualIntervention => f.write_str("Manually aborted funding negotiation"), + AbortReason::InvalidContribution(text) => { + f.write_fmt(format_args!("Invalid contribution: {}", text)) + }, AbortReason::InternalError(text) => { f.write_fmt(format_args!("Internal error: {}", text)) }, diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index a612451db60..61b92143dd9 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -175,23 +175,21 @@ fn config_with_min_funding_satoshis(min_funding_satoshis: u64) -> UserConfig { } #[cfg(test)] -fn assert_min_funding_error<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, min_funding_satoshis: u64) { - let msg_events = node.node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), 1, "{msg_events:?}"); - match &msg_events[0] { - MessageSendEvent::HandleError { - action: msgs::ErrorAction::DisconnectPeerWithWarning { msg }, - .. - } => { - assert!( - msg.data - .contains(&format!("configured min_funding_satoshis {min_funding_satoshis}")), - "unexpected warning: {}", - msg.data - ); - }, - _ => panic!("Expected HandleError with warning, got {:?}", msg_events[0]), - } +fn assert_min_funding_error<'a, 'b, 'c>( + node: &Node<'a, 'b, 'c>, recipient: PublicKey, min_funding_satoshis: u64, +) { + let msg = get_event_msg!(node, MessageSendEvent::SendTxAbort, recipient); + let data = tx_abort_data(&msg); + assert!( + data.contains(&format!("configured min_funding_satoshis {min_funding_satoshis}")), + "unexpected tx_abort: {}", + data + ); +} + +#[cfg(test)] +fn tx_abort_data(msg: &msgs::TxAbort) -> String { + String::from_utf8(msg.data.clone()).expect("tx_abort data should be valid UTF-8") } pub fn negotiate_splice_tx<'a, 'b, 'c, 'd>( @@ -1374,7 +1372,7 @@ fn test_min_funding_satoshis_rejects_splice_init_with_negative_counterparty_cont let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); assert!(splice_init.funding_contribution_satoshis < 0); nodes[1].node.handle_splice_init(node_id_0, &splice_init); - assert_min_funding_error(&nodes[1], min_funding_satoshis); + assert_min_funding_error(&nodes[1], node_id_0, min_funding_satoshis); } #[test] @@ -1472,7 +1470,7 @@ fn test_min_funding_satoshis_rejects_splice_ack_with_negative_counterparty_contr let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); assert!(splice_ack.funding_contribution_satoshis < 0); nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); - assert_min_funding_error(&nodes[0], min_funding_satoshis); + assert_min_funding_error(&nodes[0], node_id_1, min_funding_satoshis); } #[test] @@ -1514,7 +1512,7 @@ fn test_min_funding_satoshis_rejects_tx_init_rbf_with_negative_counterparty_cont let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); assert!(tx_init_rbf.funding_output_contribution.unwrap() < 0); nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); - assert_min_funding_error(&nodes[1], min_funding_satoshis); + assert_min_funding_error(&nodes[1], node_id_0, min_funding_satoshis); } #[test] @@ -1571,7 +1569,7 @@ fn test_min_funding_satoshis_rejects_tx_ack_rbf_with_negative_counterparty_contr let tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0); assert!(tx_ack_rbf.funding_output_contribution.unwrap() < 0); nodes[0].node.handle_tx_ack_rbf(node_id_1, &tx_ack_rbf); - assert_min_funding_error(&nodes[0], min_funding_satoshis); + assert_min_funding_error(&nodes[0], node_id_1, min_funding_satoshis); } #[test] @@ -5880,13 +5878,14 @@ fn do_test_splice_pending_htlcs(config: UserConfig) { splice_init.funding_contribution_satoshis -= 1; acceptor.node.handle_splice_init(node_id_initiator, &splice_init); - let msg = get_warning_msg(acceptor, &node_id_initiator); + let msg = get_event_msg!(acceptor, MessageSendEvent::SendTxAbort, node_id_initiator); assert_eq!(msg.channel_id, channel_id); let cannot_be_spliced_out = format!( - "Channel {} cannot be spliced out; their post-splice channel balance {} is smaller than our selected v2 reserve {}", - channel_id, post_splice_reserve - Amount::ONE_SAT, post_splice_reserve + "Their post-splice channel balance {} is smaller than our selected v2 reserve {}", + post_splice_reserve - Amount::ONE_SAT, + post_splice_reserve ); - assert_eq!(msg.data, cannot_be_spliced_out); + assert_eq!(tx_abort_data(&msg), format!("Invalid contribution: {cannot_be_spliced_out}")); acceptor.node.peer_disconnected(node_id_initiator); initiator.node.peer_disconnected(node_id_acceptor); @@ -9790,40 +9789,35 @@ fn do_test_0reserve_splice_counterparty_validation( get_event_msg!(acceptor, MessageSendEvent::SendSpliceAck, node_id_initiator); } else { acceptor.node.handle_splice_init(node_id_initiator, &splice_init); - let msg_events = acceptor.node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), 1); - if let MessageSendEvent::HandleError { action, .. } = &msg_events[0] { - assert!(matches!(action, msgs::ErrorAction::DisconnectPeerWithWarning { .. })); - } else { - panic!("Expected MessageSendEvent::HandleError"); - } + let msg = get_event_msg!(acceptor, MessageSendEvent::SendTxAbort, node_id_initiator); + assert_eq!(msg.channel_id, channel_id); let cannot_splice_out = if u64::try_from(funding_contribution_sat.abs()).unwrap() > initiator_value_to_self_sat { // They obviously can't afford their contribution, so we fail before even // querying `TxBuilder` format!( - "Got non-closing error: Channel {channel_id} cannot be spliced; \ - Their contribution candidate {funding_contribution_sat}sat \ + "Their contribution candidate {funding_contribution_sat}sat \ is greater than their total balance in the channel {initiator_value_to_self_sat}sat" ) } else if post_channel_value_sat < MIN_CHANNEL_VALUE_SATOSHIS { // We require all spliced channels to have a value of at least 1000 satoshis after the splice format!( - "Got non-closing error: Channel {channel_id} cannot be spliced; \ - Spliced channel value must be at least {MIN_CHANNEL_VALUE_SATOSHIS} satoshis. \ + "Spliced channel value must be at least {MIN_CHANNEL_VALUE_SATOSHIS} satoshis. \ It would be {post_channel_value_sat}" ) } else { // Last but not least, `TxBuilder` decides whether all parties can afford // HTLCs, anchors, and transaction fees while retaining at least one // output on the commitments - format!( - "Got non-closing error: Channel {channel_id} cannot \ - be spliced; Balance exhausted on local commitment" - ) + "Balance exhausted on local commitment".to_string() }; - acceptor.logger.assert_log("lightning::ln::channelmanager", cannot_splice_out, 1); + assert_eq!(tx_abort_data(&msg), format!("Invalid contribution: {cannot_splice_out}")); + acceptor.logger.assert_log( + "lightning::ln::channelmanager", + format!("Got non-closing error: Invalid contribution: {cannot_splice_out}"), + 1, + ); } channel_type @@ -10064,18 +10058,12 @@ fn do_test_splice_out_initiator_reserve_breach_zero_fee_commitments( // balance, we previously would not complain. splice_init.funding_contribution_satoshis = funding_contribution_sat; acceptor.node.handle_splice_init(node_id_initiator, &splice_init); - let msg_events = acceptor.node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), 1); - if let MessageSendEvent::HandleError { action, .. } = &msg_events[0] { - assert!(matches!(action, msgs::ErrorAction::DisconnectPeerWithWarning { .. })); - } else { - panic!("Expected MessageSendEvent::HandleError"); - } + let msg = get_event_msg!(acceptor, MessageSendEvent::SendTxAbort, node_id_initiator); + assert_eq!(msg.channel_id, channel_id); let post_splice_channel_value_sat = node_0_balance_leftover_amount.to_sat(); let cannot_splice_out = if matches!(acceptor_balance, AcceptorBalance::NoBalance) { format!( - "Got non-closing error: Channel {channel_id} cannot \ - be spliced; The post-splice channel value {post_splice_channel_value_sat} \ + "The post-splice channel value {post_splice_channel_value_sat} \ is smaller than their dust limit {high_dust_limit_satoshis}" ) } else { @@ -10088,13 +10076,17 @@ fn do_test_splice_out_initiator_reserve_breach_zero_fee_commitments( high_dust_limit_satoshis ); format!( - "Got non-closing error: Channel {channel_id} cannot \ - be spliced out; their post-splice channel balance \ + "Their post-splice channel balance \ {node_0_balance_leftover_amount} is smaller than our selected v2 reserve \ {v2_channel_reserve}" ) }; - acceptor.logger.assert_log("lightning::ln::channelmanager", cannot_splice_out, 1); + assert_eq!(tx_abort_data(&msg), format!("Invalid contribution: {cannot_splice_out}")); + acceptor.logger.assert_log( + "lightning::ln::channelmanager", + format!("Got non-closing error: Invalid contribution: {cannot_splice_out}"), + 1, + ); } } From ff3d38868b8fa982f3c31b7c0fe4b3a52e70c7dd Mon Sep 17 00:00:00 2001 From: Wilmer Paulino <wilmer@wilmerpaulino.com> Date: Wed, 17 Jun 2026 09:35:09 -0700 Subject: [PATCH 558/627] Prefer tx_abort over disconnection for inability to RBF Send `tx_abort` to terminate quiescence and avoid reconnection loops. --- lightning/src/ln/channel.rs | 51 ++++++++++++------------ lightning/src/ln/interactivetxs.rs | 5 +++ lightning/src/ln/splicing_tests.rs | 63 ++++++------------------------ 3 files changed, 41 insertions(+), 78 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index f24008a12cc..7f5c4f2b5c2 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -13407,46 +13407,41 @@ where ))); } - self.is_rbf_compatible().map_err(|msg| ChannelError::WarnAndDisconnect(msg))?; + self.is_rbf_compatible() + .map_err(|msg| ChannelError::Abort(AbortReason::RbfUnavailable(msg)))?; - let pending_splice = match &self.pending_splice { - Some(pending_splice) => pending_splice, - None => { - return Err(ChannelError::WarnAndDisconnect(format!( - "Channel {} has no pending splice to RBF", - self.context.channel_id(), - ))); - }, - }; + let (pending_splice, last_candidate) = self + .pending_splice + .as_ref() + .filter(|pending_splice| !pending_splice.negotiated_candidates.is_empty()) + .map(|pending_splice| { + ( + pending_splice, + pending_splice.negotiated_candidates.last().expect("checked above"), + ) + }) + .ok_or_else(|| { + ChannelError::Abort(AbortReason::RbfUnavailable( + "No pending splice available to RBF".into(), + )) + })?; if pending_splice.funding_negotiation.is_some() { return Err(ChannelError::Abort(AbortReason::NegotiationInProgress)); } if pending_splice.received_funding_txid.is_some() { - return Err(ChannelError::WarnAndDisconnect(format!( - "Channel {} counterparty already sent splice_locked, cannot RBF", - self.context.channel_id(), + return Err(ChannelError::Abort(AbortReason::RbfUnavailable( + "Already received splice_locked".into(), ))); } if pending_splice.sent_funding_txid.is_some() { - return Err(ChannelError::WarnAndDisconnect(format!( - "Channel {} already sent splice_locked, cannot RBF", - self.context.channel_id(), + return Err(ChannelError::Abort(AbortReason::RbfUnavailable( + "Already sent splice_locked".into(), ))); } - let last_candidate = match pending_splice.negotiated_candidates.last() { - Some(candidate) => candidate, - None => { - return Err(ChannelError::WarnAndDisconnect(format!( - "Channel {} has no negotiated splice candidates to RBF", - self.context.channel_id(), - ))); - }, - }; - let prev_feerate = pending_splice.last_funding_feerate_sat_per_1000_weight.unwrap_or_else(|| { fee_estimator.bounded_sat_per_1000_weight(ConfirmationTarget::UrgentOnChainSweep) @@ -13611,7 +13606,9 @@ where }; let last_candidate = pending_splice.negotiated_candidates.last().ok_or_else(|| { - ChannelError::WarnAndDisconnect("No negotiated splice candidates for RBF".to_owned()) + ChannelError::Abort(AbortReason::RbfUnavailable( + "No pending splice available to RBF".into(), + )) })?; let holder_pubkeys = last_candidate.get_holder_pubkeys().clone(); let counterparty_funding_pubkey = *last_candidate.counterparty_funding_pubkey(); diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index a0e325abf35..6769e2de3e5 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -144,6 +144,8 @@ pub(crate) enum AbortReason { ManualIntervention, /// The contribution is not valid given the current balances of the channel. InvalidContribution(String), + /// A RBF is not available at this time. + RbfUnavailable(String), /// Internal error InternalError(&'static str), } @@ -214,6 +216,9 @@ impl Display for AbortReason { AbortReason::InvalidContribution(text) => { f.write_fmt(format_args!("Invalid contribution: {}", text)) }, + AbortReason::RbfUnavailable(text) => { + f.write_fmt(format_args!("Rejecting RBF attempt: {}", text)) + }, AbortReason::InternalError(text) => { f.write_fmt(format_args!("Internal error: {}", text)) }, diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 61b92143dd9..16dec1d5178 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -6654,22 +6654,11 @@ fn test_splice_rbf_no_pending_splice() { nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); - let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), 1); - match &msg_events[0] { - MessageSendEvent::HandleError { action, .. } => { - assert_eq!( - *action, - msgs::ErrorAction::DisconnectPeerWithWarning { - msg: msgs::WarningMessage { - channel_id, - data: format!("Channel {} has no pending splice to RBF", channel_id), - }, - } - ); - }, - _ => panic!("Expected HandleError, got {:?}", msg_events[0]), - } + let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); + assert_eq!( + tx_abort_data(&tx_abort), + "Rejecting RBF attempt: No pending splice available to RBF" + ); } #[test] @@ -6767,25 +6756,8 @@ fn test_splice_rbf_after_splice_locked() { nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); - let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), 1); - match &msg_events[0] { - MessageSendEvent::HandleError { action, .. } => { - assert_eq!( - *action, - msgs::ErrorAction::DisconnectPeerWithWarning { - msg: msgs::WarningMessage { - channel_id, - data: format!( - "Channel {} counterparty already sent splice_locked, cannot RBF", - channel_id, - ), - }, - } - ); - }, - _ => panic!("Expected HandleError, got {:?}", msg_events[0]), - } + let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); + assert_eq!(tx_abort_data(&tx_abort), "Rejecting RBF attempt: Already received splice_locked"); } #[test] @@ -6968,22 +6940,11 @@ fn test_splice_rbf_zeroconf_rejected() { nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); - let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), 1); - match &msg_events[0] { - MessageSendEvent::HandleError { action, .. } => { - assert_eq!( - *action, - msgs::ErrorAction::DisconnectPeerWithWarning { - msg: msgs::WarningMessage { - channel_id, - data: format!("Channel {} has option_zeroconf, cannot RBF", channel_id,), - }, - } - ); - }, - _ => panic!("Expected HandleError, got {:?}", msg_events[0]), - } + let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); + assert_eq!( + tx_abort_data(&tx_abort), + format!("Rejecting RBF attempt: Channel {} has option_zeroconf, cannot RBF", channel_id) + ); } #[test] From 43c0fc714a51ad20de5d19ca093b9c2ce90e170c Mon Sep 17 00:00:00 2001 From: Wilmer Paulino <wilmer@wilmerpaulino.com> Date: Wed, 17 Jun 2026 10:36:34 -0700 Subject: [PATCH 559/627] Check channel is live while handling counterparty tx_init_rbf This mirrors what we do for counterparty `splice_init` messages, making sure we don't accept RBFs once a channel has requested shutdown. --- lightning/src/ln/channel.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 7f5c4f2b5c2..6f50d528a63 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -13397,6 +13397,11 @@ where fn validate_tx_init_rbf<F: FeeEstimator>( &self, msg: &msgs::TxInitRbf, fee_estimator: &LowerBoundedFeeEstimator<F>, ) -> Result<(ChannelPublicKeys, PublicKey), ChannelError> { + if !self.context.is_live() { + return Err(ChannelError::WarnAndDisconnect( + "RBF requested on a channel that is not live".to_owned(), + )); + } if !self.context.channel_state.is_quiescent() { return Err(ChannelError::WarnAndDisconnect("Quiescence needed for RBF".to_owned())); } From 13b19c860c0bbf0ce28bac7d62936a59afba6c44 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino <wilmer@wilmerpaulino.com> Date: Tue, 30 Jun 2026 09:50:55 -0700 Subject: [PATCH 560/627] Make SpliceFundingFailed::contribution non-optional `SpliceFundingFailed` is only constructed from a concrete `FundingContribution`, so store it directly. `Event::SpliceNegotiationFailed::contribution` still needs to be optional as it could be read from a prior version that did not store contribution data. --- lightning/src/ln/channel.rs | 14 ++++++-------- lightning/src/ln/channelmanager.rs | 16 ++++++++-------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 6f50d528a63..fb5a7de8730 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -7205,14 +7205,14 @@ pub struct SpliceFundingFailed { /// in prior rounds, which may be included in `contribution`. contributed_outputs: Vec<ScriptBuf>, - /// The funding contribution from the failed round, if available. - contribution: Option<FundingContribution>, + /// The funding contribution from the failed round. + contribution: FundingContribution, } impl SpliceFundingFailed { /// Splits into the funding info for `DiscardFunding` (if there are inputs or outputs to /// discard) and the contribution for `SpliceNegotiationFailed`. - pub(super) fn into_parts(self) -> (Option<FundingInfo>, Option<FundingContribution>) { + pub(super) fn into_parts(self) -> (Option<FundingInfo>, FundingContribution) { let funding_info = if !self.contributed_inputs.is_empty() || !self.contributed_outputs.is_empty() { Some(FundingInfo::Contribution { @@ -7239,12 +7239,10 @@ macro_rules! splice_funding_failed_for { None => SpliceFundingFailed { contributed_inputs: vec![], contributed_outputs: vec![], - contribution: Some(contribution), + contribution, }, - Some((contributed_inputs, contributed_outputs)) => SpliceFundingFailed { - contributed_inputs, - contributed_outputs, - contribution: Some(contribution), + Some((contributed_inputs, contributed_outputs)) => { + SpliceFundingFailed { contributed_inputs, contributed_outputs, contribution } }, } }}; diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 88c9b7bf8cf..b159cd368bb 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -4233,7 +4233,7 @@ impl< channel_id: *chan_id, counterparty_node_id: *counterparty_node_id, user_channel_id: chan.context().get_user_id(), - contribution, + contribution: Some(contribution), reason: events::NegotiationFailureReason::ChannelClosing, }, None, @@ -4539,7 +4539,7 @@ impl< channel_id: shutdown_res.channel_id, counterparty_node_id: shutdown_res.counterparty_node_id, user_channel_id: shutdown_res.user_channel_id, - contribution, + contribution: Some(contribution), reason: events::NegotiationFailureReason::ChannelClosing, }, None, @@ -6729,7 +6729,7 @@ impl< counterparty_node_id, user_channel_id, reason, - contribution, + contribution: Some(contribution), }, None, )); @@ -12046,7 +12046,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ channel_id, counterparty_node_id: *counterparty_node_id, user_channel_id, - contribution, + contribution: Some(contribution), reason, }, None, @@ -12384,7 +12384,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ channel_id: msg.channel_id, counterparty_node_id: *counterparty_node_id, user_channel_id: chan_entry.get().context().get_user_id(), - contribution, + contribution: Some(contribution), reason: events::NegotiationFailureReason::CounterpartyAborted { msg: UntrustedString( String::from_utf8_lossy(&msg.data).to_string(), @@ -12549,7 +12549,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ channel_id: msg.channel_id, counterparty_node_id: *counterparty_node_id, user_channel_id: chan.context().get_user_id(), - contribution, + contribution: Some(contribution), reason: events::NegotiationFailureReason::ChannelClosing, }, None, @@ -15794,7 +15794,7 @@ impl< channel_id: chan.context().channel_id(), counterparty_node_id, user_channel_id: chan.context().get_user_id(), - contribution, + contribution: Some(contribution), reason: events::NegotiationFailureReason::PeerDisconnected, }); } @@ -18435,7 +18435,7 @@ impl< counterparty_node_id: chan.context.get_counterparty_node_id(), user_channel_id: chan.context.get_user_id(), reason: events::NegotiationFailureReason::PeerDisconnected, - contribution, + contribution: Some(contribution), }, None, )); From 1afd35e60829c84b6745600854f4bc2f318fcab3 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Fri, 10 Apr 2026 14:07:33 -0400 Subject: [PATCH 561/627] ln: add trampoline mpp accumulation with rejection on completion Add our MPP accumulation logic for trampoline payments, but reject them when they fully arrive. This allows us to test parts of our trampoline flow without fully implementing outbound dispatch. This commit keeps the same first_claimable_htlc debug_assert behavior as MPP claims, asserting that we do not fail our check_claimable_incoming_htlc merge for the first HTLC that we add to a set. This assert can only be hit if our first part exceeds the `MAX_VALUE_MSAT`, which should not be hit because we check individual amounts elsewhere in the codebase (the check exists to check that multiple parts combined don't hit this overflow). --- lightning/src/ln/channelmanager.rs | 228 +++++++++++++++++++++++++-- lightning/src/ln/outbound_payment.rs | 23 ++- 2 files changed, 240 insertions(+), 11 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 3cacbdc1926..f97c824abd6 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -88,9 +88,9 @@ use crate::ln::outbound_payment; #[cfg(any(test, feature = "_externalize_tests"))] use crate::ln::outbound_payment::PaymentSendFailure; use crate::ln::outbound_payment::{ - Bolt11PaymentError, Bolt12PaymentError, OutboundPayments, PendingOutboundPayment, - ProbeSendFailure, RecipientCustomTlvs, RecipientOnionFields, Retry, RetryableInvoiceRequest, - RetryableSendFailure, SendAlongPathArgs, StaleExpiration, + Bolt11PaymentError, Bolt12PaymentError, NextTrampolineHopInfo, OutboundPayments, + PendingOutboundPayment, ProbeSendFailure, RecipientCustomTlvs, RecipientOnionFields, Retry, + RetryableInvoiceRequest, RetryableSendFailure, SendAlongPathArgs, StaleExpiration, }; use crate::ln::types::ChannelId; use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache; @@ -112,9 +112,9 @@ use crate::onion_message::messenger::{ MessageRouter, MessageSendInstructions, Responder, ResponseInstruction, }; use crate::onion_message::offers::{OffersMessage, OffersMessageHandler}; -use crate::routing::gossip::NodeId; +use crate::routing::gossip::{NodeId, RoutingFees}; use crate::routing::router::{ - BlindedTail, FixedRouter, InFlightHtlcs, Path, Payee, PaymentParameters, Route, + compute_fees, BlindedTail, FixedRouter, InFlightHtlcs, Path, Payee, PaymentParameters, Route, RouteParameters, RouteParametersConfig, Router, }; use crate::sign::ecdsa::EcdsaChannelSigner; @@ -8484,6 +8484,149 @@ impl< } } + /// Handles the addition of a HTLC associated with a trampoline forward that we need to + /// accumulate on the incoming link before forwarding onwards. If the HTLC is failed, it + /// returns the source and error that should be used to fail the HTLC(s) back. + fn handle_trampoline_htlc( + &self, mpp_part: MppPart, onion_fields: RecipientOnionFields, payment_hash: PaymentHash, + next_hop_info: NextTrampolineHopInfo, _next_node_id: PublicKey, + ) -> Result<(), (HTLCSource, HTLCFailReason)> { + let mut trampoline_payments = self.awaiting_trampoline_forwards.lock().unwrap(); + + // We should not fail if we're adding the first htlc to a ClaimablePayment (as our + // validation compares fields across parts, and our first part can't overflow maximum + // msats because each htlc's amount is individually validated - overflow is only possible + // with multiple parts). + let mut first_trampoline_htlc = false; + trampoline_payments.entry(payment_hash).or_insert_with(|| { + first_trampoline_htlc = true; + TrampolinePayment { htlcs: Vec::new(), onion_fields: onion_fields.clone() } + }); + + // TODO: add restriction to specification that trampoline should be consistent across + // MPP parts? Currently, we'll accept a MPP trampoline payments that specify different + // next_node_id destinations (just forwarding to the last one that arrives). + + // If MPP hasn't fully arrived yet, return early (saving indentation below). Once it has + // arrived, remove the entry from the map so that all downstream paths consume it. + let prev_hop = mpp_part.prev_hop.clone(); + let check_result = { + let trampoline_payment = + trampoline_payments.get_mut(&payment_hash).expect("just inserted"); + self.check_incoming_mpp_part( + &mut trampoline_payment.htlcs, + &mut trampoline_payment.onion_fields, + mpp_part, + onion_fields, + payment_hash, + ) + }; + let trampoline_payment = match check_result { + Ok(false) => return Ok(()), + Err(()) => { + debug_assert!( + !first_trampoline_htlc, + "first trampoline HTLC should not fail check_incoming_mpp_part" + ); + return Err(( + // When we couldn't add a new HTLC, we just fail back our last received htlc, + // allowing others to wait for more MPP parts to arrive. + HTLCSource::TrampolineForward { + previous_hop_data: vec![prev_hop], + outbound_payment: None, + }, + HTLCFailReason::reason( + LocalHTLCFailureReason::InvalidTrampolineForward, + vec![], + ), + )); + }, + Ok(true) => trampoline_payments.remove(&payment_hash).expect("just inserted"), + }; + + let incoming_amt_msat: u64 = trampoline_payment.htlcs.iter().map(|h| h.value).sum(); + let incoming_cltv_expiry = + trampoline_payment.htlcs.iter().map(|h| h.cltv_expiry).min().unwrap(); + + // TODO: configure and advertise the fees and CLTV delta we require once specified. + let (forwarding_fee_proportional_millionths, forwarding_fee_base_msat, cltv_delta) = { + let config = self.config.read().unwrap(); + ( + config.channel_config.forwarding_fee_proportional_millionths, + config.channel_config.forwarding_fee_base_msat, + // Note that we must floor the user-set value with our overriding minimum because + // we don't have a specific channel to call the helper get_cltv_expiry_delta which + // performs this flooring for us. When we have a more concrete policy for + // trampoline, this can be accessed with a similar helper. + cmp::max(config.channel_config.cltv_expiry_delta, MIN_CLTV_EXPIRY_DELTA).into(), + ) + }; + let trampoline_source = || -> HTLCSource { + HTLCSource::TrampolineForward { + previous_hop_data: trampoline_payment + .htlcs + .iter() + .map(|htlc| htlc.prev_hop.clone()) + .collect(), + outbound_payment: None, + } + }; + let trampoline_failure = || -> HTLCFailReason { + let mut err_data = Vec::with_capacity(10); + err_data.extend_from_slice(&forwarding_fee_base_msat.to_be_bytes()); + err_data.extend_from_slice(&forwarding_fee_proportional_millionths.to_be_bytes()); + err_data.extend_from_slice(&(cltv_delta as u16).to_be_bytes()); + HTLCFailReason::reason( + LocalHTLCFailureReason::TrampolineFeeOrExpiryInsufficient, + err_data, + ) + }; + + // We need to pick the maximum fee that we'll charge as a trampoline node. This could + // be any trampoline fee policy - this isn't specified or advertised. To keep things + // simple, we just calculate the amount that we would have charged to forward the amount + // going to the trampoline with our default fees, and make sure we have at least that. + // The amount that we actually dispatch will be slightly more than the amount for the next + // trampoline (since it'll also include fees for subsequent hops), so we're actually + // charging a little less than we would if this were a regular forward of that amount. As + // use of trampoline grows, we can investigate more sophisticated options. + let routing_fees = RoutingFees { + base_msat: forwarding_fee_base_msat, + proportional_millionths: forwarding_fee_proportional_millionths, + }; + let our_forwarding_fee_msat = compute_fees(next_hop_info.amount_msat, routing_fees); + let _max_total_routing_fee_msat = match our_forwarding_fee_msat + .and_then(|our_fee| our_fee.checked_add(next_hop_info.amount_msat)) + .and_then(|total| incoming_amt_msat.checked_sub(total)) + { + Some(amount) => amount, + None => { + return Err((trampoline_source(), trampoline_failure())); + }, + }; + + let _max_total_cltv_expiry_delta = match next_hop_info + .cltv_expiry_height + .checked_add(cltv_delta) + .and_then(|total| incoming_cltv_expiry.checked_sub(total)) + { + Some(cltv_delta) => cltv_delta, + None => { + return Err((trampoline_source(), trampoline_failure())); + }, + }; + + log_debug!( + self.logger, + "Rejecting trampoline forward because we do not fully support forwarding yet.", + ); + + Err(( + trampoline_source(), + HTLCFailReason::reason(LocalHTLCFailureReason::TemporaryTrampolineFailure, vec![]), + )) + } + fn process_receive_htlcs( &self, pending_forwards: &mut Vec<HTLCForwardInfo>, new_events: &mut VecDeque<(Event, Option<EventCompletionAction>)>, @@ -8507,6 +8650,10 @@ impl< }, .. } = payment; + // We differentiate the received value from the sender intended value if + // possible so that we don't prematurely mark MPP payments completed if routing + // nodes overpay + let value = incoming_amt_msat.unwrap_or(outgoing_amt_msat); let blinded_failure = routing.blinded_failure(); let ( cltv_expiry, @@ -8582,14 +8729,77 @@ impl< None, ) }, + PendingHTLCRouting::TrampolineForward { + onion_packet, + node_id: next_trampoline, + blinded, + incoming_cltv_expiry, + incoming_multipath_data, + next_trampoline_amt_msat, + next_trampoline_cltv_expiry, + .. + } => { + // Trampoline forwards only *need* to have MPP data if they're + // multi-part. + let onion_fields = match incoming_multipath_data { + Some(ref final_mpp) => RecipientOnionFields::secret_only( + final_mpp.payment_secret, + final_mpp.total_msat, + ), + None => RecipientOnionFields::spontaneous_empty(outgoing_amt_msat), + }; + + let next_hop_info = NextTrampolineHopInfo { + onion_packet, + blinding_point: blinded.and_then(|b| { + b.next_blinding_override.or_else(|| { + let encrypted_tlvs_ss = self + .node_signer + .ecdh(Recipient::Node, &b.inbound_blinding_point, None) + .unwrap() + .secret_bytes(); + onion_utils::next_hop_pubkey( + &self.secp_ctx, + b.inbound_blinding_point, + &encrypted_tlvs_ss, + ) + .ok() + }) + }), + amount_msat: next_trampoline_amt_msat, + cltv_expiry_height: next_trampoline_cltv_expiry, + }; + + // For trampoline forwards, construct MppPart directly and handle separately + // from claimable HTLCs. + let mpp_part = MppPart { + prev_hop, + cltv_expiry: incoming_cltv_expiry, + value, + sender_intended_value: outgoing_amt_msat, + timer_ticks: 0, + total_value_received: None, + }; + if let Err((htlc_source, failure_reason)) = self.handle_trampoline_htlc( + mpp_part, + onion_fields, + payment_hash, + next_hop_info, + next_trampoline, + ) { + failed_forwards.push(( + htlc_source, + payment_hash, + failure_reason, + HTLCHandlingFailureType::TrampolineForward {}, + )); + } + continue 'next_forwardable_htlc; + }, _ => { panic!("short_channel_id == 0 should imply any pending_forward entries are of type Receive"); }, }; - // We differentiate the received value from the sender intended value - // if possible so that we don't prematurely mark MPP payments complete - // if routing nodes overpay - let value = incoming_amt_msat.unwrap_or(outgoing_amt_msat); let htlc_source = HTLCSource::PreviousHopData(HTLCPreviousHopData { prev_outbound_scid_alias: prev_hop.prev_outbound_scid_alias, user_channel_id: prev_hop.user_channel_id, diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 20b594a1e83..e3df3debc81 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -11,7 +11,7 @@ use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::Hash; -use bitcoin::secp256k1::{self, Secp256k1, SecretKey}; +use bitcoin::secp256k1::{self, PublicKey, Secp256k1, SecretKey}; use lightning_invoice::Bolt11Invoice; use crate::blinded_path::{IntroductionNode, NodeIdLookUp}; @@ -21,7 +21,7 @@ use crate::ln::channelmanager::{ EventCompletionAction, HTLCSource, OptionalBolt11PaymentParams, PaymentCompleteUpdate, PaymentId, }; -use crate::ln::msgs::DecodeError; +use crate::ln::msgs::{DecodeError, TrampolineOnionPacket}; use crate::ln::onion_utils; use crate::ln::onion_utils::{DecodedOnionFailure, HTLCFailReason}; use crate::offers::invoice::{Bolt12Invoice, DerivedSigningPubkey, InvoiceBuilder}; @@ -172,6 +172,25 @@ pub(crate) enum PendingOutboundPayment { }, } +#[derive(Clone, Eq, PartialEq)] +pub(crate) struct NextTrampolineHopInfo { + /// The Trampoline packet to include for the next Trampoline hop. + pub(crate) onion_packet: TrampolineOnionPacket, + /// If blinded, the current_path_key to set at the next Trampoline hop. + pub(crate) blinding_point: Option<PublicKey>, + /// The amount that the next trampoline is expecting to receive. + pub(crate) amount_msat: u64, + /// The cltv expiry height that the next trampoline is expecting. + pub(crate) cltv_expiry_height: u32, +} + +impl_ser_tlv_based!(NextTrampolineHopInfo, { + (1, onion_packet, required), + (3, blinding_point, option), + (5, amount_msat, required), + (7, cltv_expiry_height, required), +}); + #[derive(Clone)] pub(crate) struct RetryableInvoiceRequest { pub(crate) invoice_request: InvoiceRequest, From c6adebfd0c13da662162d66f360ef0a6f8db3040 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Thu, 12 Mar 2026 11:48:23 -0400 Subject: [PATCH 562/627] ln: double encrypt errors received from downstream failures If we're a trampoline node and received an error from downstream that we can't fully decrypt, we want to double-wrap it for the original sender. Previously not implemented because we'd only focused on receives, where there's no possibility of a downstream error. While proper error handling will be added in a followup, we add the bare minimum required here for testing. --- lightning/src/ln/onion_utils.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index d4ee3d36374..4be803fad9a 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -2126,6 +2126,10 @@ impl HTLCFailReason { let mut err = err.clone(); let hold_time = hold_time.unwrap_or(0); + if let Some(secondary_shared_secret) = secondary_shared_secret { + process_failure_packet(&mut err, secondary_shared_secret, hold_time); + crypt_failure_packet(secondary_shared_secret, &mut err); + } process_failure_packet(&mut err, incoming_packet_shared_secret, hold_time); crypt_failure_packet(incoming_packet_shared_secret, &mut err); From 4f2429ea01be4da3cb303ff0de624f00b19f4c21 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Thu, 12 Mar 2026 11:50:45 -0400 Subject: [PATCH 563/627] ln: handle DecodedOnionFailure for local trampoline failures While proper error handling will be added in a followup, we add the bare minimum required here for testing. Note that we intentionally keep the behavior of not setting `payment_failed_permanently` for local failures because we can possibly retry it because we're the sender as a trampoline forwarder. For example, a local ChannelClosed error is considered to be permanent, but we can still retry along another channel. --- lightning/src/ln/onion_utils.rs | 45 ++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 4be803fad9a..040139b46e6 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -2141,33 +2141,44 @@ impl HTLCFailReason { pub(super) fn decode_onion_failure<T: secp256k1::Signing, L: Logger>( &self, secp_ctx: &Secp256k1<T>, logger: &L, htlc_source: &HTLCSource, ) -> DecodedOnionFailure { + let decoded_onion_failure = |short_channel_id: Option<u64>, + _failure_reason: LocalHTLCFailureReason, + _data: &[u8]| { + DecodedOnionFailure { + network_update: None, + payment_failed_permanently: false, + short_channel_id, + failed_within_blinded_path: false, + hold_times: Vec::new(), + #[cfg(any(test, feature = "_test_utils"))] + onion_error_code: Some(_failure_reason), + #[cfg(any(test, feature = "_test_utils"))] + onion_error_data: Some(_data.to_vec()), + #[cfg(test)] + attribution_failed_channel: None, + } + }; match self.0 { HTLCFailReasonRepr::LightningError { ref err, .. } => { process_onion_failure(secp_ctx, logger, &htlc_source, err.clone()) }, - #[allow(unused)] HTLCFailReasonRepr::Reason { ref data, ref failure_reason } => { // we get a fail_malformed_htlc from the first hop // TODO: We'd like to generate a NetworkUpdate for temporary // failures here, but that would be insufficient as find_route // generally ignores its view of our own channels as we provide them via // ChannelDetails. - if let &HTLCSource::OutboundRoute { ref path, .. } = htlc_source { - DecodedOnionFailure { - network_update: None, - payment_failed_permanently: false, - short_channel_id: Some(path.hops[0].short_channel_id), - failed_within_blinded_path: false, - hold_times: Vec::new(), - #[cfg(any(test, feature = "_test_utils"))] - onion_error_code: Some(*failure_reason), - #[cfg(any(test, feature = "_test_utils"))] - onion_error_data: Some(data.clone()), - #[cfg(test)] - attribution_failed_channel: None, - } - } else { - unreachable!(); + match htlc_source { + &HTLCSource::OutboundRoute { ref path, .. } => decoded_onion_failure( + Some(path.hops[0].short_channel_id), + *failure_reason, + data, + ), + &HTLCSource::TrampolineForward { ref outbound_payment, .. } => { + debug_assert!(outbound_payment.is_none()); + decoded_onion_failure(None, *failure_reason, data) + }, + _ => unreachable!(), } }, } From 4329fe92058bd7b0986e907eb306b52077b73ef7 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Thu, 2 Jul 2026 11:03:51 -0400 Subject: [PATCH 564/627] ln: process added trampoline htlcs with CLTV validation in tests We can't perform proper validation because we don't know the outgoing channel id until we forward the HTLC, so we just perform a basic CLTV check. We don't yet have proper handling of trampoline forwards on restart, so we only enable this in our tests. --- lightning/src/ln/blinded_payment_tests.rs | 124 ---------------------- lightning/src/ln/channelmanager.rs | 30 +++++- 2 files changed, 28 insertions(+), 126 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index b4de3791679..e4538e4a780 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -2741,127 +2741,3 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { claim_payment(&nodes[0], &[&nodes[1], &nodes[2]], payment_preimage); } } - -#[test] -#[rustfmt::skip] -fn test_trampoline_forward_rejection() { - const TOTAL_NODE_COUNT: usize = 3; - - let chanmon_cfgs = create_chanmon_cfgs(TOTAL_NODE_COUNT); - let node_cfgs = create_node_cfgs(TOTAL_NODE_COUNT, &chanmon_cfgs); - let node_chanmgrs = create_node_chanmgrs(TOTAL_NODE_COUNT, &node_cfgs, &vec![None; TOTAL_NODE_COUNT]); - let mut nodes = create_network(TOTAL_NODE_COUNT, &node_cfgs, &node_chanmgrs); - - let (_, _, chan_id_alice_bob, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); - let (_, _, chan_id_bob_carol, _) = create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0); - - for i in 0..TOTAL_NODE_COUNT { // connect all nodes' blocks - connect_blocks(&nodes[i], (TOTAL_NODE_COUNT as u32) * CHAN_CONFIRM_DEPTH + 1 - nodes[i].best_block_info().1); - } - - let alice_node_id = nodes[0].node().get_our_node_id(); - let bob_node_id = nodes[1].node().get_our_node_id(); - let carol_node_id = nodes[2].node().get_our_node_id(); - - let alice_bob_scid = nodes[0].node().list_channels().iter().find(|c| c.channel_id == chan_id_alice_bob).unwrap().short_channel_id.unwrap(); - let bob_carol_scid = nodes[1].node().list_channels().iter().find(|c| c.channel_id == chan_id_bob_carol).unwrap().short_channel_id.unwrap(); - - let amt_msat = 1000; - let carol_cltv_expiry_delta = 24 + 24 + 39; - let (payment_preimage, payment_hash, _) = get_payment_preimage_hash(&nodes[2], Some(amt_msat), None); - - let route = Route { - paths: vec![Path { - hops: vec![ - // Bob - RouteHop { - pubkey: bob_node_id, - node_features: NodeFeatures::empty(), - short_channel_id: alice_bob_scid, - channel_features: ChannelFeatures::empty(), - fee_msat: 1000, - cltv_expiry_delta: 48, - maybe_announced_channel: false, - }, - - // Carol - RouteHop { - pubkey: carol_node_id, - node_features: NodeFeatures::empty(), - short_channel_id: bob_carol_scid, - channel_features: ChannelFeatures::empty(), - fee_msat: 0, - cltv_expiry_delta: carol_cltv_expiry_delta, - maybe_announced_channel: false, - } - ], - blinded_tail: Some(BlindedTail { - trampoline_hops: vec![ - // Carol - TrampolineHop { - pubkey: carol_node_id, - node_features: Features::empty(), - fee_msat: amt_msat, - cltv_expiry_delta: 24, - }, - - // Alice (unreachable) - TrampolineHop { - pubkey: alice_node_id, - node_features: Features::empty(), - fee_msat: amt_msat, - cltv_expiry_delta: 24 + 39, - }, - ], - hops: vec![BlindedHop{ - // Fake public key - blinded_node_id: alice_node_id, - encrypted_payload: vec![], - }], - blinding_point: alice_node_id, - excess_final_cltv_expiry_delta: 39, - final_value_msat: amt_msat, - }) - }], - route_params: RouteParameters::from_payment_params_and_value( - PaymentParameters::from_node_id(carol_node_id, carol_cltv_expiry_delta), - amt_msat, - ), - }; - - nodes[0].node.send_payment_with_route(route.clone(), payment_hash, RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(payment_hash.0)).unwrap(); - - check_added_monitors(&nodes[0], 1); - - let mut events = nodes[0].node.get_and_clear_pending_msg_events(); - assert_eq!(events.len(), 1); - let first_message_event = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events); - - let route: &[&Node] = &[&nodes[1], &nodes[2]]; - let args = PassAlongPathArgs::new(&nodes[0], route, amt_msat, payment_hash, first_message_event) - .with_payment_preimage(payment_preimage) - .without_claimable_event() - .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }); - do_pass_along_path(args); - - { - let unblinded_node_updates = get_htlc_update_msgs(&nodes[2], &nodes[1].node.get_our_node_id()); - nodes[1].node.handle_update_fail_htlc( - nodes[2].node.get_our_node_id(), &unblinded_node_updates.update_fail_htlcs[0] - ); - do_commitment_signed_dance(&nodes[1], &nodes[2], &unblinded_node_updates.commitment_signed, true, false); - } - { - let unblinded_node_updates = get_htlc_update_msgs(&nodes[1], &nodes[0].node.get_our_node_id()); - nodes[0].node.handle_update_fail_htlc( - nodes[1].node.get_our_node_id(), &unblinded_node_updates.update_fail_htlcs[0] - ); - do_commitment_signed_dance(&nodes[0], &nodes[1], &unblinded_node_updates.commitment_signed, false, false); - } - { - // Expect UnknownNextPeer error while we are unable to route forwarding Trampoline payments. - let payment_failed_conditions = PaymentFailedConditions::new() - .expected_htlc_error_data(LocalHTLCFailureReason::UnknownNextPeer, &[0; 0]); - expect_payment_failed_conditions(&nodes[0], payment_hash, false, payment_failed_conditions); - } -} diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index f97c824abd6..1cff1671311 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5199,6 +5199,7 @@ impl< fn can_forward_htlc_should_intercept( &self, msg: &msgs::UpdateAddHTLC, prev_chan_public: bool, next_hop: &NextPacketDetails, ) -> Result<bool, LocalHTLCFailureReason> { + let cur_height = self.best_block.read().unwrap().height + 1; let outgoing_scid = match next_hop.outgoing_connector { HopConnector::ShortChannelId(scid) => scid, HopConnector::Dummy => { @@ -5206,8 +5207,34 @@ impl< debug_assert!(false, "Dummy hop reached HTLC handling."); return Err(LocalHTLCFailureReason::InvalidOnionPayload); }, + // We can't make forwarding checks on trampoline forwards where we don't know the + // outgoing channel on receipt of the incoming htlc. Our trampoline logic will check + // our required delta and fee later on, so here we just check that the forwarding node + // did not "skim" off some of the sender's intended fee/cltv. HopConnector::Trampoline(_) => { - return Err(LocalHTLCFailureReason::InvalidTrampolineForward); + // We do not yet support reloading our trampoline HTLCs on restart, so we just + // fail them for now (except in tests). + #[cfg(not(test))] + { + return Err(LocalHTLCFailureReason::InvalidTrampolineForward); + } + + #[cfg(test)] + { + if msg.amount_msat < next_hop.outgoing_amt_msat { + return Err(LocalHTLCFailureReason::FeeInsufficient); + } + + check_incoming_htlc_cltv( + cur_height, + next_hop.outgoing_cltv_value, + msg.cltv_expiry, + 0, + )?; + + // TODO: add interception flag specifically for trampoline + return Ok(false); + } }, }; // TODO: We do the fake SCID namespace check a bunch of times here (and indirectly via @@ -5246,7 +5273,6 @@ impl< }, }; - let cur_height = self.best_block.read().unwrap().height + 1; check_incoming_htlc_cltv( cur_height, next_hop.outgoing_cltv_value, From 19eefdefbc0e5b540cb80df5eea0e926ce10306e Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Thu, 2 Jul 2026 15:37:59 -0400 Subject: [PATCH 565/627] ln/tests: return BlindedPaymentPath from trampoline helper Now that PaymentParameters are required, surface path along with blinded tail for use in tests. --- lightning/src/ln/blinded_payment_tests.rs | 62 +++++++++++++---------- lightning/src/ln/functional_test_utils.rs | 11 ++-- 2 files changed, 43 insertions(+), 30 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index e4538e4a780..c67c5932a8b 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -2580,6 +2580,39 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { let override_random_bytes = [42; 32]; *nodes[0].keys_manager.override_random_bytes.lock().unwrap() = Some(override_random_bytes); + // Create a blinded tail where Carol is receiving. In our unblinded test cases, we'll + // override this anyway (with a tail sending to an unblinded receive, which LDK doesn't + // allow). + let (blinded_tail, blinded_path) = create_trampoline_forward_blinded_tail( + &secp_ctx, + &nodes[2].keys_manager, + &[], + carol_node_id, + nodes[2].keys_manager.get_receive_auth_key(), + ReceiveTlvs { + payment_secret, + payment_constraints: PaymentConstraints { + max_cltv_expiry: u32::max_value(), + htlc_minimum_msat: original_amt_msat, + }, + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { + payment_metadata: None, + }), + }, + original_trampoline_cltv, + excess_final_cltv, + original_amt_msat, + ); + + // When Carol receives over the blinded path, register it in the payment parameters as we + // would for a real blinded payment. In the unblinded test cases the blinded tail is overridden, + // so the payee is just Carol's unblinded node id. + let payment_params = if blinded { + PaymentParameters::blinded(vec![blinded_path]) + } else { + PaymentParameters::from_node_id(carol_node_id, original_trampoline_cltv + excess_final_cltv) + }; + let route = Route { paths: vec![Path { hops: vec![ @@ -2602,35 +2635,10 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { maybe_announced_channel: false, }, ], - // Create a blinded tail where Carol is receiving. In our unblinded test cases, we'll - // override this anyway (with a tail sending to an unblinded receive, which LDK doesn't - // allow). - blinded_tail: Some(create_trampoline_forward_blinded_tail( - &secp_ctx, - &nodes[2].keys_manager, - &[], - carol_node_id, - nodes[2].keys_manager.get_receive_auth_key(), - ReceiveTlvs { - payment_secret, - payment_constraints: PaymentConstraints { - max_cltv_expiry: u32::max_value(), - htlc_minimum_msat: original_amt_msat, - }, - payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { - payment_metadata: None, - }), - }, - original_trampoline_cltv, - excess_final_cltv, - original_amt_msat, - )), + blinded_tail: Some(blinded_tail), }], route_params: RouteParameters::from_payment_params_and_value( - PaymentParameters::from_node_id( - carol_node_id, - original_trampoline_cltv + excess_final_cltv, - ), + payment_params, original_amt_msat, ), }; diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 6e855c2a184..475764e075a 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -5780,12 +5780,16 @@ pub fn get_scid_from_channel_id<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, channel_id: /// /// The resulting tail contains blinded hops built from `intermediate_nodes` plus a dummy receive /// TLV, with the `TrampolineHop` fee and CLTV derived from the blinded path's aggregated payinfo. +/// The constructed [`BlindedPaymentPath`] is also returned so callers can register it in +/// [`PaymentParameters`]. +/// +/// [`PaymentParameters`]: crate::routing::router::PaymentParameters pub fn create_trampoline_forward_blinded_tail<ES: EntropySource>( secp_ctx: &bitcoin::secp256k1::Secp256k1<bitcoin::secp256k1::All>, entropy_source: ES, intermediate_nodes: &[ForwardNode<TrampolineForwardTlvs>], payee_node_id: PublicKey, payee_receive_key: ReceiveAuthKey, payee_tlvs: ReceiveTlvs, min_final_cltv_expiry_delta: u32, excess_final_cltv_delta: u32, final_value_msat: u64, -) -> BlindedTail { +) -> (BlindedTail, BlindedPaymentPath) { let blinded_path = BlindedPaymentPath::new_for_trampoline( intermediate_nodes, payee_node_id, @@ -5798,7 +5802,7 @@ pub fn create_trampoline_forward_blinded_tail<ES: EntropySource>( ) .unwrap(); - BlindedTail { + let tail = BlindedTail { trampoline_hops: vec![TrampolineHop { pubkey: intermediate_nodes.first().map(|n| n.node_id).unwrap_or(payee_node_id), node_features: types::features::Features::empty(), @@ -5817,5 +5821,6 @@ pub fn create_trampoline_forward_blinded_tail<ES: EntropySource>( blinding_point: blinded_path.blinding_point(), excess_final_cltv_expiry_delta: excess_final_cltv_delta, final_value_msat, - } + }; + (tail, blinded_path) } From b975ff319cb7db05db3296df28acf89ad0650f91 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Thu, 2 Jul 2026 15:42:13 -0400 Subject: [PATCH 566/627] ln/test: add test coverage for MPP trampoline --- lightning/src/ln/blinded_payment_tests.rs | 281 +++++++++++++++++++++- 1 file changed, 277 insertions(+), 4 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index c67c5932a8b..3ecf4ae6344 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -8,13 +8,15 @@ // licenses. use crate::blinded_path::payment::{ - BlindedPaymentPath, Bolt12RefundContext, DummyTlvs, ForwardTlvs, PaymentConstraints, - PaymentContext, PaymentForwardNode, PaymentRelay, ReceiveTlvs, PAYMENT_PADDING_ROUND_OFF, + BlindedPaymentPath, Bolt12RefundContext, DummyTlvs, ForwardNode, ForwardTlvs, + PaymentConstraints, PaymentContext, PaymentForwardNode, PaymentRelay, ReceiveTlvs, + PAYMENT_PADDING_ROUND_OFF, }; use crate::blinded_path::utils::is_padded; use crate::blinded_path::{self, BlindedHop}; +use crate::chain::channelmonitor::HTLC_FAIL_BACK_BUFFER; use crate::events::{Event, HTLCHandlingFailureType, PaymentFailureReason}; -use crate::ln::channelmanager::{self, HTLCFailureMsg, PaymentId}; +use crate::ln::channelmanager::{self, HTLCFailureMsg, PaymentId, MPP_TIMEOUT_TICKS}; use crate::ln::functional_test_utils::*; use crate::ln::inbound_payment::ExpandedKey; use crate::ln::msgs::{ @@ -34,7 +36,7 @@ use crate::routing::router::{ use crate::sign::{NodeSigner, PeerStorageKey, ReceiveAuthKey, Recipient}; use crate::types::features::{BlindedHopFeatures, ChannelFeatures, NodeFeatures}; use crate::types::payment::{PaymentHash, PaymentSecret}; -use crate::util::config::{HTLCInterceptionFlags, UserConfig}; +use crate::util::config::{ChannelConfig, HTLCInterceptionFlags, UserConfig}; use crate::util::ser::{WithoutLength, Writeable}; use crate::util::test_utils::{self, bytes_from_hex, pubkey_from_hex, secret_from_hex}; use bitcoin::hex::DisplayHex; @@ -2749,3 +2751,274 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { claim_payment(&nodes[0], &[&nodes[1], &nodes[2]], payment_preimage); } } + +/// Sets up channels and sends a trampoline MPP payment across two paths. +/// +/// Topology: +/// Alice (0) --> Bob (1) --> Carol (2, trampoline node) +/// Alice (0) --> Barry (3) --> Carol (2, trampoline node) +/// +/// Carol's inner trampoline onion is a forward to an unknown next node. We don't need the +/// next hop as a real node since forwarding isn't implemented yet -- we just need the onion to +/// contain a valid forward payload. +/// +/// Returns (payment_hash, per_path_amount, last_hop_cltv_delta, ev_to_bob, ev_to_barry). +fn send_trampoline_mpp_payment<'a, 'b, 'c>( + nodes: &'a Vec<Node<'a, 'b, 'c>>, +) -> (PaymentHash, u64, u32, MessageSendEvent, MessageSendEvent) { + let secp_ctx = Secp256k1::new(); + + let alice_bob_chan = + create_announced_chan_between_nodes_with_value(nodes, 0, 1, 1_000_000, 0).2; + let bob_carol_chan = + create_announced_chan_between_nodes_with_value(nodes, 1, 2, 1_000_000, 0).2; + let alice_barry_chan = + create_announced_chan_between_nodes_with_value(nodes, 0, 3, 1_000_000, 0).2; + let barry_carol_chan = + create_announced_chan_between_nodes_with_value(nodes, 3, 2, 1_000_000, 0).2; + + let per_path_amt = 500_000; + let total_amt = per_path_amt * 2; + let (_, payment_hash, payment_secret) = + get_payment_preimage_hash(&nodes[2], Some(total_amt), None); + + let bob_node_id = nodes[1].node.get_our_node_id(); + let carol_node_id = nodes[2].node.get_our_node_id(); + let barry_node_id = nodes[3].node.get_our_node_id(); + + let alice_bob_scid = get_scid_from_channel_id(&nodes[0], alice_bob_chan); + let bob_carol_scid = get_scid_from_channel_id(&nodes[1], bob_carol_chan); + let alice_barry_scid = get_scid_from_channel_id(&nodes[0], alice_barry_chan); + let barry_carol_scid = get_scid_from_channel_id(&nodes[3], barry_carol_chan); + + let trampoline_cltv = 42; + let excess_final_cltv = 70; + + // Note we don't actually have an outgoing channel for Carol, we just use our default fee + // policy. + let carol_relay = ChannelConfig::default(); + + let next_trampoline = PublicKey::from_slice(&[2; 33]).unwrap(); + let fwd_tail = || { + let intermediate_nodes = [ForwardNode { + tlvs: blinded_path::payment::TrampolineForwardTlvs { + next_trampoline, + payment_constraints: PaymentConstraints { + max_cltv_expiry: u32::max_value(), + htlc_minimum_msat: 1, + }, + features: BlindedHopFeatures::empty(), + payment_relay: PaymentRelay { + cltv_expiry_delta: carol_relay.cltv_expiry_delta, + fee_proportional_millionths: carol_relay.forwarding_fee_proportional_millionths, + fee_base_msat: carol_relay.forwarding_fee_base_msat, + }, + next_blinding_override: None, + }, + node_id: carol_node_id, + htlc_maximum_msat: u64::max_value(), + }]; + let payee_tlvs = ReceiveTlvs { + payment_secret: PaymentSecret([0; 32]), + payment_constraints: PaymentConstraints { + max_cltv_expiry: u32::max_value(), + htlc_minimum_msat: 1, + }, + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { + payment_metadata: None, + }), + }; + create_trampoline_forward_blinded_tail( + &secp_ctx, + &nodes[2].keys_manager, + &intermediate_nodes, + next_trampoline, + ReceiveAuthKey([0; 32]), + payee_tlvs, + trampoline_cltv, + excess_final_cltv, + per_path_amt, + ) + }; + + let hop = |pubkey, short_channel_id, fee_msat, cltv_expiry_delta| RouteHop { + pubkey, + node_features: NodeFeatures::empty(), + short_channel_id, + channel_features: ChannelFeatures::empty(), + fee_msat, + cltv_expiry_delta, + maybe_announced_channel: true, + }; + let last_hop_cltv_delta = + carol_relay.cltv_expiry_delta as u32 + trampoline_cltv + excess_final_cltv; + let build_path_hops = |first_hop_node_id, first_hop_scid, second_hop_scid| { + vec![ + hop(first_hop_node_id, first_hop_scid, 1000, 48), + hop(carol_node_id, second_hop_scid, 0, last_hop_cltv_delta), + ] + }; + + let (tail_bob, blinded_path_bob) = fwd_tail(); + let (tail_barry, blinded_path_barry) = fwd_tail(); + let payment_params = PaymentParameters::blinded(vec![blinded_path_bob, blinded_path_barry]); + let route_params = RouteParameters { + payment_params, + final_value_msat: total_amt, + max_total_routing_fee_msat: None, + }; + let route = Route { + paths: vec![ + Path { + hops: build_path_hops(bob_node_id, alice_bob_scid, bob_carol_scid), + blinded_tail: Some(tail_bob), + }, + Path { + hops: build_path_hops(barry_node_id, alice_barry_scid, barry_carol_scid), + blinded_tail: Some(tail_barry), + }, + ], + route_params, + }; + + let payment_id = PaymentId(payment_hash.0); + let onion = RecipientOnionFields::secret_only(payment_secret, total_amt); + nodes[0].node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); + check_added_monitors(&nodes[0], 2); + + let mut events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 2); + let ev_bob = remove_first_msg_event_to_node(&bob_node_id, &mut events); + let ev_barry = remove_first_msg_event_to_node(&barry_node_id, &mut events); + (payment_hash, per_path_amt, last_hop_cltv_delta, ev_bob, ev_barry) +} + +/// How an incomplete trampoline MPP times out (if at all). +enum TrampolineTimeout { + /// Tick timers until MPP timeout fires. + Ticks, + /// Mine blocks until on-chain CLTV timeout fires. + OnChain, +} + +fn do_trampoline_mpp_test(timeout: Option<TrampolineTimeout>) { + let chanmon_cfgs = create_chanmon_cfgs(4); + let node_cfgs = create_node_cfgs(4, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(4, &node_cfgs, &vec![None; 4]); + let nodes = create_network(4, &node_cfgs, &node_chanmgrs); + + let (payment_hash, per_path_amt, last_hop_cltv_delta, ev_bob, ev_barry) = + send_trampoline_mpp_payment(&nodes); + let send_both = timeout.is_none(); + + let bob_path: &[&Node] = &[&nodes[1], &nodes[2]]; + let barry_path: &[&Node] = &[&nodes[3], &nodes[2]]; + + // Pass first part along Alice -> Bob -> Carol. + let args = PassAlongPathArgs::new(&nodes[0], bob_path, per_path_amt, payment_hash, ev_bob) + .without_claimable_event(); + do_pass_along_path(args); + + // Either complete the MPP (triggering trampoline rejection) or trigger a timeout. + let expected_reason = match timeout { + None => { + let args = + PassAlongPathArgs::new(&nodes[0], barry_path, per_path_amt, payment_hash, ev_barry) + .without_clearing_recipient_events(); + do_pass_along_path(args); + LocalHTLCFailureReason::TemporaryTrampolineFailure + }, + Some(TrampolineTimeout::Ticks) => { + for _ in 0..MPP_TIMEOUT_TICKS { + nodes[2].node.timer_tick_occurred(); + } + LocalHTLCFailureReason::MPPTimeout + }, + Some(TrampolineTimeout::OnChain) => { + let current_height = nodes[2].best_block_info().1; + let send_height = nodes[0].best_block_info().1; + let htlc_cltv = send_height + 1 + last_hop_cltv_delta; + connect_blocks(&nodes[2], htlc_cltv - HTLC_FAIL_BACK_BUFFER - current_height); + LocalHTLCFailureReason::CLTVExpiryTooSoon + }, + }; + + // Carol rejects the trampoline forward (either after MPP completion or timeout). + let events = nodes[2].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match events[0] { + crate::events::Event::HTLCHandlingFailed { + ref failure_type, ref failure_reason, .. + } => { + assert_eq!(failure_type, &HTLCHandlingFailureType::TrampolineForward {}); + match failure_reason { + Some(crate::events::HTLCHandlingFailureReason::Local { reason }) => { + assert_eq!(*reason, expected_reason) + }, + Some(_) | None => panic!("expected failure_reason for failed trampoline"), + } + }, + _ => panic!("Unexpected destination"), + } + expect_and_process_pending_htlcs(&nodes[2], false); + assert!(nodes[2].node.get_and_clear_pending_events().is_empty()); + + // Propagate failures back through each forwarded path to Alice. + let both: [&[&Node]; 2] = [bob_path, barry_path]; + let one: [&[&Node]; 1] = [bob_path]; + let forwarded: &[&[&Node]] = if send_both { &both } else { &one }; + let carol_id = nodes[2].node.get_our_node_id(); + check_added_monitors(&nodes[2], forwarded.len()); + let mut carol_msgs = nodes[2].node.get_and_clear_pending_msg_events(); + assert_eq!(carol_msgs.len(), forwarded.len()); + for path in forwarded { + let hop = path[0]; + let hop_id = hop.node.get_our_node_id(); + let ev = remove_first_msg_event_to_node(&hop_id, &mut carol_msgs); + let updates = match ev { + MessageSendEvent::UpdateHTLCs { updates, .. } => updates, + _ => panic!("Expected UpdateHTLCs"), + }; + hop.node.handle_update_fail_htlc(carol_id, &updates.update_fail_htlcs[0]); + do_commitment_signed_dance(hop, &nodes[2], &updates.commitment_signed, true, false); + + let fwd = get_htlc_update_msgs(hop, &nodes[0].node.get_our_node_id()); + nodes[0].node.handle_update_fail_htlc(hop_id, &fwd.update_fail_htlcs[0]); + do_commitment_signed_dance(&nodes[0], hop, &fwd.commitment_signed, false, false); + } + + // Check Alice's failure events. + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), if send_both { 3 } else { 1 }); + for ev in &events[..forwarded.len()] { + match ev { + Event::PaymentPathFailed { payment_hash: h, payment_failed_permanently, .. } => { + assert_eq!(*h, payment_hash); + assert!(!payment_failed_permanently); + }, + _ => panic!("Expected PaymentPathFailed, got {:?}", ev), + } + } + if send_both { + match &events[2] { + Event::PaymentFailed { payment_hash: h, reason, .. } => { + assert_eq!(*h, Some(payment_hash)); + assert_eq!(*reason, Some(PaymentFailureReason::RetriesExhausted)); + }, + _ => panic!("Expected PaymentFailed, got {:?}", events[2]), + } + + // Verify no spurious timeout fires after the MPP set was dispatched. + for _ in 0..(MPP_TIMEOUT_TICKS * 3) { + nodes[2].node.timer_tick_occurred(); + } + assert!(nodes[2].node.get_and_clear_pending_events().is_empty()); + } +} + +#[test] +fn test_trampoline_mpp_accumulation() { + do_trampoline_mpp_test(None); + do_trampoline_mpp_test(Some(TrampolineTimeout::Ticks)); + do_trampoline_mpp_test(Some(TrampolineTimeout::OnChain)); +} From b90152b00d85be2a18e5b7454454c48faa6d731a Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Thu, 2 Jul 2026 14:51:05 -0400 Subject: [PATCH 567/627] ln/test: add tests for mpp accumulation of trampoline forwards --- lightning/src/ln/channelmanager.rs | 30 ++- lightning/src/ln/mod.rs | 2 + lightning/src/ln/trampoline_forward_tests.rs | 204 +++++++++++++++++++ 3 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 lightning/src/ln/trampoline_forward_tests.rs diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 1cff1671311..973c8dbc0da 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -527,7 +527,7 @@ enum OnionPayload { } #[derive(PartialEq, Eq)] -struct MppPart { +pub(super) struct MppPart { prev_hop: HTLCPreviousHopData, cltv_expiry: u32, /// The amount (in msats) of this MPP part @@ -542,6 +542,20 @@ struct MppPart { } impl MppPart { + #[cfg(test)] + pub(super) fn new( + prev_hop: HTLCPreviousHopData, value: u64, sender_intended_value: u64, cltv_expiry: u32, + ) -> Self { + MppPart { + prev_hop, + cltv_expiry, + value, + sender_intended_value, + timer_ticks: 0, + total_value_received: None, + } + } + /// Returns a boolean indicating whether the HTLC has timed out on chain, accounting for a buffer /// that gives us time to resolve it. fn check_onchain_timeout(&self, height: u32) -> bool { @@ -5821,6 +5835,20 @@ impl< self.pending_outbound_payments.test_set_payment_metadata(payment_id, new_payment_metadata); } + #[cfg(test)] + pub(super) fn test_handle_trampoline_htlc( + &self, mpp_part: MppPart, onion_fields: RecipientOnionFields, payment_hash: PaymentHash, + next_hop_info: NextTrampolineHopInfo, next_node_id: PublicKey, + ) -> Result<(), (HTLCSource, onion_utils::HTLCFailReason)> { + self.handle_trampoline_htlc( + mpp_part, + onion_fields, + payment_hash, + next_hop_info, + next_node_id, + ) + } + /// Pays a [`Bolt11Invoice`] associated with the `payment_id`. See [`Self::send_payment`] for more info. /// /// # Payment Id diff --git a/lightning/src/ln/mod.rs b/lightning/src/ln/mod.rs index d6e0b92f1d0..30a8109fc43 100644 --- a/lightning/src/ln/mod.rs +++ b/lightning/src/ln/mod.rs @@ -118,6 +118,8 @@ mod reorg_tests; mod shutdown_tests; #[cfg(any(feature = "_test_utils", test))] pub mod splicing_tests; +#[cfg(test)] +mod trampoline_forward_tests; #[cfg(any(test, feature = "_externalize_tests"))] #[allow(unused_mut)] pub mod update_fee_tests; diff --git a/lightning/src/ln/trampoline_forward_tests.rs b/lightning/src/ln/trampoline_forward_tests.rs new file mode 100644 index 00000000000..00f5074ce11 --- /dev/null +++ b/lightning/src/ln/trampoline_forward_tests.rs @@ -0,0 +1,204 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE +// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license +// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Tests for trampoline MPP accumulation and forwarding validation in +//! [`ChannelManager::handle_trampoline_htlc`]. + +use crate::chain::transaction::OutPoint; +use crate::events::HTLCHandlingFailureReason; +use crate::ln::channelmanager::{HTLCPreviousHopData, MppPart, MIN_CLTV_EXPIRY_DELTA}; +use crate::ln::functional_test_utils::*; +use crate::ln::msgs; +use crate::ln::onion_utils::LocalHTLCFailureReason; +use crate::ln::outbound_payment::{NextTrampolineHopInfo, RecipientOnionFields}; +use crate::ln::types::ChannelId; +use crate::types::payment::{PaymentHash, PaymentSecret}; + +use bitcoin::hashes::Hash; +use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + +fn test_prev_hop_data(htlc_id: u64) -> HTLCPreviousHopData { + HTLCPreviousHopData { + prev_outbound_scid_alias: 0, + user_channel_id: None, + htlc_id, + incoming_packet_shared_secret: [0; 32], + phantom_shared_secret: None, + trampoline_shared_secret: Some([0; 32]), + blinded_failure: None, + channel_id: ChannelId::from_bytes([0; 32]), + outpoint: OutPoint { txid: bitcoin::Txid::all_zeros(), index: 0 }, + counterparty_node_id: None, + cltv_expiry: None, + } +} + +fn test_trampoline_onion_packet() -> msgs::TrampolineOnionPacket { + let secp = Secp256k1::new(); + let test_secret = SecretKey::from_slice(&[42; 32]).unwrap(); + msgs::TrampolineOnionPacket { + version: 0, + public_key: PublicKey::from_secret_key(&secp, &test_secret), + hop_data: vec![0; 650], + hmac: [0; 32], + } +} + +fn test_onion_fields(total_msat: u64) -> RecipientOnionFields { + RecipientOnionFields { + payment_secret: Some(PaymentSecret([0; 32])), + total_mpp_amount_msat: total_msat, + payment_metadata: None, + custom_tlvs: Vec::new(), + } +} + +enum TrampolineMppValidationTestCase { + FeeInsufficient, + CltvInsufficient, + CltvDeltaBelowMinimum, + TrampolineAmountExceedsReceived, + TrampolineCLTVExceedsReceived, + MismatchedPaymentSecret, +} + +/// Sends two MPP parts through [`ChannelManager::handle_trampoline_htlc`], testing various MPP +/// validation steps with a base case that succeeds. +fn do_test_trampoline_mpp_validation(test_case: Option<TrampolineMppValidationTestCase>) { + let update_add_value: u64 = 500_000; // Actual amount we received in update_add_htlc. + let update_add_cltv: u32 = 500; // Actual CLTV we received in update_add_htlc. + let sender_intended_incoming_value: u64 = 500_000; // Amount we expect for one HTLC, outer onion. + let incoming_mpp_total: u64 = 1_000_000; // Total we expect to receive across MPP parts, outer onion. + let mut next_trampoline_amount: u64 = 750_000; // Total next trampoline expects, inner onion. + let mut next_trampoline_cltv: u32 = 100; // CLTV next trampoline expects, inner onion. + + // By default, set our forwarding fee and CLTV delta to exactly what we're being offered + // for this trampoline forward, so that we can force failures by just adding one. + let mut forwarding_fee_base_msat = incoming_mpp_total - next_trampoline_amount; + let mut cltv_delta = update_add_cltv - next_trampoline_cltv; + let mut mismatch_payment_secret = false; + + let expected = match test_case { + Some(TrampolineMppValidationTestCase::FeeInsufficient) => { + forwarding_fee_base_msat += 1; + LocalHTLCFailureReason::TrampolineFeeOrExpiryInsufficient + }, + Some(TrampolineMppValidationTestCase::CltvInsufficient) => { + cltv_delta += 1; + LocalHTLCFailureReason::TrampolineFeeOrExpiryInsufficient + }, + Some(TrampolineMppValidationTestCase::CltvDeltaBelowMinimum) => { + // A node operator may configure a `cltv_expiry_delta` below + // `MIN_CLTV_EXPIRY_DELTA` (the raw config field isn't floored on the way in), + // but we must still require at least the minimum when forwarding. Offer a delta + // that sits *between* the too-low configured value and the minimum. + cltv_delta = (MIN_CLTV_EXPIRY_DELTA / 2) as u32; + next_trampoline_cltv = update_add_cltv - (MIN_CLTV_EXPIRY_DELTA as u32 - 1); + LocalHTLCFailureReason::TrampolineFeeOrExpiryInsufficient + }, + Some(TrampolineMppValidationTestCase::TrampolineAmountExceedsReceived) => { + next_trampoline_amount = incoming_mpp_total + 1; + LocalHTLCFailureReason::TrampolineFeeOrExpiryInsufficient + }, + Some(TrampolineMppValidationTestCase::TrampolineCLTVExceedsReceived) => { + next_trampoline_cltv = update_add_cltv + 1; + LocalHTLCFailureReason::TrampolineFeeOrExpiryInsufficient + }, + Some(TrampolineMppValidationTestCase::MismatchedPaymentSecret) => { + mismatch_payment_secret = true; + LocalHTLCFailureReason::InvalidTrampolineForward + }, + // We currently reject trampoline forwards once accumulated. + None => LocalHTLCFailureReason::TemporaryTrampolineFailure, + }; + + let chanmon_cfgs = create_chanmon_cfgs(1); + let node_cfgs = create_node_cfgs(1, &chanmon_cfgs); + let mut cfg = test_default_channel_config(); + cfg.channel_config.forwarding_fee_base_msat = forwarding_fee_base_msat as u32; + cfg.channel_config.forwarding_fee_proportional_millionths = 0; + cfg.channel_config.cltv_expiry_delta = cltv_delta as u16; + let node_chanmgrs = create_node_chanmgrs(1, &node_cfgs, &[Some(cfg)]); + let nodes = create_network(1, &node_cfgs, &node_chanmgrs); + + let payment_hash = PaymentHash([1; 32]); + + let secp = Secp256k1::new(); + let test_secret = SecretKey::from_slice(&[2; 32]).unwrap(); + let next_trampoline = PublicKey::from_secret_key(&secp, &test_secret); + let next_hop_info = NextTrampolineHopInfo { + onion_packet: test_trampoline_onion_packet(), + blinding_point: None, + amount_msat: next_trampoline_amount, + cltv_expiry_height: next_trampoline_cltv, + }; + + let htlc1 = MppPart::new( + test_prev_hop_data(0), + update_add_value, + sender_intended_incoming_value, + update_add_cltv, + ); + assert!(nodes[0] + .node + .test_handle_trampoline_htlc( + htlc1, + test_onion_fields(incoming_mpp_total), + payment_hash, + next_hop_info.clone(), + next_trampoline, + ) + .is_ok()); + + let htlc2 = MppPart::new( + test_prev_hop_data(1), + update_add_value, + sender_intended_incoming_value, + update_add_cltv, + ); + let onion2 = if mismatch_payment_secret { + RecipientOnionFields { + payment_secret: Some(PaymentSecret([1; 32])), + total_mpp_amount_msat: incoming_mpp_total, + payment_metadata: None, + custom_tlvs: Vec::new(), + } + } else { + test_onion_fields(incoming_mpp_total) + }; + let result = nodes[0].node.test_handle_trampoline_htlc( + htlc2, + onion2, + payment_hash, + next_hop_info, + next_trampoline, + ); + + assert_eq!( + HTLCHandlingFailureReason::from(&result.expect_err("expect trampoline failure").1), + HTLCHandlingFailureReason::Local { reason: expected }, + ); +} + +#[test] +fn test_trampoline_mpp_validation() { + do_test_trampoline_mpp_validation(Some(TrampolineMppValidationTestCase::FeeInsufficient)); + do_test_trampoline_mpp_validation(Some(TrampolineMppValidationTestCase::CltvInsufficient)); + do_test_trampoline_mpp_validation(Some(TrampolineMppValidationTestCase::CltvDeltaBelowMinimum)); + do_test_trampoline_mpp_validation(Some( + TrampolineMppValidationTestCase::TrampolineAmountExceedsReceived, + )); + do_test_trampoline_mpp_validation(Some( + TrampolineMppValidationTestCase::TrampolineCLTVExceedsReceived, + )); + do_test_trampoline_mpp_validation(Some( + TrampolineMppValidationTestCase::MismatchedPaymentSecret, + )); + do_test_trampoline_mpp_validation(None); +} From 3d9a10d28514f5f90c3dfdec773a46c496dbd078 Mon Sep 17 00:00:00 2001 From: Joost Jager <joost.jager@gmail.com> Date: Fri, 3 Jul 2026 09:38:55 +0200 Subject: [PATCH 568/627] fuzz: sync reloaded monitors from their own best block A node's channel monitors can be persisted at different heights, so on reload they are not all at the same chain tip. Driving them to the tip through the shared ChainMonitor from the oldest monitor's height replays blocks that monitors already ahead have seen, which they interpret as a reorg. That reorg discards force-close claims registered at a later height, leaving the closed channel with no transaction to broadcast. Sync each monitor to the tip from its own best block instead, and sync the manager separately, matching LDK's per-listener startup contract. --- fuzz/src/chanmon_consistency.rs | 123 ++++++++++++++++++++------------ 1 file changed, 77 insertions(+), 46 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 94111ed2ea4..8d16a788b00 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1213,26 +1213,19 @@ impl<'a> HarnessNode<'a> { self.node.current_best_block().height } - // Connects a block range to ChainMonitor and ChannelManager. The start - // heights are independent because reload may pair monitors and a manager - // persisted at different chain tips. + // Connects a block range to the ChannelManager, and to the ChainMonitor when + // sync_monitors is set. Reload syncs monitors separately because they can be + // at different heights than the manager, so it leaves them out here. fn connect_chain_range( - &mut self, chain_state: &ChainState, monitor_start_height: u32, manager_start_height: u32, - target_height: u32, + &mut self, chain_state: &ChainState, start_height: u32, target_height: u32, + sync_monitors: bool, ) { assert!( - target_height >= monitor_start_height, - "connect_chain_range cannot move monitor height backward ({} -> {})", - monitor_start_height, + target_height >= start_height, + "connect_chain_range cannot move height backward ({} -> {})", + start_height, target_height ); - assert!( - target_height >= manager_start_height, - "connect_chain_range cannot move manager height backward ({} -> {})", - manager_start_height, - target_height - ); - let start_height = cmp::min(monitor_start_height, manager_start_height); let mut height = start_height; while height < target_height { let mut next_height = height + 1; @@ -1245,29 +1238,23 @@ impl<'a> HarnessNode<'a> { // allows best_block_updated to skip intermediary blocks. height = target_height; let (header, _) = chain_state.block_at(height); - if height > monitor_start_height { + if sync_monitors { self.monitor.best_block_updated(header, height); } - if height > manager_start_height { - self.node.best_block_updated(header, height); - } + self.node.best_block_updated(header, height); break; } height = next_height; let (header, txn) = chain_state.block_at(height); let txdata: Vec<_> = txn.iter().enumerate().map(|(i, tx)| (i + 1, tx)).collect(); - if height > monitor_start_height { + if sync_monitors { self.monitor.transactions_confirmed(header, &txdata, height); } - if height > manager_start_height { - self.node.transactions_confirmed(header, &txdata, height); - } - if height > monitor_start_height { + self.node.transactions_confirmed(header, &txdata, height); + if sync_monitors { self.monitor.best_block_updated(header, height); } - if height > manager_start_height { - self.node.best_block_updated(header, height); - } + self.node.best_block_updated(header, height); } } @@ -1279,7 +1266,61 @@ impl<'a> HarnessNode<'a> { }; let start_height = self.manager_height(); - self.connect_chain_range(chain_state, start_height, start_height, target_height); + self.connect_chain_range(chain_state, start_height, target_height, true); + } + + // Brings every channel monitor up to the chain tip from its own best block. + // On reload monitors can sit at different heights, so syncing them one by + // one avoids replaying a block into a monitor that already saw it, which the + // monitor would treat as a reorg. Each block is connected the same way as + // live operation: confirm its transactions, then advance the best block, + // ending with a best-block update to the tip for the trailing empty blocks. + fn sync_monitors_to_tip(&self, chain_state: &ChainState) { + let target_height = chain_state.tip_height(); + for chan_id in self.monitor.list_monitors() { + let monitor = match self.monitor.get_monitor(chan_id) { + Ok(monitor) => monitor, + Err(_) => continue, + }; + let start_height = monitor.current_best_block().height; + if start_height >= target_height { + continue; + } + for height in (start_height + 1)..=target_height { + let (header, txn) = chain_state.block_at(height); + if txn.is_empty() { + continue; + } + let txdata: Vec<_> = txn.iter().enumerate().map(|(i, tx)| (i + 1, tx)).collect(); + monitor.transactions_confirmed( + header, + &txdata, + height, + &self.broadcaster, + &self.fee_estimator, + &self.logger, + ); + monitor.best_block_updated( + header, + height, + &self.broadcaster, + &self.fee_estimator, + &self.logger, + ); + } + let (header, txn) = chain_state.block_at(target_height); + if txn.is_empty() { + // The tip block carried no transactions, so it was skipped above. + // Advance the best block over the trailing empty blocks to the tip. + monitor.best_block_updated( + header, + target_height, + &self.broadcaster, + &self.fee_estimator, + &self.logger, + ); + } + } } fn checkpoint_manager_persistence(&mut self) -> bool { @@ -3435,20 +3476,6 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { self.nodes[node_idx].splice_out(&cp_node_id, &channel_id); } - // Finds the earliest loaded monitor height for a node. Startup sync uses it - // as ChainMonitor's start height so raw monitors loaded below the manager's - // best block still see every block and transaction they missed. - fn oldest_monitor_height_for_node(&self, node_idx: usize) -> u32 { - let node = &self.nodes[node_idx]; - let mut min_monitor_height = node.manager_height(); - for chan_id in node.monitor.list_monitors() { - if let Ok(mon) = node.monitor.get_monitor(chan_id) { - min_monitor_height = cmp::min(min_monitor_height, mon.current_best_block().height); - } - } - min_monitor_height - } - fn restart_node(&mut self, node_idx: usize, v: u8, router: &'a FuzzRouter) { if !self.nodes[node_idx].deferred { self.nodes[node_idx].checkpoint_manager_persistence(); @@ -3488,14 +3515,18 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { } let loaded_manager_generation = self.nodes[node_idx].reload(v, &self.out, router, self.chan_type); - let monitor_start_height = self.oldest_monitor_height_for_node(node_idx); + // Startup sync is part of LDK's deserialization contract. Monitors and + // the manager can be loaded at different heights, so sync each monitor + // from its own best block rather than driving them all from the oldest + // one, which would look like a reorg to the monitors already ahead. let manager_start_height = self.nodes[node_idx].manager_height(); - // Startup sync is part of LDK's deserialization contract. + let tip_height = self.chain_state.tip_height(); + self.nodes[node_idx].sync_monitors_to_tip(&self.chain_state); self.nodes[node_idx].connect_chain_range( &self.chain_state, - monitor_start_height, manager_start_height, - self.chain_state.tip_height(), + tip_height, + false, ); assert_eq!( self.nodes[node_idx].manager_height(), From 48b2db75eea086af275ad9d710e60ddcfaa16a97 Mon Sep 17 00:00:00 2001 From: Joost Jager <joost.jager@gmail.com> Date: Thu, 2 Jul 2026 11:25:05 +0200 Subject: [PATCH 569/627] fuzz: factor chanmon broadcast relay helper Extract the all-node broadcaster drain into a helper and use it from the finish-time mempool cleanup loop. This leaves relay behavior unchanged while giving cleanup paths a shared relay primitive. --- fuzz/src/chanmon_consistency.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 8d16a788b00..f288d4aae98 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -3652,6 +3652,14 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { self.chain_state.relay_transactions(txs); } + fn relay_all_broadcasts(&mut self) { + let mut txs = Vec::new(); + for node in &self.nodes { + txs.extend(node.broadcaster.txn_broadcasted.borrow_mut().drain(..)); + } + self.chain_state.relay_transactions(txs); + } + fn earliest_pending_htlc_expiry(&self) -> Option<u32> { let mut earliest_expiry: Option<u32> = None; for node in &self.nodes { @@ -3738,11 +3746,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { fn mine_relayed_txs_until_quiet(&mut self) { for _ in 0..MAX_FINISH_RELAY_MINE_ROUNDS { - let mut txs = Vec::new(); - for node in &self.nodes { - txs.extend(node.broadcaster.txn_broadcasted.borrow_mut().drain(..)); - } - self.chain_state.relay_transactions(txs); + self.relay_all_broadcasts(); if self.chain_state.pending_txs.is_empty() { return; } From 05abdc5a64558348050b6d711629a559a064d499 Mon Sep 17 00:00:00 2001 From: Joost Jager <joost.jager@gmail.com> Date: Thu, 2 Jul 2026 11:25:49 +0200 Subject: [PATCH 570/627] fuzz: settle chanmon force-closes on chain Route BumpTransaction events through the wallet-backed handler and drain raw ChainMonitor events during normal event processing. This matches the background processor path and lets anchor commitments and claim transactions enter the harness mempool from fuzz opcodes as well as final cleanup. During settle_all, alternate event processing, relay, and mining until tracked force-closed channels no longer report claimable balances. The bounded loop catches stuck on-chain cleanup instead of leaving broadcasts or claims unresolved. --- fuzz/src/chanmon_consistency.rs | 87 ++++++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 12 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index f288d4aae98..cb84ff1439f 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -47,7 +47,7 @@ use lightning::chain::channelmonitor::{ChannelMonitor, ANTI_REORG_DELAY}; use lightning::chain::{ chainmonitor, channelmonitor, BlockLocator, ChannelMonitorUpdateStatus, Confirm, Watch, }; -use lightning::events; +use lightning::events::{self, EventsProvider}; use lightning::ln::channel::{ FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE, MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS, }; @@ -85,6 +85,8 @@ use lightning::util::test_channel_signer::{EnforcementState, SignerOp, TestChann use lightning::util::test_utils::TestWalletSource; use lightning::util::wallet_utils::{WalletSourceSync, WalletSync}; +use lightning::events::bump_transaction::sync::BumpTransactionEventHandlerSync; + use lightning_invoice::RawBolt11Invoice; use crate::utils::test_logger::{self, Output}; @@ -96,7 +98,7 @@ use bitcoin::secp256k1::{self, Message, PublicKey, Scalar, Secp256k1, SecretKey} use lightning::util::dyn_signer::DynSigner; -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::cmp; use std::collections::HashSet; use std::mem; @@ -105,6 +107,7 @@ use std::sync::{Arc, Mutex}; const MAX_FEE: u32 = 10_000; const MAX_SETTLE_ITERATIONS: usize = 256; +const FORCE_CLOSE_CLEANUP_ROUNDS: usize = 512; // Each wallet is seeded with enough confirmed UTXOs that repeated splice // transactions don't run out of inputs mid-run. const NUM_WALLET_UTXOS: u32 = 50; @@ -748,6 +751,12 @@ type TestChainMonitor = chainmonitor::ChainMonitor< Arc<HarnessPersister>, Arc<KeyProvider>, >; +type TestBumpTransactionEventHandler = BumpTransactionEventHandlerSync< + Arc<TestBroadcaster>, + Arc<WalletSync<Arc<TestWalletSource>, Arc<dyn Logger + MaybeSend + MaybeSync>>>, + Arc<KeyProvider>, + Arc<dyn Logger + MaybeSend + MaybeSync>, +>; struct KeyProvider { node_secret: SecretKey, @@ -1068,7 +1077,8 @@ struct HarnessNode<'a> { broadcaster: Arc<TestBroadcaster>, fee_estimator: Arc<FuzzEstimator>, wallet: Arc<TestWalletSource>, - wallet_sync: WalletSync<Arc<TestWalletSource>, Arc<dyn Logger + MaybeSend + MaybeSync>>, + wallet_sync: Arc<WalletSync<Arc<TestWalletSource>, Arc<dyn Logger + MaybeSend + MaybeSync>>>, + bump_tx_handler: TestBumpTransactionEventHandler, persistence_style: ChannelMonitorUpdateStatus, deferred: bool, serialized_manager: Vec<u8>, @@ -1140,7 +1150,16 @@ impl<'a> HarnessNode<'a> { &persister, deferred, ); - let wallet_sync = WalletSync::new(Arc::clone(&wallet), Arc::clone(&logger)); + let wallet_sync = Arc::new(WalletSync::new(Arc::clone(&wallet), Arc::clone(&logger))); + // Wallet-backed handler that completes and broadcasts the transactions + // requested by monitor BumpTransaction events. It shares the node's + // wallet sync so anchor spends and splice funding share UTXO lock state. + let bump_tx_handler = BumpTransactionEventHandlerSync::new( + Arc::clone(&broadcaster), + Arc::clone(&wallet_sync), + Arc::clone(&keys_manager), + Arc::clone(&logger), + ); let network = Network::Bitcoin; let best_block_timestamp = genesis_block(network).header.time; let params = ChainParameters { network, best_block: BlockLocator::from_network(network) }; @@ -1169,6 +1188,7 @@ impl<'a> HarnessNode<'a> { fee_estimator, wallet, wallet_sync, + bump_tx_handler, persistence_style, deferred, serialized_manager: Vec::new(), @@ -1389,6 +1409,23 @@ impl<'a> HarnessNode<'a> { self.last_htlc_clear_fee = self.fee_estimator.ret_val.load(atomic::Ordering::Acquire); } + // Drains raw ChannelMonitor events. Monitor-generated BumpTransaction events + // do not flow through the manager event queue but still produce transactions + // the harness must mine. + fn process_monitor_pending_events(&self) -> bool { + // process_pending_events takes an Fn handler, so use interior mutability + // to report whether the callback saw anything. + let had_events = Cell::new(false); + self.monitor.process_pending_events(&|event: events::Event| { + had_events.set(true); + if let events::Event::BumpTransaction(ref bump) = event { + self.bump_tx_handler.handle_event(bump); + } + Ok(()) + }); + had_events.get() + } + fn splice_in(&self, counterparty_node_id: &PublicKey, channel_id: &ChannelId) { match self.node.splice_channel(channel_id, counterparty_node_id) { Ok(funding_template) => { @@ -1398,7 +1435,7 @@ impl<'a> HarnessNode<'a> { Amount::from_sat(10_000), feerate, FeeRate::MAX, - &self.wallet_sync, + self.wallet_sync.as_ref(), ) { let _ = self.node.funding_contributed( channel_id, @@ -3332,13 +3369,15 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { events::Event::SpendableOutputs { .. } => { // The harness does not model an external sweeper wallet. }, - events::Event::BumpTransaction(_) => { - // Fee bumping is not modeled; broadcasts are relayed through - // the harness mempool directly. + events::Event::BumpTransaction(bump) => { + nodes[node_idx].bump_tx_handler.handle_event(&bump); }, _ => panic!("Unhandled event: {:?}", event), } } + // Chain monitor events are processed together with manager events, + // mirroring how a node's background processor polls both queues. + had_events |= nodes[node_idx].process_monitor_pending_events(); while nodes[node_idx].needs_pending_htlc_processing() { nodes[node_idx].process_pending_htlc_forwards(); had_events = true; @@ -3576,10 +3615,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { self.process_all_events(); if self.close_tracker.has_closed_channels() { - // Explicit closes broadcast commitment transactions. Mine the - // modeled mempool so later invariants see the post-close state. - self.mine_relayed_txs_until_quiet(); - self.process_all_events(); + self.settle_force_close_onchain(); } // Verify no payments are stuck - all should have resolved @@ -3763,6 +3799,33 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { "tx mining loop failed to quiesce", ); } + + fn settle_force_close_onchain(&mut self) { + // Alternate event processing, relay, and mining until all tracked + // closed-channel on-chain balances have resolved. + let deadline_blocked = "force-close cleanup was blocked by an HTLC fail-back deadline"; + for _ in 0..FORCE_CLOSE_CLEANUP_ROUNDS { + self.process_all_events(); + self.relay_all_broadcasts(); + if !self.chain_state.pending_txs.is_empty() { + assert!(self.mine_blocks(ANTI_REORG_DELAY) > 0, "{}", deadline_blocked); + continue; + } + let has_claimable_balance = self.nodes.iter().any(|node| { + // get_claimable_balances ignores the channels passed in. Pass + // each node's own live channels so closed-channel balances stay + // visible. + let open_channels = node.node.list_channels(); + let open_refs: Vec<_> = open_channels.iter().collect(); + !node.monitor.get_claimable_balances(&open_refs).is_empty() + }); + if !has_claimable_balance { + return; + } + assert!(self.mine_blocks(1) > 0, "{}", deadline_blocked); + } + panic!("force-close cleanup loop failed to quiesce"); + } } #[inline] From 24651a1a50f51e038ec7af6bb7c2eb3487769976 Mon Sep 17 00:00:00 2001 From: Matt Corallo <git+claude@bluematt.me> Date: Fri, 3 Jul 2026 16:53:33 +0000 Subject: [PATCH 571/627] ci: don't double-assign reviewers, support manual assignment runs The assign-reviewer workflow blindly picked a random reviewer every time it ran, so a re-run (or any future non-opened trigger) could request review from a second person even when someone was already on the PR. Teach it to inspect the PR state first: * Anyone already requested as a reviewer or who has submitted a review is never picked. The author self-reviewing (commenting on their own PR) doesn't count. * On automatic runs (including re-runs), skip assignment entirely if anyone from the REVIEWERS pool has already reviewed or been requested; reviews from people outside the pool are ignored. Also add a workflow_dispatch trigger taking a PR number so a reviewer can be assigned manually. Manual runs skip the "someone is already on it" check and always add a new (not-yet-involved) reviewer if an eligible candidate remains. This should let us fully emulate the old bot's second-reviewer assignment logic fully via the action. A first reviewer can go hunt in the actions page and trigger a second assignment (but we'll add a UI element in the PR page above merge to trigger this in a nice UI). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .forgejo/workflows/assign-reviewer.yml | 82 +++++++++++++++++++++++--- 1 file changed, 73 insertions(+), 9 deletions(-) diff --git a/.forgejo/workflows/assign-reviewer.yml b/.forgejo/workflows/assign-reviewer.yml index f743c4cd621..64fed5525ba 100644 --- a/.forgejo/workflows/assign-reviewer.yml +++ b/.forgejo/workflows/assign-reviewer.yml @@ -3,10 +3,24 @@ name: Assign a random reviewer # Forgejo has no built-in random/round-robin reviewer assignment (only # path-based CODEOWNERS), so pick a random developer for each newly opened # pull request and request their review via the API. +# +# Runs automatically when a PR is opened, and can also be run manually +# (workflow_dispatch) against any PR number to pick an additional reviewer. +# * Someone already requested as a reviewer, or who has already submitted a +# review, is never picked. +# * On automatic runs (including re-runs), nothing is done at all if anyone +# from the REVIEWERS pool has already reviewed or been requested (reviews +# from people outside the pool don't count). Manual runs skip this check +# and always add a reviewer if an eligible candidate remains. on: pull_request_target: types: [opened] + workflow_dispatch: + inputs: + pr: + description: "Pull request number to assign a reviewer to" + required: true enable-openid-connect: true @@ -24,28 +38,78 @@ jobs: echo "::add-mask::$jwt" echo "jwt=$jwt" >> "$FORGEJO_OUTPUT" - name: Request review from a random developer - # This never checks out or runs any PR code -- it only makes an API - # call with a local Authorized Integration token. + # This never checks out or runs any PR code -- it only makes API + # calls -- so running in the base-repo context (pull_request_target, + # which is what grants the token write access even for fork PRs) is safe. env: - API: ${{ forgejo.server_url }}/api/v1 - REPO: ${{ forgejo.event.repository.full_name }} - PR: ${{ forgejo.event.pull_request.number }} - AUTHOR: ${{ forgejo.event.pull_request.user.login }} + API: ${{ github.server_url }}/api/v1 + REPO: ${{ github.repository }} + EVENT_NAME: ${{ github.event_name }} + EVENT_PR: ${{ github.event.pull_request.number }} + INPUT_PR: ${{ github.event.inputs.pr }} # Space-separated pool of candidate reviewers. REVIEWERS: "matt val wpaulino joostjager jkczyz benthecarman tankyleo tnull" run: | set -eu AUTH="Authorization: bearer ${{ steps.jwt.outputs.jwt }}" - # Build the candidate pool, excluding the PR author. + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + PR="$INPUT_PR" + else + PR="$EVENT_PR" + fi + case "$PR" in + ''|*[!0-9]*) echo "Invalid PR number: '$PR'"; exit 1 ;; + esac + + PR_JSON="$(curl -fsS -H "$AUTH" "$API/repos/$REPO/pulls/$PR")" + AUTHOR="$(printf '%s' "$PR_JSON" | jq -r '.user.login')" + + # Everyone already on the PR: currently-requested reviewers plus + # anyone who has submitted a review. PENDING (unsubmitted draft) and + # REQUEST_REVIEW (the open-request marker rows in the reviews list) + # are not submitted reviews, so they are not counted as "reviewed". + REQUESTED="$(printf '%s' "$PR_JSON" | + jq -r '(.requested_reviewers // [])[] | .login? // empty')" + REVIEWED="$( + page=1 + while :; do + CHUNK="$(curl -fsS -H "$AUTH" "$API/repos/$REPO/pulls/$PR/reviews?limit=50&page=$page")" + printf '%s' "$CHUNK" | jq -r '.[] + | select(.state == "APPROVED" or .state == "REQUEST_CHANGES" or .state == "COMMENT") + | .user.login? // empty' + if [ "$(printf '%s' "$CHUNK" | jq 'length')" -lt 50 ]; then break; fi + page=$((page + 1)) + done + )" + # The author self-reviewing (commenting on their own PR) doesn't + # count as someone being on the PR. + INVOLVED="$(printf '%s\n%s\n' "$REQUESTED" "$REVIEWED" | + awk -v author="$AUTHOR" '$0 != "" && $0 != author' | sort -u)" + + # On automatic runs, if a pool member is already on the PR there is + # nothing to do. Manual runs go ahead and add another reviewer. + if [ "$EVENT_NAME" != "workflow_dispatch" ]; then + for d in $REVIEWERS; do + if printf '%s\n' "$INVOLVED" | grep -qxF "$d"; then + echo "$d has already reviewed or been requested on PR #$PR; nothing to do." + exit 0 + fi + done + fi + + # Build the candidate pool, excluding the PR author and anyone + # already requested or reviewing. POOL="" for d in $REVIEWERS; do - [ "$d" = "$AUTHOR" ] || POOL="$POOL $d" + if [ "$d" = "$AUTHOR" ]; then continue; fi + if printf '%s\n' "$INVOLVED" | grep -qxF "$d"; then continue; fi + POOL="$POOL $d" done REVIEWER="$(printf '%s\n' $POOL | shuf -n1)" if [ -z "$REVIEWER" ]; then - echo "No eligible reviewer (author is the only candidate); skipping." + echo "No eligible reviewer left in the pool; skipping." exit 0 fi From 055d334d133ad1a7f35f9592b6216c56c95a03d9 Mon Sep 17 00:00:00 2001 From: Matt Corallo <git@bluematt.me> Date: Fri, 3 Jul 2026 16:55:57 +0000 Subject: [PATCH 572/627] Fix assign-reviewer's auth by setting the authorized integration audience --- .forgejo/workflows/assign-reviewer.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/assign-reviewer.yml b/.forgejo/workflows/assign-reviewer.yml index 64fed5525ba..71fc81c8e6e 100644 --- a/.forgejo/workflows/assign-reviewer.yml +++ b/.forgejo/workflows/assign-reviewer.yml @@ -34,7 +34,7 @@ jobs: id: jwt run: | set -eu - jwt="$(curl -fsS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL" | jq -r '.value')" + jwt="$(curl -fsS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=u:1:bec84b56-6f08-4622-9cd6-1aee5b18c5b9" | jq -r '.value')" echo "::add-mask::$jwt" echo "jwt=$jwt" >> "$FORGEJO_OUTPUT" - name: Request review from a random developer From 19741bf2e86b20fb23e5ea5fc77271d6a4470280 Mon Sep 17 00:00:00 2001 From: GideonBature <infoaboutgideon@gmail.com> Date: Sat, 27 Jun 2026 15:33:51 +0100 Subject: [PATCH 573/627] Add upgrade test for legacy post-close monitor update persistence --- lightning/src/util/persist.rs | 91 +++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/lightning/src/util/persist.rs b/lightning/src/util/persist.rs index bf8a0cf8342..ddc285690bf 100644 --- a/lightning/src/util/persist.rs +++ b/lightning/src/util/persist.rs @@ -1855,6 +1855,7 @@ impl From<u64> for UpdateName { #[cfg(test)] mod tests { use super::*; + use crate::chain::channelmonitor::ChannelMonitorUpdateStep; use crate::chain::ChannelMonitorUpdateStatus; use crate::events::ClosureReason; use crate::ln::functional_test_utils::*; @@ -2261,6 +2262,96 @@ mod tests { .is_err()); } + // Confirm we still handle the `u64::MAX` `update_id` that pre-0.1 LDK used for post-close + // `ChannelMonitorUpdate`s, both when reading a leftover update from disk and when one is handed + // to the persister to write. + #[test] + fn legacy_closed_channel_update() { + let max_pending_updates = 7; + let chanmon_cfgs = create_chanmon_cfgs(2); + let kv_store = TestStore::new(false); + let persister = MonitorUpdatingPersister::new( + &kv_store, + &chanmon_cfgs[0].logger, + max_pending_updates, + &chanmon_cfgs[0].keys_manager, + &chanmon_cfgs[0].keys_manager, + &chanmon_cfgs[0].tx_broadcaster, + &chanmon_cfgs[0].fee_estimator, + ); + let mut node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let chain_mon_0 = test_utils::TestChainMonitor::new( + Some(&chanmon_cfgs[0].chain_source), + &chanmon_cfgs[0].tx_broadcaster, + &chanmon_cfgs[0].logger, + &chanmon_cfgs[0].fee_estimator, + &persister, + &chanmon_cfgs[0].keys_manager, + ); + node_cfgs[0].chain_monitor = chain_mon_0; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let _ = create_announced_chan_between_nodes(&nodes, 0, 1); + send_payment(&nodes[0], &vec![&nodes[1]][..], 8_000_000); + send_payment(&nodes[1], &vec![&nodes[0]][..], 4_000_000); + + let persisted_chan_data = persister.read_all_channel_monitors_with_updates().unwrap(); + assert_eq!(persisted_chan_data.len(), 1); + let (_, monitor) = &persisted_chan_data[0]; + let monitor_name = monitor.persistence_key(); + let monitor_key = monitor_name.to_string(); + assert_ne!(monitor.get_latest_update_id(), u64::MAX); + + let legacy_update = ChannelMonitorUpdate { + update_id: u64::MAX, + updates: vec![ChannelMonitorUpdateStep::ChannelForceClosed { should_broadcast: true }], + channel_id: Some(monitor.channel_id()), + }; + + // Store the update as a standalone file, as a pre-0.1 persister would have, and check that + // reading the monitor back replays it. + KVStoreSync::write( + &kv_store, + CHANNEL_MONITOR_UPDATE_PERSISTENCE_PRIMARY_NAMESPACE, + &monitor_key, + UpdateName::from(u64::MAX).as_str(), + legacy_update.encode(), + ) + .unwrap(); + + let persisted_chan_data = persister.read_all_channel_monitors_with_updates().unwrap(); + assert_eq!(persisted_chan_data.len(), 1); + let (_, closed_monitor) = &persisted_chan_data[0]; + assert_eq!(closed_monitor.get_latest_update_id(), u64::MAX); + + let update_list = KVStoreSync::list( + &kv_store, + CHANNEL_MONITOR_UPDATE_PERSISTENCE_PRIMARY_NAMESPACE, + &monitor_key, + ) + .unwrap(); + assert!(!update_list.is_empty()); + + // Writing a sentinel-id update should do a full monitor write rather than a standalone + // update file, and purge all stale update files. + let status = + persister.update_persisted_channel(monitor_name, Some(&legacy_update), closed_monitor); + assert_eq!(status, ChannelMonitorUpdateStatus::Completed); + + let update_list = KVStoreSync::list( + &kv_store, + CHANNEL_MONITOR_UPDATE_PERSISTENCE_PRIMARY_NAMESPACE, + &monitor_key, + ) + .unwrap(); + assert!(update_list.is_empty()); + + let persisted_chan_data = persister.read_all_channel_monitors_with_updates().unwrap(); + assert_eq!(persisted_chan_data.len(), 1); + assert_eq!(persisted_chan_data[0].1.get_latest_update_id(), u64::MAX); + } + fn persist_fn<P: Deref, ChannelSigner: EcdsaChannelSigner>(_persist: P) -> bool where P::Target: Persist<ChannelSigner>, From b9f55b6c447e53e0a96af70f253cc182676be00a Mon Sep 17 00:00:00 2001 From: Matt Corallo <git+claude@bluematt.me> Date: Sun, 5 Jul 2026 17:06:03 +0000 Subject: [PATCH 574/627] Upload new fuzz corpus entries as a short-lived CI artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fork-PR runs get no credentials from Forgejo — neither secrets nor authorized-integration identity tokens — so the fuzz job cannot push new corpus entries to the corpus repo from CI. Instead, clone the corpus from this Forgejo instance (rather than the GitHub copy, so new entries are detected against the repo they will land in), stage the new entries plus any SIG* crashes like the GitHub workflow does, and upload them as an `hfuzz-corpus` artifact with a two-day retention. The ldk-fuzzing-corpus repo's nightly job sweeps these artifacts into a corpus pull request and deletes them once processed. Unlike the GitHub workflow's version, the crash-staging loop here uses the `rust-lightning/<target>` prefix the corpus entries are actually staged under (upstream checks the wrong path, so no crash file is ever picked up there), and it stages crashes for targets that produced no new corpus entries rather than only creating the target directory as a side effect of staging corpus files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .forgejo/workflows/build.yml | 97 +++++++++++++++++++----------------- 1 file changed, 52 insertions(+), 45 deletions(-) diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 1278a385f8f..2a0d9177599 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -273,7 +273,10 @@ jobs: run: | rustup default 1.75 - name: Clone fuzzing corpus - run: git clone --depth=1 https://github.com/lightningdevkit/ldk-fuzzing-corpus.git fuzz/ldk-fuzzing-corpus + # Clone from this Forgejo instance (rather than the GitHub copy) so + # that new entries are detected against the repository the corpus + # sweep will open its pull requests on. + run: git clone --depth=1 ${{ github.server_url }}/lightningdevkit/ldk-fuzzing-corpus.git fuzz/ldk-fuzzing-corpus - name: Symlink corpus into hfuzz_workspace run: | set -eu @@ -288,55 +291,59 @@ jobs: run: cd fuzz && ./ci-fuzz.sh && cd .. env: FUZZ_MINIMIZE: ${{ contains(github.event.pull_request.labels.*.name, 'fuzz-minimize') }} - - name: Open PR with new corpus entries - # Forgejo supports neither the `workflow_run` trigger nor reading - # artifacts from another workflow run, so the corpus push that used to - # live in its own workflow is folded in here. New fuzzer inputs are - # written straight into the corpus checkout (the input dirs are - # symlinked into it above), so they show up as untracked files. - # - # The push still targets the GitHub corpus repo. On Forgejo, secrets - # are empty for `pull_request` events from forks, so CORPUS_PUSH_TOKEN - # is unset there and this step safely skips the push. + - name: Stage new corpus entries for upload + # New fuzzer inputs are written straight into the corpus checkout (the + # input dirs are symlinked into it above), so they show up as + # untracked files there. # - # A push hiccup must not fail the fuzz job (and cascade to the jobs that - # depend on it), so this step is best-effort. + # This run can't contribute them to the corpus repo itself: it mostly + # runs for pull requests from forks, and Forgejo withholds all + # credentials (secrets and identity tokens alike) from fork-PR runs. + # Instead the new entries are uploaded as a short-lived artifact + # below, which the corpus repo's nightly job sweeps into a pull + # request. if: success() || failure() - continue-on-error: true - env: - GH_TOKEN: ${{ secrets.CORPUS_PUSH_TOKEN }} - SOURCE_SHA: ${{ github.sha }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }} - RUN_ID: ${{ github.run_id }} run: | set -eu + WORKSPACE="$(pwd)" + rm -rf "$WORKSPACE/new-corpus" + mkdir -p "$WORKSPACE/new-corpus" + cd fuzz/ldk-fuzzing-corpus - if [ -z "$(git status --porcelain)" ]; then - echo "No new corpus entries to contribute." - exit 0 - fi - if [ -z "${GH_TOKEN:-}" ]; then - echo "Found new corpus entries but CORPUS_PUSH_TOKEN is unset; skipping PR." - git status --short - exit 0 - fi - BRANCH="ci/new-corpus-${RUN_ID}" - git config user.email "ldk-ci@users.noreply.github.com" - git config user.name "LDK CI" - git checkout -b "$BRANCH" - git add rust-lightning - git commit \ - -m "Add corpus entries from rust-lightning CI" \ - -m "Source commit: ${SOURCE_SHA}" \ - -m "Run: ${RUN_URL}" - REMOTE=$(git config --get remote.origin.url) - PUSH_URL="https://x-access-token:${GH_TOKEN}@${REMOTE#https://}" - git push "$PUSH_URL" "HEAD:$BRANCH" - gh pr create \ - --title "New corpus entries from rust-lightning CI run ${RUN_ID}" \ - --body "Discovered while running fuzz CI against \`${SOURCE_SHA}\`. Source: ${RUN_URL}" \ - --head "$BRANCH" \ - --base master + while IFS= read -r F; do + mkdir -p "$WORKSPACE/new-corpus/$(dirname "$F")" + cp -a "$F" "$WORKSPACE/new-corpus/$F" + done < <(git ls-files --others --exclude-standard rust-lightning/) + cd "$WORKSPACE" + + for D in fuzz/hfuzz_workspace/*_target/; do + [ -d "$D" ] || continue + BASE=$(basename "$D") + NAME="${BASE%_target}" + for F in "$D"/SIG*; do + [ -f "$F" ] || continue + FILE="$(basename "$F")" + [ -f "$WORKSPACE/new-corpus/rust-lightning/$NAME/$FILE" ] && continue + mkdir -p "$WORKSPACE/new-corpus/rust-lightning/$NAME" + cp "$F" "$WORKSPACE/new-corpus/rust-lightning/$NAME/$FILE" + done + done + + NEW=$(find new-corpus -type f 2>/dev/null | wc -l) + echo "Staged $NEW new corpus entries (including any SIG* crashes)" + - name: Upload new corpus entries + if: success() || failure() + # The forgejo/ fork, not the actions/ mirror: upstream's @actions/artifact + # client refuses to talk to any server that isn't github.com. + uses: https://data.forgejo.org/forgejo/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245 # v5 + with: + name: hfuzz-corpus + path: new-corpus + compression-level: 0 + if-no-files-found: ignore + # The nightly sweep deletes artifacts it has processed; the + # retention only has to bridge a missed nightly run. + retention-days: 2 linting: runs-on: debian-trixie From 2bd9266a37eeaf0216170346d412265d676775eb Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo <vincenzopalazzodev@gmail.com> Date: Tue, 24 Feb 2026 18:43:09 +0100 Subject: [PATCH 575/627] refactor(offers): extract payer key derivation helpers Move the invoice/refund payer key derivation logic into reusable helpers so payer proofs can derive the same signing keys without duplicating the metadata and signer flow. --- lightning/src/offers/invoice.rs | 79 ++++++++++++++++++++++++++------- lightning/src/offers/signer.rs | 62 +++++++++++++++++++++++--- 2 files changed, 120 insertions(+), 21 deletions(-) diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index 2a42d0f4e96..cf0aa22d5a8 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -131,7 +131,8 @@ use crate::offers::invoice_request::{ IV_BYTES as INVOICE_REQUEST_IV_BYTES, }; use crate::offers::merkle::{ - self, SignError, SignFn, SignatureTlvStream, SignatureTlvStreamRef, TaggedHash, TlvStream, + self, SignError, SignFn, SignatureTlvStream, SignatureTlvStreamRef, TaggedHash, TlvRecord, + TlvStream, }; use crate::offers::offer::{ Amount, ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, OfferId, OfferTlvStream, @@ -1018,6 +1019,34 @@ impl Bolt12Invoice { self.contents.verify(&self.bytes, metadata, key, iv_bytes, secp_ctx) } + /// Re-derives the payer's signing keypair for payer proof creation. + /// + /// This performs the same key derivation that occurs during invoice request creation + /// with `deriving_signing_pubkey`, allowing the payer to recover their signing keypair. + /// + /// The keypair is derived from the invoice's own payer metadata (which embeds the payer + /// [`Nonce`]), so no externally-held nonce or payment id is required. In the common + /// proof-of-payment flow, callers can use `PaidBolt12Invoice::prove_payer_derived`. + /// + /// [`Nonce`]: crate::offers::nonce::Nonce + pub fn derive_payer_signing_keys<T: secp256k1::Signing>( + &self, key: &ExpandedKey, secp_ctx: &Secp256k1<T>, + ) -> Result<Keypair, ()> { + // Mirror `verify_using_metadata`'s IV selection so the derived HMAC matches the one + // committed to in the payer metadata. + let iv_bytes = match &self.contents { + InvoiceContents::ForOffer { .. } => INVOICE_REQUEST_IV_BYTES, + InvoiceContents::ForRefund { refund, .. } => { + if refund.paths().is_empty() { + REFUND_IV_BYTES_WITH_METADATA + } else { + REFUND_IV_BYTES_WITHOUT_METADATA + } + }, + }; + self.contents.derive_payer_signing_keys(&self.bytes, key, iv_bytes, secp_ctx) + } + pub(crate) fn as_tlv_stream(&self) -> FullInvoiceTlvStreamRef<'_> { let ( payer_tlv_stream, @@ -1303,20 +1332,8 @@ impl InvoiceContents { &self, bytes: &[u8], metadata: &Metadata, key: &ExpandedKey, iv_bytes: &[u8; IV_LEN], secp_ctx: &Secp256k1<T>, ) -> Result<PaymentId, ()> { - const EXPERIMENTAL_TYPES: core::ops::Range<u64> = - EXPERIMENTAL_OFFER_TYPES.start..EXPERIMENTAL_INVOICE_REQUEST_TYPES.end; - - let offer_records = TlvStream::new(bytes).range(OFFER_TYPES); - let invreq_records = TlvStream::new(bytes).range(INVOICE_REQUEST_TYPES).filter(|record| { - match record.r#type { - PAYER_METADATA_TYPE => false, // Should be outside range - INVOICE_REQUEST_PAYER_ID_TYPE => !metadata.derives_payer_keys(), - _ => true, - } - }); - let experimental_records = TlvStream::new(bytes).range(EXPERIMENTAL_TYPES); - let tlv_stream = offer_records.chain(invreq_records).chain(experimental_records); - + let exclude_payer_id = metadata.derives_payer_keys(); + let tlv_stream = Self::payer_tlv_stream(bytes, exclude_payer_id); let signing_pubkey = self.payer_signing_pubkey(); signer::verify_payer_metadata( metadata.as_ref(), @@ -1328,6 +1345,38 @@ impl InvoiceContents { ) } + fn derive_payer_signing_keys<T: secp256k1::Signing>( + &self, bytes: &[u8], key: &ExpandedKey, iv_bytes: &[u8; IV_LEN], secp_ctx: &Secp256k1<T>, + ) -> Result<Keypair, ()> { + let metadata = self.payer_metadata(); + let tlv_stream = Self::payer_tlv_stream(bytes, true); + let signing_pubkey = self.payer_signing_pubkey(); + signer::derive_payer_keys(metadata, key, iv_bytes, signing_pubkey, tlv_stream, secp_ctx) + } + + /// Builds the TLV stream used for payer metadata verification and key derivation. + /// + /// When `exclude_payer_id` is true, the payer signing pubkey (type 88) is excluded + /// from the stream, which is needed when deriving payer keys. + fn payer_tlv_stream( + bytes: &[u8], exclude_payer_id: bool, + ) -> impl core::iter::Iterator<Item = TlvRecord<'_>> { + const EXPERIMENTAL_TYPES: core::ops::Range<u64> = + EXPERIMENTAL_OFFER_TYPES.start..EXPERIMENTAL_INVOICE_REQUEST_TYPES.end; + + let offer_records = TlvStream::new(bytes).range(OFFER_TYPES); + let invreq_records = + TlvStream::new(bytes).range(INVOICE_REQUEST_TYPES).filter(move |record| { + match record.r#type { + PAYER_METADATA_TYPE => false, + INVOICE_REQUEST_PAYER_ID_TYPE => !exclude_payer_id, + _ => true, + } + }); + let experimental_records = TlvStream::new(bytes).range(EXPERIMENTAL_TYPES); + offer_records.chain(invreq_records).chain(experimental_records) + } + fn as_tlv_stream(&self) -> PartialInvoiceTlvStreamRef<'_> { let (payer, offer, invoice_request, experimental_offer, experimental_invoice_request) = match self { diff --git a/lightning/src/offers/signer.rs b/lightning/src/offers/signer.rs index 43d1370238a..5f5f12a03f7 100644 --- a/lightning/src/offers/signer.rs +++ b/lightning/src/offers/signer.rs @@ -290,6 +290,33 @@ pub(super) fn derive_keys(nonce: Nonce, expanded_key: &ExpandedKey) -> Keypair { Keypair::from_secret_key(&secp_ctx, &privkey) } +/// Re-derives the payer signing keypair from the on-wire payer `metadata`. +/// +/// Performs the same derivation as keys created by [`Metadata::derive_from`] when using +/// [`Metadata::DerivedSigningPubkey`] with a [`MetadataMaterial`] built from a `payment_id`. +/// The `metadata` is the payer metadata as it appears on the wire (the encrypted payment id +/// followed by the [`Nonce`]); the nonce no longer needs to be supplied separately. +/// +/// The `tlv_stream` must contain the records matching what was used during the original +/// key derivation. +pub(super) fn derive_payer_keys<'a, T: secp256k1::Signing>( + metadata: &[u8], expanded_key: &ExpandedKey, iv_bytes: &[u8; IV_LEN], + signing_pubkey: PublicKey, tlv_stream: impl core::iter::Iterator<Item = TlvRecord<'a>>, + secp_ctx: &Secp256k1<T>, +) -> Result<Keypair, ()> { + match verify_payer_metadata_inner( + metadata, + expanded_key, + iv_bytes, + signing_pubkey, + tlv_stream, + secp_ctx, + )? { + Some(keys) => Ok(keys), + None => Err(()), + } +} + /// Verifies data given in a TLV stream was used to produce the given metadata, consisting of: /// - a 256-bit [`PaymentId`], /// - a 128-bit [`Nonce`], and possibly @@ -304,6 +331,34 @@ pub(super) fn verify_payer_metadata<'a, T: secp256k1::Signing>( signing_pubkey: PublicKey, tlv_stream: impl core::iter::Iterator<Item = TlvRecord<'a>>, secp_ctx: &Secp256k1<T>, ) -> Result<PaymentId, ()> { + verify_payer_metadata_inner( + metadata, + expanded_key, + iv_bytes, + signing_pubkey, + tlv_stream, + secp_ctx, + )?; + + let mut encrypted_payment_id = [0u8; PaymentId::LENGTH]; + encrypted_payment_id.copy_from_slice(&metadata[..PaymentId::LENGTH]); + let nonce = Nonce::try_from(&metadata[PaymentId::LENGTH..][..Nonce::LENGTH]).unwrap(); + let payment_id = expanded_key.crypt_for_offer(encrypted_payment_id, nonce); + + Ok(PaymentId(payment_id)) +} + +/// Shared core of [`verify_payer_metadata`] and [`derive_payer_keys`]. +/// +/// Builds the payer HMAC from the given metadata and TLV stream, then verifies it against the +/// `signing_pubkey`. The `metadata` must be at least `PaymentId::LENGTH` bytes, with the first +/// `PaymentId::LENGTH` bytes being the encrypted payment ID and the remainder being the nonce +/// (and possibly an HMAC). +fn verify_payer_metadata_inner<'a, T: secp256k1::Signing>( + metadata: &[u8], expanded_key: &ExpandedKey, iv_bytes: &[u8; IV_LEN], + signing_pubkey: PublicKey, tlv_stream: impl core::iter::Iterator<Item = TlvRecord<'a>>, + secp_ctx: &Secp256k1<T>, +) -> Result<Option<Keypair>, ()> { if metadata.len() < PaymentId::LENGTH { return Err(()); } @@ -321,12 +376,7 @@ pub(super) fn verify_payer_metadata<'a, T: secp256k1::Signing>( Hmac::from_engine(hmac), signing_pubkey, secp_ctx, - )?; - - let nonce = Nonce::try_from(&metadata[PaymentId::LENGTH..][..Nonce::LENGTH]).unwrap(); - let payment_id = expanded_key.crypt_for_offer(encrypted_payment_id, nonce); - - Ok(PaymentId(payment_id)) + ) } /// Verifies data given in a TLV stream was used to produce the given metadata, consisting of: From da6e0bb5b38da10582167578a7d77fe2bf4fc29a Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo <vincenzopalazzodev@gmail.com> Date: Mon, 15 Jun 2026 23:31:23 +0200 Subject: [PATCH 576/627] offers: add merkle selective-disclosure primitives Extend the BOLT 12 merkle module with selective-disclosure support: build the full merkle tree from a TLV stream, compute the omitted-TLV markers and the minimal set of missing hashes for omitted subtrees, and reconstruct the merkle root from a partial disclosure. These are the primitives a payer proof is built on. Co-Authored-By: Rusty Russell <rusty@rustcorp.com.au> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-Authored-By: OpenAI Codex <codex@openai.com> --- lightning/src/offers/merkle.rs | 106 ++- lightning/src/offers/mod.rs | 1 + lightning/src/offers/selective_disclosure.rs | 763 +++++++++++++++++++ 3 files changed, 852 insertions(+), 18 deletions(-) create mode 100644 lightning/src/offers/selective_disclosure.rs diff --git a/lightning/src/offers/merkle.rs b/lightning/src/offers/merkle.rs index 1a38fe5441f..9953f3a3b46 100644 --- a/lightning/src/offers/merkle.rs +++ b/lightning/src/offers/merkle.rs @@ -49,13 +49,10 @@ impl TaggedHash { /// Creates a tagged hash with the given parameters. /// /// Panics if `tlv_stream` is not a well-formed TLV stream containing at least one TLV record. - pub(super) fn from_tlv_stream<'a, I: core::iter::Iterator<Item = TlvRecord<'a>>>( + pub(super) fn from_tlv_stream<'a, I: core::iter::Iterator<Item = TlvRecord<'a>> + 'a>( tag: &'static str, tlv_stream: I, ) -> Self { - let tag_hash = sha256::Hash::hash(tag.as_bytes()); - let merkle_root = root_hash(tlv_stream); - let digest = Message::from_digest(tagged_hash(tag_hash, merkle_root).to_byte_array()); - Self { tag, merkle_root, digest } + Self::from_merkle_root(tag, root_hash(tlv_stream)) } /// Returns the digest to sign. @@ -73,6 +70,13 @@ impl TaggedHash { self.merkle_root } + /// Creates a tagged hash from a pre-computed merkle root. + pub(super) fn from_merkle_root(tag: &'static str, merkle_root: sha256::Hash) -> Self { + let tag_hash = sha256::Hash::hash(tag.as_bytes()); + let digest = Message::from_digest(tagged_hash(tag_hash, merkle_root).to_byte_array()); + Self { tag, merkle_root, digest } + } + pub(super) fn to_bytes(&self) -> [u8; 32] { *self.digest.as_ref() } @@ -146,9 +150,23 @@ pub fn verify_signature( secp_ctx.verify_schnorr(signature, digest, &pubkey) } -/// Computes a merkle root hash for the given data, which must be a well-formed TLV stream -/// containing at least one TLV record. -fn root_hash<'a, I: core::iter::Iterator<Item = TlvRecord<'a>>>(tlv_stream: I) -> sha256::Hash { +/// Per-TLV merkle hashes shared by [`root_hash`] and the selective-disclosure code. +/// Keeping this in one place ensures the signed invoice root and the payer-proof +/// reconstruction hash identical inputs the exact same way. +pub(super) struct TlvHashData { + pub(super) tlv_type: u64, + pub(super) nonce_hash: sha256::Hash, + pub(super) per_tlv_hash: sha256::Hash, +} + +/// Computes the per-TLV branch hashes for every non-signature record in `tlv_stream`. Returns the +/// iterator plus the shared `LnBranch` tag engine used to combine hashes into the root. +pub(super) fn merkle_tlv_data<'a, I>( + tlv_stream: I, +) -> (impl Iterator<Item = TlvHashData> + 'a, sha256::HashEngine) +where + I: core::iter::Iterator<Item = TlvRecord<'a>> + 'a, +{ let mut tlv_stream = tlv_stream.peekable(); let nonce_tag = tagged_hash_engine(sha256::Hash::from_engine({ let first_tlv_record = tlv_stream.peek().unwrap(); @@ -159,12 +177,29 @@ fn root_hash<'a, I: core::iter::Iterator<Item = TlvRecord<'a>>>(tlv_stream: I) - })); let leaf_tag = tagged_hash_engine(sha256::Hash::hash("LnLeaf".as_bytes())); let branch_tag = tagged_hash_engine(sha256::Hash::hash("LnBranch".as_bytes())); + let iter_branch_tag = branch_tag.clone(); - let mut leaves = Vec::new(); - for record in tlv_stream.filter(|record| !SIGNATURE_TYPES.contains(&record.r#type)) { - leaves.push(tagged_hash_from_engine(leaf_tag.clone(), &record.record_bytes)); - leaves.push(tagged_hash_from_engine(nonce_tag.clone(), &record.type_bytes)); - } + let tlv_data = + tlv_stream.filter(|record| !SIGNATURE_TYPES.contains(&record.r#type)).map(move |record| { + let leaf_hash = tagged_hash_from_engine(leaf_tag.clone(), record.record_bytes); + let nonce_hash = tagged_hash_from_engine(nonce_tag.clone(), record.type_bytes); + let per_tlv_hash = + tagged_branch_hash_from_engine(iter_branch_tag.clone(), leaf_hash, nonce_hash); + + TlvHashData { tlv_type: record.r#type, nonce_hash, per_tlv_hash } + }); + + (tlv_data, branch_tag) +} + +/// Computes a merkle root hash for the given data, which must be a well-formed TLV stream +/// containing at least one TLV record. +fn root_hash<'a, I: core::iter::Iterator<Item = TlvRecord<'a>> + 'a>( + tlv_stream: I, +) -> sha256::Hash { + let (tlv_data, branch_tag) = merkle_tlv_data(tlv_stream); + let mut leaves: Vec<sha256::Hash> = tlv_data.map(|data| data.per_tlv_hash).collect(); + assert!(!leaves.is_empty(), "TLV stream must contain at least one non-signature record"); // Calculate the merkle root hash in place. let num_leaves = leaves.len(); @@ -190,19 +225,21 @@ fn tagged_hash<T: AsRef<[u8]>>(tag: sha256::Hash, msg: T) -> sha256::Hash { tagged_hash_from_engine(engine, msg) } -fn tagged_hash_engine(tag: sha256::Hash) -> sha256::HashEngine { +pub(super) fn tagged_hash_engine(tag: sha256::Hash) -> sha256::HashEngine { let mut engine = sha256::Hash::engine(); engine.input(tag.as_ref()); engine.input(tag.as_ref()); engine } -fn tagged_hash_from_engine<T: AsRef<[u8]>>(mut engine: sha256::HashEngine, msg: T) -> sha256::Hash { +pub(super) fn tagged_hash_from_engine<T: AsRef<[u8]>>( + mut engine: sha256::HashEngine, msg: T, +) -> sha256::Hash { engine.input(msg.as_ref()); sha256::Hash::from_engine(engine) } -fn tagged_branch_hash_from_engine( +pub(super) fn tagged_branch_hash_from_engine( mut engine: sha256::HashEngine, leaf1: sha256::Hash, leaf2: sha256::Hash, ) -> sha256::Hash { if leaf1 < leaf2 { @@ -243,9 +280,23 @@ pub(super) struct TlvRecord<'a> { type_bytes: &'a [u8], // The entire TLV record. pub(super) record_bytes: &'a [u8], + // The value portion of the TLV record (after type and length). + pub(super) value_bytes: &'a [u8], pub(super) end: usize, } +impl<'a> TlvRecord<'a> { + /// Read a value from this TLV record's value bytes using [`Readable`]. + pub(super) fn read_value<T: Readable>(&self) -> Result<T, crate::ln::msgs::DecodeError> { + let mut value_bytes = self.value_bytes; + let value = Readable::read(&mut value_bytes)?; + if !value_bytes.is_empty() { + return Err(crate::ln::msgs::DecodeError::InvalidValue); + } + Ok(value) + } +} + impl<'a> Iterator for TlvStream<'a> { type Item = TlvRecord<'a>; @@ -261,12 +312,12 @@ impl<'a> Iterator for TlvStream<'a> { let offset = self.data.position(); let end = offset + length; - let _value = &self.data.get_ref()[offset as usize..end as usize]; let record_bytes = &self.data.get_ref()[start as usize..end as usize]; + let value_bytes = &self.data.get_ref()[offset as usize..end as usize]; self.data.set_position(end); - Some(TlvRecord { r#type, type_bytes, record_bytes, end: end as usize }) + Some(TlvRecord { r#type, type_bytes, record_bytes, value_bytes, end: end as usize }) } else { None } @@ -497,4 +548,23 @@ mod tests { self.fmt_bech32_str(f) } } + + #[test] + fn test_tlv_record_read_value_rejects_trailing_bytes() { + use bitcoin::secp256k1::PublicKey; + + use crate::offers::test_utils::payer_pubkey; + use crate::util::ser::{BigSize, Writeable}; + + let pubkey = payer_pubkey(); + let mut tlv_bytes = Vec::new(); + BigSize(88).write(&mut tlv_bytes).unwrap(); + BigSize(35).write(&mut tlv_bytes).unwrap(); + pubkey.write(&mut tlv_bytes).unwrap(); + tlv_bytes.extend_from_slice(&[0x00, 0x01]); + + let record = TlvStream::new(&tlv_bytes).next().unwrap(); + let result: Result<PublicKey, _> = record.read_value(); + assert!(matches!(result, Err(crate::ln::msgs::DecodeError::InvalidValue))); + } } diff --git a/lightning/src/offers/mod.rs b/lightning/src/offers/mod.rs index 5b5cf6cdc78..c270a307f8e 100644 --- a/lightning/src/offers/mod.rs +++ b/lightning/src/offers/mod.rs @@ -26,6 +26,7 @@ pub mod nonce; pub mod parse; mod payer; pub mod refund; +pub mod selective_disclosure; pub(crate) mod signer; pub mod static_invoice; #[cfg(test)] diff --git a/lightning/src/offers/selective_disclosure.rs b/lightning/src/offers/selective_disclosure.rs new file mode 100644 index 00000000000..35975cf74b5 --- /dev/null +++ b/lightning/src/offers/selective_disclosure.rs @@ -0,0 +1,763 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE +// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license +// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Selective disclosure support for BOLT 12 payer proofs. + +use alloc::collections::BTreeSet; + +use bitcoin::hashes::{sha256, Hash}; + +use crate::offers::invoice::INVOICE_TYPES; +use crate::offers::merkle::{ + merkle_tlv_data, tagged_branch_hash_from_engine, tagged_hash_engine, tagged_hash_from_engine, + TlvHashData, TlvRecord, +}; +use crate::offers::offer::EXPERIMENTAL_OFFER_TYPES; +use crate::offers::payer::PAYER_METADATA_TYPE; + +#[allow(unused_imports)] +use crate::prelude::*; + +/// Error during selective disclosure operations. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SelectiveDisclosureError { + /// The omitted markers are not in strict ascending order. + InvalidOmittedMarkersOrder, + /// The omitted markers contain an invalid marker (0 or signature type). + InvalidOmittedMarker, + /// The nonce_hashes count doesn't match included TLVs. + LeafHashCountMismatch, + /// Insufficient missing_hashes to reconstruct the tree. + InsufficientMissingHashes, +} + +/// Data needed to reconstruct a merkle root with selective disclosure. +/// +/// This is used in payer proofs to allow verification of an invoice signature +/// without revealing all invoice fields. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct SelectiveDisclosure { + /// Nonce hashes for included TLVs (in TLV type order). + pub(super) nonce_hashes: Vec<sha256::Hash>, + /// Marker numbers for omitted TLVs (excluding implicit TLV0). + pub(super) omitted_markers: Vec<u64>, + /// Minimal merkle hashes for omitted subtrees. + pub(super) missing_hashes: Vec<sha256::Hash>, + /// The complete merkle root. + pub(super) merkle_root: sha256::Hash, +} + +/// Compute selective disclosure data from a TLV stream. +/// +/// This builds the full merkle tree and extracts the data needed for a payer proof: +/// - `nonce_hashes`: nonce hashes for included TLVs +/// - `omitted_markers`: marker numbers for omitted TLVs +/// - `missing_hashes`: minimal merkle hashes for omitted subtrees +/// +/// # Arguments +/// * `records` - Iterator of [`TlvRecord`]s from the invoice +/// * `included_types` - Set of TLV types to include in the disclosure +pub(super) fn compute_selective_disclosure<'a>( + records: impl Iterator<Item = TlvRecord<'a>> + 'a, included_types: &'a BTreeSet<u64>, +) -> SelectiveDisclosure { + debug_assert!(!included_types.contains(&PAYER_METADATA_TYPE)); + let (tlv_data, branch_tag) = merkle_tlv_data(records); + let tlv_data: Vec<TlvHashData> = tlv_data.collect(); + assert!(!tlv_data.is_empty(), "TLV stream must contain at least one non-signature record"); + + let omitted_markers: Vec<u64> = + compute_omitted_markers(tlv_data.iter(), included_types).collect(); + let nonce_hashes = tlv_data + .iter() + .filter(|data| included_types.contains(&data.tlv_type)) + .map(|data| data.nonce_hash) + .collect(); + let (merkle_root, missing_hashes) = + build_tree_with_disclosure(&tlv_data, included_types, &branch_tag); + + SelectiveDisclosure { nonce_hashes, omitted_markers, missing_hashes, merkle_root } +} + +/// Returns the marker number that follows `prev` (an included TLV type or a +/// previous marker) per BOLT 12 PR 1295. +/// +/// A marker is one greater than the previous value, except that a value landing +/// in the gap between the invoice TLV range and the experimental range (the +/// signature/payer-proof range) jumps to the start of the experimental range. +/// The producer and the readers all go through this so their marker sequences +/// stay in agreement. +pub(super) fn next_marker(prev: u64) -> u64 { + let next = prev.saturating_add(1); + if (INVOICE_TYPES.end..EXPERIMENTAL_OFFER_TYPES.start).contains(&next) { + EXPERIMENTAL_OFFER_TYPES.start + } else { + next + } +} + +/// Compute omitted markers per BOLT 12 payer proof spec. +/// +/// Each omitted TLV gets the marker number following the previous included TLV +/// type or the previous marker (see [`next_marker`]). TLV type 0 is implicitly +/// omitted (never assigned a marker). +fn compute_omitted_markers<'a>( + tlv_data: impl Iterator<Item = &'a TlvHashData> + 'a, included_types: &'a BTreeSet<u64>, +) -> impl Iterator<Item = u64> + 'a { + tlv_data + // TLV 0 participates in the merkle tree but is implicitly omitted per BOLT 1295 and never + // produces an omitted marker. The scan below starts at `PAYER_METADATA_TYPE` for that reason. + .filter(|data| data.tlv_type != PAYER_METADATA_TYPE) + .scan(PAYER_METADATA_TYPE, |prev_value, data| { + if included_types.contains(&data.tlv_type) { + *prev_value = data.tlv_type; + Some(None) + } else { + let marker = next_marker(*prev_value); + *prev_value = marker; + Some(Some(marker)) + } + }) + .flatten() +} + +/// Build merkle tree recursively (DFS, left-to-right) and collect missing_hashes. +/// +/// Per the spec, missing_hashes are in depth-first left-to-right order. +/// +/// Note: a level-by-level approach (as used by `root_hash()`) cannot produce +/// DFS-ordered missing_hashes because it processes all subtrees at each depth +/// simultaneously rather than completing each subtree before the next. +fn build_tree_with_disclosure( + tlv_data: &[TlvHashData], included_types: &BTreeSet<u64>, branch_tag: &sha256::HashEngine, +) -> (sha256::Hash, Vec<sha256::Hash>) { + let mut missing_hashes = Vec::new(); + let (root, _) = build_tree_dfs(tlv_data, included_types, branch_tag, &mut missing_hashes); + (root, missing_hashes) +} + +fn build_tree_dfs( + tlv_data: &[TlvHashData], included_types: &BTreeSet<u64>, branch_tag: &sha256::HashEngine, + missing_hashes: &mut Vec<sha256::Hash>, +) -> (sha256::Hash, bool) { + if tlv_data.len() == 1 { + return (tlv_data[0].per_tlv_hash, included_types.contains(&tlv_data[0].tlv_type)); + } + + let mid = tlv_data.len().next_power_of_two() / 2; + let (left_data, right_data) = tlv_data.split_at(mid); + let (left_hash, left_incl) = + build_tree_dfs(left_data, included_types, branch_tag, missing_hashes); + let (right_hash, right_incl) = + build_tree_dfs(right_data, included_types, branch_tag, missing_hashes); + + if left_incl && !right_incl { + missing_hashes.push(right_hash); + } else if !left_incl && right_incl { + missing_hashes.push(left_hash); + } + + let combined = tagged_branch_hash_from_engine(branch_tag.clone(), left_hash, right_hash); + (combined, left_incl || right_incl) +} + +/// Decodes the per-position inclusion map (`true` = included, `false` = omitted) from included +/// TLV types and omitted markers, with the implicit omitted TLV0 at the front. +fn decode_positions( + included_types: impl ExactSizeIterator<Item = u64>, omitted_markers: &[u64], +) -> Vec<bool> { + let mut positions = Vec::with_capacity(1 + included_types.len() + omitted_markers.len()); + positions.push(false); // TLV0 is always omitted. + + let mut included = included_types.peekable(); + let mut markers = omitted_markers.iter().copied().peekable(); + let mut prev_marker = PAYER_METADATA_TYPE; + + loop { + match (included.peek().copied(), markers.peek().copied()) { + (None, None) => break, + // No more markers: every remaining position is included. + (Some(_), None) => { + included.next(); + positions.push(true); + }, + // No more included types: every remaining position is omitted. + (None, Some(marker)) => { + markers.next(); + prev_marker = marker; + positions.push(false); + }, + // Continuation of the current run -> omitted position. + (Some(_), Some(marker)) if marker == next_marker(prev_marker) => { + markers.next(); + prev_marker = marker; + positions.push(false); + }, + // Jump -> an included TLV sits here; the marker is reprocessed next iteration. + (Some(inc_type), Some(_)) => { + included.next(); + prev_marker = inc_type; + positions.push(true); + }, + } + } + + positions +} + +/// Reconstruct merkle root from selective disclosure data. +/// +/// `missing_hashes` must be in DFS (left-to-right recursive traversal) order, +/// matching the order produced by [`build_tree_with_disclosure`]. +pub(super) fn reconstruct_merkle_root( + included_records: &[TlvRecord<'_>], nonce_hashes: &[sha256::Hash], omitted_markers: &[u64], + missing_hashes: &[sha256::Hash], +) -> Result<sha256::Hash, SelectiveDisclosureError> { + debug_assert!({ + let included_types: BTreeSet<u64> = included_records.iter().map(|r| r.r#type).collect(); + validate_omitted_markers(omitted_markers, &included_types).is_ok() + }); + + if included_records.len() != nonce_hashes.len() { + return Err(SelectiveDisclosureError::LeafHashCountMismatch); + } + + let leaf_tag = tagged_hash_engine(sha256::Hash::hash("LnLeaf".as_bytes())); + let branch_tag = tagged_hash_engine(sha256::Hash::hash("LnBranch".as_bytes())); + + // Build per-position hash array: Some(hash) for included positions, None for omitted (including + // the implicit TLV0 at position 0). `decode_positions` is the shared source of truth + // for the run/jump structure, so this consumer cannot drift from the encoder or the test. + let positions = decode_positions(included_records.iter().map(|r| r.r#type), omitted_markers); + let mut hashes: Vec<Option<sha256::Hash>> = Vec::with_capacity(positions.len()); + + let mut inc_idx = 0; + for included in positions { + if included { + let record = &included_records[inc_idx]; + let leaf_hash = tagged_hash_from_engine(leaf_tag.clone(), record.record_bytes); + let nonce_hash = nonce_hashes[inc_idx]; + hashes.push(Some(tagged_branch_hash_from_engine( + branch_tag.clone(), + leaf_hash, + nonce_hash, + ))); + inc_idx += 1; + } else { + hashes.push(None); + } + } + + let mut missing_idx: usize = 0; + let root = reconstruct_merkle_root_dfs(&hashes, &branch_tag, missing_hashes, &mut missing_idx)?; + + if missing_idx != missing_hashes.len() { + return Err(SelectiveDisclosureError::InsufficientMissingHashes); + } + + root.ok_or(SelectiveDisclosureError::InsufficientMissingHashes) +} + +fn reconstruct_merkle_root_dfs( + hashes: &[Option<sha256::Hash>], branch_tag: &sha256::HashEngine, + missing_hashes: &[sha256::Hash], missing_idx: &mut usize, +) -> Result<Option<sha256::Hash>, SelectiveDisclosureError> { + if hashes.len() == 1 { + return Ok(hashes[0]); + } + + let mid = hashes.len().next_power_of_two() / 2; + let (left_hashes, right_hashes) = hashes.split_at(mid); + let left = reconstruct_merkle_root_dfs(left_hashes, branch_tag, missing_hashes, missing_idx)?; + let right = reconstruct_merkle_root_dfs(right_hashes, branch_tag, missing_hashes, missing_idx)?; + + match (left, right) { + (None, None) => Ok(None), + (Some(l), None) => { + if *missing_idx >= missing_hashes.len() { + return Err(SelectiveDisclosureError::InsufficientMissingHashes); + } + let r = missing_hashes[*missing_idx]; + *missing_idx += 1; + Ok(Some(tagged_branch_hash_from_engine(branch_tag.clone(), l, r))) + }, + (None, Some(r)) => { + if *missing_idx >= missing_hashes.len() { + return Err(SelectiveDisclosureError::InsufficientMissingHashes); + } + let l = missing_hashes[*missing_idx]; + *missing_idx += 1; + Ok(Some(tagged_branch_hash_from_engine(branch_tag.clone(), l, r))) + }, + (Some(l), Some(r)) => Ok(Some(tagged_branch_hash_from_engine(branch_tag.clone(), l, r))), + } +} + +/// Validates that `markers` is a minimized omitted-marker sequence per BOLT 12 PR 1295, relative +/// to `included_types`. Each marker MUST be strictly ascending, non-zero, MUST NOT be an included +/// TLV type, and MUST be minimized: it equals the marker following the previous marker (continuing +/// a run) or the previous included type (starting a new run), per [`next_marker`]. The +/// signature-gap jump is handled by [`next_marker`], so signature-range markers are rejected +/// implicitly. This is the single source of truth for marker minimality; callers layer any +/// additional range restrictions on top (e.g. the payer-proof valid ranges). +pub(super) fn validate_omitted_markers( + markers: &[u64], included_types: &BTreeSet<u64>, +) -> Result<(), SelectiveDisclosureError> { + let mut inc_iter = included_types.iter().copied().peekable(); + // After the implicit payer metadata marker, the first minimized marker is the next marker. + let mut expected_next: u64 = next_marker(PAYER_METADATA_TYPE); + let mut prev = PAYER_METADATA_TYPE; + + for &marker in markers { + if marker == PAYER_METADATA_TYPE { + return Err(SelectiveDisclosureError::InvalidOmittedMarker); + } + if marker <= prev { + return Err(SelectiveDisclosureError::InvalidOmittedMarkersOrder); + } + if included_types.contains(&marker) { + return Err(SelectiveDisclosureError::InvalidOmittedMarker); + } + + // Minimization: `marker` continues the current run (`expected_next`), or an included type + // X sits between the previous position and `marker` with `next_marker(X) == marker`. + if marker != expected_next { + let mut found = false; + for inc_type in inc_iter.by_ref() { + if next_marker(inc_type) == marker { + found = true; + break; + } + if inc_type >= marker { + return Err(SelectiveDisclosureError::InvalidOmittedMarker); + } + } + if !found { + return Err(SelectiveDisclosureError::InvalidOmittedMarker); + } + } + + expected_next = next_marker(marker); + prev = marker; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::compute_omitted_markers; + use crate::offers::merkle::{TlvHashData, TlvRecord, TlvStream}; + use alloc::collections::BTreeSet; + use bitcoin::hashes::{sha256, Hash}; + + /// Reconstruct the position inclusion map (`true` = included, `false` = omitted) from included + /// types and omitted markers, using the same [`super::decode_positions`] logic + /// `reconstruct_merkle_root` uses to place hashes. + fn reconstruct_positions(included_types: &[u64], omitted_markers: &[u64]) -> Vec<bool> { + super::decode_positions(included_types.iter().copied(), omitted_markers) + } + + /// Builds a synthetic TLV stream with one record per type in `types`, each carrying a fixed + /// 2-byte value. Types must be < 253 so each encodes as a single BigSize byte. Only the types + /// and their order matter for selective-disclosure marker/position logic. + fn synthetic_tlv_stream(types: &[u64]) -> Vec<u8> { + let mut bytes = Vec::new(); + for &tlv_type in types { + assert!(tlv_type < 253, "helper only supports single-byte BigSize types"); + bytes.extend_from_slice(&[tlv_type as u8, 0x02, 0x00, 0x00]); + } + bytes + } + + /// Computes the disclosure for `included` over `tlv_bytes`, checks the omitted markers and the + /// reconstructed positions, then reconstructs the merkle root and asserts it matches the + /// full-tree root. Unlike feeding hand-written markers to `reconstruct_positions`, this proves + /// the producer (`compute_selective_disclosure`) and consumer agree on the same stream. + fn assert_disclosure_round_trip( + tlv_bytes: &[u8], included: &[u64], expected_markers: &[u64], expected_positions: &[bool], + ) { + let included_types: BTreeSet<u64> = included.iter().copied().collect(); + + let disclosure = + super::compute_selective_disclosure(TlvStream::new(tlv_bytes), &included_types); + assert_eq!(disclosure.omitted_markers.as_slice(), expected_markers); + assert_eq!( + reconstruct_positions(included, &disclosure.omitted_markers).as_slice(), + expected_positions, + ); + + let included_records: Vec<TlvRecord<'_>> = + TlvStream::new(tlv_bytes).filter(|r| included_types.contains(&r.r#type)).collect(); + let reconstructed = super::reconstruct_merkle_root( + &included_records, + &disclosure.nonce_hashes, + &disclosure.omitted_markers, + &disclosure.missing_hashes, + ) + .unwrap(); + assert_eq!(reconstructed, disclosure.merkle_root); + } + + /// BOLT 12 payer proof spec example. + /// TLVs: 0(omit), 10(incl), 20(omit), 30(omit), 40(incl), 50(omit), 60(omit) + #[test] + fn test_reconstruct_positions_spec_example() { + assert_disclosure_round_trip( + &synthetic_tlv_stream(&[0, 10, 20, 30, 40, 50, 60]), + &[10, 40], + &[11, 12, 41, 42], + &[false, true, false, false, true, false, false], + ); + } + + /// Omitted TLVs before the first included one. + /// TLVs: 0(omit), 5(omit), 10(incl), 20(omit) + #[test] + fn test_reconstruct_positions_omitted_before_included() { + assert_disclosure_round_trip( + &synthetic_tlv_stream(&[0, 5, 10, 20]), + &[10], + &[1, 11], + &[false, false, true, false], + ); + } + + /// Only included TLVs (just the implicit TLV0 is omitted). + /// TLVs: 0(omit), 10(incl), 20(incl) + #[test] + fn test_reconstruct_positions_no_omitted() { + assert_disclosure_round_trip( + &synthetic_tlv_stream(&[0, 10, 20]), + &[10, 20], + &[], + &[false, true, true], + ); + } + + /// Only omitted TLVs (nothing included). This is not a real proof shape -- a proof must + /// disclose the required fields -- so there is no disclosed leaf to anchor reconstruction. + /// The producer still emits markers/positions, but reconstructing the root must fail. + /// TLVs: 0(omit), 5(omit), 10(omit) + #[test] + fn test_reconstruct_positions_no_included() { + let tlv_bytes = synthetic_tlv_stream(&[0, 5, 10]); + let included_types = BTreeSet::new(); + let disclosure = + super::compute_selective_disclosure(TlvStream::new(&tlv_bytes), &included_types); + assert_eq!(disclosure.omitted_markers, vec![1, 2]); + assert_eq!( + reconstruct_positions(&[], &disclosure.omitted_markers), + vec![false, false, false], + ); + + assert_eq!( + super::reconstruct_merkle_root( + &[], + &disclosure.nonce_hashes, + &disclosure.omitted_markers, + &disclosure.missing_hashes, + ), + Err(super::SelectiveDisclosureError::InsufficientMissingHashes), + ); + } + + #[test] + fn test_validate_omitted_markers_edge_cases() { + let included_types = |types: &[u64]| -> BTreeSet<u64> { types.iter().copied().collect() }; + + assert!(super::validate_omitted_markers(&[1, 2, 3, 41, 42], &included_types(&[40])).is_ok()); + assert!(super::validate_omitted_markers(&[11, 12], &included_types(&[10])).is_ok()); + assert!(super::validate_omitted_markers(&[], &included_types(&[10, 20])).is_ok()); + assert!(super::validate_omitted_markers(&[1_000_000_000], &included_types(&[239])).is_ok()); + + assert_eq!( + super::validate_omitted_markers(&[0], &included_types(&[])), + Err(super::SelectiveDisclosureError::InvalidOmittedMarker) + ); + assert_eq!( + super::validate_omitted_markers(&[11, 11], &included_types(&[10])), + Err(super::SelectiveDisclosureError::InvalidOmittedMarkersOrder) + ); + assert_eq!( + super::validate_omitted_markers(&[10], &included_types(&[10])), + Err(super::SelectiveDisclosureError::InvalidOmittedMarker) + ); + assert_eq!( + super::validate_omitted_markers(&[11, 15, 41], &included_types(&[10, 40])), + Err(super::SelectiveDisclosureError::InvalidOmittedMarker) + ); + assert_eq!( + super::validate_omitted_markers(&[11, 12, 45], &included_types(&[10, 40])), + Err(super::SelectiveDisclosureError::InvalidOmittedMarker) + ); + } + + #[test] + fn compute_selective_disclosure_skips_signature_tlv_records() { + let bytes_without_signature = vec![ + 0x00, 0x01, 0x00, // payer_metadata + 0x0a, 0x01, 0x01, // type 10 + 0x14, 0x01, 0x02, // type 20 + ]; + let bytes_with_signature = vec![ + 0x00, 0x01, 0x00, // payer_metadata + 0x0a, 0x01, 0x01, // type 10 + 0xf0, 0x00, // signature type 240, ignored by merkle calculation + 0x14, 0x01, 0x02, // type 20 + ]; + let included = [10, 20].into_iter().collect::<BTreeSet<_>>(); + + assert_eq!( + super::compute_selective_disclosure(TlvStream::new(&bytes_with_signature), &included), + super::compute_selective_disclosure( + TlvStream::new(&bytes_without_signature), + &included + ) + ); + } + + /// Test round-trip: compute selective disclosure then reconstruct merkle root. + #[test] + fn test_selective_disclosure_round_trip() { + // Build TLV stream matching spec example structure + // TLVs: 0, 10, 20, 30, 40, 50, 60 + let mut tlv_bytes = Vec::new(); + tlv_bytes.extend_from_slice(&[0x00, 0x04, 0x00, 0x00, 0x00, 0x00]); // TLV 0 + tlv_bytes.extend_from_slice(&[0x0a, 0x02, 0x00, 0x00]); // TLV 10 + tlv_bytes.extend_from_slice(&[0x14, 0x02, 0x00, 0x00]); // TLV 20 + tlv_bytes.extend_from_slice(&[0x1e, 0x02, 0x00, 0x00]); // TLV 30 + tlv_bytes.extend_from_slice(&[0x28, 0x02, 0x00, 0x00]); // TLV 40 + tlv_bytes.extend_from_slice(&[0x32, 0x02, 0x00, 0x00]); // TLV 50 + tlv_bytes.extend_from_slice(&[0x3c, 0x02, 0x00, 0x00]); // TLV 60 + + // Include types 10 and 40 + let mut included = BTreeSet::new(); + included.insert(10); + included.insert(40); + + // Compute selective disclosure + let disclosure = super::compute_selective_disclosure(TlvStream::new(&tlv_bytes), &included); + + // Verify markers match spec example + assert_eq!(disclosure.omitted_markers, vec![11, 12, 41, 42]); + + // Verify nonce_hashes count matches included TLVs + assert_eq!(disclosure.nonce_hashes.len(), 2); + + // Collect included records for reconstruction + let included_records: Vec<TlvRecord<'_>> = + TlvStream::new(&tlv_bytes).filter(|r| included.contains(&r.r#type)).collect(); + + // Reconstruct merkle root + let reconstructed = super::reconstruct_merkle_root( + &included_records, + &disclosure.nonce_hashes, + &disclosure.omitted_markers, + &disclosure.missing_hashes, + ) + .unwrap(); + + // Must match original + assert_eq!(reconstructed, disclosure.merkle_root); + } + + /// Test that the synthetic 7-node example still requires four missing hashes. + /// + /// For the synthetic tree with TLVs [0(o), 10(I), 20(o), 30(o), 40(I), 50(o), 60(o)]: + /// - hash(0) covers type 0 + /// - hash(B(20,30)) covers types 20-30 + /// - hash(50) covers type 50 + /// - hash(60) covers type 60 + /// + /// This still needs 4 missing hashes. The DFS-ordering fix changes the order + /// they are emitted and consumed in, but not the count for this tree shape. + #[test] + fn test_missing_hashes_for_synthetic_tree() { + // Build TLV stream: 0, 10, 20, 30, 40, 50, 60 + let mut tlv_bytes = Vec::new(); + tlv_bytes.extend_from_slice(&[0x00, 0x04, 0x00, 0x00, 0x00, 0x00]); // TLV 0 + tlv_bytes.extend_from_slice(&[0x0a, 0x02, 0x00, 0x00]); // TLV 10 + tlv_bytes.extend_from_slice(&[0x14, 0x02, 0x00, 0x00]); // TLV 20 + tlv_bytes.extend_from_slice(&[0x1e, 0x02, 0x00, 0x00]); // TLV 30 + tlv_bytes.extend_from_slice(&[0x28, 0x02, 0x00, 0x00]); // TLV 40 + tlv_bytes.extend_from_slice(&[0x32, 0x02, 0x00, 0x00]); // TLV 50 + tlv_bytes.extend_from_slice(&[0x3c, 0x02, 0x00, 0x00]); // TLV 60 + + // Include types 10 and 40 (same as spec example) + let mut included = BTreeSet::new(); + included.insert(10); + included.insert(40); + + let disclosure = super::compute_selective_disclosure(TlvStream::new(&tlv_bytes), &included); + + // We should still have 4 missing hashes for omitted types: + // - type 0 (single leaf) + // - types 20+30 (combined branch) + // - type 50 (single leaf) + // - type 60 (single leaf) + assert_eq!( + disclosure.missing_hashes.len(), + 4, + "Expected 4 missing hashes for omitted types [0, 20+30, 50, 60]" + ); + + // Verify the round-trip still works with the correct ordering + let included_records: Vec<TlvRecord<'_>> = + TlvStream::new(&tlv_bytes).filter(|r| included.contains(&r.r#type)).collect(); + + let reconstructed = super::reconstruct_merkle_root( + &included_records, + &disclosure.nonce_hashes, + &disclosure.omitted_markers, + &disclosure.missing_hashes, + ) + .unwrap(); + + assert_eq!(reconstructed, disclosure.merkle_root); + } + + /// Test that reconstruction fails with wrong number of missing_hashes. + #[test] + fn test_reconstruction_fails_with_wrong_missing_hashes() { + let mut tlv_bytes = Vec::new(); + tlv_bytes.extend_from_slice(&[0x00, 0x04, 0x00, 0x00, 0x00, 0x00]); // TLV 0 + tlv_bytes.extend_from_slice(&[0x0a, 0x02, 0x00, 0x00]); // TLV 10 + tlv_bytes.extend_from_slice(&[0x14, 0x02, 0x00, 0x00]); // TLV 20 + + let mut included = BTreeSet::new(); + included.insert(10); + + let disclosure = super::compute_selective_disclosure(TlvStream::new(&tlv_bytes), &included); + + let included_records: Vec<TlvRecord<'_>> = + TlvStream::new(&tlv_bytes).filter(|r| included.contains(&r.r#type)).collect(); + + // Try with empty missing_hashes (should fail) + let result = super::reconstruct_merkle_root( + &included_records, + &disclosure.nonce_hashes, + &disclosure.omitted_markers, + &[], // Wrong! + ); + + assert!(result.is_err()); + } + + /// Verify that [`compute_omitted_markers`] jumps from the top of the low + /// marker range (239) to the start of the high range (1_000_000_000) per + /// BOLT 12 PR 1295, rather than entering the signature type range. Real + /// BOLT 12 invoices have far fewer than 239 non-signature TLVs, so this + /// case is unreachable in practice. + #[test] + fn compute_omitted_markers_jumps_to_high_range_after_239() { + // 240 consecutive omitted TLVs at types 1..=240. The first 239 markers + // climb 1..=239; the 240th would be 240 (in the signature range), so it + // jumps to 1_000_000_000 instead. + let dummy_hash = sha256::Hash::all_zeros(); + let included = BTreeSet::new(); + let tlv_data: Vec<TlvHashData> = (1u64..=240) + .map(|tlv_type| TlvHashData { + tlv_type, + nonce_hash: dummy_hash, + per_tlv_hash: dummy_hash, + }) + .collect(); + + let markers: Vec<u64> = compute_omitted_markers(tlv_data.iter(), &included).collect(); + + let mut expected: Vec<u64> = (1..=239).collect(); + expected.push(1_000_000_000); + assert_eq!(markers, expected); + } + + /// An *included* TLV at the top of the low range (type 239) followed by an + /// omitted TLV: the marker must skip the signature/payer-proof gap and jump + /// to the start of the experimental range, not land on 240. + #[test] + fn compute_omitted_markers_jumps_after_included_at_top_of_low_range() { + let dummy_hash = sha256::Hash::all_zeros(); + let included = [239u64].into_iter().collect::<BTreeSet<_>>(); + let tlv_data = [ + TlvHashData { tlv_type: 239, nonce_hash: dummy_hash, per_tlv_hash: dummy_hash }, + TlvHashData { + tlv_type: 1_500_000_000, + nonce_hash: dummy_hash, + per_tlv_hash: dummy_hash, + }, + ]; + let markers: Vec<u64> = compute_omitted_markers(tlv_data.iter(), &included).collect(); + assert_eq!(markers, vec![1_000_000_000]); + } + + /// After a jump into the experimental range, subsequent omitted markers + /// continue sequentially within that range. + #[test] + fn compute_omitted_markers_continue_in_experimental_range_after_jump() { + let dummy_hash = sha256::Hash::all_zeros(); + let included = [239u64].into_iter().collect::<BTreeSet<_>>(); + let tlv_data = [ + TlvHashData { tlv_type: 239, nonce_hash: dummy_hash, per_tlv_hash: dummy_hash }, + TlvHashData { + tlv_type: 3_000_000_000, + nonce_hash: dummy_hash, + per_tlv_hash: dummy_hash, + }, + TlvHashData { + tlv_type: 3_000_000_001, + nonce_hash: dummy_hash, + per_tlv_hash: dummy_hash, + }, + ]; + let markers: Vec<u64> = compute_omitted_markers(tlv_data.iter(), &included).collect(); + assert_eq!(markers, vec![1_000_000_000, 1_000_000_001]); + } + + /// [`next_marker`] increments by one within a range but jumps over the + /// signature/payer-proof gap, so producer and readers stay in agreement. + #[test] + fn next_marker_jumps_the_gap() { + assert_eq!(super::next_marker(super::PAYER_METADATA_TYPE), 1); + assert_eq!(super::next_marker(5), 6); + assert_eq!(super::next_marker(238), 239); + // 240 would land in the signature range, so it jumps to the experimental range. + assert_eq!(super::next_marker(239), 1_000_000_000); + assert_eq!(super::next_marker(1_000_000_000), 1_000_000_001); + } + + #[test] + fn validate_omitted_markers_direct() { + use alloc::collections::BTreeSet; + let none: BTreeSet<u64> = BTreeSet::new(); + + // A minimized leading run with nothing included is accepted. + assert!(super::validate_omitted_markers(&[1, 2, 3], &none).is_ok()); + // The empty sequence is accepted. + assert!(super::validate_omitted_markers(&[], &none).is_ok()); + // Zero is rejected (it is the implicit TLV0 marker). + assert!(super::validate_omitted_markers(&[0], &none).is_err()); + // A non-ascending sequence is rejected. + assert!(super::validate_omitted_markers(&[2, 1], &none).is_err()); + // A gap with no intervening included type to justify it is non-minimized -> rejected. + assert!(super::validate_omitted_markers(&[1, 3], &none).is_err()); + + // The same `[1, 3]` is accepted when included type 2 sits between them, because + // next_marker(2) == 3 justifies the jump. + let inc2: BTreeSet<u64> = [2u64].into_iter().collect(); + assert!(super::validate_omitted_markers(&[1, 3], &inc2).is_ok()); + // A marker equal to an included type is rejected. + assert!(super::validate_omitted_markers(&[2], &inc2).is_err()); + + // The signature-gap jump is accepted when justified by an included type at the top of the + // low range: included 239, omitted marker 1_000_000_000 (next_marker(239)). + let inc239: BTreeSet<u64> = [239u64].into_iter().collect(); + assert!(super::validate_omitted_markers(&[1_000_000_000], &inc239).is_ok()); + // ...but not without that justification. + assert!(super::validate_omitted_markers(&[1_000_000_000], &none).is_err()); + } +} From 5747866a6b575cbe17e9ea250d85149a172e787b Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo <vincenzopalazzodev@gmail.com> Date: Mon, 15 Jun 2026 23:31:23 +0200 Subject: [PATCH 577/627] offers: add BOLT 12 payer proof primitives Add the `payer_proof` module: `PayerProof`/`UnsignedPayerProof`, the `PayerProofBuilder` (with selective disclosure and a derived-key path), bech32 `lnp` encoding, and parse-time verification, implementing the payer proof extension to BOLT 12 (https://github.com/lightning/bolts/pull/1295). Also exposes the offer/invoice TLV-type constants and an invoice-bytes accessor used to build proofs, and a `Sha256` `Writeable`/`Readable` impl for the proof hashes. Co-Authored-By: Rusty Russell <rusty@rustcorp.com.au> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-Authored-By: OpenAI Codex <codex@openai.com> --- lightning/src/offers/invoice.rs | 45 +- lightning/src/offers/mod.rs | 1 + lightning/src/offers/nonce.rs | 2 +- lightning/src/offers/offer.rs | 10 +- lightning/src/offers/payer_proof.rs | 2570 +++++++++++++++++++++++++++ lightning/src/util/ser.rs | 15 + 6 files changed, 2631 insertions(+), 12 deletions(-) create mode 100644 lightning/src/offers/payer_proof.rs diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index cf0aa22d5a8..e48967d2830 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -984,6 +984,11 @@ impl Bolt12Invoice { self.signature } + /// The raw serialized bytes of the invoice. + pub(super) fn invoice_bytes(&self) -> &[u8] { + &self.bytes + } + /// Hash that was used for signing the invoice. pub fn signable_hash(&self) -> [u8; 32] { self.tagged_hash.as_digest().as_ref().clone() @@ -1021,13 +1026,20 @@ impl Bolt12Invoice { /// Re-derives the payer's signing keypair for payer proof creation. /// - /// This performs the same key derivation that occurs during invoice request creation - /// with `deriving_signing_pubkey`, allowing the payer to recover their signing keypair. + /// For an invoice requested with [`Offer::request_invoice`], this performs the same key + /// derivation that occurs when the originating offer was created with + /// [`OfferBuilder::deriving_signing_pubkey`], allowing the payer to recover their signing + /// keypair. Likewise, for the refund flow, this performs the same key derivation used by + /// [`RefundBuilder::deriving_signing_pubkey`]. /// /// The keypair is derived from the invoice's own payer metadata (which embeds the payer /// [`Nonce`]), so no externally-held nonce or payment id is required. In the common - /// proof-of-payment flow, callers can use `PaidBolt12Invoice::prove_payer_derived`. + /// proof-of-payment flow, callers can use [`PaidBolt12Invoice::prove_payer_derived`]. /// + /// [`Offer::request_invoice`]: crate::offers::offer::Offer::request_invoice + /// [`OfferBuilder::deriving_signing_pubkey`]: crate::offers::offer::OfferBuilder::deriving_signing_pubkey + /// [`RefundBuilder::deriving_signing_pubkey`]: crate::offers::refund::RefundBuilder::deriving_signing_pubkey + /// [`PaidBolt12Invoice::prove_payer_derived`]: crate::offers::payer_proof::PaidBolt12Invoice::prove_payer_derived /// [`Nonce`]: crate::offers::nonce::Nonce pub fn derive_payer_signing_keys<T: secp256k1::Signing>( &self, key: &ExpandedKey, secp_ctx: &Secp256k1<T>, @@ -1535,22 +1547,37 @@ impl TryFrom<Vec<u8>> for Bolt12Invoice { /// Valid type range for invoice TLV records. pub(super) const INVOICE_TYPES: core::ops::Range<u64> = 160..240; +/// TLV record type for the invoice creation timestamp. +pub(super) const INVOICE_CREATED_AT_TYPE: u64 = 164; + +/// TLV record type for [`Bolt12Invoice::payment_hash`]. +pub(super) const INVOICE_PAYMENT_HASH_TYPE: u64 = 168; + +/// TLV record type for [`Bolt12Invoice::amount_msats`]. +pub(super) const INVOICE_AMOUNT_TYPE: u64 = 170; + +/// TLV record type for [`Bolt12Invoice::invoice_features`]. +pub(super) const INVOICE_FEATURES_TYPE: u64 = 174; + +/// TLV record type for [`Bolt12Invoice::signing_pubkey`]. +pub(super) const INVOICE_NODE_ID_TYPE: u64 = 176; + tlv_stream!(InvoiceTlvStream, InvoiceTlvStreamRef<'a>, INVOICE_TYPES, { (160, paths: (Vec<BlindedPath>, WithoutLength, Iterable<'a, BlindedPathIter<'a>, BlindedPath>)), (162, blindedpay: (Vec<BlindedPayInfo>, WithoutLength, Iterable<'a, BlindedPayInfoIter<'a>, BlindedPayInfo>)), - (164, created_at: (u64, HighZeroBytesDroppedBigSize)), + (INVOICE_CREATED_AT_TYPE, created_at: (u64, HighZeroBytesDroppedBigSize)), (166, relative_expiry: (u32, HighZeroBytesDroppedBigSize)), - (168, payment_hash: PaymentHash), - (170, amount: (u64, HighZeroBytesDroppedBigSize)), + (INVOICE_PAYMENT_HASH_TYPE, payment_hash: PaymentHash), + (INVOICE_AMOUNT_TYPE, amount: (u64, HighZeroBytesDroppedBigSize)), (172, fallbacks: (Vec<FallbackAddress>, WithoutLength)), - (174, features: (Bolt12InvoiceFeatures, WithoutLength)), - (176, node_id: PublicKey), + (INVOICE_FEATURES_TYPE, features: (Bolt12InvoiceFeatures, WithoutLength)), + (INVOICE_NODE_ID_TYPE, node_id: PublicKey), // Only present in `StaticInvoice`s. (236, held_htlc_available_paths: (Vec<BlindedMessagePath>, WithoutLength)), }); /// Valid type range for experimental invoice TLV records. -pub(super) const EXPERIMENTAL_INVOICE_TYPES: core::ops::RangeFrom<u64> = 3_000_000_000..; +pub(super) const EXPERIMENTAL_INVOICE_TYPES: core::ops::Range<u64> = 3_000_000_000..4_000_000_000; #[cfg(not(test))] tlv_stream!( diff --git a/lightning/src/offers/mod.rs b/lightning/src/offers/mod.rs index c270a307f8e..c80c9e07e8d 100644 --- a/lightning/src/offers/mod.rs +++ b/lightning/src/offers/mod.rs @@ -25,6 +25,7 @@ pub mod merkle; pub mod nonce; pub mod parse; mod payer; +pub mod payer_proof; pub mod refund; pub mod selective_disclosure; pub(crate) mod signer; diff --git a/lightning/src/offers/nonce.rs b/lightning/src/offers/nonce.rs index 8c99a464abc..4eee35bc306 100644 --- a/lightning/src/offers/nonce.rs +++ b/lightning/src/offers/nonce.rs @@ -25,7 +25,7 @@ use crate::prelude::*; /// [`Offer::metadata`]: crate::offers::offer::Offer::metadata /// [`Offer::issuer_signing_pubkey`]: crate::offers::offer::Offer::issuer_signing_pubkey /// [`ExpandedKey`]: crate::ln::inbound_payment::ExpandedKey -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct Nonce(pub(crate) [u8; Self::LENGTH]); impl Nonce { diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index 8bafb004aaf..8e6b36f7311 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -1211,6 +1211,12 @@ pub(super) const OFFER_TYPES: core::ops::Range<u64> = 1..80; /// TLV record type for [`Offer::metadata`]. const OFFER_METADATA_TYPE: u64 = 4; +/// TLV record type for [`Offer::description`]. +pub(super) const OFFER_DESCRIPTION_TYPE: u64 = 10; + +/// TLV record type for [`Offer::issuer`]. +pub(super) const OFFER_ISSUER_TYPE: u64 = 18; + /// TLV record type for [`Offer::issuer_signing_pubkey`]. const OFFER_ISSUER_ID_TYPE: u64 = 22; @@ -1219,11 +1225,11 @@ tlv_stream!(OfferTlvStream, OfferTlvStreamRef<'a>, OFFER_TYPES, { (OFFER_METADATA_TYPE, metadata: (Vec<u8>, WithoutLength)), (6, currency: [u8; 3]), (8, amount: (u64, HighZeroBytesDroppedBigSize)), - (10, description: (String, WithoutLength)), + (OFFER_DESCRIPTION_TYPE, description: (String, WithoutLength)), (12, features: (OfferFeatures, WithoutLength)), (14, absolute_expiry: (u64, HighZeroBytesDroppedBigSize)), (16, paths: (Vec<BlindedMessagePath>, WithoutLength)), - (18, issuer: (String, WithoutLength)), + (OFFER_ISSUER_TYPE, issuer: (String, WithoutLength)), (20, quantity_max: (u64, HighZeroBytesDroppedBigSize)), (OFFER_ISSUER_ID_TYPE, issuer_id: PublicKey), }); diff --git a/lightning/src/offers/payer_proof.rs b/lightning/src/offers/payer_proof.rs new file mode 100644 index 00000000000..866e43a3754 --- /dev/null +++ b/lightning/src/offers/payer_proof.rs @@ -0,0 +1,2570 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE +// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license +// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Payer proofs for BOLT 12 invoices. +//! +//! A [`PayerProof`] cryptographically proves that a BOLT 12 invoice was paid by demonstrating: +//! - Possession of the payment preimage (proving the payment occurred) +//! - A valid invoice signature over a merkle root (proving the invoice is authentic) +//! - The payer's signature (proving who authorized the payment) +//! +//! This implements the payer proof extension to BOLT 12 as specified in +//! <https://github.com/lightning/bolts/pull/1295>. + +use alloc::collections::BTreeSet; + +use crate::io; +use crate::ln::channelmanager::PaymentId; +use crate::ln::inbound_payment::ExpandedKey; +use crate::ln::msgs::DecodeError; +use crate::offers::invoice::{ + Bolt12Invoice, DerivedSigningPubkey, ExperimentalInvoiceTlvStream, ExplicitSigningPubkey, + InvoiceTlvStream, SigningPubkeyStrategy, EXPERIMENTAL_INVOICE_TYPES, INVOICE_AMOUNT_TYPE, + INVOICE_CREATED_AT_TYPE, INVOICE_FEATURES_TYPE, INVOICE_NODE_ID_TYPE, + INVOICE_PAYMENT_HASH_TYPE, SIGNATURE_TAG, +}; +use crate::offers::invoice_request::{ + ExperimentalInvoiceRequestTlvStream, InvoiceRequestTlvStream, INVOICE_REQUEST_PAYER_ID_TYPE, +}; +use crate::offers::merkle::{self, SignError, TaggedHash, TlvRecord, TlvStream, SIGNATURE_TYPES}; +use crate::offers::offer::{ + ExperimentalOfferTlvStream, OfferTlvStream, EXPERIMENTAL_OFFER_TYPES, OFFER_DESCRIPTION_TYPE, + OFFER_ISSUER_TYPE, +}; +use crate::offers::parse::{Bech32Encode, Bolt12ParseError, Bolt12SemanticError, ParsedMessage}; +use crate::offers::payer::PAYER_METADATA_TYPE; +use crate::offers::selective_disclosure::{self, SelectiveDisclosure, SelectiveDisclosureError}; +use crate::offers::static_invoice::StaticInvoice; +use crate::types::payment::{PaymentHash, PaymentPreimage}; +use crate::util::ser::{ + BigSize, CursorReadable, HighZeroBytesDroppedBigSize, WithoutLength, Writeable, Writer, +}; +use lightning_types::string::PrintableString; + +use bitcoin::hashes::{sha256, Hash}; +use bitcoin::secp256k1; +use bitcoin::secp256k1::schnorr::Signature; +use bitcoin::secp256k1::{PublicKey, Secp256k1}; + +use core::convert::TryFrom; +use core::time::Duration; + +#[allow(unused_imports)] +use crate::prelude::*; + +/// The BOLT 12 invoice that was paid. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum PaidBolt12Invoice { + /// A standard BOLT 12 invoice, allowing proof of payment. + /// + /// The payer signing key needed to build a payer proof is re-derived from the invoice's own + /// payer metadata, so no separate [`Nonce`] needs to be stored alongside it. + /// + /// [`Nonce`]: crate::offers::nonce::Nonce + Bolt12Invoice(Bolt12Invoice), + /// A static invoice used in async payments, where proof of payment is not possible. + StaticInvoice(StaticInvoice), +} + +// The length-prefixed, variant-tagged wire layout (variant `u8`, then `BigSize` length, then the +// invoice) matches how the paid invoice has always been serialized in its containers. +impl_ser_tlv_based_enum!(PaidBolt12Invoice, + {0, Bolt12Invoice} => (), + {2, StaticInvoice} => (), +); + +/// A paid BOLT 12 invoice. +/// +/// For standard [`Bolt12Invoice`] payments, use [`Self::prove_payer`] or +/// [`Self::prove_payer_derived`] to build a [`PayerProof`] that selectively discloses +/// invoice fields to a third-party verifier. +/// +/// For async payments (i.e., [`StaticInvoice`]), payer proofs are not supported and those +/// methods will return [`PayerProofError::IncompatibleInvoice`]. +/// +/// Surfaced in [`Event::PaymentSent::bolt12_invoice`]. +/// +/// [`Event::PaymentSent::bolt12_invoice`]: crate::events::Event::PaymentSent::bolt12_invoice +impl PaidBolt12Invoice { + /// Returns the [`Bolt12Invoice`] if the payment was for a standard BOLT 12 invoice. + pub fn bolt12_invoice(&self) -> Option<&Bolt12Invoice> { + match self { + PaidBolt12Invoice::Bolt12Invoice(invoice) => Some(invoice), + _ => None, + } + } + + /// Returns the [`StaticInvoice`] if the payment was for an async payment. + pub fn static_invoice(&self) -> Option<&StaticInvoice> { + match self { + PaidBolt12Invoice::StaticInvoice(invoice) => Some(invoice), + _ => None, + } + } + + /// Creates a [`PayerProofBuilder`] for this paid invoice. + pub fn prove_payer( + &self, payment_preimage: PaymentPreimage, + ) -> Result<PayerProofBuilder<ExplicitSigningPubkey>, PayerProofError> { + let invoice = self.bolt12_invoice().ok_or(PayerProofError::IncompatibleInvoice)?; + PayerProofBuilder::new(invoice, payment_preimage) + } + + /// Creates a [`PayerProofBuilder`] with a pre-derived signing keypair. + /// + /// The payer signing key is re-derived from the invoice's own payer metadata, failing early + /// if derivation fails. The supplied `payment_id` (e.g. from [`Event::PaymentSent`]) is + /// checked against the payment id recovered from that metadata to guard against a mismatched + /// invoice or key. + /// + /// [`Event::PaymentSent`]: crate::events::Event::PaymentSent + pub fn prove_payer_derived<T: secp256k1::Signing>( + &self, payment_preimage: PaymentPreimage, expanded_key: &ExpandedKey, + payment_id: PaymentId, secp_ctx: &Secp256k1<T>, + ) -> Result<PayerProofBuilder<DerivedSigningPubkey>, PayerProofError> { + let invoice = self.bolt12_invoice().ok_or(PayerProofError::IncompatibleInvoice)?; + let recovered = invoice + .verify_using_metadata(expanded_key, secp_ctx) + .map_err(|_| PayerProofError::KeyDerivationFailed)?; + if recovered != payment_id { + return Err(PayerProofError::KeyDerivationFailed); + } + PayerProofBuilder::new_derived(invoice, payment_preimage, expanded_key, secp_ctx) + } +} + +const PAYER_PROOF_ISSUER_SIGNATURE_TYPE: u64 = 240; +const PAYER_PROOF_PROOF_SIGNATURE_TYPE: u64 = 241; +const PAYER_PROOF_PREIMAGE_TYPE: u64 = 1001; +const PAYER_PROOF_OMITTED_TLVS_TYPE: u64 = 1002; +const PAYER_PROOF_MISSING_HASHES_TYPE: u64 = 1003; +const PAYER_PROOF_LEAF_HASHES_TYPE: u64 = 1004; +const PAYER_PROOF_PROOF_NOTE_TYPE: u64 = 1005; + +/// Range covering the data-bearing payer-proof TLVs. +pub(super) const PAYER_PROOF_DATA_TYPES: core::ops::Range<u64> = 1001..1_000_000_000; + +/// Human-readable prefix for payer proofs in bech32 encoding. +pub const PAYER_PROOF_HRP: &str = "lnp"; + +/// Tag for `proof_signature` computation per BOLT 12 signature calculation. +/// Format: "lightning" || messagename || fieldname +const PROOF_SIGNATURE_TAG: &str = concat!("lightning", "payer_proof", "proof_signature"); + +/// Error when building or verifying a payer proof. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PayerProofError { + /// The invoice is not a [`Bolt12Invoice`] (e.g., it is a [`StaticInvoice`]). + /// + /// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice + IncompatibleInvoice, + /// The preimage doesn't match the invoice's payment hash. + PreimageMismatch, + /// Error during merkle tree operations. + MerkleError(SelectiveDisclosureError), + /// The invoice signature is invalid. + InvalidInvoiceSignature, + /// Failed to re-derive the payer signing key from the invoice's payer metadata. + KeyDerivationFailed, + /// The given TLV type cannot be included in a payer proof. Carries the offending + /// type number. Reasons include `PAYER_METADATA_TYPE`, TLVs in `SIGNATURE_TYPES`, + /// or TLVs in `PAYER_PROOF_DATA_TYPES`. + DisallowedTlvType(u64), + + /// Error decoding the payer proof. + DecodeError(DecodeError), +} + +impl From<SelectiveDisclosureError> for PayerProofError { + fn from(e: SelectiveDisclosureError) -> Self { + PayerProofError::MerkleError(e) + } +} + +impl From<DecodeError> for PayerProofError { + fn from(e: DecodeError) -> Self { + PayerProofError::DecodeError(e) + } +} + +/// A cryptographic proof that a BOLT 12 invoice was paid. +/// +/// Contains the payment preimage, selective disclosure of invoice fields, +/// the invoice signature, and a payer signature proving who paid. +#[derive(Clone, Debug)] +pub struct PayerProof { + bytes: Vec<u8>, + contents: PayerProofContents, + proof_signature: Signature, + merkle_root: sha256::Hash, +} + +/// The contents of a [`PayerProof`] -- everything shared between a signed +/// [`PayerProof`] and its [`UnsignedPayerProof`] sibling, with the exception +/// of the `proof_signature` which is only available after signing. +#[derive(Clone, Debug)] +struct PayerProofContents { + payer_signing_pubkey: PublicKey, + payment_hash: PaymentHash, + issuer_signing_pubkey: PublicKey, + preimage: PaymentPreimage, + invoice_signature: Signature, + proof_note: Option<String>, + disclosed_fields: DisclosedFields, +} + +#[derive(Clone, Debug, Default)] +struct DisclosedFields { + offer_description: Option<String>, + offer_issuer: Option<String>, + invoice_amount_msats: Option<u64>, + invoice_created_at: Option<Duration>, +} + +/// Builds a [`PayerProof`] from a paid invoice and its preimage. +/// +/// By default, only the required fields are included ([`payer_signing_pubkey`], +/// [`payment_hash`], [`issuer_signing_pubkey`]). Additional fields can be included for +/// selective disclosure using the `include_*` methods. +/// +/// [`payer_signing_pubkey`]: PayerProof::payer_signing_pubkey +/// [`payment_hash`]: PayerProof::payment_hash +/// [`issuer_signing_pubkey`]: PayerProof::issuer_signing_pubkey +pub struct PayerProofBuilder<S: SigningPubkeyStrategy> { + /// The paid invoice's TLV bytes, kept so the selective disclosure can be recomputed at build + /// time once the caller has finished choosing which types to include. Owned (rather than a + /// borrow of the `Bolt12Invoice`) so the builder is `'static`, which is much friendlier for + /// language bindings. + invoice_bytes: Vec<u8>, + /// The proof contents, pre-populated with everything known up front. `disclosed_fields` starts + /// empty and is filled in by [`Self::build_unsigned`] from `included_types`. + contents: PayerProofContents, + included_types: BTreeSet<u64>, + signing_strategy: S, +} + +/// The default set of TLV types always included in a payer proof: payer_id, +/// payment_hash, issuer signing pubkey, and invoice features when present. +fn default_included_types(invoice: &Bolt12Invoice) -> BTreeSet<u64> { + let mut types = BTreeSet::new(); + types.insert(INVOICE_REQUEST_PAYER_ID_TYPE); + types.insert(INVOICE_PAYMENT_HASH_TYPE); + types.insert(INVOICE_NODE_ID_TYPE); + if TlvStream::new(invoice.invoice_bytes()).any(|r| r.r#type == INVOICE_FEATURES_TYPE) { + types.insert(INVOICE_FEATURES_TYPE); + } + types +} + +/// Builds the [`PayerProofContents`] known at builder-construction time. The `disclosed_fields` +/// are left empty here and populated from the chosen `included_types` in +/// [`PayerProofBuilder::build_unsigned`]. +fn pending_contents(invoice: &Bolt12Invoice, preimage: PaymentPreimage) -> PayerProofContents { + PayerProofContents { + payer_signing_pubkey: invoice.payer_signing_pubkey(), + payment_hash: invoice.payment_hash(), + issuer_signing_pubkey: invoice.signing_pubkey(), + preimage, + invoice_signature: invoice.signature(), + proof_note: None, + disclosed_fields: DisclosedFields::default(), + } +} + +impl PayerProofBuilder<ExplicitSigningPubkey> { + /// Create a new builder from an invoice and its payment preimage. + /// + /// Returns an error if the preimage doesn't match the invoice's payment hash. + pub(super) fn new( + invoice: &Bolt12Invoice, preimage: PaymentPreimage, + ) -> Result<Self, PayerProofError> { + let computed_hash: PaymentHash = preimage.into(); + if computed_hash != invoice.payment_hash() { + return Err(PayerProofError::PreimageMismatch); + } + + Ok(Self { + invoice_bytes: invoice.invoice_bytes().to_vec(), + contents: pending_contents(invoice, preimage), + included_types: default_included_types(invoice), + signing_strategy: ExplicitSigningPubkey {}, + }) + } + + /// Builds an [`UnsignedPayerProof`] that can be signed with [`UnsignedPayerProof::sign`]. + pub fn build(self) -> Result<UnsignedPayerProof, PayerProofError> { + self.build_unsigned() + } +} + +impl PayerProofBuilder<DerivedSigningPubkey> { + /// Create a new builder with a pre-derived signing keypair. + /// + /// Derives the payer signing key using the same derivation scheme as invoice requests + /// created with `deriving_signing_pubkey`. Fails early if key derivation fails. + fn new_derived<T: secp256k1::Signing>( + invoice: &Bolt12Invoice, preimage: PaymentPreimage, expanded_key: &ExpandedKey, + secp_ctx: &Secp256k1<T>, + ) -> Result<Self, PayerProofError> { + let computed_hash = sha256::Hash::hash(&preimage.0); + if computed_hash.as_byte_array() != &invoice.payment_hash().0 { + return Err(PayerProofError::PreimageMismatch); + } + + let keys = invoice + .derive_payer_signing_keys(expanded_key, secp_ctx) + .map_err(|_| PayerProofError::KeyDerivationFailed)?; + + Ok(Self { + invoice_bytes: invoice.invoice_bytes().to_vec(), + contents: pending_contents(invoice, preimage), + included_types: default_included_types(invoice), + signing_strategy: DerivedSigningPubkey(keys), + }) + } + + /// Builds and signs a [`PayerProof`] using the keypair derived at construction time. + pub fn build_and_sign(self) -> Result<PayerProof, PayerProofError> { + let secp_ctx = Secp256k1::signing_only(); + let keys = self.signing_strategy.0; + let unsigned = self.build_unsigned()?; + // Signing with a derived keypair and an infallible closure cannot fail: + // the signing function never errors and verification succeeds because we + // derived the matching pubkey. + let proof = unsigned + .sign(|proof: &UnsignedPayerProof| { + Ok(secp_ctx.sign_schnorr_no_aux_rand(proof.as_ref().as_digest(), &keys)) + }) + .expect("signing with derived keys and infallible closure cannot fail"); + Ok(proof) + } +} + +impl<S: SigningPubkeyStrategy> PayerProofBuilder<S> { + /// Include a specific TLV type in the proof. + /// + /// Returns an error if the type is not allowed: `PAYER_METADATA_TYPE`, TLVs in + /// `SIGNATURE_TYPES`, or TLVs in `PAYER_PROOF_DATA_TYPES`. + pub fn include_type(mut self, tlv_type: u64) -> Result<Self, PayerProofError> { + if tlv_type == PAYER_METADATA_TYPE + || SIGNATURE_TYPES.contains(&tlv_type) + || PAYER_PROOF_DATA_TYPES.contains(&tlv_type) + { + return Err(PayerProofError::DisallowedTlvType(tlv_type)); + } + self.included_types.insert(tlv_type); + Ok(self) + } + + /// Include the offer description in the proof. + pub fn include_offer_description(mut self) -> Self { + self.included_types.insert(OFFER_DESCRIPTION_TYPE); + self + } + + /// Include the offer issuer in the proof. + pub fn include_offer_issuer(mut self) -> Self { + self.included_types.insert(OFFER_ISSUER_TYPE); + self + } + + /// Include the invoice amount in the proof. + pub fn include_invoice_amount(mut self) -> Self { + self.included_types.insert(INVOICE_AMOUNT_TYPE); + self + } + + /// Include the invoice creation timestamp in the proof. + pub fn include_invoice_created_at(mut self) -> Self { + self.included_types.insert(INVOICE_CREATED_AT_TYPE); + self + } + + /// Attach a `proof_note` to this proof. The note is scoped to the proof and + /// is committed to by the `proof_signature` alongside the invoice's merkle + /// root. It is independent of any [`InvoiceRequest::payer_note`] set during + /// the payment flow. + /// + /// [`InvoiceRequest::payer_note`]: crate::offers::invoice_request::InvoiceRequest::payer_note + pub fn with_proof_note(mut self, note: String) -> Self { + self.contents.proof_note = Some(note); + self + } + + fn build_unsigned(mut self) -> Result<UnsignedPayerProof, PayerProofError> { + let disclosed_fields = + DisclosedFields::from_records(TlvStream::new(&self.invoice_bytes).filter(|r| { + self.included_types.contains(&r.r#type) && !SIGNATURE_TYPES.contains(&r.r#type) + }))?; + + let disclosure = selective_disclosure::compute_selective_disclosure( + TlvStream::new(&self.invoice_bytes), + &self.included_types, + ); + + self.contents.disclosed_fields = disclosed_fields; + + Ok(UnsignedPayerProof::new( + &self.invoice_bytes, + &self.included_types, + self.contents, + disclosure, + )) + } +} + +/// Computes the [`TaggedHash`] for the `proof_signature` over the merkle root +/// of the payer-proof TLV stream. +fn proof_signature_hash(bytes: &[u8]) -> TaggedHash { + TaggedHash::from_valid_tlv_stream_bytes(PROOF_SIGNATURE_TAG, bytes) +} + +/// An unsigned [`PayerProof`] ready for signing. +/// +/// The serialised proof is stored as two byte buffers split at the +/// `proof_signature` TLV insertion point. [`Self::sign`] writes the freshly +/// computed `proof_signature` TLV between them to produce the final +/// [`PayerProof`] bytes, so no second serialisation pass is needed. The +/// [`TaggedHash`] is computed up front over the same concatenated stream. +pub struct UnsignedPayerProof { + /// Bytes of the included invoice records up to and including the + /// `invoice_signature` TLV (`PAYER_PROOF_ISSUER_SIGNATURE_TYPE`). + bytes_before_proof_signature: Vec<u8>, + /// Bytes of the payer-proof data TLVs followed by any disclosed + /// experimental invoice TLVs. Together with the bytes above, these form + /// the merkle-root input the `proof_signature` is computed over. + bytes_after_proof_signature: Vec<u8>, + contents: PayerProofContents, + /// Merkle root of the underlying invoice, surfaced on the resulting + /// [`PayerProof`]. + merkle_root: sha256::Hash, + tagged_hash: TaggedHash, +} + +impl UnsignedPayerProof { + /// Build an `UnsignedPayerProof` from the underlying invoice bytes, the + /// included TLV types, the proof contents (everything except + /// `proof_signature`), and the precomputed selective-disclosure data. + /// + /// This performs the byte-level serialization split at the + /// `proof_signature` TLV insertion point and computes the tagged hash, + /// so callers never see a partially-initialised struct. + fn new( + invoice_bytes: &[u8], included_types: &BTreeSet<u64>, contents: PayerProofContents, + disclosure: SelectiveDisclosure, + ) -> Self { + // Pre-`proof_signature` bytes hold the included invoice records below the signature range + // plus the `invoice_signature` TLV; post-`proof_signature` bytes hold the payer-proof data + // TLVs (preimage, omitted markers, missing/leaf hashes, note) plus any disclosed + // experimental invoice records. The pre-signature buffer is sized to hold its own records + // (a subset of `invoice_bytes`); the post-signature buffer starts at a fixed allowance for + // the data TLVs. Once both halves are built, the pre-signature buffer is grown to also hold + // the bytes `sign()` appends to it (see below). + const PROOF_DATA_TLVS_ALLOCATION_SIZE: usize = 256; + let mut bytes_before_proof_signature = Vec::with_capacity(invoice_bytes.len()); + let mut bytes_after_proof_signature = Vec::with_capacity(PROOF_DATA_TLVS_ALLOCATION_SIZE); + + // Emit included invoice records below the signature range, then the + // `invoice_signature` TLV. The `proof_signature` TLV is inserted at + // sign time between the buffer above and the buffer assembled below. + for record in TlvStream::new(invoice_bytes) + .range(0..PAYER_PROOF_ISSUER_SIGNATURE_TYPE) + .filter(|r| included_types.contains(&r.r#type)) + { + bytes_before_proof_signature.extend_from_slice(record.record_bytes); + } + let invoice_signature_tlv = PayerProofSignatureTlvStreamRef { + invoice_signature: Some(&contents.invoice_signature), + proof_signature: None, + }; + invoice_signature_tlv + .write(&mut bytes_before_proof_signature) + .expect("Vec write should not fail"); + + // Post-signature half: payer-proof data TLVs, then disclosed + // experimental invoice records. + let proof_omitted_markers = (!disclosure.omitted_markers.is_empty()) + .then(|| disclosure.omitted_markers.iter().copied().map(BigSize).collect::<Vec<_>>()); + let data = PayerProofDataTlvStreamRef { + proof_preimage: Some(&contents.preimage), + proof_omitted_markers: proof_omitted_markers.as_ref(), + proof_missing_hashes: (!disclosure.missing_hashes.is_empty()) + .then_some(&disclosure.missing_hashes), + proof_leaf_hashes: (!disclosure.nonce_hashes.is_empty()) + .then_some(&disclosure.nonce_hashes), + proof_note: contents.proof_note.as_ref(), + }; + data.write(&mut bytes_after_proof_signature).expect("Vec write should not fail"); + for record in TlvStream::new(invoice_bytes) + .range(EXPERIMENTAL_OFFER_TYPES.start..) + .filter(|r| included_types.contains(&r.r#type)) + { + bytes_after_proof_signature.extend_from_slice(record.record_bytes); + } + + // `sign()` reuses `bytes_before_proof_signature` as the final proof buffer: it appends the + // `proof_signature` TLV and then all of `bytes_after_proof_signature`. Reserve that exact + // size now so signing never has to resize the buffer. The `proof_signature` TLV is a + // fixed-size record: a `BigSize` type and length prefix around a 64-byte Schnorr signature. + const SIGNATURE_LEN: usize = 64; + let proof_signature_tlv_len = BigSize(PAYER_PROOF_PROOF_SIGNATURE_TYPE).serialized_length() + + BigSize(SIGNATURE_LEN as u64).serialized_length() + + SIGNATURE_LEN; + bytes_before_proof_signature + .reserve(proof_signature_tlv_len + bytes_after_proof_signature.len()); + + // The tagged hash for `proof_signature` is the merkle root over the + // full proof TLV stream excluding the `proof_signature` TLV itself. + // Iterate the two halves in sequence so no third buffer is allocated. + let tlv_stream = TlvStream::new(&bytes_before_proof_signature) + .chain(TlvStream::new(&bytes_after_proof_signature)); + let tagged_hash = TaggedHash::from_tlv_stream(PROOF_SIGNATURE_TAG, tlv_stream); + + Self { + bytes_before_proof_signature, + bytes_after_proof_signature, + contents, + merkle_root: disclosure.merkle_root, + tagged_hash, + } + } + + /// Signs the [`UnsignedPayerProof`] using the given function. + pub fn sign<F: SignPayerProofFn>(self, sign: F) -> Result<PayerProof, SignError> { + let pubkey = self.contents.payer_signing_pubkey; + let proof_signature = merkle::sign_message(sign, &self, pubkey)?; + + // Assemble the final proof bytes by inserting the proof_signature TLV + // between the pre- and post-signature halves we serialised at build + // time. + let mut bytes = self.bytes_before_proof_signature; + let proof_signature_tlv = PayerProofSignatureTlvStreamRef { + invoice_signature: None, + proof_signature: Some(&proof_signature), + }; + proof_signature_tlv.write(&mut bytes).expect("Vec write should not fail"); + bytes.extend_from_slice(&self.bytes_after_proof_signature); + + Ok(PayerProof { + bytes, + contents: self.contents, + proof_signature, + merkle_root: self.merkle_root, + }) + } +} + +impl AsRef<TaggedHash> for UnsignedPayerProof { + fn as_ref(&self) -> &TaggedHash { + &self.tagged_hash + } +} + +/// A function for signing an [`UnsignedPayerProof`]. +pub trait SignPayerProofFn { + /// Signs a [`TaggedHash`] computed over the payer-proof TLV stream, excluding + /// the `proof_signature` TLV being produced. + fn sign_payer_proof(&self, message: &UnsignedPayerProof) -> Result<Signature, ()>; +} + +impl<F> SignPayerProofFn for F +where + F: Fn(&UnsignedPayerProof) -> Result<Signature, ()>, +{ + fn sign_payer_proof(&self, message: &UnsignedPayerProof) -> Result<Signature, ()> { + self(message) + } +} + +impl<F> merkle::SignFn<UnsignedPayerProof> for F +where + F: SignPayerProofFn, +{ + fn sign(&self, message: &UnsignedPayerProof) -> Result<Signature, ()> { + self.sign_payer_proof(message) + } +} + +// The proof's signature TLVs sit in the BOLT 12 `SIGNATURE_TYPES` range and are +// excluded from the standard merkle-root computation. +tlv_stream!( + PayerProofSignatureTlvStream, PayerProofSignatureTlvStreamRef<'a>, SIGNATURE_TYPES, { + (PAYER_PROOF_ISSUER_SIGNATURE_TYPE, invoice_signature: Signature), + (PAYER_PROOF_PROOF_SIGNATURE_TYPE, proof_signature: Signature), + } +); + +// The data-bearing TLVs sit in `PAYER_PROOF_DATA_TYPES`, outside the signature +// range, so the standard merkle root for `proof_signature` includes them as +// leaves. +tlv_stream!( + PayerProofDataTlvStream, PayerProofDataTlvStreamRef<'a>, PAYER_PROOF_DATA_TYPES, { + (PAYER_PROOF_PREIMAGE_TYPE, proof_preimage: PaymentPreimage), + (PAYER_PROOF_OMITTED_TLVS_TYPE, proof_omitted_markers: (Vec<BigSize>, WithoutLength)), + (PAYER_PROOF_MISSING_HASHES_TYPE, proof_missing_hashes: (Vec<sha256::Hash>, WithoutLength)), + (PAYER_PROOF_LEAF_HASHES_TYPE, proof_leaf_hashes: (Vec<sha256::Hash>, WithoutLength)), + (PAYER_PROOF_PROOF_NOTE_TYPE, proof_note: (String, WithoutLength)), + } +); + +// Ordered to match canonical TLV ordering: offer, invoice_request, invoice, +// signature, proof data, experimental_offer, experimental_invoice_request, +// experimental_invoice. +type FullPayerProofTlvStream = ( + OfferTlvStream, + InvoiceRequestTlvStream, + InvoiceTlvStream, + PayerProofSignatureTlvStream, + PayerProofDataTlvStream, + ExperimentalOfferTlvStream, + ExperimentalInvoiceRequestTlvStream, + ExperimentalInvoiceTlvStream, +); + +impl CursorReadable for FullPayerProofTlvStream { + fn read<R: AsRef<[u8]>>(r: &mut io::Cursor<R>) -> Result<Self, DecodeError> { + let offer = CursorReadable::read(r)?; + let invoice_request = CursorReadable::read(r)?; + let invoice = CursorReadable::read(r)?; + let payer_proof_signatures = CursorReadable::read(r)?; + let payer_proof_data = CursorReadable::read(r)?; + let experimental_offer = CursorReadable::read(r)?; + let experimental_invoice_request = CursorReadable::read(r)?; + let experimental_invoice = CursorReadable::read(r)?; + + Ok(( + offer, + invoice_request, + invoice, + payer_proof_signatures, + payer_proof_data, + experimental_offer, + experimental_invoice_request, + experimental_invoice, + )) + } +} + +impl PayerProof { + /// The payment preimage proving the invoice was paid. + pub fn payment_preimage(&self) -> PaymentPreimage { + self.contents.preimage + } + + /// The payer's public key (who paid). + pub fn payer_signing_pubkey(&self) -> PublicKey { + self.contents.payer_signing_pubkey + } + + /// The issuer's signing public key (the key that signed the invoice). + pub fn issuer_signing_pubkey(&self) -> PublicKey { + self.contents.issuer_signing_pubkey + } + + /// The payment hash. + pub fn payment_hash(&self) -> PaymentHash { + self.contents.payment_hash + } + + /// The invoice signature over the merkle root. + pub fn invoice_signature(&self) -> Signature { + self.contents.invoice_signature + } + + /// The payer's schnorr signature proving who authorized the payment. + pub fn proof_signature(&self) -> Signature { + self.proof_signature + } + + /// The disclosed offer description, if included in the proof. + pub fn offer_description(&self) -> Option<PrintableString<'_>> { + self.contents.disclosed_fields.offer_description.as_deref().map(PrintableString) + } + + /// The disclosed offer issuer, if included in the proof. + pub fn offer_issuer(&self) -> Option<PrintableString<'_>> { + self.contents.disclosed_fields.offer_issuer.as_deref().map(PrintableString) + } + + /// The disclosed invoice amount, if included in the proof. + pub fn invoice_amount_msats(&self) -> Option<u64> { + self.contents.disclosed_fields.invoice_amount_msats + } + + /// The disclosed invoice creation time, if included in the proof. + pub fn invoice_created_at(&self) -> Option<Duration> { + self.contents.disclosed_fields.invoice_created_at + } + + /// A note the payer attached to this proof, if any. + /// + /// This is distinct from [`InvoiceRequest::payer_note`]: the invoice-request note is + /// sent to the payee at payment time, while this note is scoped to the proof and is + /// committed to by the [`proof_signature`] alongside the invoice's merkle root. + /// + /// [`InvoiceRequest::payer_note`]: crate::offers::invoice_request::InvoiceRequest::payer_note + /// [`proof_signature`]: Self::proof_signature + pub fn proof_note(&self) -> Option<PrintableString<'_>> { + self.contents.proof_note.as_deref().map(PrintableString) + } + + /// The merkle root of the original invoice. + pub fn merkle_root(&self) -> sha256::Hash { + self.merkle_root + } + + /// The raw bytes of the payer proof. + pub fn bytes(&self) -> &[u8] { + &self.bytes + } +} + +impl Bech32Encode for PayerProof { + const BECH32_HRP: &'static str = PAYER_PROOF_HRP; +} + +impl Writeable for PayerProof { + fn write<W: Writer>(&self, writer: &mut W) -> Result<(), io::Error> { + WithoutLength(&self.bytes).write(writer) + } +} + +impl AsRef<[u8]> for PayerProof { + fn as_ref(&self) -> &[u8] { + &self.bytes + } +} + +impl DisclosedFields { + fn update(&mut self, record: &TlvRecord<'_>) -> Result<(), DecodeError> { + match record.r#type { + OFFER_DESCRIPTION_TYPE => { + self.offer_description = Some( + String::from_utf8(record.value_bytes.to_vec()) + .map_err(|_| DecodeError::InvalidValue)?, + ); + }, + OFFER_ISSUER_TYPE => { + self.offer_issuer = Some( + String::from_utf8(record.value_bytes.to_vec()) + .map_err(|_| DecodeError::InvalidValue)?, + ); + }, + INVOICE_CREATED_AT_TYPE => { + self.invoice_created_at = Some(Duration::from_secs( + record.read_value::<HighZeroBytesDroppedBigSize<u64>>()?.0, + )); + }, + INVOICE_AMOUNT_TYPE => { + self.invoice_amount_msats = + Some(record.read_value::<HighZeroBytesDroppedBigSize<u64>>()?.0); + }, + _ => {}, + } + + Ok(()) + } + + fn from_records<'a>( + records: impl core::iter::Iterator<Item = TlvRecord<'a>>, + ) -> Result<Self, DecodeError> { + let mut disclosed_fields = DisclosedFields::default(); + for record in records { + disclosed_fields.update(&record)?; + } + Ok(disclosed_fields) + } +} + +struct ParsedPayerProofFields { + contents: PayerProofContents, + proof_signature: Signature, + omitted_markers: Vec<u64>, + missing_hashes: Vec<sha256::Hash>, + leaf_hashes: Vec<sha256::Hash>, +} + +impl TryFrom<FullPayerProofTlvStream> for ParsedPayerProofFields { + type Error = Bolt12ParseError; + + fn try_from(tlv_stream: FullPayerProofTlvStream) -> Result<Self, Self::Error> { + let ( + OfferTlvStream { description, issuer, .. }, + // `payer_id` is the TLV-stream field name (tied to the spec TLV). Rebind to + // `payer_signing_pubkey` to match `PayerProofContents` naming. + InvoiceRequestTlvStream { payer_id: payer_signing_pubkey, .. }, + InvoiceTlvStream { created_at, payment_hash, amount, node_id, .. }, + PayerProofSignatureTlvStream { invoice_signature, proof_signature }, + PayerProofDataTlvStream { + proof_preimage, + proof_omitted_markers, + proof_missing_hashes, + proof_leaf_hashes, + proof_note, + }, + _experimental_offer, + _experimental_invoice_request, + _experimental_invoice, + ) = tlv_stream; + + let payer_signing_pubkey = payer_signing_pubkey.ok_or( + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::MissingPayerSigningPubkey), + )?; + let payment_hash = payment_hash + .ok_or(Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::MissingPaymentHash))?; + let issuer_signing_pubkey = node_id + .ok_or(Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::MissingSigningPubkey))?; + let invoice_signature = invoice_signature + .ok_or(Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::MissingSignature))?; + let preimage = proof_preimage.ok_or(Bolt12ParseError::Decode(DecodeError::InvalidValue))?; + let proof_signature = proof_signature + .ok_or(Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::MissingSignature))?; + // Per BOLT 12 PR 1295, both `proof_missing_hashes` and `proof_leaf_hashes` + // TLVs MUST be present. `proof_omitted_markers` MAY be omitted when empty. + let missing_hashes = + proof_missing_hashes.ok_or(Bolt12ParseError::Decode(DecodeError::InvalidValue))?; + let leaf_hashes = + proof_leaf_hashes.ok_or(Bolt12ParseError::Decode(DecodeError::InvalidValue))?; + + Ok(Self { + contents: PayerProofContents { + payer_signing_pubkey, + payment_hash, + issuer_signing_pubkey, + preimage, + invoice_signature, + proof_note, + disclosed_fields: DisclosedFields { + offer_description: description, + offer_issuer: issuer, + invoice_amount_msats: amount, + invoice_created_at: created_at.map(Duration::from_secs), + }, + }, + proof_signature, + omitted_markers: proof_omitted_markers + .unwrap_or_default() + .into_iter() + .map(|marker| marker.0) + .collect(), + missing_hashes, + leaf_hashes, + }) + } +} + +fn tlv_stream_iter<'a>(bytes: &'a [u8]) -> impl core::iter::Iterator<Item = TlvRecord<'a>> { + // Strip both `SIGNATURE_TYPES` and `PAYER_PROOF_DATA_TYPES` so the + // remaining records reconstruct the invoice merkle root. + TlvStream::new(bytes).filter(|record| { + !SIGNATURE_TYPES.contains(&record.r#type) + && !PAYER_PROOF_DATA_TYPES.contains(&record.r#type) + }) +} + +impl TryFrom<Vec<u8>> for PayerProof { + type Error = Bolt12ParseError; + + fn try_from(bytes: Vec<u8>) -> Result<Self, Self::Error> { + let parsed_proof = ParsedMessage::<FullPayerProofTlvStream>::try_from(bytes)?; + let ParsedMessage { bytes, tlv_stream } = parsed_proof; + let ParsedPayerProofFields { + contents, + proof_signature, + omitted_markers, + missing_hashes, + leaf_hashes, + } = ParsedPayerProofFields::try_from(tlv_stream)?; + let included_records: Vec<_> = tlv_stream_iter(&bytes).collect(); + let included_types = included_records.iter().map(|record| record.r#type).collect(); + + validate_omitted_markers_for_parsing(&omitted_markers, &included_types) + .map_err(Bolt12ParseError::Decode)?; + + if leaf_hashes.len() != included_records.len() { + return Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)); + } + + let merkle_root = selective_disclosure::reconstruct_merkle_root( + &included_records, + &leaf_hashes, + &omitted_markers, + &missing_hashes, + ) + .map_err(|_| Bolt12ParseError::Decode(DecodeError::InvalidValue))?; + + // Verify preimage matches payment hash. + let computed = sha256::Hash::hash(&contents.preimage.0); + if computed.as_byte_array() != &contents.payment_hash.0 { + return Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)); + } + + // Verify the invoice signature against the issuer signing pubkey. + let tagged_hash = TaggedHash::from_merkle_root(SIGNATURE_TAG, merkle_root); + merkle::verify_signature( + &contents.invoice_signature, + &tagged_hash, + contents.issuer_signing_pubkey, + ) + .map_err(|_| Bolt12ParseError::Decode(DecodeError::InvalidValue))?; + + // Verify the payer signature against the merkle root of the proof + // itself, computed over every payer-proof TLV except the + // `proof_signature` TLV being verified. See module docs. + let proof_tagged_hash = proof_signature_hash(&bytes); + merkle::verify_signature( + &proof_signature, + &proof_tagged_hash, + contents.payer_signing_pubkey, + ) + .map_err(|_| Bolt12ParseError::Decode(DecodeError::InvalidValue))?; + + Ok(PayerProof { bytes, contents, proof_signature, merkle_root }) + } +} + +/// Validate omitted markers during parsing. +/// +/// Per spec: +/// - MUST NOT contain 0 +/// - MUST be in one of the two valid ranges: `1..=239` or +/// `1_000_000_000..=3_999_999_999`. Anything in the signature range +/// (`240..=1000`), the payer-proof data range (`1001..=999_999_999`), or +/// above the experimental invoice range (`>= 4_000_000_000`) is rejected. +/// - MUST be in strict ascending order +/// - MUST NOT contain the number of an included TLV field +/// - Markers MUST be minimized: each marker is the marker number following the +/// previous marker (or the previous included type X) — one greater, except a +/// value that would land in the signature/payer-proof gap jumps to the +/// experimental range. This naturally allows a trailing run of omitted TLVs +/// after the final included type. +fn validate_omitted_markers_for_parsing( + omitted_markers: &[u64], included_types: &BTreeSet<u64>, +) -> Result<(), DecodeError> { + // Payer-proof range restriction: each marker MUST be inside one of the two valid ranges + // (`1..=239` or `1_000_000_000..=3_999_999_999`), i.e. outside the signature and + // payer-proof-data ranges and below the end of the experimental range. + for &marker in omitted_markers { + if SIGNATURE_TYPES.contains(&marker) + || PAYER_PROOF_DATA_TYPES.contains(&marker) + || marker >= EXPERIMENTAL_INVOICE_TYPES.end + { + return Err(DecodeError::InvalidValue); + } + } + + // Ordering, non-zero, not-an-included-type, and minimization are enforced by the merkle layer, + // the single source of truth for marker validity (see `selective_disclosure::validate_omitted_markers`). + selective_disclosure::validate_omitted_markers(omitted_markers, included_types) + .map_err(|_| DecodeError::InvalidValue) +} + +impl core::str::FromStr for PayerProof { + type Err = Bolt12ParseError; + + fn from_str(s: &str) -> Result<Self, <Self as core::str::FromStr>::Err> { + Self::from_bech32_str(s) + } +} + +impl core::fmt::Display for PayerProof { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + self.fmt_bech32_str(f) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ln::channelmanager::PaymentId; + use crate::ln::inbound_payment::ExpandedKey; + use crate::offers::nonce::Nonce; + #[cfg(not(c_bindings))] + use crate::offers::refund::RefundBuilder; + #[cfg(c_bindings)] + use crate::offers::refund::RefundMaybeWithDerivedMetadataBuilder as RefundBuilder; + use crate::offers::selective_disclosure::compute_selective_disclosure; + use crate::offers::test_utils::*; + use crate::util::ser::Readable; + use bitcoin::hashes::Hash; + use bitcoin::secp256k1::{Keypair, Secp256k1, SecretKey}; + use core::time::Duration; + + const EXPERIMENTAL_TEST_TLV_TYPE: u64 = 1_000_000_001; + + fn write_tlv_record<T: Writeable>(bytes: &mut Vec<u8>, tlv_type: u64, value: &T) { + let mut value_bytes = Vec::new(); + value.write(&mut value_bytes).expect("Vec write should not fail"); + + BigSize(tlv_type).write(bytes).expect("Vec write should not fail"); + BigSize(value_bytes.len() as u64).write(bytes).expect("Vec write should not fail"); + bytes.extend_from_slice(&value_bytes); + } + + fn write_tlv_record_bytes(bytes: &mut Vec<u8>, tlv_type: u64, value_bytes: &[u8]) { + BigSize(tlv_type).write(bytes).expect("Vec write should not fail"); + BigSize(value_bytes.len() as u64).write(bytes).expect("Vec write should not fail"); + bytes.extend_from_slice(value_bytes); + } + + /// Builds a proof whose underlying invoice carries a synthetic experimental TLV + /// at type [`EXPERIMENTAL_TEST_TLV_TYPE`] (`1_000_000_001`). Constructed by + /// hand because the public `RefundBuilder` API doesn't expose a way to write + /// arbitrary experimental TLV types — the existing `experimental_foo` family + /// of methods uses fixed type numbers that wouldn't exercise the same code + /// path. + fn build_round_trip_proof_with_included_experimental_tlv() -> PayerProof { + let secp_ctx = Secp256k1::new(); + + let payer_secret = SecretKey::from_slice(&[42; 32]).unwrap(); + let payer_keys = Keypair::from_secret_key(&secp_ctx, &payer_secret); + let payer_signing_pubkey = payer_keys.public_key(); + + let issuer_secret = SecretKey::from_slice(&[43; 32]).unwrap(); + let issuer_keys = Keypair::from_secret_key(&secp_ctx, &issuer_secret); + let issuer_signing_pubkey = issuer_keys.public_key(); + + let preimage = PaymentPreimage([44; 32]); + let payment_hash = PaymentHash(sha256::Hash::hash(&preimage.0).to_byte_array()); + + let mut invoice_bytes = Vec::new(); + write_tlv_record_bytes(&mut invoice_bytes, PAYER_METADATA_TYPE, &[45; 32]); + write_tlv_record(&mut invoice_bytes, INVOICE_REQUEST_PAYER_ID_TYPE, &payer_signing_pubkey); + write_tlv_record(&mut invoice_bytes, INVOICE_PAYMENT_HASH_TYPE, &payment_hash); + write_tlv_record(&mut invoice_bytes, INVOICE_NODE_ID_TYPE, &issuer_signing_pubkey); + write_tlv_record_bytes( + &mut invoice_bytes, + EXPERIMENTAL_TEST_TLV_TYPE, + b"experimental-payer-proof-field", + ); + + let invoice_message = + TaggedHash::from_valid_tlv_stream_bytes(SIGNATURE_TAG, &invoice_bytes); + let invoice_signature = + secp_ctx.sign_schnorr_no_aux_rand(invoice_message.as_digest(), &issuer_keys); + + let included_types: BTreeSet<u64> = [ + INVOICE_REQUEST_PAYER_ID_TYPE, + INVOICE_PAYMENT_HASH_TYPE, + INVOICE_NODE_ID_TYPE, + EXPERIMENTAL_TEST_TLV_TYPE, + ] + .into_iter() + .collect(); + let disclosed_fields = DisclosedFields::from_records( + TlvStream::new(&invoice_bytes).filter(|r| included_types.contains(&r.r#type)), + ) + .unwrap(); + let disclosure = + compute_selective_disclosure(TlvStream::new(&invoice_bytes), &included_types); + + let contents = PayerProofContents { + payer_signing_pubkey, + payment_hash, + issuer_signing_pubkey, + preimage, + invoice_signature, + proof_note: None, + disclosed_fields, + }; + let unsigned = + UnsignedPayerProof::new(&invoice_bytes, &included_types, contents, disclosure); + + unsigned + .sign(|proof: &UnsignedPayerProof| { + Ok(secp_ctx.sign_schnorr_no_aux_rand(proof.as_ref().as_digest(), &payer_keys)) + }) + .unwrap() + } + + /// Builds a proof with two consecutive *trailing* omitted experimental TLVs at + /// types `1_000_000_001` and `1_000_000_003`. The exact layout is load-bearing + /// for the `omitted_markers == [177, 178]` assertion on the parsed proof, and + /// the public `RefundBuilder` API doesn't expose a way to produce that exact + /// pair of trailing experimental types — so this helper writes the invoice + /// bytes by hand. + fn build_round_trip_proof_with_multiple_trailing_omitted_tlvs() -> PayerProof { + let secp_ctx = Secp256k1::new(); + + let payer_secret = SecretKey::from_slice(&[52; 32]).unwrap(); + let payer_keys = Keypair::from_secret_key(&secp_ctx, &payer_secret); + let payer_signing_pubkey = payer_keys.public_key(); + + let issuer_secret = SecretKey::from_slice(&[53; 32]).unwrap(); + let issuer_keys = Keypair::from_secret_key(&secp_ctx, &issuer_secret); + let issuer_signing_pubkey = issuer_keys.public_key(); + + let preimage = PaymentPreimage([54; 32]); + let payment_hash = PaymentHash(sha256::Hash::hash(&preimage.0).to_byte_array()); + + let mut invoice_bytes = Vec::new(); + write_tlv_record_bytes(&mut invoice_bytes, PAYER_METADATA_TYPE, &[55; 32]); + write_tlv_record(&mut invoice_bytes, INVOICE_REQUEST_PAYER_ID_TYPE, &payer_signing_pubkey); + write_tlv_record(&mut invoice_bytes, INVOICE_PAYMENT_HASH_TYPE, &payment_hash); + write_tlv_record(&mut invoice_bytes, INVOICE_NODE_ID_TYPE, &issuer_signing_pubkey); + write_tlv_record_bytes(&mut invoice_bytes, 1_000_000_001, b"first-omitted-experimental"); + write_tlv_record_bytes(&mut invoice_bytes, 1_000_000_003, b"second-omitted-experimental"); + + let invoice_message = + TaggedHash::from_valid_tlv_stream_bytes(SIGNATURE_TAG, &invoice_bytes); + let invoice_signature = + secp_ctx.sign_schnorr_no_aux_rand(invoice_message.as_digest(), &issuer_keys); + + let included_types: BTreeSet<u64> = + [INVOICE_REQUEST_PAYER_ID_TYPE, INVOICE_PAYMENT_HASH_TYPE, INVOICE_NODE_ID_TYPE] + .into_iter() + .collect(); + let disclosed_fields = DisclosedFields::from_records( + TlvStream::new(&invoice_bytes).filter(|r| included_types.contains(&r.r#type)), + ) + .unwrap(); + let disclosure = + compute_selective_disclosure(TlvStream::new(&invoice_bytes), &included_types); + assert_eq!(disclosure.omitted_markers, vec![177, 178]); + + let contents = PayerProofContents { + payer_signing_pubkey, + payment_hash, + issuer_signing_pubkey, + preimage, + invoice_signature, + proof_note: None, + disclosed_fields, + }; + let unsigned = + UnsignedPayerProof::new(&invoice_bytes, &included_types, contents, disclosure); + + unsigned + .sign(|proof: &UnsignedPayerProof| { + Ok(secp_ctx.sign_schnorr_no_aux_rand(proof.as_ref().as_digest(), &payer_keys)) + }) + .unwrap() + } + + fn build_round_trip_proof_with_disclosed_fields() -> PayerProof { + let preimage = PaymentPreimage([64; 32]); + let payment_hash = PaymentHash(*sha256::Hash::hash(&preimage.0).as_byte_array()); + let invoice = RefundBuilder::new(vec![1; 32], payer_pubkey(), 42_000) + .unwrap() + .description("coffee beans".into()) + .issuer("LDK Roastery".into()) + .build() + .unwrap() + .respond_with_no_std( + payment_paths(), + payment_hash, + recipient_pubkey(), + Duration::from_secs(1_700_000_000), + ) + .unwrap() + .build() + .unwrap() + .sign(recipient_sign) + .unwrap(); + + let paid_invoice = PaidBolt12Invoice::Bolt12Invoice(invoice); + paid_invoice + .prove_payer(preimage) + .unwrap() + .include_offer_description() + .include_offer_issuer() + .include_invoice_amount() + .include_invoice_created_at() + .build() + .unwrap() + .sign(|proof: &UnsignedPayerProof| payer_sign(proof)) + .unwrap() + } + + /// Returns a fresh builder over a dummy paid invoice, for exercising the `include_type` API. + fn payer_proof_builder() -> PayerProofBuilder<ExplicitSigningPubkey> { + let preimage = PaymentPreimage([64; 32]); + let payment_hash = PaymentHash(*sha256::Hash::hash(&preimage.0).as_byte_array()); + let invoice = RefundBuilder::new(vec![1; 32], payer_pubkey(), 42_000) + .unwrap() + .build() + .unwrap() + .respond_with_no_std( + payment_paths(), + payment_hash, + recipient_pubkey(), + Duration::from_secs(1_700_000_000), + ) + .unwrap() + .build() + .unwrap() + .sign(recipient_sign) + .unwrap(); + PaidBolt12Invoice::Bolt12Invoice(invoice).prove_payer(preimage).unwrap() + } + + #[test] + fn test_selective_disclosure_computation() { + // Test that the merkle selective disclosure works correctly + // Simple TLV stream with types 1, 2 + let tlv_bytes = vec![ + 0x01, 0x03, 0xe8, 0x03, 0xe8, // type 1, length 3, value + 0x02, 0x08, 0x00, 0x00, 0x01, 0x00, 0x00, 0x02, 0x00, 0x03, // type 2 + ]; + + let mut included = BTreeSet::new(); + included.insert(1); + + let disclosure = compute_selective_disclosure(TlvStream::new(&tlv_bytes), &included); + assert_eq!(disclosure.nonce_hashes.len(), 1); // One included TLV + assert!(!disclosure.missing_hashes.is_empty()); // Should have missing hashes for omitted + } + + /// Test the omitted_markers marker algorithm with two included runs (10 and 40). + /// + /// TLVs: 0 (omitted), 10 (included), 20 (omitted), 30 (omitted), + /// 40 (included), 50 (omitted), 60 (omitted) + /// + /// Expected markers: [11, 12, 41, 42] + /// + /// The algorithm: + /// - TLV 0 is always omitted and implicit (not in markers) + /// - For omitted TLV after included: marker = prev_included_type + 1 + /// - For consecutive omitted TLVs: marker = prev_marker + 1 + #[test] + fn test_omitted_markers_two_included_runs() { + // Build a synthetic TLV stream + // TLV format: type (BigSize) || length (BigSize) || value + let mut tlv_bytes = Vec::new(); + + // TLV 0: type=0, len=4, value=dummy + tlv_bytes.extend_from_slice(&[0x00, 0x04, 0x00, 0x00, 0x00, 0x00]); + // TLV 10: type=10, len=2, value=dummy + tlv_bytes.extend_from_slice(&[0x0a, 0x02, 0x00, 0x00]); + // TLV 20: type=20, len=2, value=dummy + tlv_bytes.extend_from_slice(&[0x14, 0x02, 0x00, 0x00]); + // TLV 30: type=30, len=2, value=dummy + tlv_bytes.extend_from_slice(&[0x1e, 0x02, 0x00, 0x00]); + // TLV 40: type=40, len=2, value=dummy + tlv_bytes.extend_from_slice(&[0x28, 0x02, 0x00, 0x00]); + // TLV 50: type=50, len=2, value=dummy + tlv_bytes.extend_from_slice(&[0x32, 0x02, 0x00, 0x00]); + // TLV 60: type=60, len=2, value=dummy + tlv_bytes.extend_from_slice(&[0x3c, 0x02, 0x00, 0x00]); + + // Include types 10 and 40 + let mut included = BTreeSet::new(); + included.insert(10); + included.insert(40); + + let disclosure = compute_selective_disclosure(TlvStream::new(&tlv_bytes), &included); + + assert_eq!(disclosure.omitted_markers, vec![11, 12, 41, 42]); + + // nonce_hashes should have 2 entries (one for each included TLV) + assert_eq!(disclosure.nonce_hashes.len(), 2); + } + + /// Test the omitted_markers + missing_hashes algorithms against the BOLT 12 + /// PR 1295 spec example (post commit `d6dbb9d8`). + /// + /// TLVs: 0, 10, 20, 30 (all omitted), 40 (included), 50, 60 (both omitted) + /// + /// Per spec lines 1131-1146 of `12-offer-encoding.md`: + /// - `omitted_tlvs` array = `[1, 2, 3, 41, 42]` (markers 1..3 cover the + /// leading omitted run after implicit TLV0; 41,42 cover the trailing run) + /// - `missing_hashes` is in post-order DFS order: + /// 1. leaf hash for TLV 50 + /// 2. leaf hash for TLV 60 + /// 3. the entire `(0,10) | (20,30)` left subtree (asterisk node) + #[test] + fn test_omitted_markers_spec_example() { + // TLV format: type (BigSize) || length (BigSize) || value + let mut tlv_bytes = Vec::new(); + + // TLV 0: type=0, len=4, value=dummy + tlv_bytes.extend_from_slice(&[0x00, 0x04, 0x00, 0x00, 0x00, 0x00]); + // TLV 10: type=10, len=2, value=dummy + tlv_bytes.extend_from_slice(&[0x0a, 0x02, 0x00, 0x00]); + // TLV 20: type=20, len=2, value=dummy + tlv_bytes.extend_from_slice(&[0x14, 0x02, 0x00, 0x00]); + // TLV 30: type=30, len=2, value=dummy + tlv_bytes.extend_from_slice(&[0x1e, 0x02, 0x00, 0x00]); + // TLV 40: type=40, len=2, value=dummy + tlv_bytes.extend_from_slice(&[0x28, 0x02, 0x00, 0x00]); + // TLV 50: type=50, len=2, value=dummy + tlv_bytes.extend_from_slice(&[0x32, 0x02, 0x00, 0x00]); + // TLV 60: type=60, len=2, value=dummy + tlv_bytes.extend_from_slice(&[0x3c, 0x02, 0x00, 0x00]); + + // Include only TLV 40 (matching the spec example) + let mut included = BTreeSet::new(); + included.insert(40); + + let disclosure = compute_selective_disclosure(TlvStream::new(&tlv_bytes), &included); + + // Per spec example: omitted_markers = [1, 2, 3, 41, 42] + assert_eq!(disclosure.omitted_markers, vec![1, 2, 3, 41, 42]); + + // One leaf_hash for the single included TLV (40) + assert_eq!(disclosure.nonce_hashes.len(), 1); + + // Post-order DFS missing_hashes: [TLV50_leaf, TLV60_leaf, left_subtree] + assert_eq!(disclosure.missing_hashes.len(), 3); + } + + /// Test that the marker algorithm handles edge cases correctly. + #[test] + fn test_omitted_markers_edge_cases() { + // Test with only one included TLV at the start + let mut tlv_bytes = Vec::new(); + tlv_bytes.extend_from_slice(&[0x00, 0x04, 0x00, 0x00, 0x00, 0x00]); // TLV 0 + tlv_bytes.extend_from_slice(&[0x0a, 0x02, 0x00, 0x00]); // TLV 10 + tlv_bytes.extend_from_slice(&[0x14, 0x02, 0x00, 0x00]); // TLV 20 + tlv_bytes.extend_from_slice(&[0x1e, 0x02, 0x00, 0x00]); // TLV 30 + + let mut included = BTreeSet::new(); + included.insert(10); + + let disclosure = compute_selective_disclosure(TlvStream::new(&tlv_bytes), &included); + + // After included type 10, omitted types 20 and 30 get markers 11 and 12 + assert_eq!(disclosure.omitted_markers, vec![11, 12]); + } + + /// Test that all included TLVs produce no omitted markers (except implicit TLV0). + #[test] + fn test_omitted_markers_all_included() { + let mut tlv_bytes = Vec::new(); + tlv_bytes.extend_from_slice(&[0x00, 0x04, 0x00, 0x00, 0x00, 0x00]); // TLV 0 (always omitted) + tlv_bytes.extend_from_slice(&[0x0a, 0x02, 0x00, 0x00]); // TLV 10 + tlv_bytes.extend_from_slice(&[0x14, 0x02, 0x00, 0x00]); // TLV 20 + + let mut included = BTreeSet::new(); + included.insert(10); + included.insert(20); + + let disclosure = compute_selective_disclosure(TlvStream::new(&tlv_bytes), &included); + + // Only TLV 0 is omitted (implicit), so no markers needed + assert!(disclosure.omitted_markers.is_empty()); + } + + /// Test validation of omitted_markers - must not contain 0. + #[test] + fn test_validate_omitted_markers_rejects_zero() { + let omitted = vec![0, 11, 12]; + let included: BTreeSet<u64> = [10, 30].iter().copied().collect(); + + let result = validate_omitted_markers_for_parsing(&omitted, &included); + assert!(result.is_err()); + } + + /// Test validation of omitted_markers - must not contain signature types. + #[test] + fn test_validate_omitted_markers_rejects_signature_types() { + // included=[10], markers=[1, 2, 250] — 250 is a signature type + let omitted = vec![1, 2, 250]; + let included: BTreeSet<u64> = [10].iter().copied().collect(); + + let result = validate_omitted_markers_for_parsing(&omitted, &included); + assert!(result.is_err()); + } + + /// Test validation of omitted_markers - must not contain payer-proof data + /// range types (1001..=999_999_999) per BOLT 12 PR 1295. + #[test] + fn test_validate_omitted_markers_rejects_data_range_types() { + let included: BTreeSet<u64> = [10].iter().copied().collect(); + + // 1001 is the low end of the data range + assert!(validate_omitted_markers_for_parsing(&[1, 2, 1001], &included).is_err()); + // somewhere in the middle of the data range + assert!(validate_omitted_markers_for_parsing(&[1, 2, 500_000_000], &included).is_err()); + // 999_999_999 is the high end of the data range + assert!(validate_omitted_markers_for_parsing(&[1, 2, 999_999_999], &included).is_err()); + } + + /// Test validation of omitted_markers - must not contain values above the + /// experimental invoice range (>= 4_000_000_000) per BOLT 12 PR 1295. + #[test] + fn test_validate_omitted_markers_rejects_above_experimental_range() { + let included: BTreeSet<u64> = [10].iter().copied().collect(); + + // 4_000_000_000 is the lowest invalid value + assert!(validate_omitted_markers_for_parsing(&[1, 2, 4_000_000_000], &included).is_err()); + // far above + assert!(validate_omitted_markers_for_parsing(&[1, 2, u64::MAX], &included).is_err()); + } + + /// Test validation of omitted_markers - must be strictly ascending. + #[test] + fn test_validate_omitted_markers_rejects_non_ascending() { + // markers=[1, 11, 9]: 1 ok, 11 ok (after included 10), but 9 <= 11 fails ascending + let omitted = vec![1, 11, 9]; + let included: BTreeSet<u64> = [10, 30].iter().copied().collect(); + + let result = validate_omitted_markers_for_parsing(&omitted, &included); + assert!(result.is_err()); + } + + /// Test validation of omitted_markers - must not contain included types. + #[test] + fn test_validate_omitted_markers_rejects_included_types() { + // included=[10, 30], markers=[1, 10] — 10 is in included set + let omitted = vec![1, 10]; + let included: BTreeSet<u64> = [10, 30].iter().copied().collect(); + + let result = validate_omitted_markers_for_parsing(&omitted, &included); + assert!(matches!(result, Err(DecodeError::InvalidValue))); + } + + /// Test that a minimized trailing run is accepted. + #[test] + fn test_validate_omitted_markers_accepts_trailing_run() { + // included=[10, 20], markers=[1, 21, 22] — both 21 and 22 > max included (20) + let omitted = vec![1, 21, 22]; + let included: BTreeSet<u64> = [10, 20].iter().copied().collect(); + + let result = validate_omitted_markers_for_parsing(&omitted, &included); + assert!(result.is_ok()); + } + + /// Test that valid minimized omitted_markers pass validation. + #[test] + fn test_validate_omitted_markers_accepts_valid() { + // Realistic payer proof: included types include required fields (88, 168, 176) + // so max_included=176 and markers are well below it. + // Layout: 0(omit), 10(incl), 20(omit), 30(omit), 40(incl), 50(omit), 88(incl), + // 168(incl), 176(incl) + // markers=[11, 12, 41, 89] + let omitted = vec![11, 12, 41, 89]; + let included: BTreeSet<u64> = [10, 40, 88, 168, 176].iter().copied().collect(); + + let result = validate_omitted_markers_for_parsing(&omitted, &included); + assert!(result.is_ok()); + } + + /// Reproduces the producer/consumer gap-jump mismatch. + /// + /// `compute_omitted_markers` emits `[1, ..., 239, 1_000_000_000]` for 240 + /// consecutive omitted TLVs (see the merkle.rs test + /// `compute_omitted_markers_jumps_to_high_range_after_239`): the marker after + /// 239 jumps over the signature/payer-proof gap into the experimental range. + /// `validate_omitted_markers_for_parsing` must accept that jump as a valid + /// minimized sequence, otherwise a proof the producer can legitimately build + /// is rejected on parse. + #[test] + fn test_validate_omitted_markers_accepts_gap_jump() { + let mut omitted: Vec<u64> = (1..=239).collect(); + omitted.push(1_000_000_000); + let included: BTreeSet<u64> = BTreeSet::new(); + + let result = validate_omitted_markers_for_parsing(&omitted, &included); + assert!(result.is_ok(), "gap-jumped markers must be accepted, got {:?}", result); + } + + /// An included TLV of type 239 followed by an omitted TLV: the producer emits + /// marker `next_marker(239)` = `1_000_000_000`. The reader's + /// jump-after-included-type path must accept it. + #[test] + fn test_validate_omitted_markers_accepts_gap_jump_after_included() { + let omitted = vec![1_000_000_000]; + let included: BTreeSet<u64> = [239].iter().copied().collect(); + + let result = validate_omitted_markers_for_parsing(&omitted, &included); + assert!( + result.is_ok(), + "gap-jump after included type 239 must be accepted, got {:?}", + result + ); + } + + /// Test that non-minimized markers are rejected. + #[test] + fn test_validate_omitted_markers_rejects_non_minimized() { + // included=[10, 40], markers=[11, 15, 41, 42] + // marker 15 should be 12 (continuation of run after 11) + let omitted = vec![11, 15, 41, 42]; + let included: BTreeSet<u64> = [10, 40].iter().copied().collect(); + + let result = validate_omitted_markers_for_parsing(&omitted, &included); + assert!(result.is_err()); + } + + /// Test that non-minimized first marker in a run is rejected. + #[test] + fn test_validate_omitted_markers_rejects_non_minimized_run_start() { + // included=[10, 40], markers=[11, 12, 45, 46] + // marker 45 should be 41 (first omitted after included 40) + let omitted = vec![11, 12, 45, 46]; + let included: BTreeSet<u64> = [10, 40].iter().copied().collect(); + + let result = validate_omitted_markers_for_parsing(&omitted, &included); + assert!(result.is_err()); + } + + /// Test minimized markers with omitted TLVs before any included type. + #[test] + fn test_validate_omitted_markers_accepts_leading_run() { + // included=[40], markers=[1, 2, 41] + // Two omitted before any included type, one after 40 + let omitted = vec![1, 2, 41]; + let included: BTreeSet<u64> = [40].iter().copied().collect(); + + let result = validate_omitted_markers_for_parsing(&omitted, &included); + assert!(result.is_ok()); + } + + /// Test minimized markers with consecutive included types (no markers between them). + #[test] + fn test_validate_omitted_markers_accepts_consecutive_included() { + // included=[10, 20, 40], markers=[1, 41] + // One omitted before 10, no omitted between 10-20 or 20-40, one after 40 + let omitted = vec![1, 41]; + let included: BTreeSet<u64> = [10, 20, 40].iter().copied().collect(); + + let result = validate_omitted_markers_for_parsing(&omitted, &included); + assert!(result.is_ok()); + } + + /// Test that invreq_metadata (type 0) cannot be explicitly included via include_type. + #[test] + fn test_invreq_metadata_not_allowed() { + assert_eq!(PAYER_METADATA_TYPE, 0); + } + + /// Test that out-of-order TLVs are rejected during parsing. + #[test] + fn test_parsing_rejects_out_of_order_tlvs() { + use core::convert::TryFrom; + + // Create a malformed TLV stream with out-of-order types (20 before 10) + // TLV format: type (BigSize) || length (BigSize) || value + let mut bytes = Vec::new(); + // TLV type 20, length 2, value + bytes.extend_from_slice(&[0x14, 0x02, 0x00, 0x00]); + // TLV type 10, length 2, value (OUT OF ORDER!) + bytes.extend_from_slice(&[0x0a, 0x02, 0x00, 0x00]); + + let result = PayerProof::try_from(bytes); + assert!(result.is_err()); + } + + /// Test that duplicate TLVs are rejected during parsing. + #[test] + fn test_parsing_rejects_duplicate_tlvs() { + use core::convert::TryFrom; + + // Create a malformed TLV stream with duplicate type 10 + let mut bytes = Vec::new(); + // TLV type 10, length 2, value + bytes.extend_from_slice(&[0x0a, 0x02, 0x00, 0x00]); + // TLV type 10 again (DUPLICATE!) + bytes.extend_from_slice(&[0x0a, 0x02, 0x00, 0x00]); + + let result = PayerProof::try_from(bytes); + assert!(result.is_err()); + } + + /// Test that an invalid `proof_missing_hashes` length (not a multiple of 32) + /// is rejected. + #[test] + fn test_parsing_rejects_invalid_hash_length() { + use core::convert::TryFrom; + + // `proof_missing_hashes` decodes as a `WithoutLength` `Vec<sha256::Hash>`, + // so a value length that is not a multiple of 32 cannot decode to whole + // hashes. + let mut bytes = Vec::new(); + BigSize(PAYER_PROOF_MISSING_HASHES_TYPE).write(&mut bytes).unwrap(); + BigSize(33).write(&mut bytes).unwrap(); // 33 is not a multiple of 32 + bytes.extend_from_slice(&[0x00; 33]); + + let result = PayerProof::try_from(bytes); + assert!( + matches!(result, Err(Bolt12ParseError::Decode(DecodeError::ShortRead))), + "expected Decode(ShortRead), got {:?}", + result, + ); + } + + /// Test that an invalid `proof_leaf_hashes` length (not a multiple of 32) is + /// rejected. + #[test] + fn test_parsing_rejects_invalid_leaf_hashes_length() { + use core::convert::TryFrom; + + // `proof_leaf_hashes` decodes as a `WithoutLength` `Vec<sha256::Hash>`, + // so a value length that is not a multiple of 32 cannot decode to whole + // hashes. + let mut bytes = Vec::new(); + BigSize(PAYER_PROOF_LEAF_HASHES_TYPE).write(&mut bytes).unwrap(); + BigSize(31).write(&mut bytes).unwrap(); // 31 is not a multiple of 32 + bytes.extend_from_slice(&[0x00; 31]); + + let result = PayerProof::try_from(bytes); + assert!( + matches!(result, Err(Bolt12ParseError::Decode(DecodeError::ShortRead))), + "expected Decode(ShortRead), got {:?}", + result, + ); + } + + /// `include_type` must reject `payer_metadata` (0), the signature/payer-proof ranges, and the + /// gap before the experimental ranges, while accepting types below the signature range and the + /// experimental ranges. + #[test] + fn test_include_type_rejects_signature_types() { + let gap_top = EXPERIMENTAL_OFFER_TYPES.start - 1; + for ty in [0, 240, 250, 1000, 1001, gap_top] { + assert!(matches!( + payer_proof_builder().include_type(ty), + Err(PayerProofError::DisallowedTlvType(t)) if t == ty, + )); + } + for ty in [239, EXPERIMENTAL_OFFER_TYPES.start, u64::MAX] { + assert!(payer_proof_builder().include_type(ty).is_ok()); + } + } + + #[test] + fn test_round_trip_accepts_included_experimental_tlv() { + let proof = build_round_trip_proof_with_included_experimental_tlv(); + let result = PayerProof::try_from(proof.bytes().to_vec()); + assert!( + result.is_ok(), + "Included experimental TLVs should survive payer proof parsing: {:?}", + result + ); + } + + #[test] + fn test_round_trip_accepts_multiple_trailing_omitted_tlvs() { + let proof = build_round_trip_proof_with_multiple_trailing_omitted_tlvs(); + let result = PayerProof::try_from(proof.bytes().to_vec()); + assert!( + result.is_ok(), + "Multiple trailing omitted TLVs should survive payer proof parsing: {:?}", + result + ); + } + + /// Confirms that type 0 (`payer_metadata`) is rejected when parsing a payer proof — + /// matching the same behavior as `FullOfferTlvStream`. + /// + /// `FullPayerProofTlvStream` has no sub-stream that covers type 0 (the lowest sub-stream + /// is `OfferTlvStream`, range `1..80`). Each `CursorReadable` impl reads the type BigSize, + /// finds it out of range, rewinds the type bytes, and breaks — without consuming the + /// length or value. The cursor is therefore left before the type-0 TLV, and the + /// all-bytes-consumed check in `ParsedMessage::try_from` rejects the input with + /// `DecodeError::InvalidValue` before any semantic validation runs. + #[test] + fn test_parsing_rejects_payer_metadata() { + let proof = build_round_trip_proof_with_multiple_trailing_omitted_tlvs(); + let mut bytes = Vec::new(); + write_tlv_record_bytes(&mut bytes, PAYER_METADATA_TYPE, &[1; 32]); + bytes.extend_from_slice(proof.bytes()); + + let result = PayerProof::try_from(bytes); + assert!(matches!(result, Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)))); + } + + #[test] + fn test_round_trip_rejects_unknown_odd_data_range_tlv() { + // Unknown odd TLVs in the `PAYER_PROOF_DATA_TYPES` range are merkle + // leaves; inserting one after signing shifts the merkle root and the + // `proof_signature` no longer verifies. + let unknown_odd_data_range_type = PAYER_PROOF_PROOF_NOTE_TYPE + 2; + assert_eq!(unknown_odd_data_range_type % 2, 1); + assert!(PAYER_PROOF_DATA_TYPES.contains(&unknown_odd_data_range_type)); + + let proof = build_round_trip_proof_with_multiple_trailing_omitted_tlvs(); + let mut bytes = proof.bytes().to_vec(); + write_tlv_record_bytes(&mut bytes, unknown_odd_data_range_type, b"ignored"); + + assert!(matches!( + PayerProof::try_from(bytes), + Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)) + )); + } + + #[test] + fn test_parsed_proof_exposes_disclosed_fields() { + let proof = build_round_trip_proof_with_disclosed_fields(); + let parsed = PayerProof::try_from(proof.bytes().to_vec()).unwrap(); + + assert_eq!(parsed.offer_description().map(|s| s.0), Some("coffee beans")); + assert_eq!(parsed.offer_issuer().map(|s| s.0), Some("LDK Roastery")); + assert_eq!(parsed.invoice_amount_msats(), Some(42_000)); + assert_eq!(parsed.invoice_created_at(), Some(Duration::from_secs(1_700_000_000))); + } + + /// Test that unknown even TLV types in every payer-proof BOLT 12 sub-stream + /// namespace are rejected by the `tlv_stream!`-based parser, and that types + /// in the unused gap ranges between sub-streams are rejected by + /// `ParsedMessage`'s all-bytes-consumed check. + /// + /// Per BOLT convention, even types are mandatory-to-understand. For payer + /// proofs this is stricter than the general invoice rule because including + /// an unknown even TLV in a proof implies the verifier must check something + /// about it, and it cannot. See the upstream discussion: + /// <https://github.com/lightningdevkit/rust-lightning/pull/4297#discussion_r3107812262>. + #[test] + fn test_parsing_rejects_unknown_even_tlvs_in_every_range() { + use core::convert::TryFrom; + + /// Parse a payer-proof byte stream that contains only a single TLV with + /// the given type and a 4-byte dummy value, and assert it is rejected + /// with the expected error variant. + fn assert_rejected(tlv_type: u64, expected: DecodeError, label: &str) { + let mut bytes = Vec::new(); + BigSize(tlv_type).write(&mut bytes).expect("Vec write should not fail"); + BigSize(4).write(&mut bytes).expect("Vec write should not fail"); + bytes.extend_from_slice(b"test"); + + match PayerProof::try_from(bytes) { + Err(Bolt12ParseError::Decode(ref err)) if err == &expected => {}, + other => panic!( + "{} (type {}): expected {:?}, got {:?}", + label, tlv_type, expected, other, + ), + } + } + + // Sub-stream ranges: rejected by `tlv_stream!`'s unknown-even fallback. + assert_rejected(50, DecodeError::UnknownRequiredFeature, "offer range"); + assert_rejected(100, DecodeError::UnknownRequiredFeature, "invoice_request range"); + assert_rejected(200, DecodeError::UnknownRequiredFeature, "invoice range"); + // 240 and 241 are the known signature TLVs; 254 is unknown. + assert_rejected(254, DecodeError::UnknownRequiredFeature, "payer-proof/signature range"); + // 1001..=1005 are the known data TLVs; 1006 is unknown. + assert_rejected(1006, DecodeError::UnknownRequiredFeature, "payer-proof data range (low)"); + assert_rejected( + 1_000_000, + DecodeError::UnknownRequiredFeature, + "payer-proof data range (mid)", + ); + assert_rejected( + 1_500_000_000, + DecodeError::UnknownRequiredFeature, + "experimental offer range", + ); + assert_rejected( + 2_500_000_000, + DecodeError::UnknownRequiredFeature, + "experimental invoice_request range", + ); + assert_rejected( + 3_500_000_000, + DecodeError::UnknownRequiredFeature, + "experimental invoice range", + ); + + // Type 0 is rejected separately by the `payer_metadata` check + // (see `test_parsing_rejects_payer_metadata`). + } + + /// Test that malformed TLV framing is rejected without panicking. + /// + /// TlvStream::new() panics on malformed BigSize values or out-of-bounds + /// lengths. The parser must validate framing before constructing TlvStream. + #[test] + fn test_parsing_rejects_malformed_tlv_framing() { + use core::convert::TryFrom; + + // Truncated BigSize type (0xFD prefix requires 2 more bytes) + let result = PayerProof::try_from(vec![0xFD, 0x01]); + assert!(result.is_err(), "Truncated BigSize type should be rejected"); + + // Valid type but truncated length + let result = PayerProof::try_from(vec![0x0a]); + assert!(result.is_err(), "Missing length should be rejected"); + + // Length exceeds remaining bytes + let result = PayerProof::try_from(vec![0x0a, 0x04, 0x00, 0x00]); + assert!(result.is_err(), "Length exceeding data should be rejected"); + + // Empty input should not panic + let result = PayerProof::try_from(vec![]); + assert!(result.is_err(), "Empty input should be rejected"); + + // Completely invalid bytes + let result = PayerProof::try_from(vec![0xFF, 0xFF]); + assert!(result.is_err(), "Invalid bytes should be rejected"); + } + + /// Test that duplicate type-0 TLVs are rejected. + /// + /// Previously the ordering check used `u64` initialized to 0, which + /// skipped the check for the first TLV if its type was 0, allowing + /// duplicate type-0 records. + #[test] + fn test_parsing_rejects_duplicate_type_zero() { + use core::convert::TryFrom; + + // Two TLV records both with type 0 + let mut bytes = Vec::new(); + bytes.extend_from_slice(&[0x00, 0x02, 0x00, 0x00]); // type 0, len 2 + bytes.extend_from_slice(&[0x00, 0x02, 0x00, 0x00]); // type 0 again (DUPLICATE!) + + let result = PayerProof::try_from(bytes); + assert!(result.is_err(), "Duplicate type-0 TLVs should be rejected"); + } + + /// Test that a `proof_signature` TLV with a value shorter than 64 bytes is + /// rejected. + #[test] + fn test_parsing_rejects_short_proof_signature() { + use core::convert::TryFrom; + + // `proof_signature` decodes as a 64-byte schnorr `Signature`; a 32-byte + // value is too short. + let mut bytes = Vec::new(); + BigSize(PAYER_PROOF_PROOF_SIGNATURE_TYPE).write(&mut bytes).unwrap(); + BigSize(32).write(&mut bytes).unwrap(); // too short for a 64-byte signature + bytes.extend_from_slice(&[0x00; 32]); + + let result = PayerProof::try_from(bytes); + assert!( + matches!(result, Err(Bolt12ParseError::Decode(DecodeError::ShortRead))), + "expected Decode(ShortRead), got {:?}", + result, + ); + } + + /// Helper: serialize a payer_proof's bytes minus any TLV record matching `drop_type`. + fn proof_bytes_without_tlv(proof: &PayerProof, drop_type: u64) -> Vec<u8> { + let mut out = Vec::new(); + for record in TlvStream::new(proof.bytes()) { + if record.r#type != drop_type { + out.extend_from_slice(record.record_bytes); + } + } + out + } + + /// Helper: copy a payer_proof's bytes, applying `mutator` to the value of any + /// TLV record matching `target_type`. The TLV's length stays the same; only + /// the value bytes are mutated in place. + fn proof_bytes_with_mutated_tlv_value<F: FnMut(&mut [u8])>( + proof: &PayerProof, target_type: u64, mut mutator: F, + ) -> Vec<u8> { + let mut out = Vec::with_capacity(proof.bytes().len()); + for record in TlvStream::new(proof.bytes()) { + if record.r#type == target_type { + let prefix_len = record.record_bytes.len() - record.value_bytes.len(); + out.extend_from_slice(&record.record_bytes[..prefix_len]); + let mut value = record.value_bytes.to_vec(); + mutator(&mut value); + out.extend_from_slice(&value); + } else { + out.extend_from_slice(record.record_bytes); + } + } + out + } + + /// Helper: drop the first 32-byte sha256 hash from any TLV record matching + /// `target_type`, re-encoding the BigSize length. Useful for crafting a + /// shorter `proof_leaf_hashes` / `proof_missing_hashes` to test count checks. + fn proof_bytes_with_first_hash_dropped(proof: &PayerProof, target_type: u64) -> Vec<u8> { + let mut out = Vec::with_capacity(proof.bytes().len()); + for record in TlvStream::new(proof.bytes()) { + if record.r#type == target_type { + assert!( + record.value_bytes.len() >= 32, + "target TLV {} value too short to drop a hash", + target_type + ); + BigSize(target_type).write(&mut out).expect("Vec write should not fail"); + let new_len = record.value_bytes.len() - 32; + BigSize(new_len as u64).write(&mut out).expect("Vec write should not fail"); + out.extend_from_slice(&record.value_bytes[32..]); + } else { + out.extend_from_slice(record.record_bytes); + } + } + out + } + + /// Per BOLT 12 PR 1295: SHA256(`proof_preimage`) must equal `invoice_payment_hash`, + /// otherwise the reader MUST reject. Flipping a byte in `proof_preimage` (TLV 1001) + /// must therefore fail parsing. + #[test] + fn test_parsing_rejects_modified_preimage() { + let proof = build_round_trip_proof_with_disclosed_fields(); + let mutated = + proof_bytes_with_mutated_tlv_value(&proof, PAYER_PROOF_PREIMAGE_TYPE, |value| { + value[0] ^= 0x01; + }); + let result = PayerProof::try_from(mutated); + assert!( + matches!(result, Err(Bolt12ParseError::Decode(DecodeError::InvalidValue))), + "modified proof_preimage must be rejected with InvalidValue, got {:?}", + result + ); + } + + /// Flipping a byte inside `proof_leaf_hashes` (TLV 1004) changes the + /// reconstructed invoice merkle root, which makes the issuer's `signature` + /// fail verification. + #[test] + fn test_parsing_rejects_modified_leaf_hash() { + let proof = build_round_trip_proof_with_disclosed_fields(); + let mutated = + proof_bytes_with_mutated_tlv_value(&proof, PAYER_PROOF_LEAF_HASHES_TYPE, |value| { + value[0] ^= 0x01; + }); + let result = PayerProof::try_from(mutated); + assert!( + matches!(result, Err(Bolt12ParseError::Decode(DecodeError::InvalidValue))), + "modified proof_leaf_hashes must be rejected with InvalidValue, got {:?}", + result + ); + } + + /// Flipping a byte inside `proof_missing_hashes` (TLV 1003) changes the + /// reconstructed invoice merkle root, which makes the issuer's `signature` + /// fail verification. + #[test] + fn test_parsing_rejects_modified_missing_hash() { + let proof = build_round_trip_proof_with_disclosed_fields(); + let mutated = + proof_bytes_with_mutated_tlv_value(&proof, PAYER_PROOF_MISSING_HASHES_TYPE, |value| { + value[0] ^= 0x01; + }); + let result = PayerProof::try_from(mutated); + assert!( + matches!(result, Err(Bolt12ParseError::Decode(DecodeError::InvalidValue))), + "modified proof_missing_hashes must be rejected with InvalidValue, got {:?}", + result + ); + } + + /// Per BOLT 12 PR 1295: `proof_leaf_hashes` MUST contain exactly one hash for + /// each non-signature TLV field. Dropping one hash must therefore fail parsing. + #[test] + fn test_parsing_rejects_leaf_hashes_count_mismatch() { + let proof = build_round_trip_proof_with_disclosed_fields(); + let stripped = proof_bytes_with_first_hash_dropped(&proof, PAYER_PROOF_LEAF_HASHES_TYPE); + let result = PayerProof::try_from(stripped); + assert!( + matches!(result, Err(Bolt12ParseError::Decode(DecodeError::InvalidValue))), + "proof_leaf_hashes count mismatch must be rejected with InvalidValue, got {:?}", + result + ); + } + + /// Per BOLT 12 PR 1295: the reader MUST reject a payer_proof if `proof_missing_hashes` + /// (TLV 1003) is missing. + #[test] + fn test_parsing_rejects_missing_proof_missing_hashes() { + let proof = build_round_trip_proof_with_disclosed_fields(); + let stripped = proof_bytes_without_tlv(&proof, PAYER_PROOF_MISSING_HASHES_TYPE); + let result = PayerProof::try_from(stripped); + assert!(result.is_err(), "missing proof_missing_hashes TLV must be rejected"); + } + + /// Per BOLT 12 PR 1295: the reader MUST reject a payer_proof if `proof_leaf_hashes` + /// (TLV 1004) is missing. + #[test] + fn test_parsing_rejects_missing_proof_leaf_hashes() { + let proof = build_round_trip_proof_with_disclosed_fields(); + let stripped = proof_bytes_without_tlv(&proof, PAYER_PROOF_LEAF_HASHES_TYPE); + let result = PayerProof::try_from(stripped); + assert!(result.is_err(), "missing proof_leaf_hashes TLV must be rejected"); + } + + /// Per BOLT 12 PR 1295: the reader MUST reject a payer_proof if `invreq_payer_id` + /// (TLV 88) is missing. + #[test] + fn test_parsing_rejects_missing_payer_id() { + let proof = build_round_trip_proof_with_disclosed_fields(); + let stripped = proof_bytes_without_tlv(&proof, INVOICE_REQUEST_PAYER_ID_TYPE); + let result = PayerProof::try_from(stripped); + assert!( + matches!( + result, + Err(Bolt12ParseError::InvalidSemantics( + Bolt12SemanticError::MissingPayerSigningPubkey + )) + ), + "missing invreq_payer_id TLV must be rejected with MissingPayerSigningPubkey, got {:?}", + result + ); + } + + /// Per BOLT 12 PR 1295: the reader MUST reject a payer_proof if `invoice_payment_hash` + /// (TLV 168) is missing. + #[test] + fn test_parsing_rejects_missing_payment_hash() { + let proof = build_round_trip_proof_with_disclosed_fields(); + let stripped = proof_bytes_without_tlv(&proof, INVOICE_PAYMENT_HASH_TYPE); + let result = PayerProof::try_from(stripped); + assert!( + matches!( + result, + Err(Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::MissingPaymentHash)) + ), + "missing invoice_payment_hash TLV must be rejected with MissingPaymentHash, got {:?}", + result + ); + } + + /// Per BOLT 12 PR 1295: the reader MUST reject a payer_proof if `invoice_node_id` + /// (TLV 176) is missing. + #[test] + fn test_parsing_rejects_missing_node_id() { + let proof = build_round_trip_proof_with_disclosed_fields(); + let stripped = proof_bytes_without_tlv(&proof, INVOICE_NODE_ID_TYPE); + let result = PayerProof::try_from(stripped); + assert!( + matches!( + result, + Err(Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::MissingSigningPubkey)) + ), + "missing invoice_node_id TLV must be rejected with MissingSigningPubkey, got {:?}", + result + ); + } + + /// Per BOLT 12 PR 1295: the reader MUST reject a payer_proof if the issuer + /// `signature` (TLV 240) is missing. + #[test] + fn test_parsing_rejects_missing_invoice_signature() { + let proof = build_round_trip_proof_with_disclosed_fields(); + let stripped = proof_bytes_without_tlv(&proof, PAYER_PROOF_ISSUER_SIGNATURE_TYPE); + let result = PayerProof::try_from(stripped); + assert!( + matches!( + result, + Err(Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::MissingSignature)) + ), + "missing invoice signature TLV must be rejected with MissingSignature, got {:?}", + result + ); + } + + /// Per BOLT 12 PR 1295: the reader MUST reject a payer_proof if `proof_signature` + /// (TLV 241) is missing. + #[test] + fn test_parsing_rejects_missing_proof_signature() { + let proof = build_round_trip_proof_with_disclosed_fields(); + let stripped = proof_bytes_without_tlv(&proof, PAYER_PROOF_PROOF_SIGNATURE_TYPE); + let result = PayerProof::try_from(stripped); + assert!( + matches!( + result, + Err(Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::MissingSignature)) + ), + "missing proof_signature TLV must be rejected with MissingSignature, got {:?}", + result + ); + } + + /// Per BOLT 12 PR 1295: the reader MUST reject a payer_proof if `proof_preimage` + /// (TLV 1001) is missing. + #[test] + fn test_parsing_rejects_missing_proof_preimage() { + let proof = build_round_trip_proof_with_disclosed_fields(); + let stripped = proof_bytes_without_tlv(&proof, PAYER_PROOF_PREIMAGE_TYPE); + let result = PayerProof::try_from(stripped); + assert!( + matches!(result, Err(Bolt12ParseError::Decode(DecodeError::InvalidValue))), + "missing proof_preimage TLV must be rejected with InvalidValue, got {:?}", + result + ); + } + + #[test] + fn test_round_trip_with_trailing_experimental_tlvs() { + use core::convert::TryFrom; + + let preimage = PaymentPreimage([1; 32]); + let payment_hash = PaymentHash(*sha256::Hash::hash(&preimage.0).as_byte_array()); + let invoice = RefundBuilder::new(vec![1; 32], payer_pubkey(), 1000) + .unwrap() + .experimental_foo(42) + .experimental_bar(43) + .build() + .unwrap() + .respond_with_no_std(payment_paths(), payment_hash, recipient_pubkey(), now()) + .unwrap() + .experimental_baz(44) + .build() + .unwrap() + .sign(recipient_sign) + .unwrap(); + + let secp_ctx = Secp256k1::signing_only(); + let payer_keys = payer_keys(); + let paid_invoice = PaidBolt12Invoice::Bolt12Invoice(invoice); + let payer_proof = paid_invoice + .prove_payer(preimage) + .unwrap() + .build() + .unwrap() + .sign(|proof: &UnsignedPayerProof| { + Ok(secp_ctx.sign_schnorr_no_aux_rand(proof.as_ref().as_digest(), &payer_keys)) + }) + .unwrap(); + let parsed = PayerProof::try_from(payer_proof.bytes().to_vec()).unwrap(); + + assert_eq!(parsed.bytes(), payer_proof.bytes()); + assert_eq!(parsed.payment_preimage(), preimage); + assert_eq!(parsed.payment_hash(), payment_hash); + } + + #[test] + fn test_build_with_derived_signing_keys_for_refund_invoice() { + use core::convert::TryFrom; + + let expanded_key = ExpandedKey::new([42; 32]); + let entropy = FixedEntropy {}; + let nonce = Nonce::from_entropy_source(&entropy); + let secp_ctx = Secp256k1::new(); + let payment_id = PaymentId([1; 32]); + let preimage = PaymentPreimage([2; 32]); + let payment_hash = PaymentHash(*sha256::Hash::hash(&preimage.0).as_byte_array()); + + let invoice = RefundBuilder::deriving_signing_pubkey( + payer_pubkey(), + &expanded_key, + nonce, + &secp_ctx, + 1000, + payment_id, + ) + .unwrap() + .path(blinded_path()) + .experimental_foo(42) + .experimental_bar(43) + .build() + .unwrap() + .respond_with_no_std(payment_paths(), payment_hash, recipient_pubkey(), now()) + .unwrap() + .experimental_baz(44) + .build() + .unwrap() + .sign(recipient_sign) + .unwrap(); + + let paid_invoice = PaidBolt12Invoice::Bolt12Invoice(invoice); + let payer_proof = paid_invoice + .prove_payer_derived(preimage, &expanded_key, payment_id, &secp_ctx) + .unwrap() + .with_proof_note("refund".into()) + .build_and_sign() + .unwrap(); + let parsed = PayerProof::try_from(payer_proof.bytes().to_vec()).unwrap(); + + assert_eq!(parsed.payment_preimage(), preimage); + assert_eq!(parsed.payment_hash(), payment_hash); + assert_eq!(parsed.proof_note().map(|note| note.to_string()), Some("refund".to_string())); + } + + /// `PaidBolt12Invoice` round-trips through its `Writeable`/`Readable` implementations. This is + /// the contract the containers (`HTLCSource`, `PendingOutboundPayment`, `Event::PaymentSent`) + /// rely on when they serialize the paid invoice. + #[test] + fn test_bolt12_invoice_type_round_trips() { + let expanded_key = ExpandedKey::new([42; 32]); + let entropy = FixedEntropy {}; + let nonce = Nonce::from_entropy_source(&entropy); + let secp_ctx = Secp256k1::new(); + let payment_id = PaymentId([1; 32]); + let preimage = PaymentPreimage([2; 32]); + let payment_hash = PaymentHash(*sha256::Hash::hash(&preimage.0).as_byte_array()); + + let invoice = RefundBuilder::deriving_signing_pubkey( + payer_pubkey(), + &expanded_key, + nonce, + &secp_ctx, + 1000, + payment_id, + ) + .unwrap() + .path(blinded_path()) + .build() + .unwrap() + .respond_with_no_std(payment_paths(), payment_hash, recipient_pubkey(), now()) + .unwrap() + .build() + .unwrap() + .sign(recipient_sign) + .unwrap(); + + let original = PaidBolt12Invoice::Bolt12Invoice(invoice); + let bytes = original.encode(); + let read = <PaidBolt12Invoice as Readable>::read(&mut io::Cursor::new(&bytes)).unwrap(); + assert_eq!(read, original); + } + + /// Per BOLT 12 PR 1295: building a payer proof with a preimage whose SHA256 + /// doesn't match the invoice's `payment_hash` must fail at construction time + /// with `PreimageMismatch`. + #[test] + fn test_prove_payer_rejects_wrong_preimage() { + let preimage = PaymentPreimage([1; 32]); + let payment_hash = PaymentHash(*sha256::Hash::hash(&preimage.0).as_byte_array()); + let invoice = RefundBuilder::new(vec![1; 32], payer_pubkey(), 1000) + .unwrap() + .build() + .unwrap() + .respond_with_no_std(payment_paths(), payment_hash, recipient_pubkey(), now()) + .unwrap() + .build() + .unwrap() + .sign(recipient_sign) + .unwrap(); + + let wrong_preimage = PaymentPreimage([0xDE; 32]); + let paid_invoice = PaidBolt12Invoice::Bolt12Invoice(invoice); + assert!(matches!( + paid_invoice.prove_payer(wrong_preimage), + Err(PayerProofError::PreimageMismatch) + )); + } + + /// Per BOLT 12 PR 1295: deriving the payer signing key with the wrong + /// `payment_id` must fail at construction time with `KeyDerivationFailed`. + #[test] + fn test_prove_payer_derived_rejects_wrong_payment_id() { + let expanded_key = ExpandedKey::new([42; 32]); + let entropy = FixedEntropy {}; + let nonce = Nonce::from_entropy_source(&entropy); + let secp_ctx = Secp256k1::new(); + let payment_id = PaymentId([1; 32]); + let preimage = PaymentPreimage([2; 32]); + let payment_hash = PaymentHash(*sha256::Hash::hash(&preimage.0).as_byte_array()); + + let invoice = RefundBuilder::deriving_signing_pubkey( + payer_pubkey(), + &expanded_key, + nonce, + &secp_ctx, + 1000, + payment_id, + ) + .unwrap() + .path(blinded_path()) + .build() + .unwrap() + .respond_with_no_std(payment_paths(), payment_hash, recipient_pubkey(), now()) + .unwrap() + .build() + .unwrap() + .sign(recipient_sign) + .unwrap(); + + let paid_invoice = PaidBolt12Invoice::Bolt12Invoice(invoice); + + let wrong_payment_id = PaymentId([0xFF; 32]); + let result = + paid_invoice.prove_payer_derived(preimage, &expanded_key, wrong_payment_id, &secp_ctx); + assert!(matches!(result, Err(PayerProofError::KeyDerivationFailed))); + } + + /// The builder owns its data instead of borrowing the `Bolt12Invoice`, which is what makes it + /// friendly to language bindings. This test builds the builder in an inner scope where the paid + /// invoice is then dropped, and finishes the proof afterwards -- it only compiles because the + /// builder no longer holds a reference to the invoice. + #[test] + fn payer_proof_builder_outlives_invoice() { + let expanded_key = ExpandedKey::new([42; 32]); + let entropy = FixedEntropy {}; + let nonce = Nonce::from_entropy_source(&entropy); + let secp_ctx = Secp256k1::new(); + let payment_id = PaymentId([1; 32]); + let preimage = PaymentPreimage([2; 32]); + let payment_hash = PaymentHash(*sha256::Hash::hash(&preimage.0).as_byte_array()); + + let builder = { + let invoice = RefundBuilder::deriving_signing_pubkey( + payer_pubkey(), + &expanded_key, + nonce, + &secp_ctx, + 1000, + payment_id, + ) + .unwrap() + .path(blinded_path()) + .build() + .unwrap() + .respond_with_no_std(payment_paths(), payment_hash, recipient_pubkey(), now()) + .unwrap() + .build() + .unwrap() + .sign(recipient_sign) + .unwrap(); + let paid_invoice = PaidBolt12Invoice::Bolt12Invoice(invoice); + paid_invoice + .prove_payer_derived(preimage, &expanded_key, payment_id, &secp_ctx) + .unwrap() + // `paid_invoice` (and the `Bolt12Invoice` it owns) is dropped here. + }; + + let proof = builder.include_offer_description().build_and_sign().unwrap(); + assert_eq!(proof.payment_hash(), payment_hash); + assert_eq!(proof.payment_preimage(), preimage); + } + + // BOLT 12 payer proof test vectors (from bolt12/payer-proof-test.json). + // Each vector carries its own invoice; all share the payer secret and preimage. + const PAYER_SECRET_HEX: &str = + "4242424242424242424242424242424242424242424242424242424242424242"; + const PREIMAGE_HEX: &str = "0101010101010101010101010101010101010101010101010101010101010101"; + + struct PayerProofVector { + name: &'static str, + invoice_hex: &'static str, + included_types: &'static [u64], + note: Option<&'static str>, + leaf_hashes_hex: &'static str, + omitted_tlvs: &'static [u64], + missing_hashes_hex: &'static str, + /// The merkle root of the invoice the proof is derived from. + merkle_root_hex: &'static str, + bech32: &'static str, + /// `true` when LDK's encoder reproduces `bech32` byte-for-byte. The + /// `empty_proof_omitted_tlvs_explicit` vector serializes an empty + /// `proof_omitted_tlvs` TLV, which LDK omits per the spec's "MAY omit" + /// rule, so for that vector only the parse path is exercised. + byte_exact: bool, + } + + const PAYER_PROOF_VECTORS: &[PayerProofVector] = &[ + PayerProofVector { + name: "full_disclosure", + invoice_hex: "0010000000000000000000000000000000001621024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382520203e858210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1ca076027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000a21c00000001000000020003000000000000000400000000000000050000a40467527988a82072cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793aa0203e8ae0d08000000000000000000000000b021024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382f040fbb932e6a9d5b4d88ca0ddc9cf9f8cc880ef41e3ec9574da89f624db898ab3e9d3ed6caa8744633b855167da009119d9834ae71f7b06f02732dc4c1debab0577feb2d05e010142", + included_types: &[22, 82, 88, 160, 162, 164, 168, 170, 174, 176, 3000000001], + note: None, + leaf_hashes_hex: "8c9057ed88f3c5a6b6441dcac3b5e4cefb3615904d7362b86e78427fb695f4618dc54a97453dee6f207fa5216a30f1567442712ca98852bc789b73885029283cf2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67f54f80c94a87383f2a8ef7c3e461c62b67a51da5bccf6cd96a7dbab29bea51fa7849b8b856e1d2a63d9ce7dc1a78e05cbb2def1f5d7709c48e8707e0a59fe51e19e7e4eee6bf56c6c589fe50035490c1a7c91b753cb8007c4b52838a6772f997f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1adc0b8de03f1a0b0531bff146982d7d613ef6e1ef8d3bdd9590971fc18d835ffbc14cfffaa314261bcbb2ed4ca24d5717bb608d8a6cc9910790bc1d49af7858ab7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cbabaab91b367e30fea7026daf9f2590bb7e9cc31db8221f4013c67289e38f22c8", + omitted_tlvs: &[], + missing_hashes_hex: "0b510ba4c6884d603159ced2f0ca21e772424b59e52a2191bbfbcf07377805a1", + merkle_root_hex: "cb9e0c81bb39fc244f9f523c748ab4de0e09f1a5fef74359c2e1f7cc7cdc7447", + bech32: "lnp1zcssyj7z5vfx29flqlnsuzatppeyu6u9ugtl3ntz3n4k996zg7a5jvuz2gpq86zcyypjgef743p5fzqq9nqxh0ah7y87rzv3ud0eleps9kl2d5348hq2k89qwcp87v0tc4rzc87uuxmn0m8l2tfh6aw75s7wz8r56fd299ckt74zqpcr9s9he72nyjs86pfe3vjqzaxups47g3xedv2e4fk877c7v6rgpxgszqhd4w73ddqusdcmjthj7pxprpd57qakmn2jh2dh3kwhezwg7gs3g5qpqqqqqqqqqqqqqqqqqqqqqqqqqq9zrsqqqqqpqqqqqqsqqvqqqqqqqqqqqpqqqqqqqqqqqqzsqq9yq3n4y7vg4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0ya2qgp73tsdpqqqqqqqqqqqqqqqqqqqpvppqf9u9gcjv52n7pl8pc96kzrjfe4ctcshlrxk9r8tv2t5y3amfyec9uzqlwun9e4f6k6d3r9qmhyul8uvezqw7s0raj2hfk5f7cjdhzv2k05a8mtv42r5gcems4gk0ksqjyvanq62uu0hkphsyuedcnqaaw4s2al3gpykzve5p8698d233l9uvc5ndl95dekpmwxev0zyke74valsll8r43wyy2far0qjnzcdvueq5aewyzsxcp5alfc8nhujq7m82dthxhwhl5p7jgqpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpq87s86eqpdgshfxx3pxkqv2eemf0pj3puaeyyj6eu54zrydml08swdmcqksl6qlvl5qkprys2lkc3u7956myg8w2cw67fnhmxc2eqntnv2uxu7zz07mftarp3hz549698hhx7grl55sk5v832e6yyufv4xy990rcndecs5pf9q709h40tuctu08d3878cfx5y2qehur27rjg5v2z8w7suf3570pauel4f7qvjj588qlj4rhhc0jxr33tv7j3mfdueakdj6nah2efh6j3lfuynw9c2msa9f3annnacxncupwtkt00rawhwzwy36rs0c99nlj3ux08unhwd06kcmzcnljsqd2fpsd8eydh209cqp7yk55r3fnh97vh7qv3cdgqqfr42judpgvk3x98jjlnm6yesft3z7xexxhlke20psddczuduql35zc9xxllz35c947kz0hku8hc6w7ajkgfw87p3kp4l77pfnll4gc5ycduhvhdfj3y64chhdsgmznvexgs0y9ur4y677zc4dlf9dmmncuyxeg0dnt7a99kw5l2nhe4xdcskpx7u6r26dm9zkjuh2a2hydnvl3sl6nsymd0nujepwm7nnp3mwpzraqp83nj383c7gkgl6edqhspq9pq", + byte_exact: true, + }, + PayerProofVector { + name: "minimal_disclosure", + invoice_hex: "0010000000000000000000000000000000001621024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382520203e858210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1ca076027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000a21c00000001000000020003000000000000000400000000000000050000a40467527988a82072cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793aa0203e8b021024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382f0400a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319", + included_types: &[88, 168, 176], + note: None, + leaf_hashes_hex: "f2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1a7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cb", + omitted_tlvs: &[1, 2, 89, 90, 91, 169], + missing_hashes_hex: "bf8cb2b1d6fa9bcdcab501b59f82c65c506b7f43514737f7197f1fcfeaebad41b9406f4ce526a6a0d4e0b3a63ed89a832e31cb9939dfe1a7b5dd7232d32c02abcd9c44b53b31700c9ed0e3330ce425f7f18fac2fc1d566a34468439274f0e3169f9830f2c3070cfbad13fde30ee36cd7143591164ed12040a9cd595c96840ac9998ab7fa9c743fb9dbdb0d8d46fbe3ad333400bd07f328dcdb6008790bc9d2db", + merkle_root_hex: "0501ea6d4ad9fe7fce7edd5e3795987bd409d66c5709c2a17f9c0dfb839e3d8e", + bech32: "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8mrq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxn7j2mhh83cgdjs7mxha62tvaf7480n2vm3pvzdae5x45mk29d9ev", + byte_exact: true, + }, + PayerProofVector { + name: "with_note", + invoice_hex: "0010000000000000000000000000000000001621024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382520203e858210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1ca076027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000a21c00000001000000020003000000000000000400000000000000050000a40467527988a82072cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793aa0203e8b021024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382f0400a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319", + included_types: &[88, 168, 176], + note: Some("test note"), + leaf_hashes_hex: "f2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1a7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cb", + omitted_tlvs: &[1, 2, 89, 90, 91, 169], + missing_hashes_hex: "bf8cb2b1d6fa9bcdcab501b59f82c65c506b7f43514737f7197f1fcfeaebad41b9406f4ce526a6a0d4e0b3a63ed89a832e31cb9939dfe1a7b5dd7232d32c02abcd9c44b53b31700c9ed0e3330ce425f7f18fac2fc1d566a34468439274f0e3169f9830f2c3070cfbad13fde30ee36cd7143591164ed12040a9cd595c96840ac9998ab7fa9c743fb9dbdb0d8d46fbe3ad333400bd07f328dcdb6008790bc9d2db", + merkle_root_hex: "0501ea6d4ad9fe7fce7edd5e3795987bd409d66c5709c2a17f9c0dfb839e3d8e", + bech32: "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qz53lesac2aq2pr8tg9fa3na7wnczs5wa5nkds5qcugmvuk4arqawacga8gtmdxw7yaj8pw7pjwj2tafmd9mjkgcj7nxlmjhxzpxnhyt7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8mrq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxn7j2mhh83cgdjs7mxha62tvaf7480n2vm3pvzdae5x45mk29d9e07s8mgfw3jhxapqdehhgeg", + byte_exact: true, + }, + PayerProofVector { + name: "left_subtree_omitted", + invoice_hex: "0010000000000000000000000000000000001621024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382520203e858210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1ca076027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000a21c00000001000000020003000000000000000400000000000000050000a40467527988a82072cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793aa0203e8b021024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382f0400a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319", + included_types: &[88, 168, 170, 176], + note: None, + leaf_hashes_hex: "f2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1adc0b8de03f1a0b0531bff146982d7d613ef6e1ef8d3bdd9590971fc18d835ffb7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cb", + omitted_tlvs: &[1, 2, 89, 90, 91], + missing_hashes_hex: "bf8cb2b1d6fa9bcdcab501b59f82c65c506b7f43514737f7197f1fcfeaebad41b9406f4ce526a6a0d4e0b3a63ed89a832e31cb9939dfe1a7b5dd7232d32c02abcd9c44b53b31700c9ed0e3330ce425f7f18fac2fc1d566a34468439274f0e3169f9830f2c3070cfbad13fde30ee36cd7143591164ed12040a9cd595c96840ac9", + merkle_root_hex: "0501ea6d4ad9fe7fce7edd5e3795987bd409d66c5709c2a17f9c0dfb839e3d8e", + bech32: "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0ya2qgp73vppqf9u9gcjv52n7pl8pc96kzrjfe4ctcshlrxk9r8tv2t5y3amfyec9uzqpgejy3tgk64wdmf9yqft6llpqukq867u5layf72mq0cu6zd79zc2s0yuxgg9j7xdsrdqdztevckgp7sqlujsenwy6x9hp8lar3awxx03grks8mzc6kkxwefefp4md6xk7wymvd6mv6fllhes4yu3jkgdw3868ylegkjauwa404ju0asaauwwl292qwrjtv2m7ra8jl0apr9fw5u2l5p7jgqpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpq87s86s9qyp9jkjml5p7hq9l3jetr4h6n0xu4dgpkk0c93ju2p4h7s63gumlwxtlrl8746adgxu5qm6vu5n2dgx5uze6v0kcn2pjuvwtnyualcd8khwhyvkn9sp2hnvugj6nkvtspj0dpcenpnjztal337kzlsw4v635g6zrjf60pcckn7vrpukrqux0htgnlh3sacmv6u2rtygkfmgjqs9fe4v4e95yptyl6qlvsredat6lxzlremvfl37zf4pzsxdlq6hsuj9rzs3mh58zvd8nc00x0uqers6sqqj8249c6zsedzv2099l8h5fnqjhz9udjvd0ldj57rq6ms9cmcplrg9s2vdl79rfsttavyl0dc0035aam9vsju0urrvrtlahay4h0w0rssm9pakd0m55ke6na2wlx5ehzzcymmngdtfhv526tjc", + byte_exact: true, + }, + PayerProofVector { + name: "empty_proof_omitted_tlvs_explicit", + invoice_hex: "0010000000000000000000000000000000001621024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382520203e858210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1ca076027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000a21c00000001000000020003000000000000000400000000000000050000a40467527988a82072cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793aa0203e8b021024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382f0400a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319", + included_types: &[22, 82, 88, 160, 162, 164, 168, 170, 176], + note: None, + leaf_hashes_hex: "8c9057ed88f3c5a6b6441dcac3b5e4cefb3615904d7362b86e78427fb695f4618dc54a97453dee6f207fa5216a30f1567442712ca98852bc789b73885029283cf2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67f54f80c94a87383f2a8ef7c3e461c62b67a51da5bccf6cd96a7dbab29bea51fa7849b8b856e1d2a63d9ce7dc1a78e05cbb2def1f5d7709c48e8707e0a59fe51e19e7e4eee6bf56c6c589fe50035490c1a7c91b753cb8007c4b52838a6772f997f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1adc0b8de03f1a0b0531bff146982d7d613ef6e1ef8d3bdd9590971fc18d835ffb7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cb", + omitted_tlvs: &[], + missing_hashes_hex: "0b510ba4c6884d603159ced2f0ca21e772424b59e52a2191bbfbcf07377805a1", + merkle_root_hex: "0501ea6d4ad9fe7fce7edd5e3795987bd409d66c5709c2a17f9c0dfb839e3d8e", + bech32: "lnp1zcssyj7z5vfx29flqlnsuzatppeyu6u9ugtl3ntz3n4k996zg7a5jvuz2gpq86zcyypjgef743p5fzqq9nqxh0ah7y87rzv3ud0eleps9kl2d5348hq2k89qwcp87v0tc4rzc87uuxmn0m8l2tfh6aw75s7wz8r56fd299ckt74zqpcr9s9he72nyjs86pfe3vjqzaxups47g3xedv2e4fk877c7v6rgpxgszqhd4w73ddqusdcmjthj7pxprpd57qakmn2jh2dh3kwhezwg7gs3g5qpqqqqqqqqqqqqqqqqqqqqqqqqqq9zrsqqqqqpqqqqqqsqqvqqqqqqqqqqqpqqqqqqqqqqqqzsqq9yq3n4y7vg4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0ya2qgp73vppqf9u9gcjv52n7pl8pc96kzrjfe4ctcshlrxk9r8tv2t5y3amfyec9uzqpgejy3tgk64wdmf9yqft6llpqukq867u5layf72mq0cu6zd79zc2s0yuxgg9j7xdsrdqdztevckgp7sqlujsenwy6x9hp8lar3awxx03gr0luckkg3kpste9q0ncpl8qnlzu7vgw7999faja6803yspek75f0u55q3c2cruc2luzdv3j9zwq438xjf72vlvq29nlkzkax5hc3tw3l5p7jgqpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpq87s86sql5p7kgqt2y96f35gf4srzkww6tcv5g08wfpykk099gserwlmeurnw7q9587s8m8aqysgeyzhaky083dxkezpmjkrkhjva7ekzkgy6umzhph8ssnlk62lgcvdc49fw3faaehjqla9y94rpu2kw3p8zt9f3pftc7ymwwy9q2fg8nedat6lxzlremvfl37zf4pzsxdlq6hsuj9rzs3mh58zvd8nc00x0a20sry54pec8u4gaa7ru3suv2m855w6t0x0dnvk5ld6k2d755060pym3wzku8f2v0vuulwp578qtjajmmclt4msn3ywsur7pfvlu50pnelyamnt74kxckylu5qr2jgvrf7frd6newqq03949qu2vae0n9lsrywr2qqzga25hrg2r95f3fu5hu773xvz2ugh3kf34lak2ncvrtwqhr0q8udqkpf3hlc5dxpd04snaahpa7xnhhv4jzt3lsvdsd0lkl5jkaaeuwzrv58ke4lwjjm8204fmu6nxugtqn0wdp4dxaj3tfwt", + byte_exact: false, + }, + ]; + + fn hex_decode(s: &str) -> Vec<u8> { + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() + } + + fn hex_encode(b: &[u8]) -> String { + b.iter().map(|x| format!("{:02x}", x)).collect() + } + + /// Split a concatenated hex string into 32-byte hash hex strings. + fn split_hashes_hex(hex: &str) -> Vec<String> { + (0..hex.len()).step_by(64).map(|i| hex[i..i + 64].to_string()).collect() + } + + /// Build a focused failure report for two bech32 strings that are expected + /// to be byte-identical except in the `payer_signature` region. + /// + /// Returns `None` when the strings match exactly. Otherwise returns a + /// `String` summarizing the divergence: how many leading/trailing bytes + /// match, the byte range of the differing region, and a short snippet + /// from each side. This avoids dumping ~1700-char bech32 strings into + /// the panic message. + fn report_bech32_mismatch(label: &str, got: &str, want: &str) -> Option<String> { + if got == want { + return None; + } + + let first_diff = got.bytes().zip(want.bytes()).position(|(a, b)| a != b); + let Some(first) = first_diff else { + return Some(format!( + "{}: bech32 length differs (got {} chars, want {} chars), \ + but the common prefix matches", + label, + got.len(), + want.len(), + )); + }; + + // Walk from the end to find where the strings reconverge. + let trailing_match = + got.bytes().rev().zip(want.bytes().rev()).position(|(a, b)| a != b).unwrap_or(0); + let got_diff_end = got.len() - trailing_match; + let want_diff_end = want.len() - trailing_match; + let snippet = 40usize; + let got_snippet = &got[first..got_diff_end.min(first + snippet)]; + let want_snippet = &want[first..want_diff_end.min(first + snippet)]; + let got_truncated = got_diff_end > first + snippet; + let want_truncated = want_diff_end > first + snippet; + + Some(format!( + "{label}: bech32 differs in chars [{first}..{got_diff_end}] (got len {got_len}) \ + and [{first}..{want_diff_end}] (want len {want_len}). \ + First {first} chars match; last {trailing_match} chars match.\n \ + got : \"{got_snippet}{got_ellipsis}\"\n \ + want : \"{want_snippet}{want_ellipsis}\"", + label = label, + first = first, + got_diff_end = got_diff_end, + want_diff_end = want_diff_end, + got_len = got.len(), + want_len = want.len(), + trailing_match = trailing_match, + got_snippet = got_snippet, + got_ellipsis = if got_truncated { "…" } else { "" }, + want_snippet = want_snippet, + want_ellipsis = if want_truncated { "…" } else { "" }, + )) + } + + #[test] + fn check_against_spec_vectors() { + let secp_ctx = Secp256k1::new(); + let payer_keys = Keypair::from_secret_key( + &secp_ctx, + &SecretKey::from_slice(&hex_decode(PAYER_SECRET_HEX)).unwrap(), + ); + + let preimage = PaymentPreimage(hex_decode(PREIMAGE_HEX).try_into().unwrap()); + + for vector in PAYER_PROOF_VECTORS { + let invoice = Bolt12Invoice::try_from(hex_decode(vector.invoice_hex)) + .unwrap_or_else(|e| panic!("{}: failed to parse invoice: {:?}", vector.name, e)); + + let mut builder = PayerProofBuilder::new(&invoice, preimage) + .unwrap_or_else(|e| panic!("{}: builder failed: {:?}", vector.name, e)); + for &typ in vector.included_types { + if typ != INVOICE_REQUEST_PAYER_ID_TYPE + && typ != INVOICE_PAYMENT_HASH_TYPE + && typ != INVOICE_NODE_ID_TYPE + { + builder = builder.include_type(typ).unwrap_or_else(|e| { + panic!("{}: include_type({}) failed: {:?}", vector.name, typ, e) + }); + } + } + + if let Some(note) = vector.note { + builder = builder.with_proof_note(note.to_owned()); + } + + // The selective-disclosure data is derived from the invoice's merkle + // tree and is independent of how the proof's optional TLVs are + // encoded, so every spec vector must match here. Recompute it + // independently of the builder so we can compare leaf hashes, + // omitted markers, missing hashes, and merkle root against the + // spec vector before signing the proof. + let invoice_bytes_for_check = invoice.invoice_bytes(); + let included_types_for_check: BTreeSet<u64> = + vector.included_types.iter().copied().collect(); + let disclosure = compute_selective_disclosure( + TlvStream::new(invoice_bytes_for_check), + &included_types_for_check, + ); + + let got_leaves: Vec<String> = + disclosure.nonce_hashes.iter().map(|h| hex_encode(h.as_ref())).collect(); + assert_eq!( + got_leaves, + split_hashes_hex(vector.leaf_hashes_hex), + "{}: leaf_hashes mismatch", + vector.name + ); + + assert_eq!( + disclosure.omitted_markers, vector.omitted_tlvs, + "{}: omitted_tlvs mismatch", + vector.name + ); + + let got_missing: Vec<String> = + disclosure.missing_hashes.iter().map(|h| hex_encode(h.as_ref())).collect(); + assert_eq!( + got_missing, + split_hashes_hex(vector.missing_hashes_hex), + "{}: missing_hashes mismatch", + vector.name + ); + + let got_root = hex_encode(disclosure.merkle_root.as_ref()); + assert_eq!(got_root, vector.merkle_root_hex, "{}: merkle_root mismatch", vector.name); + + let unsigned = builder + .build_unsigned() + .unwrap_or_else(|e| panic!("{}: build failed: {:?}", vector.name, e)); + + let proof = unsigned + .sign(|proof: &UnsignedPayerProof| { + Ok(secp_ctx.sign_schnorr_no_aux_rand(proof.as_ref().as_digest(), &payer_keys)) + }) + .unwrap_or_else(|e| panic!("{}: sign failed: {:?}", vector.name, e)); + + // Every spec vector must be readable, including the one that encodes + // an explicit empty `proof_omitted_tlvs` TLV. + vector + .bech32 + .parse::<PayerProof>() + .unwrap_or_else(|e| panic!("{}: spec proof failed to parse: {:?}", vector.name, e)); + + if vector.byte_exact { + // LDK's encoder must also reproduce the spec proof byte-for-byte. + if let Some(report) = + report_bech32_mismatch(vector.name, &proof.to_string(), vector.bech32) + { + panic!("{}", report); + } + } + } + } +} diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs index 1411ba8dcc6..eefe0457e43 100644 --- a/lightning/src/util/ser.rs +++ b/lightning/src/util/ser.rs @@ -1207,6 +1207,21 @@ impl Readable for SecretKey { } } +impl Writeable for Sha256 { + fn write<W: Writer>(&self, w: &mut W) -> Result<(), io::Error> { + w.write_all(&self[..]) + } +} + +impl Readable for Sha256 { + fn read<R: Read>(r: &mut R) -> Result<Self, DecodeError> { + use bitcoin::hashes::Hash; + + let buf: [u8; 32] = Readable::read(r)?; + Ok(Sha256::from_byte_array(buf)) + } +} + impl Writeable for Hmac<Sha256> { fn write<W: Writer>(&self, w: &mut W) -> Result<(), io::Error> { w.write_all(&self[..]) From 580c9bc86a1b48e691dc36a68b220ac8b0aa5abe Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo <vincenzopalazzodev@gmail.com> Date: Mon, 15 Jun 2026 23:31:23 +0200 Subject: [PATCH 578/627] ln: persist the paid BOLT 12 invoice and build payer proofs Carry the paid `Bolt12Invoice` through the outbound payment so it survives restarts, and surface it as a `PaidBolt12Invoice` on `Event::PaymentSent` so the payer can build a payer proof. The payer signing key is re-derived from the invoice's own payer metadata, so no extra key material is stored. `PaidBolt12Invoice` now lives in `offers::payer_proof`; existing async payment tests and a test helper are updated to construct it via the new API. Adds an end-to-end test that pays a BOLT 12 offer and builds + verifies a payer proof from the resulting `Event::PaymentSent`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- lightning/src/events/mod.rs | 31 +---- lightning/src/ln/functional_test_utils.rs | 4 +- lightning/src/ln/offers_tests.rs | 157 +++++++++++++++++++++- lightning/src/ln/outbound_payment.rs | 62 ++++++++- 4 files changed, 226 insertions(+), 28 deletions(-) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 2e56d35c887..1f1a3586a26 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -32,6 +32,7 @@ use crate::ln::outbound_payment::RecipientOnionFields; use crate::ln::types::ChannelId; use crate::offers::invoice::Bolt12Invoice; use crate::offers::invoice_request::InvoiceRequest; +pub use crate::offers::payer_proof::PaidBolt12Invoice; use crate::offers::static_invoice::StaticInvoice; use crate::onion_message::messenger::Responder; use crate::routing::gossip::NetworkUpdate; @@ -1206,17 +1207,13 @@ pub enum Event { /// /// [`Route::get_total_fees`]: crate::routing::router::Route::get_total_fees fee_paid_msat: Option<u64>, - /// The BOLT 12 invoice that was paid. `None` if the payment was a non BOLT 12 payment. + /// The paid BOLT 12 invoice bundled with the data needed to construct a + /// [`PayerProof`], which selectively discloses invoice fields to prove payment to a + /// third party. /// - /// The BOLT 12 invoice is useful for proof of payment because it contains the - /// payment hash. A third party can verify that the payment was made by - /// showing the invoice and confirming that the payment hash matches - /// the hash of the payment preimage. + /// `None` for non-BOLT 12 payments. /// - /// However, the [`PaidBolt12Invoice`] can also be of type [`StaticInvoice`], which - /// is a special [`Bolt12Invoice`] where proof of payment is not possible. - /// - /// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice + /// [`PayerProof`]: crate::offers::payer_proof::PayerProof bolt12_invoice: Option<PaidBolt12Invoice>, }, /// Indicates an outbound payment failed. Individual [`Event::PaymentPathFailed`] events @@ -3314,19 +3311,3 @@ impl<T: EventHandler> EventHandler for Arc<T> { self.deref().handle_event(event) } } - -/// The BOLT 12 invoice that was paid, surfaced in [`Event::PaymentSent::bolt12_invoice`]. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub enum PaidBolt12Invoice { - /// The BOLT 12 invoice specified by the BOLT 12 specification, - /// allowing the user to perform proof of payment. - Bolt12Invoice(Bolt12Invoice), - /// The Static invoice, used in the async payment specification update proposal, - /// where the user cannot perform proof of payment. - StaticInvoice(StaticInvoice), -} - -impl_ser_tlv_based_enum!(PaidBolt12Invoice, - {0, Bolt12Invoice} => (), - {2, StaticInvoice} => (), -); diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 6e855c2a184..5fc33229ab9 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -20,7 +20,7 @@ use crate::events::bump_transaction::sync::BumpTransactionEventHandlerSync; use crate::events::bump_transaction::BumpTransactionEvent; use crate::events::{ ClaimedHTLC, ClosureReason, Event, FundingInfo, HTLCHandlingFailureType, - NegotiationFailureReason, PaidBolt12Invoice, PathFailure, PaymentFailureReason, PaymentPurpose, + NegotiationFailureReason, PathFailure, PaymentFailureReason, PaymentPurpose, }; use crate::ln::chan_utils::{ commitment_tx_base_weight, COMMITMENT_TX_WEIGHT_PER_HTLC, TRUC_MAX_WEIGHT, @@ -39,6 +39,7 @@ use crate::ln::outbound_payment::RecipientOnionFields; use crate::ln::outbound_payment::Retry; use crate::ln::peer_handler::IgnoringMessageHandler; use crate::ln::types::ChannelId; +use crate::offers::payer_proof::PaidBolt12Invoice; use crate::onion_message::messenger::OnionMessenger; use crate::routing::gossip::{NetworkGraph, NetworkUpdate, P2PGossipSync}; use crate::routing::router::{self, PaymentParameters, Route, RouteParameters}; @@ -3016,6 +3017,7 @@ pub fn expect_payment_sent<CM: AChannelManager, H: NodeHolder<CM = CM>>( ref amount_msat, ref fee_paid_msat, ref bolt12_invoice, + .. } => { assert_eq!(expected_payment_preimage, *payment_preimage); assert_eq!(expected_payment_hash, *payment_hash); diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 8f073168465..68a89ba6a91 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -64,11 +64,12 @@ use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestFields, Invoi use crate::offers::nonce::Nonce; use crate::offers::offer::OfferBuilder; use crate::offers::parse::Bolt12SemanticError; +use crate::offers::payer_proof::PayerProof; use crate::onion_message::messenger::{DefaultMessageRouter, Destination, MessageRouter, MessageSendInstructions, NodeIdMessageRouter, NullMessageRouter, PeeledOnion, DUMMY_HOPS_PATH_LENGTH, QR_CODED_DUMMY_HOPS_PATH_LENGTH}; use crate::onion_message::offers::OffersMessage; use crate::routing::router::{DEFAULT_PAYMENT_DUMMY_HOPS, PaymentParameters, RouteParameters, RouteParametersConfig}; use crate::sign::NodeSigner; -use crate::util::ser::Writeable; +use crate::util::ser::{MaybeReadable, Writeable}; /// This used to determine whether we built a compact path or not, but now its just a random /// constant we apply to blinded path expiry in these tests. @@ -234,6 +235,22 @@ fn extract_offer_nonce<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, message: &OnionMessa } } +/// Extract the payer's [`PaymentId`] from an invoice onion message received by the payer. +/// +/// When the payer receives an invoice through their reply path, the blinded path context carries +/// the [`PaymentId`] for the payment. The payer signing key needed to build a +/// [`PayerProof`](crate::offers::payer_proof::PayerProof) via +/// [`PaidBolt12Invoice::prove_payer_derived`] is re-derived from the invoice's own payer metadata. +fn extract_payer_context<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, message: &OnionMessage) -> PaymentId { + match node.onion_messenger.peel_onion_message(message) { + Ok(PeeledOnion::Offers(_, Some(OffersContext::OutboundPaymentForOffer { payment_id, .. }), _)) => payment_id, + Ok(PeeledOnion::Offers(_, context, _)) => panic!("Expected OutboundPaymentForOffer context, got: {:?}", context), + Ok(PeeledOnion::Forward(_, _)) => panic!("Unexpected onion message forward"), + Ok(_) => panic!("Unexpected onion message"), + Err(e) => panic!("Failed to process onion message {:?}", e), + } +} + pub(super) fn extract_invoice_request<'a, 'b, 'c>( node: &Node<'a, 'b, 'c>, message: &OnionMessage ) -> (InvoiceRequest, BlindedMessagePath) { @@ -2676,3 +2693,141 @@ fn creates_and_pays_for_phantom_offer() { assert!(nodes[0].onion_messenger.next_onion_message_for_peer(node_c_id).is_none()); } } + +/// Tests the full payer proof lifecycle: offer -> invoice_request -> invoice -> payment -> +/// proof creation with derived key signing -> verification -> bech32 round-trip. +/// +/// This exercises the primary API path where a wallet pays a BOLT 12 offer and then creates +/// a payer proof using the derived signing key (same key derivation as the invoice request). +#[test] +fn creates_and_verifies_payer_proof_after_offer_payment() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + + let alice = &nodes[0]; // recipient (offer creator) + let alice_id = alice.node.get_our_node_id(); + let bob = &nodes[1]; // payer + let bob_id = bob.node.get_our_node_id(); + + // Alice creates an offer + let offer = alice.node + .create_offer_builder().unwrap() + .amount_msats(10_000_000) + .build().unwrap(); + + // Bob initiates payment + let payment_id = PaymentId([1; 32]); + bob.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + expect_recent_payment!(bob, RecentPaymentDetails::AwaitingInvoice, payment_id); + + // Bob sends invoice request to Alice + let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap(); + alice.onion_messenger.handle_onion_message(bob_id, &onion_message); + + let (invoice_request, _) = extract_invoice_request(alice, &onion_message); + + // Alice sends invoice back to Bob + let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); + bob.onion_messenger.handle_onion_message(alice_id, &onion_message); + + let (invoice, _) = extract_invoice(bob, &onion_message); + assert_eq!(invoice.amount_msats(), 10_000_000); + + // Extract the payment_id from Bob's reply path context. In a real wallet it would be + // persisted alongside the payment for later payer proof creation. + let context_payment_id = extract_payer_context(bob, &onion_message); + assert_eq!(context_payment_id, payment_id); + + // Route the payment + route_bolt12_payment(bob, &[alice], &invoice); + expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); + + // Get the payment preimage from Alice's PaymentClaimable event and claim it. + // In a real wallet, the payer receives the preimage via Event::PaymentSent after the + // recipient claims. For the test, we extract it from the recipient's claimable event. + let payment_preimage = match get_event!(alice, Event::PaymentClaimable) { + Event::PaymentClaimable { purpose, .. } => { + match &purpose { + PaymentPurpose::Bolt12OfferPayment { payment_context, .. } => { + assert_eq!(payment_context.offer_id, offer.id()); + assert_eq!( + payment_context.invoice_request.payer_signing_pubkey, + invoice_request.payer_signing_pubkey(), + ); + }, + _ => panic!("Expected Bolt12OfferPayment purpose"), + } + purpose.preimage().unwrap() + }, + _ => panic!("Expected Event::PaymentClaimable"), + }; + + let paid_invoice = claim_payment(bob, &[alice], payment_preimage).unwrap(); + expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); + + // The paid invoice is carried so the payer can re-derive their signing key (from the invoice's + // own payer metadata) when building a payer proof. + assert!(paid_invoice.bolt12_invoice().is_some()); + + // Regression guard: the `Event::PaymentSent` container persists the paid invoice and reads it + // back. Round-tripping the event must preserve the invoice. + let payment_sent = Event::PaymentSent { + payment_id: Some(payment_id), + payment_preimage, + payment_hash: invoice.payment_hash(), + amount_msat: Some(10_000_000), + fee_paid_msat: None, + bolt12_invoice: Some(paid_invoice.clone()), + }; + let encoded = payment_sent.encode(); + let decoded = Event::read(&mut &encoded[..]).unwrap().unwrap(); + assert_eq!(decoded, payment_sent); + match decoded { + Event::PaymentSent { bolt12_invoice: Some(decoded_invoice), .. } => { + assert!(decoded_invoice.bolt12_invoice().is_some()); + }, + _ => panic!("expected a PaymentSent event carrying a paid invoice"), + } + + // --- Payer Proof Creation --- + // Bob (the payer) creates a proof-of-payment with selective disclosure, end to end from the + // invoice he actually paid. The negative paths (`PreimageMismatch`, `KeyDerivationFailed`) are + // covered by the unit tests in `offers::payer_proof::tests`. + let expanded_key = bob.keys_manager.get_expanded_key(); + let secp_ctx = Secp256k1::new(); + let payer_proof = paid_invoice.prove_payer_derived( + payment_preimage, &expanded_key, payment_id, &secp_ctx, + ).unwrap() + .include_offer_description() + .include_invoice_amount() + .include_invoice_created_at() + .build_and_sign() + .unwrap(); + + // The proof binds the payment Bob actually made. + assert_eq!(payer_proof.payment_preimage(), payment_preimage); + assert_eq!(payer_proof.payment_hash(), invoice.payment_hash()); + + // Parsing the bech32 string back re-runs verification (preimage, invoice and proof signatures), + // just as a third-party verifier would. + let encoded = payer_proof.to_string(); + let verified: PayerProof = encoded.parse().unwrap(); + assert_eq!(verified.bytes(), payer_proof.bytes()); + assert_eq!(verified.to_string(), encoded); + + // The verified proof binds the same payment and preserves every disclosed field. + assert_eq!(verified.payment_preimage(), payment_preimage); + assert_eq!(verified.payment_hash(), invoice.payment_hash()); + assert_eq!(verified.payer_signing_pubkey(), invoice_request.payer_signing_pubkey()); + assert_eq!(verified.issuer_signing_pubkey(), invoice.signing_pubkey()); + assert_eq!(verified.invoice_amount_msats(), Some(invoice.amount_msats())); + assert_eq!(verified.invoice_created_at(), Some(invoice.created_at())); + assert_eq!( + verified.offer_description().map(|desc| desc.to_string()), + offer.description().map(|desc| desc.to_string()), + ); +} diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 20b594a1e83..c66166d9be5 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -2892,6 +2892,7 @@ mod tests { use crate::offers::invoice_request::InvoiceRequest; use crate::offers::nonce::Nonce; use crate::offers::offer::OfferBuilder; + use crate::offers::payer_proof::PaidBolt12Invoice; use crate::offers::test_utils::*; use crate::routing::gossip::NetworkGraph; use crate::routing::router::{ @@ -2902,10 +2903,13 @@ mod tests { use crate::types::features::{Bolt12InvoiceFeatures, ChannelFeatures, NodeFeatures}; use crate::types::payment::{PaymentHash, PaymentPreimage}; use crate::util::errors::APIError; - use crate::util::hash_tables::new_hash_map; + use crate::util::hash_tables::{new_hash_map, new_hash_set}; use crate::util::logger::WithContext; + use crate::util::ser::{MaybeReadable, Writeable}; use crate::util::test_utils; + use super::PaymentAttempts; + use alloc::collections::VecDeque; #[test] @@ -3472,6 +3476,62 @@ mod tests { assert!(pending_events.lock().unwrap().is_empty()); } + #[test] + fn retryable_payment_round_trips_bolt12_invoice() { + // A `Retryable` payment serializes its `bolt12_invoice` and reads it back. This guards that + // the paid invoice (needed to build payer proofs on retried paths) survives the round-trip. + let secp_ctx = Secp256k1::new(); + let expanded_key = ExpandedKey::new([42; 32]); + let nonce = Nonce([7; 16]); + let payment_id = PaymentId([3; 32]); + + let invoice = OfferBuilder::new(recipient_pubkey()) + .amount_msats(1000) + .build() + .unwrap() + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .unwrap() + .build_and_sign() + .unwrap() + .respond_with_no_std(payment_paths(), payment_hash(), now()) + .unwrap() + .build() + .unwrap() + .sign(recipient_sign) + .unwrap(); + + let mut session_privs = new_hash_set(); + session_privs.insert([1; 32]); + let payment = PendingOutboundPayment::Retryable { + retry_strategy: Some(Retry::Attempts(0)), + attempts: PaymentAttempts::new(), + payment_params: None, + session_privs, + payment_hash: payment_hash(), + payment_secret: None, + payment_metadata: None, + keysend_preimage: None, + invoice_request: None, + bolt12_invoice: Some(PaidBolt12Invoice::Bolt12Invoice(invoice)), + custom_tlvs: Vec::new(), + pending_amt_msat: 1000, + pending_fee_msat: None, + total_msat: 1000, + onion_total_msat: 1000, + starting_block_height: 0, + remaining_max_total_routing_fee_msat: None, + }; + + let encoded = payment.encode(); + let decoded = PendingOutboundPayment::read(&mut &encoded[..]).unwrap().unwrap(); + match decoded { + PendingOutboundPayment::Retryable { bolt12_invoice, .. } => { + assert!(matches!(bolt12_invoice, Some(PaidBolt12Invoice::Bolt12Invoice(_)))); + }, + _ => panic!("expected a Retryable payment"), + } + } + #[rustfmt::skip] fn dummy_invoice_request() -> InvoiceRequest { let expanded_key = ExpandedKey::new([42; 32]); From 1817d6213f45ed5f8ab8554427ea0a1e2215503c Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo <vincenzopalazzodev@gmail.com> Date: Mon, 15 Jun 2026 23:31:23 +0200 Subject: [PATCH 579/627] fuzz: add a payer proof deserialization target Throw arbitrary bytes at `PayerProof::try_from` to exercise the merkle-root reconstruction and the deserialization path together. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../src/bin/payer_proof_deser_target.rs | 137 ++++++++++++++++++ fuzz/src/bin/gen_target.sh | 1 + fuzz/src/lib.rs | 1 + fuzz/src/payer_proof_deser.rs | 31 ++++ fuzz/targets.h | 1 + 5 files changed, 171 insertions(+) create mode 100644 fuzz/fuzz-fake-hashes/src/bin/payer_proof_deser_target.rs create mode 100644 fuzz/src/payer_proof_deser.rs diff --git a/fuzz/fuzz-fake-hashes/src/bin/payer_proof_deser_target.rs b/fuzz/fuzz-fake-hashes/src/bin/payer_proof_deser_target.rs new file mode 100644 index 00000000000..c7f9d8619b8 --- /dev/null +++ b/fuzz/fuzz-fake-hashes/src/bin/payer_proof_deser_target.rs @@ -0,0 +1,137 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE +// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license +// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +// This file is auto-generated by gen_target.sh based on target_template.txt +// To modify it, modify target_template.txt and run gen_target.sh instead. + +#![cfg_attr(feature = "libfuzzer_fuzz", no_main)] +#![cfg_attr(rustfmt, rustfmt_skip)] + +#[cfg(not(fuzzing))] +compile_error!("Fuzz targets need cfg=fuzzing"); + +#[cfg(not(hashes_fuzz))] +compile_error!("Fuzz target does not support cfg(not(hashes_fuzz))"); + +#[cfg(not(secp256k1_fuzz))] +compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); + +extern crate lightning_fuzz; +use lightning_fuzz::payer_proof_deser::*; +use lightning_fuzz::utils::test_logger; + +#[cfg(feature = "afl")] +#[macro_use] extern crate afl; +#[cfg(feature = "afl")] +fn main() { + fuzz!(|data| { + payer_proof_deser_test(&data, test_logger::DevNull {}); + }); +} + +#[cfg(feature = "honggfuzz")] +#[macro_use] extern crate honggfuzz; +#[cfg(feature = "honggfuzz")] +fn main() { + loop { + fuzz!(|data| { + payer_proof_deser_test(&data, test_logger::DevNull {}); + }); + } +} + +#[cfg(feature = "libfuzzer_fuzz")] +#[macro_use] extern crate libfuzzer_sys; +#[cfg(feature = "libfuzzer_fuzz")] +fuzz_target!(|data: &[u8]| { + payer_proof_deser_test(data, test_logger::DevNull {}); +}); + +#[cfg(feature = "stdin_fuzz")] +fn main() { + use std::io::Read; + + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + + let mut data = Vec::with_capacity(8192); + std::io::stdin().read_to_end(&mut data).unwrap(); + if std::env::var_os("LDK_FUZZ_SUPPRESS_LOGS").is_some() { + payer_proof_deser_test(&data, test_logger::DevNull {}); + } else { + payer_proof_deser_test(&data, test_logger::Stdout {}); + } +} + +#[test] +fn run_test_cases() { + use std::fs; + use std::io::Read; + use lightning_fuzz::utils::test_logger::StringBuffer; + + use std::sync::{atomic, Arc}; + { + let data: Vec<u8> = vec![0]; + payer_proof_deser_test(&data, test_logger::DevNull {}); + } + let mut threads = Vec::new(); + let threads_running = Arc::new(atomic::AtomicUsize::new(0)); + if let Ok(tests) = fs::read_dir("../test_cases/payer_proof_deser") { + for test in tests { + let mut data: Vec<u8> = Vec::new(); + let path = test.unwrap().path(); + fs::File::open(&path).unwrap().read_to_end(&mut data).unwrap(); + threads_running.fetch_add(1, atomic::Ordering::AcqRel); + + let thread_count_ref = Arc::clone(&threads_running); + let main_thread_ref = std::thread::current(); + threads.push((path.file_name().unwrap().to_str().unwrap().to_string(), + std::thread::spawn(move || { + let string_logger = StringBuffer::new(); + + let panic_logger = string_logger.clone(); + let res = if ::std::panic::catch_unwind(move || { + payer_proof_deser_test(&data, panic_logger); + }).is_err() { + Some(string_logger.into_string()) + } else { None }; + thread_count_ref.fetch_sub(1, atomic::Ordering::AcqRel); + main_thread_ref.unpark(); + res + }) + )); + while threads_running.load(atomic::Ordering::Acquire) > 32 { + std::thread::park(); + } + } + } + let mut failed_outputs = Vec::new(); + for (test, thread) in threads.drain(..) { + if let Some(output) = thread.join().unwrap() { + println!("\nOutput of {}:\n{}\n", test, output); + failed_outputs.push(test); + } + } + if !failed_outputs.is_empty() { + println!("Test cases which failed: "); + for case in failed_outputs { + println!("{}", case); + } + panic!(); + } +} diff --git a/fuzz/src/bin/gen_target.sh b/fuzz/src/bin/gen_target.sh index 868a07652c6..96268712f7e 100755 --- a/fuzz/src/bin/gen_target.sh +++ b/fuzz/src/bin/gen_target.sh @@ -34,6 +34,7 @@ GEN_FAKE_HASHES_TEST onion_message GEN_FAKE_HASHES_TEST peer_crypt GEN_FAKE_HASHES_TEST process_network_graph GEN_FAKE_HASHES_TEST process_onion_failure +GEN_FAKE_HASHES_TEST payer_proof_deser GEN_FAKE_HASHES_TEST refund_deser GEN_FAKE_HASHES_TEST router GEN_FAKE_HASHES_TEST zbase32 diff --git a/fuzz/src/lib.rs b/fuzz/src/lib.rs index 25c2fffa23e..be5b34acbc7 100644 --- a/fuzz/src/lib.rs +++ b/fuzz/src/lib.rs @@ -36,6 +36,7 @@ pub mod lsps_message; pub mod offer_deser; pub mod onion_hop_data; pub mod onion_message; +pub mod payer_proof_deser; pub mod peer_crypt; pub mod process_network_graph; pub mod process_onion_failure; diff --git a/fuzz/src/payer_proof_deser.rs b/fuzz/src/payer_proof_deser.rs new file mode 100644 index 00000000000..adccbe5f1bc --- /dev/null +++ b/fuzz/src/payer_proof_deser.rs @@ -0,0 +1,31 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE +// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license +// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use crate::utils::test_logger; +use core::convert::TryFrom; +use lightning::offers::payer_proof::PayerProof; +use lightning::util::ser::Writeable; + +#[inline] +pub fn do_test<Out: test_logger::Output>(data: &[u8], _out: Out) { + if let Ok(payer_proof) = PayerProof::try_from(data.to_vec()) { + let mut bytes = Vec::with_capacity(data.len()); + payer_proof.write(&mut bytes).unwrap(); + assert_eq!(data, bytes); + } +} + +pub fn payer_proof_deser_test<Out: test_logger::Output>(data: &[u8], out: Out) { + do_test(data, out); +} + +#[no_mangle] +pub extern "C" fn payer_proof_deser_run(data: *const u8, datalen: usize) { + do_test(unsafe { std::slice::from_raw_parts(data, datalen) }, test_logger::DevNull {}); +} diff --git a/fuzz/targets.h b/fuzz/targets.h index ef8e899b178..3a0699d2dac 100644 --- a/fuzz/targets.h +++ b/fuzz/targets.h @@ -12,6 +12,7 @@ void onion_message_run(const unsigned char* data, size_t data_len); void peer_crypt_run(const unsigned char* data, size_t data_len); void process_network_graph_run(const unsigned char* data, size_t data_len); void process_onion_failure_run(const unsigned char* data, size_t data_len); +void payer_proof_deser_run(const unsigned char* data, size_t data_len); void refund_deser_run(const unsigned char* data, size_t data_len); void router_run(const unsigned char* data, size_t data_len); void zbase32_run(const unsigned char* data, size_t data_len); From 13c97eb59e0fb5db475e7fffd6bc31224eb75e11 Mon Sep 17 00:00:00 2001 From: Joost Jager <joost.jager@gmail.com> Date: Tue, 7 Jul 2026 09:13:39 +0200 Subject: [PATCH 580/627] Tolerate stale STFU warnings in chanmon fuzz When stale message events are delivered after a channel close, the recipient can respond with the expected STFU warning instead of the control error path. Treat that warning as expected for channels the harness already tracks closed, while documenting that stale events should still be delivered so handlers exercise their normal error paths. --- fuzz/src/chanmon_consistency.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index dfd1b4a54f7..55014891294 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -969,6 +969,14 @@ fn assert_disconnect_action<'a>( ); ExpectedControlAction::Error(msg) }, + msgs::ErrorAction::SendWarningMessage { ref msg, .. } => { + assert!( + close_tracker.is_expected_closed_channel_warning_msg(msg), + "Expected closed-channel warning, got: {:?}", + msg, + ); + ExpectedControlAction::Warning(msg, false) + }, _ => panic!("Expected harness control error, got: {:?}", action), } } @@ -1044,6 +1052,11 @@ impl ChannelCloseTracker { == "Peer sent an invalid channel_reestablish to force close in a non-standard way" || msg.data.contains("when we needed a channel_reestablish") } + + fn is_expected_closed_channel_warning_msg(&self, msg: &msgs::WarningMessage) -> bool { + self.closed_channels.contains_key(&msg.channel_id) + && msg.data == "Peer sent `stfu` when we were not in a live state" + } } #[derive(Clone, Copy, PartialEq)] @@ -3080,6 +3093,8 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { corrupt_forward: bool, limit_events: ProcessMessages, nodes: &[HarnessNode<'_>; 3], close_tracker: &ChannelCloseTracker, out: &Out, ) -> Option<MessageSendEvent> { + // Always deliver message events, even when the harness knows they are stale, + // so message handlers exercise their normal error paths. match event { MessageSendEvent::UpdateHTLCs { node_id, channel_id, updates } => { handle_update_htlcs_event( From 53b399c4e96f5dfa6d6ab065095da32bc7a28db2 Mon Sep 17 00:00:00 2001 From: Joost Jager <joost.jager@gmail.com> Date: Tue, 7 Jul 2026 09:15:01 +0200 Subject: [PATCH 581/627] Filter stale closed hop sends in chanmon fuzz Hop sends can route over an open SCID while LDK's non-strict forwarding still selects a parallel channel that the harness has already tracked closed but a node still lists. Skip those API sends until the stale listing clears, while keeping the existing open-id checks for fully dropped closed channels. --- fuzz/src/chanmon_consistency.rs | 34 +++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 55014891294..c8444a966c6 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -2855,6 +2855,20 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { self.link_between(source_idx, dest_idx).first_channel_id() } + // API calls are filtered before we make them if the harness knows they would + // target stale state. The open-channel filters below still handle tracked- + // closed channel ids after both peers have dropped them from list_channels. + fn has_stale_closed_channel_between(&self, source_idx: usize, dest_idx: usize) -> bool { + let channel_ids = self.channel_ids_between(source_idx, dest_idx); + let source_channels = self.nodes[source_idx].list_channels(); + let dest_channels = self.nodes[dest_idx].list_channels(); + channel_ids.iter().any(|channel_id| { + self.close_tracker.is_closed_or_closing(channel_id) + && (source_channels.iter().any(|chan| chan.channel_id == *channel_id) + || dest_channels.iter().any(|chan| chan.channel_id == *channel_id)) + }) + } + fn send_on_channel( &mut self, source_idx: usize, dest_idx: usize, dest_chan_id: ChannelId, amt: u64, ) -> bool { @@ -2874,6 +2888,16 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { } fn send_hop(&mut self, source_idx: usize, middle_idx: usize, dest_idx: usize, amt: u64) { + // Even if we route over an open SCID, the middle node's non-strict + // forwarding can pick a parallel channel that the harness has already + // tracked closed but the node still lists. In that window, the downstream + // HTLC may never get committed, so close cleanup has nothing to fail back + // and the source payment can remain pending. + if self.has_stale_closed_channel_between(source_idx, middle_idx) + || self.has_stale_closed_channel_between(middle_idx, dest_idx) + { + return; + } let middle_chan_id = self.first_channel_id_between(source_idx, middle_idx); let dest_chan_id = self.first_channel_id_between(middle_idx, dest_idx); if !self.close_tracker.is_open(&middle_chan_id) @@ -2929,6 +2953,16 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { &mut self, source_idx: usize, middle_idx: usize, dest_idx: usize, channels: MppHopChannels, amt: u64, ) { + // Even if we route over an open SCID, the middle node's non-strict + // forwarding can pick a parallel channel that the harness has already + // tracked closed but the node still lists. In that window, the downstream + // HTLC may never get committed, so close cleanup has nothing to fail back + // and the source payment can remain pending. + if self.has_stale_closed_channel_between(source_idx, middle_idx) + || self.has_stale_closed_channel_between(middle_idx, dest_idx) + { + return; + } let middle_chan_ids = self.channel_ids_between(source_idx, middle_idx); let dest_chan_ids = self.channel_ids_between(middle_idx, dest_idx); let middle_first_chan_id = middle_chan_ids[0]; From ed250c02a4b3ccd824a851a585be9c7501617e1b Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz <jkczyz@gmail.com> Date: Fri, 26 Jun 2026 14:47:57 -0500 Subject: [PATCH 582/627] Use a builder for sign_interactive_funding_tx arguments The signing helper had accumulated several boolean/option parameters beyond the two nodes, so call sites passed opaque positional `false`s and bare `None`s whose meaning was unclear without consulting the signature. Replace the two overloaded functions with a single `sign_interactive_funding_tx` taking a `SignInteractiveFundingTxArgs` builder, mirroring `PassAlongPathArgs`: `new(initiator, acceptor)` defaults to a first-attempt splice on a confirmed channel with no acceptor contribution, and each non-default behavior is opted into by a named method (`zero_conf`, `with_acceptor_contribution`, `replacing`). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- lightning/src/ln/splicing_tests.rs | 202 +++++++++++++++++------------ 1 file changed, 116 insertions(+), 86 deletions(-) diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 16dec1d5178..2bf7703217b 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -551,26 +551,59 @@ pub fn complete_interactive_funding_negotiation_for_both<'a, 'b, 'c, 'd>( assert!(expected_acceptor_scripts.is_empty(), "Not all acceptor outputs were sent"); } -pub fn sign_interactive_funding_tx<'a, 'b, 'c, 'd>( - initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, is_0conf: bool, +/// Arguments for [`sign_interactive_funding_tx`]. [`SignInteractiveFundingTxArgs::new`] defaults to a +/// first-attempt splice on a confirmed channel where only the initiator contributes; augment with the +/// builder methods as the scenario requires. +pub struct SignInteractiveFundingTxArgs<'a, 'b, 'c, 'd> { + initiator: &'a Node<'b, 'c, 'd>, + acceptor: &'a Node<'b, 'c, 'd>, + is_0conf: bool, + acceptor_has_contribution: bool, expected_replaced_txid: Option<Txid>, +} + +impl<'a, 'b, 'c, 'd> SignInteractiveFundingTxArgs<'a, 'b, 'c, 'd> { + pub fn new(initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>) -> Self { + Self { + initiator, + acceptor, + is_0conf: false, + acceptor_has_contribution: false, + expected_replaced_txid: None, + } + } + + /// The channel is zero-conf, so `splice_locked` is exchanged at signing and the initiator's + /// `splice_locked` is returned. + pub fn zero_conf(mut self) -> Self { + self.is_0conf = true; + self + } + + /// The acceptor contributed inputs and so must also sign the funding transaction. + pub fn with_acceptor_contribution(mut self) -> Self { + self.acceptor_has_contribution = true; + self + } + + /// This is an RBF replacing the negotiated candidate `prior_txid`, expected as the prior candidate + /// in the `TransactionType::InteractiveFunding` broadcast. + pub fn replacing(mut self, prior_txid: Txid) -> Self { + self.expected_replaced_txid = Some(prior_txid); + self + } +} + +pub fn sign_interactive_funding_tx<'a, 'b, 'c, 'd>( + args: SignInteractiveFundingTxArgs<'a, 'b, 'c, 'd>, ) -> (Transaction, Option<(msgs::SpliceLocked, PublicKey)>) { - sign_interactive_funding_tx_with_acceptor_contribution( + let SignInteractiveFundingTxArgs { initiator, acceptor, is_0conf, - false, + acceptor_has_contribution, expected_replaced_txid, - ) -} - -/// `expected_replaced_txid` is the expected txid of the prior negotiated candidate in the -/// `TransactionType::InteractiveFunding` broadcast: `None` for a first splice attempt; `Some(txid)` -/// for an RBF replacing that prior negotiated candidate. -pub fn sign_interactive_funding_tx_with_acceptor_contribution<'a, 'b, 'c, 'd>( - initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, is_0conf: bool, - acceptor_has_contribution: bool, expected_replaced_txid: Option<Txid>, -) -> (Transaction, Option<(msgs::SpliceLocked, PublicKey)>) { + } = args; let node_id_initiator = initiator.node.get_our_node_id(); let node_id_acceptor = acceptor.node.get_our_node_id(); @@ -712,7 +745,8 @@ pub fn splice_channel<'a, 'b, 'c, 'd>( funding_contribution, new_funding_script.clone(), ); - let (splice_tx, splice_locked) = sign_interactive_funding_tx(initiator, acceptor, false, None); + let (splice_tx, splice_locked) = + sign_interactive_funding_tx(SignInteractiveFundingTxArgs::new(initiator, acceptor)); assert!(splice_locked.is_none()); expect_splice_pending_event(initiator, &node_id_acceptor); @@ -1743,7 +1777,8 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { }), ); - let (splice_tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false, None); + let (splice_tx, splice_locked) = + sign_interactive_funding_tx(SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1])); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_1_id); @@ -1947,8 +1982,8 @@ fn do_test_splice_tiebreak( ); // Sign (acceptor has contribution) and broadcast. - let (tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( - &nodes[0], &nodes[1], false, true, None, + let (tx, splice_locked) = sign_interactive_funding_tx( + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]).with_acceptor_contribution(), ); assert!(splice_locked.is_none()); @@ -2015,9 +2050,8 @@ fn do_test_splice_tiebreak( ); // Sign (no acceptor contribution) and broadcast. - let (tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( - &nodes[0], &nodes[1], false, false, None, - ); + let (tx, splice_locked) = + sign_interactive_funding_tx(SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1])); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); @@ -2064,7 +2098,7 @@ fn do_test_splice_tiebreak( ); let (new_splice_tx, splice_locked) = - sign_interactive_funding_tx(&nodes[1], &nodes[0], false, None); + sign_interactive_funding_tx(SignInteractiveFundingTxArgs::new(&nodes[1], &nodes[0])); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[1], &node_id_0); @@ -3580,9 +3614,12 @@ fn do_test_propose_splice_while_disconnected(use_0conf: bool) { splice_ack.funding_contribution_satoshis, new_funding_script, ); - let (splice_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( - &nodes[0], &nodes[1], use_0conf, true, None, - ); + let mut args = + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]).with_acceptor_contribution(); + if use_0conf { + args = args.zero_conf(); + } + let (splice_tx, splice_locked) = sign_interactive_funding_tx(args); expect_splice_pending_event(&nodes[0], &node_id_1); expect_splice_pending_event(&nodes[1], &node_id_0); @@ -4018,7 +4055,8 @@ fn acceptor_can_cancel_queued_funding_contributed_during_counterparty_splice() { new_funding_script, ); - let (splice_tx, splice_locked) = sign_interactive_funding_tx(initiator, acceptor, false, None); + let (splice_tx, splice_locked) = + sign_interactive_funding_tx(SignInteractiveFundingTxArgs::new(initiator, acceptor)); assert!(splice_locked.is_none()); expect_splice_pending_event(initiator, &node_id_acceptor); assert!(acceptor.node.get_and_clear_pending_events().is_empty()); @@ -6179,10 +6217,8 @@ fn test_splice_rbf_acceptor_basic() { // Step 10: Sign and broadcast. The prior candidate in the broadcast's // `TransactionType::InteractiveFunding` must point at the first splice tx it is replacing. let (rbf_tx, splice_locked) = sign_interactive_funding_tx( - &nodes[0], - &nodes[1], - false, - Some(first_splice_tx.compute_txid()), + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]) + .replacing(first_splice_tx.compute_txid()), ); assert!(splice_locked.is_none()); @@ -6280,10 +6316,8 @@ fn test_splice_rbf_discard_unique_contribution() { ); let (rbf_tx, splice_locked) = sign_interactive_funding_tx( - &nodes[0], - &nodes[1], - false, - Some(first_splice_tx.compute_txid()), + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]) + .replacing(first_splice_tx.compute_txid()), ); assert!(splice_locked.is_none()); @@ -6348,10 +6382,8 @@ fn test_splice_rbf_at_high_feerate() { new_funding_script.clone(), ); let (rbf_tx_1, splice_locked) = sign_interactive_funding_tx( - &nodes[0], - &nodes[1], - false, - Some(first_splice_tx.compute_txid()), + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]) + .replacing(first_splice_tx.compute_txid()), ); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); @@ -6372,8 +6404,9 @@ fn test_splice_rbf_at_high_feerate() { contribution, new_funding_script, ); - let (_, splice_locked) = - sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(rbf_tx_1.compute_txid())); + let (_, splice_locked) = sign_interactive_funding_tx( + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]).replacing(rbf_tx_1.compute_txid()), + ); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); @@ -6573,8 +6606,9 @@ fn test_splice_rbf_insufficient_feerate_high() { contribution, new_funding_script, ); - let (_, splice_locked) = - sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(splice_tx.compute_txid())); + let (_, splice_locked) = sign_interactive_funding_tx( + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]).replacing(splice_tx.compute_txid()), + ); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); @@ -7159,12 +7193,10 @@ pub fn do_test_splice_rbf_tiebreak( ); // Sign (acceptor has contribution) and broadcast. - let (rbf_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( - &nodes[0], - &nodes[1], - false, - true, - Some(first_splice_tx.compute_txid()), + let (rbf_tx, splice_locked) = sign_interactive_funding_tx( + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]) + .with_acceptor_contribution() + .replacing(first_splice_tx.compute_txid()), ); assert!(splice_locked.is_none()); @@ -7240,12 +7272,9 @@ pub fn do_test_splice_rbf_tiebreak( ); // Sign (acceptor has no contribution) and broadcast. - let (rbf_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( - &nodes[0], - &nodes[1], - false, - false, - Some(first_splice_tx.compute_txid()), + let (rbf_tx, splice_locked) = sign_interactive_funding_tx( + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]) + .replacing(first_splice_tx.compute_txid()), ); assert!(splice_locked.is_none()); @@ -7309,7 +7338,7 @@ pub fn do_test_splice_rbf_tiebreak( // Sign (no acceptor contribution) and broadcast. let (new_splice_tx, splice_locked) = - sign_interactive_funding_tx(&nodes[1], &nodes[0], false, None); + sign_interactive_funding_tx(SignInteractiveFundingTxArgs::new(&nodes[1], &nodes[0])); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[1], &node_id_0); @@ -7491,8 +7520,8 @@ fn test_splice_rbf_acceptor_recontributes() { new_funding_script.clone(), ); - let (first_splice_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( - &nodes[0], &nodes[1], false, true, None, + let (first_splice_tx, splice_locked) = sign_interactive_funding_tx( + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]).with_acceptor_contribution(), ); assert!(splice_locked.is_none()); @@ -7529,12 +7558,10 @@ fn test_splice_rbf_acceptor_recontributes() { ); // Step 11: Sign (acceptor has contribution) and broadcast. - let (rbf_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( - &nodes[0], - &nodes[1], - false, - true, - Some(first_splice_tx.compute_txid()), + let (rbf_tx, splice_locked) = sign_interactive_funding_tx( + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]) + .with_acceptor_contribution() + .replacing(first_splice_tx.compute_txid()), ); assert!(splice_locked.is_none()); @@ -7626,8 +7653,8 @@ fn test_splice_rbf_after_counterparty_rbf_aborted() { new_funding_script, ); - let (_first_splice_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( - &nodes[0], &nodes[1], false, true, None, + let (_first_splice_tx, splice_locked) = sign_interactive_funding_tx( + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]).with_acceptor_contribution(), ); assert!(splice_locked.is_none()); @@ -7759,8 +7786,8 @@ fn test_splice_rbf_recontributes_feerate_too_high() { new_funding_script.clone(), ); - let (_first_splice_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( - &nodes[0], &nodes[1], false, true, None, + let (_first_splice_tx, splice_locked) = sign_interactive_funding_tx( + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]).with_acceptor_contribution(), ); assert!(splice_locked.is_none()); @@ -7846,8 +7873,10 @@ fn test_splice_rbf_sequential() { funding_contribution_1, new_funding_script.clone(), ); - let (splice_tx_1, splice_locked) = - sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(splice_tx_0.compute_txid())); + let (splice_tx_1, splice_locked) = sign_interactive_funding_tx( + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]) + .replacing(splice_tx_0.compute_txid()), + ); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); @@ -7867,8 +7896,10 @@ fn test_splice_rbf_sequential() { funding_contribution_2, new_funding_script.clone(), ); - let (rbf_tx_final, splice_locked) = - sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(splice_tx_1.compute_txid())); + let (rbf_tx_final, splice_locked) = sign_interactive_funding_tx( + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]) + .replacing(splice_tx_1.compute_txid()), + ); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); @@ -7936,8 +7967,9 @@ fn test_splice_rbf_amends_prior_net_positive_contribution_request() { contribution, new_funding_script.clone(), ); - let (tx, splice_locked) = - sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(replaced_txid)); + let (tx, splice_locked) = sign_interactive_funding_tx( + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]).replacing(replaced_txid), + ); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); @@ -8070,8 +8102,9 @@ fn test_splice_rbf_amends_prior_net_negative_contribution_request() { contribution, new_funding_script.clone(), ); - let (tx, splice_locked) = - sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(replaced_txid)); + let (tx, splice_locked) = sign_interactive_funding_tx( + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]).replacing(replaced_txid), + ); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); @@ -8232,8 +8265,8 @@ fn test_splice_rbf_acceptor_contributes_then_disconnects() { new_funding_script.clone(), ); - let (_first_splice_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( - &nodes[0], &nodes[1], false, true, None, + let (_first_splice_tx, splice_locked) = sign_interactive_funding_tx( + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]).with_acceptor_contribution(), ); assert!(splice_locked.is_none()); @@ -9103,10 +9136,8 @@ fn test_splice_rbf_rejects_low_feerate_after_several_attempts() { new_funding_script.clone(), ); let (rbf_tx, splice_locked) = sign_interactive_funding_tx( - &nodes[0], - &nodes[1], - false, - Some(prev_splice_tx.compute_txid()), + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]) + .replacing(prev_splice_tx.compute_txid()), ); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); @@ -9178,10 +9209,8 @@ fn test_splice_rbf_rejects_own_low_feerate_after_several_attempts() { new_funding_script.clone(), ); let (rbf_tx, splice_locked) = sign_interactive_funding_tx( - &nodes[0], - &nodes[1], - false, - Some(prev_splice_tx.compute_txid()), + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]) + .replacing(prev_splice_tx.compute_txid()), ); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); @@ -9252,7 +9281,8 @@ fn test_no_disconnect_after_splice_completes() { funding_contribution, new_funding_script, ); - let (_, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false, None); + let (_, splice_locked) = + sign_interactive_funding_tx(SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1])); assert!(splice_locked.is_none()); let _node_id_0 = nodes[0].node.get_our_node_id(); From 40957aad8342a264a246f8b59d93f399112b94aa Mon Sep 17 00:00:00 2001 From: Matt Corallo <git@bluematt.me> Date: Tue, 7 Jul 2026 18:36:47 +0000 Subject: [PATCH 583/627] Require `htlc_value_satoshis` in [pending] `HTLCUpdate`s In 0.0.100 we started tracking the amounts being claimed in `OnchainEvent::HTLCUpdate` and then also in `MonitorEvent::HTLCUpdate`'s `HTLCUpdate`. It was always set, but stored as an `Option` to support further downgrade. Because these objects time out after `ANTI_REORG_DELAY` (6) blocks, there's not really much reason to keep supporting backwards compatibility to upgrade with such objects without an amount. In 0.0.115, we started providing the amount in `PaymentForwarded`. For whatever reason, despite the event only being generated in cases where we had amounts, the field was added as an `Option`. Still, in 0.0.118 we started generating them from both off-chain and on-chain claims. For off-chain claims it was always set, but for claims which originated from on-chain claims, the amounts came from the `MonitorEvent::HTLCUpdate` and thus were always an `Option`. If we no longer care about `MonitorEvent::HTLCUpdate`'s without a claim amount, we no longer need to worry about `Event::PaymentForwarded` either. Thus, we make it required here as well. --- lightning/src/chain/channelmonitor.rs | 22 +++++++++++----------- lightning/src/events/mod.rs | 8 ++++---- lightning/src/ln/channelmanager.rs | 16 ++++++---------- lightning/src/ln/functional_tests.rs | 6 +++--- 4 files changed, 24 insertions(+), 28 deletions(-) diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs index a2412bbaf5e..24c1031f0c3 100644 --- a/lightning/src/chain/channelmonitor.rs +++ b/lightning/src/chain/channelmonitor.rs @@ -254,11 +254,11 @@ pub struct HTLCUpdate { pub(crate) payment_hash: PaymentHash, pub(crate) payment_preimage: Option<PaymentPreimage>, pub(crate) source: HTLCSource, - pub(crate) htlc_value_satoshis: Option<u64>, + pub(crate) htlc_value_satoshis: u64, } impl_ser_tlv_based!(HTLCUpdate, { (0, payment_hash, required), - (1, htlc_value_satoshis, option), + (1, htlc_value_satoshis, required), (2, source, required), (4, payment_preimage, option), }); @@ -529,7 +529,7 @@ enum OnchainEvent { HTLCUpdate { source: HTLCSource, payment_hash: PaymentHash, - htlc_value_satoshis: Option<u64>, + htlc_value_satoshis: u64, /// None in the second case, above, ie when there is no relevant output in the commitment /// transaction which appeared on chain. commitment_tx_output_idx: Option<u32>, @@ -614,7 +614,7 @@ impl MaybeReadable for OnchainEventEntry { impl_writeable_tlv_based_enum_upgradable!(OnchainEvent, (0, HTLCUpdate) => { (0, source, required), - (1, htlc_value_satoshis, option), + (1, htlc_value_satoshis, required), (2, payment_hash, required), (3, commitment_tx_output_idx, option), }, @@ -2688,7 +2688,7 @@ impl<Signer: EcdsaChannelSigner> ChannelMonitorImpl<Signer> { debug_assert!(htlc_spend_tx_opt.is_none()); htlc_spend_tx_opt = event.transaction.as_ref(); debug_assert!(holder_timeout_spend_pending.is_none()); - debug_assert_eq!(htlc_value_satoshis.unwrap(), htlc.amount_msat / 1000); + debug_assert_eq!(htlc_value_satoshis, htlc.amount_msat / 1000); holder_timeout_spend_pending = Some(event.confirmation_threshold()); }, OnchainEvent::HTLCSpendConfirmation { commitment_tx_output_idx, preimage, .. } @@ -3343,7 +3343,7 @@ macro_rules! fail_unbroadcast_htlcs { event: OnchainEvent::HTLCUpdate { source: (**source).clone(), payment_hash: htlc.payment_hash.clone(), - htlc_value_satoshis: Some(htlc.amount_msat / 1000), + htlc_value_satoshis: htlc.amount_msat / 1000, commitment_tx_output_idx: None, }, }; @@ -4506,7 +4506,7 @@ impl<Signer: EcdsaChannelSigner> ChannelMonitorImpl<Signer> { if self.counterparty_fulfilled_htlcs.get(&SentHTLCId::from_source(source)).is_some() { continue; } - let htlc_value_satoshis = Some(amount_msat / 1000); + let htlc_value_satoshis = amount_msat / 1000; let logger = WithContext::from(logger, None, None, Some(payment_hash)); // Defensively mark the HTLC as failed back so the expiry-based failure // path in `block_connected` doesn't generate a duplicate `HTLCUpdate` @@ -5936,7 +5936,7 @@ impl<Signer: EcdsaChannelSigner> ChannelMonitorImpl<Signer> { source: source.clone(), payment_preimage: None, payment_hash: htlc.payment_hash, - htlc_value_satoshis: Some(htlc.amount_msat / 1000), + htlc_value_satoshis: htlc.amount_msat / 1000, })); } } @@ -6353,7 +6353,7 @@ impl<Signer: EcdsaChannelSigner> ChannelMonitorImpl<Signer> { source, payment_preimage: Some(payment_preimage), payment_hash, - htlc_value_satoshis: Some(amount_msat / 1000), + htlc_value_satoshis: amount_msat / 1000, })); } } else if offered_preimage_claim { @@ -6377,7 +6377,7 @@ impl<Signer: EcdsaChannelSigner> ChannelMonitorImpl<Signer> { source, payment_preimage: Some(payment_preimage), payment_hash, - htlc_value_satoshis: Some(amount_msat / 1000), + htlc_value_satoshis: amount_msat / 1000, })); } } else { @@ -6398,7 +6398,7 @@ impl<Signer: EcdsaChannelSigner> ChannelMonitorImpl<Signer> { event: OnchainEvent::HTLCUpdate { source, payment_hash, - htlc_value_satoshis: Some(amount_msat / 1000), + htlc_value_satoshis: amount_msat / 1000, commitment_tx_output_idx: Some(input.previous_output.vout), }, }; diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 1f1a3586a26..ad493a1cfbe 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -1525,7 +1525,7 @@ pub enum Event { /// The final amount forwarded, in milli-satoshis, after the fee is deducted. /// /// The caveat described above the `total_fee_earned_msat` field applies here as well. - outbound_amount_forwarded_msat: Option<u64>, + outbound_amount_forwarded_msat: u64, }, /// Used to indicate that a channel with the given `channel_id` is being opened and pending /// confirmation on-chain. @@ -2225,7 +2225,7 @@ impl Writeable for Event { (1, Some(legacy_prev.channel_id), option), (2, claim_from_onchain_tx, required), (3, Some(legacy_next.channel_id), option), - (5, outbound_amount_forwarded_msat, option), + (5, outbound_amount_forwarded_msat, required), (7, skimmed_fee_msat, option), (9, legacy_prev.user_channel_id, option), (11, legacy_next.user_channel_id, option), @@ -2763,7 +2763,7 @@ impl MaybeReadable for Event { let mut total_fee_earned_msat = None; let mut skimmed_fee_msat = None; let mut claim_from_onchain_tx = false; - let mut outbound_amount_forwarded_msat = None; + let mut outbound_amount_forwarded_msat = 0; let mut prev_htlcs = vec![]; let mut next_htlcs = vec![]; read_tlv_fields!(reader, { @@ -2771,7 +2771,7 @@ impl MaybeReadable for Event { (1, prev_channel_id_legacy, option), (2, claim_from_onchain_tx, required), (3, next_channel_id_legacy, option), - (5, outbound_amount_forwarded_msat, option), + (5, outbound_amount_forwarded_msat, required), (7, skimmed_fee_msat, option), (9, prev_user_channel_id_legacy, option), (11, next_user_channel_id_legacy, option), diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index b86168a07f5..93dfd1c2cfd 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -10459,7 +10459,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ fn claim_funds_internal( &self, source: HTLCSource, payment_preimage: PaymentPreimage, - forwarded_htlc_value_msat: Option<u64>, skimmed_fee_msat: Option<u64>, from_onchain: bool, + forwarded_htlc_value_msat: u64, skimmed_fee_msat: Option<u64>, from_onchain: bool, next_channel_counterparty_node_id: PublicKey, next_channel_outpoint: OutPoint, next_channel_id: ChannelId, next_user_channel_id: Option<u128>, attribution_data: Option<AttributionData>, send_timestamp: Option<Duration>, @@ -10527,12 +10527,8 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ payment_preimage, |htlc_claim_value_msat: Option<u64>| -> Option<events::Event> { let total_fee_earned_msat = - if let Some(forwarded_htlc_value) = forwarded_htlc_value_msat { - if let Some(claimed_htlc_value) = htlc_claim_value_msat { - Some(claimed_htlc_value - forwarded_htlc_value) - } else { - None - } + if let Some(claimed_htlc_value) = htlc_claim_value_msat { + Some(claimed_htlc_value - forwarded_htlc_value_msat) } else { None }; @@ -13103,7 +13099,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ self.claim_funds_internal( htlc_source, msg.payment_preimage.clone(), - Some(forwarded_htlc_value), + forwarded_htlc_value, skimmed_fee_msat, false, *counterparty_node_id, @@ -14122,7 +14118,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ self.claim_funds_internal( htlc_update.source, preimage, - htlc_update.htlc_value_satoshis.map(|v| v * 1000), + htlc_update.htlc_value_satoshis * 1000, None, true, counterparty_node_id, @@ -21183,7 +21179,7 @@ impl< channel_manager.claim_funds_internal( source, preimage, - Some(downstream_value), + downstream_value, None, downstream_closed, downstream_node_id, diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index 826b07750fa..b84a486a007 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -1508,7 +1508,7 @@ pub fn test_htlc_on_chain_success() { assert_eq!(prev_htlcs[0].channel_id, chan_id); assert_eq!(claim_from_onchain_tx, true); assert_eq!(next_htlcs[0].channel_id, chan_2.2); - assert_eq!(outbound_amount_forwarded_msat, Some(3000000)); + assert_eq!(outbound_amount_forwarded_msat, 3000000); }, _ => panic!(), } @@ -1525,7 +1525,7 @@ pub fn test_htlc_on_chain_success() { assert_eq!(prev_htlcs[0].channel_id, chan_id); assert_eq!(claim_from_onchain_tx, true); assert_eq!(next_htlcs[0].channel_id, chan_2.2); - assert_eq!(outbound_amount_forwarded_msat, Some(3000000)); + assert_eq!(outbound_amount_forwarded_msat, 3000000); }, _ => panic!(), } @@ -4046,7 +4046,7 @@ pub fn test_onchain_to_onchain_claim() { assert_eq!(prev_htlcs[0].channel_id, chan_1.2); assert_eq!(claim_from_onchain_tx, true); assert_eq!(next_htlcs[0].channel_id, chan_2.2); - assert_eq!(outbound_amount_forwarded_msat, Some(3000000)); + assert_eq!(outbound_amount_forwarded_msat, 3000000); }, _ => panic!("Unexpected event"), } From 707d65ce71d452b6e725e511767c75a4f85eb8b4 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz <jkczyz@gmail.com> Date: Fri, 12 Jun 2026 17:13:21 -0500 Subject: [PATCH 584/627] Store splice contributions with their negotiated candidates PendingFunding tracked our splice contributions in a compact list implicitly aligned to the tail of the negotiated candidates, with the in-flight negotiation round's contribution as the implicit last entry. Every consumer had to re-derive this positional relationship, which is easy to get wrong -- e.g., attributing an in-flight round's contribution to a completed counterparty-only candidate. Instead, store each candidate's contribution with the candidate itself and give the in-flight round's contribution its own field, making such misattribution unrepresentable. The contributions still form a suffix of the candidates -- once a round includes our contribution, every subsequent round carries it forward (possibly feerate-adjusted) so the splice intention is never lost -- which is now asserted when a round completes. Serialize this so a single (non-RBF) pending splice stays loadable by LDK 0.2 while RBF is refused loudly. 0.2 predates per-candidate contributions, the in-flight contribution, and the last-negotiated feerate, so writing any of them in an even (required) TLV would make 0.2 refuse even a single splice it can otherwise operate. The legacy TLV 3 therefore carries only the first candidate's funding -- the single-splice view 0.2 reads -- while the full candidate list, the in-flight contribution, and the feerate go in odd TLVs that 0.2 skips. An even gate TLV is written only when there is more than one negotiation round (RBF), so 0.2 loads single splices and refuses RBF, which it cannot operate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- lightning/src/ln/channel.rs | 342 ++++++++++++++++++++++++------------ 1 file changed, 229 insertions(+), 113 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index fb5a7de8730..3242ac87add 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -84,7 +84,7 @@ use crate::util::config::{ use crate::util::errors::APIError; use crate::util::logger::{Level as LoggerLevel, Logger, Record, WithContext}; use crate::util::scid_utils::{block_from_scid, scid_from_parts}; -use crate::util::ser::{Readable, ReadableArgs, RequiredWrapper, Writeable, Writer}; +use crate::util::ser::{Iterable, Readable, ReadableArgs, RequiredWrapper, Writeable, Writer}; use crate::util::wallet_utils::{ConfirmedUtxo, Input}; use crate::{impl_readable_for_vec, impl_writeable_for_vec}; @@ -2964,9 +2964,20 @@ impl FundingScope { struct PendingFunding { funding_negotiation: Option<FundingNegotiation>, + /// Our contribution to the funding negotiation round currently in progress, if we are + /// contributing to it. Set when the round starts, moved into the [`NegotiatedCandidate`] + /// when negotiation completes, and dropped in + /// [`FundedChannel::reset_pending_splice_state`] if the round is abandoned. + /// + /// When the counterparty initiates an RBF and a prior round included our contribution, this + /// is set to that contribution adjusted to the new feerate (or the RBF is rejected if the + /// adjustment fails, in which case no round starts). This ensures a splice we contributed to + /// never loses our contribution in subsequent rounds. + negotiation_contribution: Option<FundingContribution>, + /// Funding candidates that have been negotiated but have not reached enough confirmations /// by both counterparties to have exchanged `splice_locked` and be promoted. - negotiated_candidates: Vec<FundingScope>, + negotiated_candidates: Vec<NegotiatedCandidate>, /// The funding txid used in the `splice_locked` sent to the counterparty. sent_funding_txid: Option<Txid>, @@ -2977,21 +2988,26 @@ struct PendingFunding { /// The feerate used in the last successfully negotiated funding transaction. /// Used for validating the minimum feerate increase rule on RBF attempts. last_funding_feerate_sat_per_1000_weight: Option<u32>, +} - /// The funding contributions from splice/RBF rounds where we contributed. - /// - /// A new entry is appended when we contribute to a negotiation round (either as initiator - /// or acceptor). Rounds where we don't contribute (e.g., counterparty-only splice) do not - /// add an entry. Once non-empty, every subsequent round appends: when the counterparty - /// initiates an RBF, the last entry is adjusted to the new feerate and appended as a new - /// entry (or the RBF is rejected if the adjustment fails, in which case no round starts). - /// - /// If the round aborts, the last entry is popped in - /// [`FundedChannel::reset_pending_splice_state`], restoring the prior round's contribution - /// as the most recent entry. - contributions: Vec<FundingContribution>, +/// A funding candidate that has been negotiated, together with our contribution, if any, to the +/// negotiation round that produced it. +#[derive(Debug)] +struct NegotiatedCandidate { + funding: FundingScope, + + /// Our contribution to the negotiation round that produced this candidate, or `None` if only + /// the counterparty contributed. Once a candidate includes our contribution, every later + /// candidate does as well: RBF rounds carry the contribution forward (possibly adjusted to a + /// new feerate) rather than dropping it, preserving the splice intention. + contribution: Option<FundingContribution>, } +impl_ser_tlv_based!(NegotiatedCandidate, { + (1, funding, required), + (3, contribution, option), +}); + #[derive(Debug)] enum FundingNegotiation { AwaitingAck { @@ -3050,20 +3066,44 @@ impl Writeable for PendingFundingWriteable<'_> { Some(FundingNegotiation::AwaitingSignatures { .. }) ) ); - let contributions_len = if self.reset_funding_negotiation - && self.pending_funding.funding_negotiation.is_some() - { - self.pending_funding.contributions.len().saturating_sub(1) - } else { - self.pending_funding.contributions.len() - }; + // The in-flight round's contribution is only written if its negotiation survives + // serialization round trips. It goes in an odd TLV that LDK 0.2 skips (0.2 never tracked + // contributions), so a single in-flight splice we contributed to stays loadable there. + let negotiation_contribution = funding_negotiation + .is_some() + .then(|| self.pending_funding.negotiation_contribution.as_ref()) + .flatten(); + let candidates = &self.pending_funding.negotiated_candidates; + debug_assert!( + self.pending_funding.contributions_form_suffix(), + "contributions must form a suffix of the negotiated candidates", + ); + // TLV 3 exposes only the first candidate's funding: the single-splice view LDK 0.2 + // understands. The authoritative candidate list -- each funding bundled with its + // contribution -- goes in the odd TLV 11, which current reads and 0.2 skips. A single + // non-contributory splice is fully captured by TLV 3 alone, so the bundle is then omitted. + // When a single splice does carry a contribution, 0.2 skips it (and operates the splice + // without it), so it need not block 0.2 from loading. + // + // The even TLV 14 is the only thing that makes 0.2 refuse, and it's written exactly when + // there is more than one negotiation round (RBF) -- the one thing 0.2 cannot operate. The + // odd contribution fields are safe despite being load-bearing for RBF: this gate makes 0.2 + // refuse the whole channel in that case, so no reader ever skips them when they matter. + let first_funding = Iterable(candidates.iter().take(1).map(|candidate| &candidate.funding)); + let any_contribution = candidates.iter().any(|candidate| candidate.contribution.is_some()); + let negotiated_candidates = + (candidates.len() > 1 || any_contribution).then(|| Iterable(candidates.iter())); + let is_rbf = candidates.len() + usize::from(funding_negotiation.is_some()) > 1; + let rbf_gate = is_rbf.then_some(()); write_tlv_fields!(writer, { (1, funding_negotiation, upgradable_option), - (3, self.pending_funding.negotiated_candidates, required_vec), + (3, first_funding, required), (5, self.pending_funding.sent_funding_txid, option), (7, self.pending_funding.received_funding_txid, option), - (8, self.pending_funding.last_funding_feerate_sat_per_1000_weight, option), - (10, self.pending_funding.contributions[..contributions_len], optional_vec), + (9, self.pending_funding.last_funding_feerate_sat_per_1000_weight, option), + (11, negotiated_candidates, option), + (13, negotiation_contribution, option), + (14, rbf_gate, option), }); Ok(()) } @@ -3071,14 +3111,58 @@ impl Writeable for PendingFundingWriteable<'_> { impl Readable for PendingFunding { fn read<R: io::Read>(reader: &mut R) -> Result<Self, DecodeError> { - Ok(_decode_and_build!(reader, Self, { + let mut funding_negotiation = None; + let mut legacy_negotiated_candidates: Option<Vec<FundingScope>> = None; + let mut sent_funding_txid = None; + let mut received_funding_txid = None; + let mut last_funding_feerate_sat_per_1000_weight = None; + let mut negotiated_candidates: Option<Vec<NegotiatedCandidate>> = None; + let mut negotiation_contribution: Option<FundingContribution> = None; + let mut rbf_gate: Option<()> = None; + + read_tlv_fields!(reader, { (1, funding_negotiation, upgradable_option), - (3, negotiated_candidates, required_vec), + (3, legacy_negotiated_candidates, optional_vec), (5, sent_funding_txid, option), (7, received_funding_txid, option), - (8, last_funding_feerate_sat_per_1000_weight, option), - (10, contributions, optional_vec), - })) + (9, last_funding_feerate_sat_per_1000_weight, option), + (11, negotiated_candidates, optional_vec), + (13, negotiation_contribution, option), + (14, rbf_gate, option), + }); + + // TLV 11 (the candidate list, each funding bundled with its contribution) is authoritative + // when present. It is omitted for a single non-contributory splice (TLV 3 holds its + // funding) and for data written by LDK 0.2 (which only ever wrote TLV 3 and tracked no + // contributions); in both cases the candidates carry no contribution. + let negotiated_candidates = negotiated_candidates.unwrap_or_else(|| { + legacy_negotiated_candidates + .unwrap_or_default() + .into_iter() + .map(|funding| NegotiatedCandidate { funding, contribution: None }) + .collect() + }); + // An in-flight contribution is only written alongside a surviving negotiation round, so a + // contribution without one is invalid. + if funding_negotiation.is_none() && negotiation_contribution.is_some() { + return Err(DecodeError::InvalidValue); + } + // TLV 14 (the RBF gate) is written exactly when there is more than one negotiation round, so + // pre-RBF readers (LDK 0.2) refuse an RBF they cannot operate. Current reconstructs RBF state + // from the candidate list, but a gate inconsistent with that state is invalid. + let is_rbf = negotiated_candidates.len() + usize::from(funding_negotiation.is_some()) > 1; + if rbf_gate.is_some() != is_rbf { + return Err(DecodeError::InvalidValue); + } + + Ok(PendingFunding { + funding_negotiation, + negotiation_contribution, + negotiated_candidates, + sent_funding_txid, + received_funding_txid, + last_funding_feerate_sat_per_1000_weight, + }) } } @@ -3175,6 +3259,16 @@ impl FundingNegotiation { } impl PendingFunding { + /// Whether our contributions form a suffix of the negotiated candidates: once a round includes + /// our contribution, every later round carries it forward (so the splice intention is never + /// lost). + fn contributions_form_suffix(&self) -> bool { + self.negotiated_candidates + .iter() + .skip_while(|candidate| candidate.contribution.is_none()) + .all(|candidate| candidate.contribution.is_some()) + } + fn awaiting_ack_context( &self, msg_name: &str, ) -> Result<(&FundingNegotiationContext, &PublicKey), ChannelError> { @@ -3228,22 +3322,42 @@ impl PendingFunding { feerate_sat_per_kw >= min_feerate } + /// All stored contributions: those of the negotiated candidates followed by the in-flight + /// negotiation round's, if any. + fn contributions(&self) -> impl Iterator<Item = &FundingContribution> + '_ { + self.negotiated_candidates + .iter() + .filter_map(|candidate| candidate.contribution.as_ref()) + .chain(self.negotiation_contribution.as_ref()) + } + fn contributed_inputs(&self) -> impl Iterator<Item = bitcoin::OutPoint> + '_ { - self.contributions.iter().flat_map(|c| c.contributed_inputs()) + self.contributions().flat_map(|c| c.contributed_inputs()) } fn contributed_outputs(&self) -> impl Iterator<Item = &bitcoin::Script> + '_ { - self.contributions.iter().flat_map(|c| c.contributed_outputs()) + self.contributions().flat_map(|c| c.contributed_outputs()) } fn prior_contributed_inputs(&self) -> impl Iterator<Item = bitcoin::OutPoint> + '_ { - let len = self.contributions.len(); - self.contributions[..len.saturating_sub(1)].iter().flat_map(|c| c.contributed_inputs()) + self.negotiated_candidates + .iter() + .filter_map(|candidate| candidate.contribution.as_ref()) + .flat_map(|c| c.contributed_inputs()) } fn prior_contributed_outputs(&self) -> impl Iterator<Item = &bitcoin::Script> + '_ { - let len = self.contributions.len(); - self.contributions[..len.saturating_sub(1)].iter().flat_map(|c| c.contributed_outputs()) + self.negotiated_candidates + .iter() + .filter_map(|candidate| candidate.contribution.as_ref()) + .flat_map(|c| c.contributed_outputs()) + } + + /// Our most recent contribution across rounds, including any round still under negotiation. + fn latest_contribution(&self) -> Option<&FundingContribution> { + self.negotiation_contribution.as_ref().or_else(|| { + self.negotiated_candidates.last().and_then(|candidate| candidate.contribution.as_ref()) + }) } fn check_get_splice_locked<SP: SignerProvider>( @@ -3251,7 +3365,7 @@ impl PendingFunding { ) -> Option<msgs::SpliceLocked> { debug_assert!(confirmed_funding_index < self.negotiated_candidates.len()); - let funding = &self.negotiated_candidates[confirmed_funding_index]; + let funding = &self.negotiated_candidates[confirmed_funding_index].funding; if !context.check_funding_meets_minimum_depth(funding, height) { return None; } @@ -7274,8 +7388,9 @@ where /// Builds a [`SpliceFundingFailed`] from a contribution, filtering out inputs/outputs /// that are still committed to a prior splice round. fn splice_funding_failed_for(&self, contribution: FundingContribution) -> SpliceFundingFailed { - // The contribution was never pushed to `contributions`, so `contributed_inputs()` and - // `contributed_outputs()` return only prior rounds' entries for filtering. + // The contribution was never stored in the pending splice state, so + // `contributed_inputs()` and `contributed_outputs()` return only prior rounds' entries + // for filtering. splice_funding_failed_for!(self, contribution, contributed_inputs, contributed_outputs) } @@ -7318,12 +7433,15 @@ where }) } - fn pending_funding(&self) -> &[FundingScope] { - if let Some(pending_splice) = &self.pending_splice { - pending_splice.negotiated_candidates.as_slice() - } else { - &[] - } + fn negotiated_candidates(&self) -> &[NegotiatedCandidate] { + self.pending_splice + .as_ref() + .map(|pending_splice| pending_splice.negotiated_candidates.as_slice()) + .unwrap_or(&[]) + } + + fn pending_funding(&self) -> impl ExactSizeIterator<Item = &FundingScope> + '_ { + self.negotiated_candidates().iter().map(|candidate| &candidate.funding) } fn funding_and_pending_funding_iter_mut(&mut self) -> impl Iterator<Item = &mut FundingScope> { @@ -7332,7 +7450,8 @@ where .as_mut() .map(|pending_splice| pending_splice.negotiated_candidates.as_mut_slice()) .unwrap_or(&mut []) - .iter_mut(), + .iter_mut() + .map(|candidate| &mut candidate.funding), ) } @@ -7419,7 +7538,7 @@ where "reset_pending_splice_state requires an active funding negotiation" ); pending_splice.funding_negotiation.take(); - let contribution = pending_splice.contributions.pop(); + let contribution = pending_splice.negotiation_contribution.take(); if let Some(ref contribution) = contribution { debug_assert!( pending_splice @@ -7430,13 +7549,13 @@ where ); } - // After pop, `contributed_inputs()` / `contributed_outputs()` return only prior - // rounds for filtering. + // With the in-flight contribution taken, `contributed_inputs()` / + // `contributed_outputs()` return only prior rounds' entries for filtering. let splice_funding_failed = contribution.map(|contribution| { splice_funding_failed_for!(self, contribution, contributed_inputs, contributed_outputs) }); - if self.pending_funding().is_empty() { + if self.negotiated_candidates().is_empty() { self.pending_splice.take(); } @@ -7458,7 +7577,7 @@ where pending_splice.funding_negotiation.is_some(), "maybe_splice_funding_failed requires an active funding negotiation" ); - let contribution = pending_splice.contributions.last().cloned()?; + let contribution = pending_splice.negotiation_contribution.clone()?; Some(splice_funding_failed_for!( self, contribution, @@ -8090,7 +8209,7 @@ where } core::iter::once(&self.funding) - .chain(self.pending_funding().iter()) + .chain(self.pending_funding()) .try_for_each(|funding| self.context.validate_update_add_htlc(funding, msg, fee_estimator))?; // Now update local state: @@ -8522,7 +8641,7 @@ where let funding_contribution = self .pending_splice .as_ref() - .and_then(|pending_splice| pending_splice.contributions.last()) + .and_then(|pending_splice| pending_splice.negotiation_contribution.as_ref()) .cloned(); log_info!( @@ -8585,7 +8704,7 @@ where ) -> Result<Option<ChannelMonitorUpdate>, ChannelError> { self.commitment_signed_check_state()?; - if !self.pending_funding().is_empty() { + if !self.negotiated_candidates().is_empty() { return Err(ChannelError::close( "Got a single commitment_signed message when expecting a batch".to_owned(), )); @@ -8662,7 +8781,7 @@ where // pending splice transaction has confirmed since receiving the batch. let mut commitment_txs = Vec::with_capacity(self.pending_funding().len() + 1); let mut htlc_data = None; - for funding in core::iter::once(&self.funding).chain(self.pending_funding().iter()) { + for funding in core::iter::once(&self.funding).chain(self.pending_funding()) { let funding_txid = funding.get_funding_txid().expect("Funding txid must be known for pending scope"); let msg = messages.get(&funding_txid).ok_or_else(|| { @@ -9542,7 +9661,14 @@ where .map(|signing_session| signing_session.has_local_contribution()) .unwrap_or(false); - pending_splice.negotiated_candidates.push(funding); + let contribution = pending_splice.negotiation_contribution.take(); + pending_splice + .negotiated_candidates + .push(NegotiatedCandidate { funding, contribution }); + debug_assert!( + pending_splice.contributions_form_suffix(), + "a round following one we contributed to must carry our contribution", + ); let splice_negotiated = SpliceFundingNegotiated { funding_txo: funding_txo.into_bitcoin_outpoint(), @@ -9566,29 +9692,21 @@ where ); } - let contrib_offset = pending_splice - .negotiated_candidates - .len() - .saturating_sub(pending_splice.contributions.len()); let candidates = pending_splice .negotiated_candidates .iter() - .enumerate() - .map(|(i, funding)| { - let txid = funding + .map(|candidate| { + let txid = candidate + .funding .get_funding_txid() .expect("negotiated candidates should have a funding txid"); - let contribution = i - .checked_sub(contrib_offset) - .and_then(|j| pending_splice.contributions.get(j)) - .cloned(); FundingCandidate { txid, channels: vec![ChannelFunding { counterparty_node_id: self.context.counterparty_node_id, channel_id: self.context.channel_id, purpose: FundingPurpose::Splice, - contribution, + contribution: candidate.contribution.clone(), }], } }) @@ -9749,7 +9867,7 @@ where debug_assert!(!self.funding.get_channel_type().supports_anchor_zero_fee_commitments()); let can_send_update_fee = core::iter::once(&self.funding) - .chain(self.pending_funding().iter()) + .chain(self.pending_funding()) .all(|funding| self.context.can_send_update_fee(funding, feerate_per_kw, fee_estimator, logger)); if !can_send_update_fee { return None; @@ -10100,7 +10218,7 @@ where } core::iter::once(&self.funding) - .chain(self.pending_funding().iter()) + .chain(self.pending_funding()) .try_for_each(|funding| FundedChannel::<SP>::check_remote_fee(funding.get_channel_type(), fee_estimator, msg.feerate_per_kw, Some(self.context.feerate_per_kw), logger))?; self.context.pending_update_fee = Some((msg.feerate_per_kw, FeeUpdateState::RemoteAnnounced)); @@ -10808,7 +10926,6 @@ where // for this `txid`. let inferred_splice_locked = msg.my_current_funding_locked.as_ref().and_then(|funding_locked| { self.pending_funding() - .iter() .find(|funding| funding.get_funding_txid() == Some(funding_locked.txid)) .and_then(|_| { self.pending_splice.as_ref().and_then(|pending_splice| { @@ -11605,7 +11722,7 @@ where ); core::iter::once(&self.funding) - .chain(self.pending_funding().iter()) + .chain(self.pending_funding()) .try_for_each(|funding| self.context.can_accept_incoming_htlc(funding, dust_exposure_limiting_feerate, &logger)) } @@ -11923,6 +12040,7 @@ where let funding = pending_splice .negotiated_candidates .iter_mut() + .map(|candidate| &mut candidate.funding) .find(|funding| funding.get_funding_txid() == Some(splice_txid)) .unwrap(); @@ -11937,9 +12055,12 @@ where .funding_transaction .as_ref() .expect("Promoted splice funding should have a funding transaction"); - let contributions = core::mem::take(&mut pending_splice.contributions); - contributions + let candidates = core::mem::take(&mut pending_splice.negotiated_candidates); + let negotiation_contribution = pending_splice.negotiation_contribution.take(); + candidates .into_iter() + .filter_map(|candidate| candidate.contribution) + .chain(negotiation_contribution) .filter_map(|contribution| { contribution.into_unique_contributions( promoted_tx.input.iter().map(|i| i.previous_output), @@ -12028,7 +12149,9 @@ where let mut confirmed_funding_index = None; let mut funding_already_confirmed = false; - for (index, funding) in pending_splice.negotiated_candidates.iter_mut().enumerate() { + let candidates = + pending_splice.negotiated_candidates.iter_mut().map(|candidate| &mut candidate.funding); + for (index, funding) in candidates.enumerate() { if self.context.check_for_funding_tx_confirmed( funding, block_hash, height, index_in_block, &mut confirmed_tx, logger, )? { @@ -12188,7 +12311,8 @@ where if let Some(pending_splice) = &mut self.pending_splice { let mut confirmed_funding_index = None; - for (index, funding) in pending_splice.negotiated_candidates.iter().enumerate() { + let candidates = pending_splice.negotiated_candidates.iter().map(|candidate| &candidate.funding); + for (index, funding) in candidates.enumerate() { if funding.funding_tx_confirmation_height != 0 { if confirmed_funding_index.is_some() { let err_reason = "splice tx of another pending funding already confirmed"; @@ -12200,7 +12324,8 @@ where } if let Some(confirmed_funding_index) = confirmed_funding_index { - let funding = &mut pending_splice.negotiated_candidates[confirmed_funding_index]; + let funding = + &mut pending_splice.negotiated_candidates[confirmed_funding_index].funding; // Check if the splice funding transaction was unconfirmed if funding.get_funding_tx_confirmations(height) == 0 { @@ -12256,7 +12381,7 @@ where pub fn get_relevant_txids(&self) -> impl Iterator<Item = (Txid, u32, Option<BlockHash>)> + '_ { core::iter::once(&self.funding) - .chain(self.pending_funding().iter()) + .chain(self.pending_funding()) .map(|funding| { ( funding.get_funding_txid(), @@ -12706,15 +12831,7 @@ where ); let min_rbf_feerate = prev_feerate.map(min_rbf_feerate); let prior = if pending_splice.last_funding_feerate_sat_per_1000_weight.is_some() { - if let Some(prior) = self - .pending_splice - .as_ref() - .and_then(|pending_splice| pending_splice.contributions.last()) - { - Some(prior.clone()) - } else { - None - } + pending_splice.latest_contribution().cloned() } else { None }; @@ -12981,7 +13098,9 @@ where } } - fn send_splice_init(&mut self, context: FundingNegotiationContext) -> msgs::SpliceInit { + fn send_splice_init( + &mut self, context: FundingNegotiationContext, contribution: FundingContribution, + ) -> msgs::SpliceInit { debug_assert!(self.pending_splice.is_none()); // Rotate the funding pubkey using the prev_funding_txid as a tweak let prev_funding_txid = self.funding.get_funding_txid(); @@ -13004,11 +13123,11 @@ where FundingNegotiation::AwaitingAck { context, new_holder_funding_key: funding_pubkey }; self.pending_splice = Some(PendingFunding { funding_negotiation: Some(funding_negotiation), + negotiation_contribution: Some(contribution), negotiated_candidates: vec![], sent_funding_txid: None, received_funding_txid: None, last_funding_feerate_sat_per_1000_weight: None, - contributions: vec![], }); msgs::SpliceInit { @@ -13021,7 +13140,9 @@ where } } - fn send_tx_init_rbf(&mut self, context: FundingNegotiationContext) -> msgs::TxInitRbf { + fn send_tx_init_rbf( + &mut self, context: FundingNegotiationContext, contribution: FundingContribution, + ) -> msgs::TxInitRbf { let pending_splice = self.pending_splice.as_mut().expect("pending_splice should exist for RBF"); debug_assert!(!pending_splice.negotiated_candidates.is_empty()); @@ -13030,6 +13151,7 @@ where .negotiated_candidates .first() .unwrap() + .funding .get_holder_pubkeys() .funding_pubkey; @@ -13039,6 +13161,7 @@ where pending_splice.funding_negotiation = Some(FundingNegotiation::AwaitingAck { context, new_holder_funding_key }); + pending_splice.negotiation_contribution = Some(contribution); msgs::TxInitRbf { channel_id: self.context.channel_id, @@ -13376,11 +13499,11 @@ where ); self.pending_splice = Some(PendingFunding { funding_negotiation: Some(funding_negotiation), + negotiation_contribution: adjusted_contribution, negotiated_candidates: Vec::new(), received_funding_txid: None, sent_funding_txid: None, last_funding_feerate_sat_per_1000_weight: None, - contributions: adjusted_contribution.into_iter().collect(), }); Ok(msgs::SpliceAck { @@ -13462,8 +13585,8 @@ where // Reuse funding pubkeys from the last negotiated candidate since all RBF candidates // for the same splice share the same funding output script. Ok(( - last_candidate.get_holder_pubkeys().clone(), - *last_candidate.counterparty_funding_pubkey(), + last_candidate.funding.get_holder_pubkeys().clone(), + *last_candidate.funding.counterparty_funding_pubkey(), )) } @@ -13487,7 +13610,7 @@ where } else if let Some(prior) = self .pending_splice .as_ref() - .and_then(|pending_splice| pending_splice.contributions.last()) + .and_then(|pending_splice| pending_splice.latest_contribution()) { let net_value = holder_balance .ok_or_else(|| ChannelError::Abort(AbortReason::InsufficientRbfFeerate)) @@ -13534,16 +13657,14 @@ where self.pending_splice .as_mut() .expect("pending_splice is Some") - .contributions - .push(adjusted_contribution.clone()); + .negotiation_contribution = Some(adjusted_contribution.clone()); adjusted_contribution.into_tx_parts() } else if prior_net_value.is_some() { let prior_contribution = self .pending_splice .as_ref() .expect("pending_splice is Some") - .contributions - .last() + .latest_contribution() .expect("prior_net_value was Some") .clone(); let adjusted_contribution = prior_contribution @@ -13552,8 +13673,7 @@ where self.pending_splice .as_mut() .expect("pending_splice is Some") - .contributions - .push(adjusted_contribution.clone()); + .negotiation_contribution = Some(adjusted_contribution.clone()); adjusted_contribution.into_tx_parts() } else { Default::default() @@ -13613,8 +13733,8 @@ where "No pending splice available to RBF".into(), )) })?; - let holder_pubkeys = last_candidate.get_holder_pubkeys().clone(); - let counterparty_funding_pubkey = *last_candidate.counterparty_funding_pubkey(); + let holder_pubkeys = last_candidate.funding.get_holder_pubkeys().clone(); + let counterparty_funding_pubkey = *last_candidate.funding.counterparty_funding_pubkey(); let new_funding = self .validate_splice_contributions( @@ -13877,7 +13997,7 @@ where if !pending_splice .negotiated_candidates .iter() - .any(|funding| funding.get_funding_txid() == Some(msg.splice_txid)) + .any(|candidate| candidate.funding.get_funding_txid() == Some(msg.splice_txid)) { let err = "unknown splice funding txid"; return Err(ChannelError::close(err.to_string())); @@ -14075,7 +14195,7 @@ where &self, fee_estimator: &LowerBoundedFeeEstimator<F>, ) -> Result<AvailableBalances, ()> { let init = self.context.get_available_balances_for_scope(&self.funding, fee_estimator)?; - self.pending_funding().iter().try_fold(init, |acc, funding| { + self.pending_funding().try_fold(init, |acc, funding| { let e = self.context.get_available_balances_for_scope(funding, fee_estimator)?; Ok(AvailableBalances { inbound_capacity_msat: acc.inbound_capacity_msat.min(e.inbound_capacity_msat), @@ -14137,7 +14257,7 @@ where } self.context.resend_order = RAACommitmentOrder::RevokeAndACKFirst; - let update = if self.pending_funding().is_empty() { + let update = if self.negotiated_candidates().is_empty() { let (htlcs_ref, counterparty_commitment_tx) = self.build_commitment_no_state_update(&self.funding, logger); let htlc_outputs = htlcs_ref @@ -14168,7 +14288,7 @@ where } else { let mut htlc_data = None; let commitment_txs = core::iter::once(&self.funding) - .chain(self.pending_funding().iter()) + .chain(self.pending_funding()) .map(|funding| { let (htlcs_ref, counterparty_commitment_tx) = self.build_commitment_no_state_update(funding, logger); @@ -14230,7 +14350,7 @@ where &self, logger: &L, ) -> Result<Vec<msgs::CommitmentSigned>, ChannelError> { core::iter::once(&self.funding) - .chain(self.pending_funding().iter()) + .chain(self.pending_funding()) .map(|funding| self.send_commitment_no_state_update_for_funding(funding, logger)) .collect::<Result<Vec<_>, ChannelError>>() } @@ -14699,16 +14819,12 @@ where ), )); } - let tx_init_rbf = self.send_tx_init_rbf(context); - self.pending_splice.as_mut().unwrap() - .contributions.push(prior_contribution); + let tx_init_rbf = self.send_tx_init_rbf(context, prior_contribution); return Ok(Some(StfuResponse::TxInitRbf(tx_init_rbf))); } - let splice_init = self.send_splice_init(context); + let splice_init = self.send_splice_init(context, prior_contribution); debug_assert!(self.pending_splice.is_some()); - self.pending_splice.as_mut().unwrap() - .contributions.push(prior_contribution); return Ok(Some(StfuResponse::SpliceInit(splice_init))); }, #[cfg(any(test, fuzzing, feature = "_test_utils"))] @@ -16371,7 +16487,7 @@ impl<SP: SignerProvider> Writeable for FundedChannel<SP> { // resumed on reestablishment, but keep any already-negotiated candidates. let reset_funding_negotiation = self.should_reset_pending_splice_state(true); let should_persist_pending_splice = - !reset_funding_negotiation || !self.pending_funding().is_empty(); + !reset_funding_negotiation || !self.negotiated_candidates().is_empty(); let pending_splice = should_persist_pending_splice .then(|| ()) .and_then(|_| self.pending_splice.as_ref()) From 3b46ec989691f440c62530b31965d1e105372cf3 Mon Sep 17 00:00:00 2001 From: Joost Jager <joost.jager@gmail.com> Date: Tue, 7 Jul 2026 11:59:17 +0200 Subject: [PATCH 585/627] Panic on unexpected chanmon monitor events Monitor event draining expects BumpTransaction events, which the harness must mine, plus SpendableOutputs and DiscardFunding events, which it intentionally ignores because it does not model an external wallet. Make that whitelist explicit by panicking on any other monitor event instead of silently ignoring it. --- fuzz/src/chanmon_consistency.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index c8444a966c6..bc895516705 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1425,15 +1425,21 @@ impl<'a> HarnessNode<'a> { // Drains raw ChannelMonitor events. Monitor-generated BumpTransaction events // do not flow through the manager event queue but still produce transactions - // the harness must mine. + // the harness must mine. SpendableOutputs and DiscardFunding may also surface + // here, but the harness does not model an external sweeper wallet. fn process_monitor_pending_events(&self) -> bool { // process_pending_events takes an Fn handler, so use interior mutability // to report whether the callback saw anything. let had_events = Cell::new(false); self.monitor.process_pending_events(&|event: events::Event| { had_events.set(true); - if let events::Event::BumpTransaction(ref bump) = event { - self.bump_tx_handler.handle_event(bump); + match event { + events::Event::BumpTransaction(bump) => { + self.bump_tx_handler.handle_event(&bump); + }, + events::Event::SpendableOutputs { .. } => {}, + events::Event::DiscardFunding { .. } => {}, + event => panic!("Unhandled monitor event: {:?}", event), } Ok(()) }); From 588054e48d4057fb65fd05699368765df250d410 Mon Sep 17 00:00:00 2001 From: benthecarman <benthecarman@live.com> Date: Tue, 30 Jun 2026 18:00:49 -0500 Subject: [PATCH 586/627] Add amounts to HTLC locators Include per-HTLC amounts in `PaymentForwarded` locators so callers can account for each channel independently when a forward uses multiple incoming or outgoing HTLCs. AI-assisted-by: OpenAI Codex --- lightning/src/events/mod.rs | 50 ++++++++++++++++++++ lightning/src/ln/channel.rs | 1 + lightning/src/ln/channelmanager.rs | 39 +++++++++++---- lightning/src/ln/functional_test_utils.rs | 14 ++++++ lightning/src/ln/functional_tests.rs | 4 ++ lightning/src/ln/trampoline_forward_tests.rs | 1 + 6 files changed, 99 insertions(+), 10 deletions(-) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index ad493a1cfbe..6bbcf4f15ae 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -866,6 +866,9 @@ pub struct HTLCLocator { /// The channel that the HTLC was sent or received on. pub channel_id: ChannelId, + /// The amount, in milli-satoshis, of the HTLC that was sent or received, if known. + pub amount_msat: Option<u64>, + /// The `user_channel_id` for `channel_id`. /// /// This will be `None` if the payment was settled via an on-chain transaction. It will also @@ -883,6 +886,7 @@ impl_ser_tlv_based!(HTLCLocator, { (1, channel_id, required), (3, user_channel_id, option), (5, node_id, option), + (7, amount_msat, option), }); /// An Event which you should probably take some action in response to. @@ -2215,6 +2219,7 @@ impl Writeable for Event { ); let empty_locator = HTLCLocator { channel_id: ChannelId::new_zero(), + amount_msat: None, user_channel_id: None, node_id: None, }; @@ -2782,11 +2787,14 @@ impl MaybeReadable for Event { // with pending forwards to 0.1 for any version 0.0.123 or earlier. (17, prev_htlcs, (default_value, vec![HTLCLocator{ channel_id: prev_channel_id_legacy.ok_or(DecodeError::InvalidValue)?, + amount_msat: total_fee_earned_msat + .map(|fee| outbound_amount_forwarded_msat + fee), user_channel_id: prev_user_channel_id_legacy, node_id: prev_node_id_legacy, }])), (19, next_htlcs, (default_value, vec![HTLCLocator{ channel_id: next_channel_id_legacy.ok_or(DecodeError::InvalidValue)?, + amount_msat: Some(outbound_amount_forwarded_msat), user_channel_id: next_user_channel_id_legacy, node_id: next_node_id_legacy, }])), @@ -3228,6 +3236,48 @@ impl MaybeReadable for Event { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_payment_forwarded_preserves_unknown_inbound_htlc_amount() { + let prev_channel_id = ChannelId::from_bytes([1; 32]); + let next_channel_id = ChannelId::from_bytes([2; 32]); + let mut encoded_legacy_event = vec![ + 7, // Event::PaymentForwarded + 81, // TLV stream length + 1, 32, // prev_channel_id + ]; + encoded_legacy_event.extend_from_slice(&[1; 32]); + encoded_legacy_event.extend_from_slice(&[2, 1, 0]); // claim_from_onchain_tx + encoded_legacy_event.extend_from_slice(&[3, 32]); // next_channel_id + encoded_legacy_event.extend_from_slice(&[2; 32]); + // outbound_amount_forwarded_msat + encoded_legacy_event.extend_from_slice(&[5, 8, 0, 0, 0, 0, 0, 45, 198, 192]); + + match Event::read(&mut &encoded_legacy_event[..]).unwrap().unwrap() { + Event::PaymentForwarded { + prev_htlcs, + next_htlcs, + total_fee_earned_msat, + outbound_amount_forwarded_msat, + .. + } => { + assert_eq!(total_fee_earned_msat, None); + assert_eq!(outbound_amount_forwarded_msat, 3_000_000); + assert_eq!(prev_htlcs.len(), 1); + assert_eq!(prev_htlcs[0].channel_id, prev_channel_id); + assert_eq!(prev_htlcs[0].amount_msat, None); + assert_eq!(next_htlcs.len(), 1); + assert_eq!(next_htlcs[0].channel_id, next_channel_id); + assert_eq!(next_htlcs[0].amount_msat, Some(3_000_000)); + }, + _ => panic!("expected PaymentForwarded event"), + } + } +} + /// A trait indicating an object may generate events. /// /// Events are processed by passing an [`EventHandler`] to [`process_pending_events`]. diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index fb5a7de8730..6e20b6ce7c3 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -8175,6 +8175,7 @@ where let prev_hop_data = HTLCPreviousHopData { prev_outbound_scid_alias, user_channel_id: Some(user_channel_id), + amount_msat: Some(htlc.amount_msat), htlc_id: htlc.htlc_id, incoming_packet_shared_secret: *incoming_packet_shared_secret, phantom_shared_secret: *phantom_shared_secret, diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 93dfd1c2cfd..27765f962c6 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -481,6 +481,7 @@ impl PendingAddHTLCInfo { HTLCPreviousHopData { prev_outbound_scid_alias: self.prev_outbound_scid_alias, user_channel_id: Some(self.prev_user_channel_id), + amount_msat: self.forward_info.incoming_amt_msat, outpoint: self.prev_funding_outpoint, channel_id: self.prev_channel_id, counterparty_node_id: Some(self.prev_counterparty_node_id), @@ -944,6 +945,7 @@ mod fuzzy_channelmanager { pub struct HTLCPreviousHopData { pub prev_outbound_scid_alias: u64, pub user_channel_id: Option<u128>, + pub amount_msat: Option<u64>, pub htlc_id: u64, pub incoming_packet_shared_secret: [u8; 32], pub phantom_shared_secret: Option<[u8; 32]>, @@ -960,12 +962,13 @@ mod fuzzy_channelmanager { pub cltv_expiry: Option<u32>, } - impl From<&HTLCPreviousHopData> for events::HTLCLocator { - fn from(value: &HTLCPreviousHopData) -> Self { + impl HTLCPreviousHopData { + pub(super) fn htlc_locator(&self, amount_msat: Option<u64>) -> events::HTLCLocator { events::HTLCLocator { - channel_id: value.channel_id, - user_channel_id: value.user_channel_id, - node_id: value.counterparty_node_id, + channel_id: self.channel_id, + amount_msat, + user_channel_id: self.user_channel_id, + node_id: self.counterparty_node_id, } } } @@ -8860,6 +8863,7 @@ impl< let htlc_source = HTLCSource::PreviousHopData(HTLCPreviousHopData { prev_outbound_scid_alias: prev_hop.prev_outbound_scid_alias, user_channel_id: prev_hop.user_channel_id, + amount_msat: Some(value), counterparty_node_id: prev_hop.counterparty_node_id, channel_id: prev_channel_id, outpoint: prev_funding_outpoint, @@ -10522,7 +10526,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } }, HTLCSource::PreviousHopData(hop_data) => { - let prev_htlcs = vec![events::HTLCLocator::from(&hop_data)]; + let event_prev_hop_data = hop_data.clone(); self.claim_funds_from_htlc_forward_hop( payment_preimage, |htlc_claim_value_msat: Option<u64>| -> Option<events::Event> { @@ -10536,11 +10540,16 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ skimmed_fee_msat <= total_fee_earned_msat, "skimmed_fee_msat must always be included in total_fee_earned_msat" ); + let prev_htlc_amount_msat = + event_prev_hop_data.amount_msat.or(htlc_claim_value_msat); Some(events::Event::PaymentForwarded { - prev_htlcs, + prev_htlcs: vec![ + event_prev_hop_data.htlc_locator(prev_htlc_amount_msat) + ], next_htlcs: vec![events::HTLCLocator { channel_id: next_channel_id, + amount_msat: Some(forwarded_htlc_value_msat), user_channel_id: next_user_channel_id, node_id: Some(next_channel_counterparty_node_id), }], @@ -10561,20 +10570,29 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }, HTLCSource::TrampolineForward { previous_hop_data, .. } => { // Only emit a single event for trampoline claims. - let prev_htlcs: Vec<events::HTLCLocator> = - previous_hop_data.iter().map(Into::into).collect(); + let mut event_prev_htlcs = Some( + previous_hop_data.iter().map(|hop| hop.htlc_locator(hop.amount_msat)).collect(), + ); for (i, current_previous_hop_data) in previous_hop_data.into_iter().enumerate() { self.claim_funds_from_htlc_forward_hop( payment_preimage, |_: Option<u64>| -> Option<events::Event> { if i == 0 { + let Some(prev_htlcs) = event_prev_htlcs.take() else { + debug_assert!( + false, + "trampoline forward event already emitted" + ); + return None; + }; Some(events::Event::PaymentForwarded { - prev_htlcs: prev_htlcs.clone(), + prev_htlcs, // TODO: When trampoline payments are tracked in our // pending_outbound_payments, we'll be able to provide all the // outgoing htlcs for this forward. next_htlcs: vec![events::HTLCLocator { channel_id: next_channel_id, + amount_msat: Some(forwarded_htlc_value_msat), user_channel_id: next_user_channel_id, node_id: Some(next_channel_counterparty_node_id), }], @@ -18343,6 +18361,7 @@ impl_ser_tlv_based!(HTLCPreviousHopData, { (9, channel_id, (default_value, ChannelId::v1_from_funding_outpoint(outpoint.0.unwrap()))), (11, counterparty_node_id, option), (13, trampoline_shared_secret, option), + (15, amount_msat, option), }); fn write_claimable_htlc<W: Writer>( diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index acb67826116..36016759566 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -3097,6 +3097,7 @@ pub fn expect_payment_forwarded<CM: AChannelManager, H: NodeHolder<CM = CM>>( total_fee_earned_msat, skimmed_fee_msat, claim_from_onchain_tx, + outbound_amount_forwarded_msat, .. } => { assert_eq!(prev_htlcs.len(), 1); @@ -3115,6 +3116,19 @@ pub fn expect_payment_forwarded<CM: AChannelManager, H: NodeHolder<CM = CM>>( // Check that the (knowingly) withheld amount is always less or equal to the expected // overpaid amount. assert!(skimmed_fee_msat == expected_extra_fees_msat); + match expected_fee { + Some(_) => { + let actual_fee = total_fee_earned_msat.unwrap(); + assert_eq!(next_htlcs[0].amount_msat, Some(outbound_amount_forwarded_msat)); + assert_eq!( + prev_htlcs[0].amount_msat, + Some(next_htlcs[0].amount_msat.unwrap() + actual_fee) + ); + }, + None => { + assert_eq!(total_fee_earned_msat, None); + }, + } if !upstream_force_closed { let prev_node_id = prev_htlcs[0].node_id.unwrap(); let prev_channel_id = prev_htlcs[0].channel_id; diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index b84a486a007..2e2197426ac 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -1506,8 +1506,10 @@ pub fn test_htlc_on_chain_success() { } => { assert_eq!(total_fee_earned_msat, Some(1000)); assert_eq!(prev_htlcs[0].channel_id, chan_id); + assert_eq!(prev_htlcs[0].amount_msat, Some(3001000)); assert_eq!(claim_from_onchain_tx, true); assert_eq!(next_htlcs[0].channel_id, chan_2.2); + assert_eq!(next_htlcs[0].amount_msat, Some(3000000)); assert_eq!(outbound_amount_forwarded_msat, 3000000); }, _ => panic!(), @@ -1523,8 +1525,10 @@ pub fn test_htlc_on_chain_success() { } => { assert_eq!(total_fee_earned_msat, Some(1000)); assert_eq!(prev_htlcs[0].channel_id, chan_id); + assert_eq!(prev_htlcs[0].amount_msat, Some(3001000)); assert_eq!(claim_from_onchain_tx, true); assert_eq!(next_htlcs[0].channel_id, chan_2.2); + assert_eq!(next_htlcs[0].amount_msat, Some(3000000)); assert_eq!(outbound_amount_forwarded_msat, 3000000); }, _ => panic!(), diff --git a/lightning/src/ln/trampoline_forward_tests.rs b/lightning/src/ln/trampoline_forward_tests.rs index 00f5074ce11..c2c4f698399 100644 --- a/lightning/src/ln/trampoline_forward_tests.rs +++ b/lightning/src/ln/trampoline_forward_tests.rs @@ -27,6 +27,7 @@ fn test_prev_hop_data(htlc_id: u64) -> HTLCPreviousHopData { HTLCPreviousHopData { prev_outbound_scid_alias: 0, user_channel_id: None, + amount_msat: None, htlc_id, incoming_packet_shared_secret: [0; 32], phantom_shared_secret: None, From 47a090aa2fe7ec0149f10f93c64833332becf9a0 Mon Sep 17 00:00:00 2001 From: Abeeujah <abeeujah@gmail.com> Date: Wed, 8 Jul 2026 19:20:48 +0100 Subject: [PATCH 587/627] Remove redundant witness program check With rust-bitcoin `0.32.4` release, verifying a script is a witness program delegates to `Script::witness_version`, This makes adding `Script::is_witness_program` check alongside a witness_version check redundant. --- lightning/src/ln/interactivetxs.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index 6769e2de3e5..3a93306a2be 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -1252,13 +1252,9 @@ impl NegotiationContext { // with witness versions V1 and up are always considered standard. Yes, the scripts can be // anyone-can-spend-able, but if our counterparty wants to add an output like that then it's none // of our concern really ¯\_(ツ)_/¯ - // - // TODO: The last check would be simplified when https://github.com/rust-bitcoin/rust-bitcoin/commit/1656e1a09a1959230e20af90d20789a4a8f0a31b - // hits the next release of rust-bitcoin. if !(msg.script.is_p2wpkh() || msg.script.is_p2wsh() - || (msg.script.is_witness_program() - && msg.script.witness_version().map(|v| v.to_num() >= 1).unwrap_or(false))) + || msg.script.witness_version().map(|v| v.to_num() >= 1).unwrap_or(false)) { return Err(AbortReason::InvalidOutputScript); } From cb77c1c3c3d1608e65ceac41c66b0336f4f63f25 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz <jkczyz@gmail.com> Date: Fri, 12 Jun 2026 17:13:33 -0500 Subject: [PATCH 588/627] Expose pending splice details in ChannelDetails A channel may have splice attempts in progress: a contribution we have committed but not yet begun negotiating, one under negotiation with the counterparty, and any negotiated transactions (the original splice and any RBF replacements) waiting on confirmations. This state was only observable through events and the broadcaster's TransactionType::InteractiveFunding, neither of which can be queried on demand. Add an optional splice_details field to ChannelDetails. Every splice or RBF round on the channel that has not yet locked is reported as a candidate, each carrying our contribution to it (if any) and a status giving the stage it has reached, from a contribution awaiting quiescence, through negotiation, to a signed transaction awaiting confirmations. This also reports the single candidate that has confirmed, with its confirmation progress and whether we have sent splice_locked for it, and the txid of any splice_locked received from the counterparty. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- fuzz/src/chanmon_consistency.rs | 7 + fuzz/src/router.rs | 1 + lightning/src/ln/channel.rs | 171 ++++- lightning/src/ln/channel_state.rs | 263 ++++++- lightning/src/ln/splicing_tests.rs | 1141 ++++++++++++++++++++++++++++ lightning/src/routing/router.rs | 2 + 6 files changed, 1581 insertions(+), 4 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index dfd1b4a54f7..959509c1205 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -4233,6 +4233,13 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) { }, _ => break 'fuzz_loop, } + + // Compute `ChannelDetails` for every channel after each step (ignoring the result) so the + // fuzzer exercises the splice-details derivation in `to_details` across as many states as + // possible. + for node in harness.nodes.iter() { + let _ = node.list_channels(); + } } harness.finish(); } diff --git a/fuzz/src/router.rs b/fuzz/src/router.rs index 2295ae3d7ff..aa3d274dac2 100644 --- a/fuzz/src/router.rs +++ b/fuzz/src/router.rs @@ -257,6 +257,7 @@ pub fn do_test<Out: test_logger::Output>(data: &[u8], out: Out) { pending_inbound_htlcs: Vec::new(), pending_outbound_htlcs: Vec::new(), current_dust_exposure_msat: None, + splice_details: None, }); } Some(&$first_hops_vec[..]) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 3242ac87add..ce357876c5a 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -47,8 +47,9 @@ use crate::ln::chan_utils::{ EMPTY_SCRIPT_SIG_WEIGHT, FUNDING_TRANSACTION_WITNESS_WEIGHT, }; use crate::ln::channel_state::{ - ChannelShutdownState, CounterpartyForwardingInfo, InboundHTLCDetails, InboundHTLCStateDetails, - OutboundHTLCDetails, OutboundHTLCStateDetails, + ChannelShutdownState, ConfirmedSpliceCandidate, CounterpartyForwardingInfo, InboundHTLCDetails, + InboundHTLCStateDetails, OutboundHTLCDetails, OutboundHTLCStateDetails, SpliceCandidateDetails, + SpliceCandidateStatus, SpliceDetails, }; use crate::ln::channelmanager::{ self, BlindedFailure, ChannelReadyOrder, FundingConfirmedMessage, HTLCFailureMsg, @@ -3360,6 +3361,88 @@ impl PendingFunding { }) } + fn to_details<SP: SignerProvider>( + &self, context: &ChannelContext<SP>, best_block_height: u32, + ) -> SpliceDetails { + let mut candidates: Vec<SpliceCandidateDetails> = self + .negotiated_candidates + .iter() + .map(|candidate| SpliceCandidateDetails { + contribution: candidate.contribution.clone(), + status: SpliceCandidateStatus::Negotiated { + txid: candidate + .funding + .get_funding_txid() + .expect("negotiated candidates should have a funding txid"), + new_channel_value_satoshis: candidate.funding.get_value_satoshis(), + }, + }) + .collect(); + + // The round currently under negotiation, if any, follows the negotiated candidates. + if let Some(funding_negotiation) = self.funding_negotiation.as_ref() { + let is_initiator = funding_negotiation.is_initiator(); + let funding_feerate_sat_per_1000_weight = + funding_negotiation.funding_feerate_sat_per_1000_weight(); + let status = match funding_negotiation { + FundingNegotiation::AwaitingAck { .. } => SpliceCandidateStatus::AwaitingAck { + is_initiator, + funding_feerate_sat_per_1000_weight, + }, + FundingNegotiation::ConstructingTransaction { funding, .. } => { + SpliceCandidateStatus::ConstructingTransaction { + is_initiator, + funding_feerate_sat_per_1000_weight, + new_channel_value_satoshis: funding.get_value_satoshis(), + } + }, + FundingNegotiation::AwaitingSignatures { funding, .. } => { + SpliceCandidateStatus::AwaitingSignatures { + is_initiator, + funding_feerate_sat_per_1000_weight, + new_channel_value_satoshis: funding.get_value_satoshis(), + txid: funding + .get_funding_txid() + .expect("a splice awaiting signatures should have a funding txid"), + } + }, + }; + candidates.push(SpliceCandidateDetails { + contribution: self.negotiation_contribution.clone(), + status, + }); + } + // At most one candidate can confirm, as they all double-spend the same input. A zero-conf + // splice is locked (we send `splice_locked`) before it has any confirmations, so also report + // a candidate we have locked even at zero confirmations. + let confirmed_candidate = self.negotiated_candidates.iter().find_map(|candidate| { + let confirmations = candidate.funding.get_funding_tx_confirmations(best_block_height); + let txid = candidate + .funding + .get_funding_txid() + .expect("negotiated candidates should have a funding txid"); + // The `splice_locked` we sent always refers to the confirmed candidate, as it is + // cleared if that candidate is ever unconfirmed by a reorg. + let splice_locked_sent = self.sent_funding_txid == Some(txid); + if confirmations == 0 && !splice_locked_sent { + return None; + } + Some(ConfirmedSpliceCandidate { + txid, + confirmations, + confirmations_required: context + .minimum_depth(&candidate.funding) + .expect("set for a ready channel"), + splice_locked_sent, + }) + }); + SpliceDetails { + candidates, + confirmed_candidate, + received_splice_locked_txid: self.received_funding_txid, + } + } + fn check_get_splice_locked<SP: SignerProvider>( &mut self, context: &ChannelContext<SP>, confirmed_funding_index: usize, height: u32, ) -> Option<msgs::SpliceLocked> { @@ -7455,6 +7538,46 @@ where ) } + /// Returns details about any pending splice attempts for inclusion in + /// [`crate::ln::channel_state::ChannelDetails`]. + pub fn pending_splice_details(&self, best_block_height: u32) -> Option<SpliceDetails> { + let mut details = self + .pending_splice + .as_ref() + .map(|pending_splice| pending_splice.to_details(&self.context, best_block_height)); + + // A contribution committed via `funding_contributed` sits in `quiescent_action` until + // quiescence is reached and it begins negotiating; surface it as the last candidate, in a + // `WaitingOn*` status describing what it is waiting on. + if let Some(contribution) = self.queued_funding_contribution() { + // It begins negotiating at the next quiescence if there is no pending candidate or it can + // replace one via RBF; otherwise it must wait for the pending candidate to lock. + let status = if self.pending_splice.is_none() + || self.queued_contribution_can_rbf(contribution) + { + SpliceCandidateStatus::WaitingOnQuiescence + } else { + SpliceCandidateStatus::WaitingOnLock + }; + let candidate = + SpliceCandidateDetails { contribution: Some(contribution.clone()), status }; + match &mut details { + Some(details) => details.candidates.push(candidate), + // No `PendingFunding` yet (a first splice still awaiting quiescence), but the queued + // contribution is still worth surfacing. + None => { + details = Some(SpliceDetails { + candidates: vec![candidate], + confirmed_candidate: None, + received_splice_locked_txid: None, + }); + }, + } + } + + details + } + fn has_pending_splice_awaiting_signatures(&self) -> bool { self.pending_splice .as_ref() @@ -12869,6 +12992,38 @@ where Ok(()) } + /// Whether a committed-but-not-yet-negotiating contribution can replace the pending candidate + /// via RBF, rather than having to wait for that candidate to lock. Used to classify a queued + /// contribution's status while it awaits quiescence. + fn queued_contribution_can_rbf(&self, contribution: &FundingContribution) -> bool { + let pending_splice = match &self.pending_splice { + Some(pending_splice) => pending_splice, + None => return false, + }; + // A zero-conf channel can never RBF, and a candidate that is already locking can no longer + // be replaced. + if self.is_rbf_compatible().is_err() { + return false; + } + if pending_splice.sent_funding_txid.is_some() + || pending_splice.received_funding_txid.is_some() + { + return false; + } + // The replacement must pay a higher feerate than the most recent round: the one currently + // under negotiation if any (which is the candidate we would replace once it signs), + // otherwise the most recently negotiated candidate. The in-flight feerate is fixed when the + // round starts, so affordability is determinable even before it signs. + let prev_feerate = match pending_splice.funding_negotiation.as_ref() { + Some(funding_negotiation) => funding_negotiation.funding_feerate_sat_per_1000_weight(), + None => match pending_splice.last_funding_feerate_sat_per_1000_weight { + Some(prev_feerate) => prev_feerate, + None => return false, + }, + }; + contribution.feerate() >= min_rbf_feerate(prev_feerate) + } + fn can_initiate_rbf(&self) -> Result<FeeRate, String> { self.is_rbf_compatible()?; @@ -13075,6 +13230,18 @@ where contribution }; + // A queued splice never coexists with a negotiation we initiated: we return early above if + // one is already in flight, and a queued action is cleared the moment it becomes our + // negotiation at quiescence. It may coexist with a counterparty-initiated negotiation (e.g. + // queuing our own contribution while accepting their splice), so we only rule out our own. + debug_assert!( + self.pending_splice + .as_ref() + .and_then(|pending_splice| pending_splice.funding_negotiation.as_ref()) + .map_or(true, |funding_negotiation| !funding_negotiation.is_initiator()), + "A queued splice must not coexist with a funding negotiation we initiated", + ); + self.propose_quiescence(logger, QuiescentAction::Splice { contribution, locktime }) } diff --git a/lightning/src/ln/channel_state.rs b/lightning/src/ln/channel_state.rs index 6e5d633e920..ea99d4c676b 100644 --- a/lightning/src/ln/channel_state.rs +++ b/lightning/src/ln/channel_state.rs @@ -12,10 +12,12 @@ use alloc::vec::Vec; use bitcoin::secp256k1::PublicKey; +use bitcoin::Txid; use crate::chain::chaininterface::{FeeEstimator, LowerBoundedFeeEstimator}; use crate::chain::transaction::OutPoint; use crate::ln::channel::Channel; +use crate::ln::funding::FundingContribution; use crate::ln::types::ChannelId; use crate::sign::SignerProvider; use crate::types::features::{ChannelTypeFeatures, InitFeatures}; @@ -275,7 +277,8 @@ impl_ser_tlv_based!(ChannelCounterparty, { /// /// When a channel is spliced, most fields continue to refer to the original pre-splice channel /// state until the splice transaction reaches sufficient confirmations to be locked (and we -/// exchange `splice_locked` messages with our peer). See individual fields for details. +/// exchange `splice_locked` messages with our peer). See individual fields for details, and +/// [`SpliceDetails`] for how a splice is negotiated and locked. /// /// [`ChannelManager::list_channels`]: crate::ln::channelmanager::ChannelManager::list_channels /// [`ChannelManager::list_usable_channels`]: crate::ln::channelmanager::ChannelManager::list_usable_channels @@ -494,6 +497,11 @@ pub struct ChannelDetails { /// /// [`ChannelConfig::max_dust_htlc_exposure`]: crate::util::config::ChannelConfig::max_dust_htlc_exposure pub current_dust_exposure_msat: Option<u64>, + /// Details of any pending splice attempts on this channel, or `None` if no splice is pending. + /// + /// See [`SpliceDetails`] for what is included. This will be `None` for objects serialized with + /// LDK versions prior to 0.3. + pub splice_details: Option<SpliceDetails>, } impl ChannelDetails { @@ -619,6 +627,9 @@ impl ChannelDetails { pending_inbound_htlcs: context.get_pending_inbound_htlc_details(funding), pending_outbound_htlcs: context.get_pending_outbound_htlc_details(funding), current_dust_exposure_msat: Some(balance.dust_exposure_msat), + splice_details: channel + .as_funded() + .and_then(|chan| chan.pending_splice_details(best_block_height)), } } } @@ -661,11 +672,230 @@ impl_ser_tlv_based!(ChannelDetails, { (45, pending_outbound_htlcs, optional_vec), (47, funding_redeem_script, option), (49, current_dust_exposure_msat, option), + (51, splice_details, option), (_unused, user_channel_id, (static_value, _user_channel_id_low.unwrap_or(0) as u128 | ((_user_channel_id_high.unwrap_or(0) as u128) << 64) )), }); +/// Details of pending splice attempts on a channel, as returned in +/// [`ChannelDetails::splice_details`]. +/// +/// Every splice or RBF round on the channel that has not yet locked is reported as a +/// [`SpliceCandidateDetails`] in [`candidates`], from the moment a contribution is committed +/// through negotiation, signing, and confirmation; see [`SpliceCandidateStatus`] for the stages. +/// +/// A splice is initiated by calling [`ChannelManager::splice_channel`] to obtain a +/// [`FundingTemplate`], building a [`FundingContribution`] from it, and committing that +/// contribution with [`ChannelManager::funding_contributed`]. The contribution first appears as a +/// candidate awaiting quiescence; once the channel is quiescent it is negotiated with the +/// counterparty, and a completed negotiation produces a signed *candidate* splice transaction. +/// While a candidate has been negotiated but not yet locked, calling +/// [`ChannelManager::splice_channel`] again and contributing a higher-feerate replacement RBFs it, +/// adding another candidate; the candidates all double-spend the same input, so at most one +/// confirms. A node sends `splice_locked` for a candidate once it has sufficient confirmations +/// (immediately, on a zero-conf channel), and considers the splice locked once it has both sent its +/// own `splice_locked` and received the counterparty's, at which point that candidate is promoted +/// to the channel's funding. The two sides may lock at different times, both because each counts +/// confirmations from its own chain view and because they may require different numbers of +/// confirmations. +/// +/// The counterparty may also initiate a splice or RBF. Such a round is reported here as well, so a +/// candidate may appear that we did not initiate; our [`contribution`] to it is `None` unless we +/// added funds of our own. +/// +/// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel +/// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed +/// [`FundingTemplate`]: crate::ln::funding::FundingTemplate +/// [`candidates`]: Self::candidates +/// [`contribution`]: SpliceCandidateDetails::contribution +#[derive(Clone, Debug, PartialEq)] +pub struct SpliceDetails { + /// The splice and RBF rounds on this channel that have not yet locked, in order: any negotiated + /// candidates awaiting confirmation (oldest first), the round currently under negotiation (if + /// any), and a contribution we have committed but not yet begun negotiating (last). + /// + /// More than one entry indicates an in-flight negotiation and/or RBF replacements alongside + /// negotiated candidates; the candidates all double-spend the same input, so at most one + /// ultimately confirms. + /// + /// Note that entries before [`SpliceCandidateStatus::AwaitingSignatures`] do not survive a + /// restart, as they reflect in-memory negotiation state. + pub candidates: Vec<SpliceCandidateDetails>, + /// The negotiated candidate that has confirmed on-chain (or, on a zero-conf channel, that we + /// have locked at zero confirmations), if any, along with its confirmation progress. + /// + /// At most one candidate can confirm, as the candidates all double-spend the same input, so + /// this identifies the single confirming candidate rather than tracking confirmations on each. + pub confirmed_candidate: Option<ConfirmedSpliceCandidate>, + /// The txid announced in the `splice_locked` received from the counterparty, i.e., the + /// candidate that they consider to have sufficient confirmations. + /// + /// Unlike the `splice_locked` we sent (see [`ConfirmedSpliceCandidate::splice_locked_sent`]), + /// this need not match [`confirmed_candidate`]: during a reorg, our counterparty may observe a + /// different candidate confirm. + /// + /// [`confirmed_candidate`]: Self::confirmed_candidate + pub received_splice_locked_txid: Option<Txid>, +} + +impl_ser_tlv_based!(SpliceDetails, { + (1, candidates, required_vec), + (3, confirmed_candidate, option), + (5, received_splice_locked_txid, option), +}); + +/// A single splice or RBF round on a channel, as reported in [`SpliceDetails::candidates`]. +/// +/// The stage this round has reached is given by [`status`]; the details it carries (initiator, +/// feerate, value, txid) become available as it progresses and are accessed through the +/// [`SpliceCandidateStatus`] variant rather than as separate optional fields. +/// +/// [`status`]: Self::status +#[derive(Clone, Debug, PartialEq)] +pub struct SpliceCandidateDetails { + /// Our contribution to this round, or `None` if we did not contribute (a counterparty-only + /// round). + /// + /// Once a round includes our contribution, every later round does as well: RBF attempts carry + /// the contribution forward (possibly adjusted to a new feerate) rather than dropping it, + /// preserving the splice intention. + /// + /// Note that [`FundingContribution::feerate`] is the feerate used when selecting the + /// contribution's inputs, which is not necessarily the exact feerate of the negotiated + /// transaction. + pub contribution: Option<FundingContribution>, + /// The stage this round has reached. + pub status: SpliceCandidateStatus, +} + +impl_ser_tlv_based!(SpliceCandidateDetails, { + (1, contribution, option), + (3, status, required), +}); + +/// The stage a splice or RBF round has reached, as reported in [`SpliceCandidateDetails::status`]. +/// +/// A round committed via [`ChannelManager::funding_contributed`] begins in one of the `WaitingOn*` +/// statuses, advances through the negotiation statuses once the channel is quiescent, and finally +/// reaches [`Negotiated`] once signed. +/// +/// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed +/// [`Negotiated`]: Self::Negotiated +#[derive(Clone, Debug, PartialEq)] +pub enum SpliceCandidateStatus { + /// We have committed a contribution and are awaiting quiescence before it begins negotiating — + /// the first splice on the channel if there are no other candidates, or an RBF replacing an + /// existing candidate otherwise. If the counterparty initiates a round first, the contribution + /// may instead be included in that round. + WaitingOnQuiescence, + /// We have committed a contribution but cannot replace the pending candidate via RBF (our + /// contribution's feerate is too low, the channel is zero-conf, or a candidate is already + /// locking). It will be spliced once the pending candidate locks or, when only the feerate + /// prevents the RBF, sooner if the counterparty initiates an RBF that the contribution can + /// be included in. + WaitingOnLock, + /// We have proposed this round to the counterparty and are awaiting their acknowledgement. + AwaitingAck { + /// Whether we are the initiator of this round. When both sides want to splice, a tie-break at + /// quiescence decides which is the initiator and which is the acceptor. The initiator pays the + /// fees for the transaction's common fields and for the shared input and output (the previous + /// and new channel funding). + is_initiator: bool, + /// The feerate of the splice transaction under negotiation, denominated in satoshi per 1000 + /// weight units. + funding_feerate_sat_per_1000_weight: u32, + }, + /// The splice transaction is being interactively constructed. + ConstructingTransaction { + /// Whether we are the initiator of this round. When both sides want to splice, a tie-break at + /// quiescence decides which is the initiator and which is the acceptor. The initiator pays the + /// fees for the transaction's common fields and for the shared input and output (the previous + /// and new channel funding). + is_initiator: bool, + /// The feerate of the splice transaction under negotiation, denominated in satoshi per 1000 + /// weight units. + funding_feerate_sat_per_1000_weight: u32, + /// The value, in satoshis, of the channel once this round confirms and is promoted. + new_channel_value_satoshis: u64, + }, + /// The splice transaction has been negotiated and is awaiting signatures from both + /// counterparties. + AwaitingSignatures { + /// Whether we are the initiator of this round. When both sides want to splice, a tie-break at + /// quiescence decides which is the initiator and which is the acceptor. The initiator pays the + /// fees for the transaction's common fields and for the shared input and output (the previous + /// and new channel funding). + is_initiator: bool, + /// The feerate of the splice transaction under negotiation, denominated in satoshi per 1000 + /// weight units. + funding_feerate_sat_per_1000_weight: u32, + /// The value, in satoshis, of the channel once this round confirms and is promoted. + new_channel_value_satoshis: u64, + /// The txid of the splice transaction. + txid: Txid, + }, + /// The splice transaction has been signed and is awaiting sufficient on-chain confirmations for + /// both counterparties to exchange `splice_locked`. + Negotiated { + /// The txid of the splice transaction. + txid: Txid, + /// The value, in satoshis, of the channel once this candidate confirms and is promoted. + new_channel_value_satoshis: u64, + }, +} + +impl_ser_tlv_based_enum!(SpliceCandidateStatus, + (1, WaitingOnQuiescence) => {}, + (3, WaitingOnLock) => {}, + (5, AwaitingAck) => { + (1, is_initiator, required), + (3, funding_feerate_sat_per_1000_weight, required), + }, + (7, ConstructingTransaction) => { + (1, is_initiator, required), + (3, funding_feerate_sat_per_1000_weight, required), + (5, new_channel_value_satoshis, required), + }, + (9, AwaitingSignatures) => { + (1, is_initiator, required), + (3, funding_feerate_sat_per_1000_weight, required), + (5, new_channel_value_satoshis, required), + (7, txid, required), + }, + (11, Negotiated) => { + (1, txid, required), + (3, new_channel_value_satoshis, required), + }, +); + +/// The confirmation progress of the negotiated splice candidate that has confirmed on-chain, as +/// exposed in [`SpliceDetails::confirmed_candidate`]. +/// +/// At most one candidate can confirm, as the candidates all double-spend the same input, so this +/// identifies the single confirming candidate by its txid. +#[derive(Clone, Debug, PartialEq)] +pub struct ConfirmedSpliceCandidate { + /// The txid of the candidate that has confirmed on-chain. This matches the txid of the + /// [`SpliceCandidateStatus::Negotiated`] entry in [`SpliceDetails::candidates`] that confirmed. + pub txid: Txid, + /// The current number of confirmations of the candidate's transaction. + pub confirmations: u32, + /// The number of confirmations required before `splice_locked` can be sent for the candidate. + pub confirmations_required: u32, + /// Whether we have sent `splice_locked` for this candidate, i.e., we consider it to have + /// sufficient confirmations. The `splice_locked` we sent always refers to this confirmed + /// candidate, so it is tracked here rather than as a separate txid. + pub splice_locked_sent: bool, +} + +impl_ser_tlv_based!(ConfirmedSpliceCandidate, { + (1, txid, required), + (3, confirmations, required), + (5, confirmations_required, required), + (7, splice_locked_sent, required), +}); + #[derive(Clone, Copy, Debug, PartialEq, Eq)] /// Further information on the details of the channel shutdown. /// Upon channels being forced closed (i.e. commitment transaction confirmation detected @@ -718,7 +948,10 @@ mod tests { }, }; - use super::{ChannelCounterparty, ChannelDetails, ChannelShutdownState}; + use super::{ + ChannelCounterparty, ChannelDetails, ChannelShutdownState, ConfirmedSpliceCandidate, + SpliceCandidateDetails, SpliceCandidateStatus, SpliceDetails, + }; #[test] fn test_channel_details_serialization() { @@ -783,6 +1016,32 @@ mod tests { is_dust: false, }], current_dust_exposure_msat: Some(150_000), + splice_details: Some(SpliceDetails { + // A reachable arrangement: a negotiated candidate we have confirmed and sent + // `splice_locked` for, followed by a committed contribution that cannot yet be spliced + // (that candidate is locking) and so waits. There is at most one in-flight round and at + // most one `WaitingOn*` entry, which is always last. + candidates: vec![ + SpliceCandidateDetails { + contribution: None, + status: SpliceCandidateStatus::Negotiated { + txid: bitcoin::Txid::from_slice(&[7; 32]).unwrap(), + new_channel_value_satoshis: 60_000, + }, + }, + SpliceCandidateDetails { + contribution: None, + status: SpliceCandidateStatus::WaitingOnLock, + }, + ], + confirmed_candidate: Some(ConfirmedSpliceCandidate { + txid: bitcoin::Txid::from_slice(&[7; 32]).unwrap(), + confirmations: 6, + confirmations_required: 6, + splice_locked_sent: true, + }), + received_splice_locked_txid: None, + }), }; let mut buffer = Vec::new(); channel_details.write(&mut buffer).unwrap(); diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 2bf7703217b..04fe241a14e 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -22,6 +22,7 @@ use crate::ln::channel::{ DISCONNECT_PEER_AWAITING_RESPONSE_TICKS, FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE, MIN_CHANNEL_VALUE_SATOSHIS, }; +use crate::ln::channel_state::{SpliceCandidateDetails, SpliceCandidateStatus, SpliceDetails}; use crate::ln::channelmanager::{provided_init_features, PaymentId, BREAKDOWN_TIMEOUT}; use crate::ln::functional_test_utils::*; use crate::ln::funding::{FundingContribution, FundingContributionError, FundingTemplate}; @@ -560,6 +561,7 @@ pub struct SignInteractiveFundingTxArgs<'a, 'b, 'c, 'd> { is_0conf: bool, acceptor_has_contribution: bool, expected_replaced_txid: Option<Txid>, + unconfirmed_funding_txid: Option<Txid>, } impl<'a, 'b, 'c, 'd> SignInteractiveFundingTxArgs<'a, 'b, 'c, 'd> { @@ -570,6 +572,7 @@ impl<'a, 'b, 'c, 'd> SignInteractiveFundingTxArgs<'a, 'b, 'c, 'd> { is_0conf: false, acceptor_has_contribution: false, expected_replaced_txid: None, + unconfirmed_funding_txid: None, } } @@ -592,6 +595,14 @@ impl<'a, 'b, 'c, 'd> SignInteractiveFundingTxArgs<'a, 'b, 'c, 'd> { self.expected_replaced_txid = Some(prior_txid); self } + + /// The channel's funding transaction, identified by `unconfirmed_funding_txid`, is still + /// unconfirmed, so signing also (re-)broadcasts it; the helper asserts it is broadcast alongside + /// the splice. + pub fn with_unconfirmed_funding(mut self, unconfirmed_funding_txid: Txid) -> Self { + self.unconfirmed_funding_txid = Some(unconfirmed_funding_txid); + self + } } pub fn sign_interactive_funding_tx<'a, 'b, 'c, 'd>( @@ -603,6 +614,7 @@ pub fn sign_interactive_funding_tx<'a, 'b, 'c, 'd>( is_0conf, acceptor_has_contribution, expected_replaced_txid, + unconfirmed_funding_txid, } = args; let node_id_initiator = initiator.node.get_our_node_id(); let node_id_acceptor = acceptor.node.get_our_node_id(); @@ -695,6 +707,19 @@ pub fn sign_interactive_funding_tx<'a, 'b, 'c, 'd>( let tx = { let mut initiator_txn = initiator.tx_broadcaster.txn_broadcast_with_types(); + if let Some(unconfirmed_funding_txid) = unconfirmed_funding_txid { + // The initiator (re-)broadcasts its still-unconfirmed funding alongside the splice; + // remove it so only the splice (InteractiveFunding) remains to compare against the acceptor. + assert_eq!(initiator_txn.len(), 2); + let pos = initiator_txn + .iter() + .position(|(tx, tx_type)| { + tx.compute_txid() == unconfirmed_funding_txid + && matches!(tx_type, TransactionType::Funding { .. }) + }) + .expect("the unconfirmed funding should be (re-)broadcast"); + initiator_txn.remove(pos); + } assert_eq!(initiator_txn.len(), 1); let mut acceptor_txn = acceptor.tx_broadcaster.txn_broadcast_with_types(); assert_eq!(acceptor_txn.len(), 1); @@ -1228,6 +1253,21 @@ fn test_reload_resets_splice_negotiation_without_dropping_candidates() { ); let _ = get_event!(&nodes[0], Event::SpliceNegotiationFailed); + // The reload dropped the in-flight RBF round (a `ConstructingTransaction` state does not persist), + // but the previously negotiated candidate survives as the sole candidate, with its contribution. + let details = nodes[0] + .node + .list_channels() + .iter() + .find(|channel| channel.channel_id == channel_id) + .unwrap() + .splice_details + .clone() + .unwrap(); + assert_eq!(details.candidates.len(), 1); + assert!(matches!(details.candidates[0].status, SpliceCandidateStatus::Negotiated { .. })); + assert_eq!(details.candidates[0].contribution, Some(funding_contribution.clone())); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); assert_eq!(funding_template.min_rbf_feerate(), Some(rbf_feerate)); assert_eq!(funding_template.prior_contribution().unwrap(), &funding_contribution); @@ -2493,6 +2533,25 @@ fn do_test_splice_reestablish(reload: bool, async_monitor_update: bool) { ); // We should have another signing event generated upon reload as they're not persisted. let _ = get_event!(nodes[0], Event::FundingTransactionReadyForSigning); + + // The negotiation is awaiting signatures, so it has no negotiated candidate yet, only our + // in-flight contribution. That contribution (written under its own TLV) survives the reload. + let details = nodes[0] + .node + .list_channels() + .iter() + .find(|channel| channel.channel_id == channel_id) + .unwrap() + .splice_details + .clone() + .unwrap(); + assert_eq!(details.candidates.len(), 1); + assert!(matches!( + details.candidates[0].status, + SpliceCandidateStatus::AwaitingSignatures { .. } + )); + assert!(details.candidates[0].contribution.is_some()); + if async_monitor_update { persister_0a.set_update_ret(ChannelMonitorUpdateStatus::InProgress); persister_1a.set_update_ret(ChannelMonitorUpdateStatus::InProgress); @@ -4036,6 +4095,27 @@ fn acceptor_can_cancel_queued_funding_contributed_during_counterparty_splice() { .unwrap(); assert!(acceptor.node.get_and_clear_pending_msg_events().is_empty()); + // The acceptor is mid-negotiation on the counterparty's splice and has its own contribution + // queued behind it; both surface at once. + let details = acceptor + .node + .list_channels() + .into_iter() + .find(|channel| channel.channel_id == channel_id) + .unwrap() + .splice_details + .unwrap(); + assert_eq!(details.candidates.len(), 2); + // The counterparty's in-flight round, which we did not contribute to. + assert!(matches!( + details.candidates[0].status, + SpliceCandidateStatus::ConstructingTransaction { is_initiator: false, .. } + )); + assert_eq!(details.candidates[0].contribution, None); + // Our own contribution, queued to RBF the counterparty's round once it completes. + assert_eq!(details.candidates[1].status, SpliceCandidateStatus::WaitingOnQuiescence); + assert_eq!(details.candidates[1].contribution, Some(queued_contribution.clone())); + acceptor.node.cancel_funding_contributed(&channel_id, &node_id_initiator).unwrap(); let reason = NegotiationFailureReason::LocallyCanceled; expect_splice_failed_events(acceptor, &channel_id, queued_contribution, reason); @@ -10459,3 +10539,1064 @@ fn test_async_splice_receives_tx_signatures_while_unrelated_monitor_update_pendi ); expect_payment_sent(initiator, payment_preimage, None, true, true); } + +/// Returns the txid carried by a candidate's status, panicking for statuses that have none. +#[cfg(test)] +fn candidate_txid(candidate: &SpliceCandidateDetails) -> Txid { + match candidate.status { + SpliceCandidateStatus::AwaitingSignatures { txid, .. } + | SpliceCandidateStatus::Negotiated { txid, .. } => txid, + ref other => panic!("candidate status carries no txid: {other:?}"), + } +} + +/// Returns the new channel value carried by a candidate's status, panicking for statuses that have +/// none. +#[cfg(test)] +fn candidate_value(candidate: &SpliceCandidateDetails) -> u64 { + match candidate.status { + SpliceCandidateStatus::ConstructingTransaction { new_channel_value_satoshis, .. } + | SpliceCandidateStatus::AwaitingSignatures { new_channel_value_satoshis, .. } + | SpliceCandidateStatus::Negotiated { new_channel_value_satoshis, .. } => { + new_channel_value_satoshis + }, + ref other => panic!("candidate status carries no value: {other:?}"), + } +} + +#[test] +fn test_channel_details_pending_splice() { + // Test that `ChannelDetails::splice_details` reflects pending splice state throughout + // negotiation, signing, RBF, restarts, and locking. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let (persister_0, persister_1); + let (chain_monitor_0, chain_monitor_1); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let (node_0, node_1); + let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let splice_details = |node: &Node<'_, '_, '_>| { + node.node + .list_channels() + .iter() + .find(|channel| channel.channel_id == channel_id) + .unwrap() + .splice_details + .clone() + }; + + // No splice is pending yet. + assert_eq!(splice_details(&nodes[0]), None); + assert_eq!(splice_details(&nodes[1]), None); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Contributing funds queues the contribution but does not start the negotiation; that begins + // once the channel becomes quiescent and splice_init is sent. Until then it surfaces as a single + // candidate awaiting quiescence, carrying our contribution. + let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + assert_eq!( + splice_details(&nodes[0]), + Some(SpliceDetails { + candidates: vec![SpliceCandidateDetails { + contribution: Some(contribution.clone()), + status: SpliceCandidateStatus::WaitingOnQuiescence, + }], + confirmed_candidate: None, + received_splice_locked_txid: None, + }), + ); + assert_eq!(splice_details(&nodes[1]), None); + + let new_channel_value_sat = + (initial_channel_value_sat as i64 + contribution.net_value().to_sat()) as u64; + + let stfu_init = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_init); + let stfu_ack = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_ack); + + // Once quiescent, the initiator sends splice_init and awaits the counterparty's splice_ack. The + // new channel value and txid are not yet known, so the AwaitingAck status carries neither. + let details = splice_details(&nodes[0]).unwrap(); + assert_eq!(details.candidates.len(), 1); + assert_eq!( + details.candidates[0].status, + SpliceCandidateStatus::AwaitingAck { + is_initiator: true, + funding_feerate_sat_per_1000_weight: FEERATE_FLOOR_SATS_PER_KW, + }, + ); + assert_eq!(details.candidates[0].contribution, Some(contribution.clone())); + assert_eq!(splice_details(&nodes[1]), None); + + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + + // The acceptor starts constructing the transaction upon receiving splice_init, at which + // point both contributions are known. + let details = splice_details(&nodes[1]).unwrap(); + assert_eq!(details.candidates.len(), 1); + assert_eq!( + details.candidates[0].status, + SpliceCandidateStatus::ConstructingTransaction { + is_initiator: false, + funding_feerate_sat_per_1000_weight: FEERATE_FLOOR_SATS_PER_KW, + new_channel_value_satoshis: new_channel_value_sat, + }, + ); + assert_eq!(details.candidates[0].contribution, None); + + let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); + + let details = splice_details(&nodes[0]).unwrap(); + assert_eq!(details.candidates.len(), 1); + assert_eq!( + details.candidates[0].status, + SpliceCandidateStatus::ConstructingTransaction { + is_initiator: true, + funding_feerate_sat_per_1000_weight: FEERATE_FLOOR_SATS_PER_KW, + new_channel_value_satoshis: new_channel_value_sat, + }, + ); + + let new_funding_script = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + contribution.clone(), + new_funding_script.clone(), + ); + + // Once construction completes, the negotiation awaits signatures and the txid is known. + let details_0 = splice_details(&nodes[0]).unwrap(); + let details_1 = splice_details(&nodes[1]).unwrap(); + assert_eq!(details_0.candidates.len(), 1); + assert_eq!(details_1.candidates.len(), 1); + assert!(matches!( + details_0.candidates[0].status, + SpliceCandidateStatus::AwaitingSignatures { is_initiator: true, .. } + )); + assert!(matches!( + details_1.candidates[0].status, + SpliceCandidateStatus::AwaitingSignatures { is_initiator: false, .. } + )); + assert_eq!(candidate_txid(&details_0.candidates[0]), candidate_txid(&details_1.candidates[0])); + assert_eq!(candidate_value(&details_0.candidates[0]), new_channel_value_sat); + assert_eq!(details_0.candidates[0].contribution, Some(contribution.clone())); + assert_eq!(details_1.candidates[0].contribution, None); + + let (splice_tx, splice_locked) = + sign_interactive_funding_tx(SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1])); + assert!(splice_locked.is_none()); + assert_eq!(candidate_txid(&details_0.candidates[0]), splice_tx.compute_txid()); + + expect_splice_pending_event(&nodes[0], &node_id_1); + // The acceptor did not contribute, so it gets no `SpliceNegotiated` event. + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); + + // With signatures exchanged, the negotiated splice is a candidate awaiting confirmations. + let details = splice_details(&nodes[0]).unwrap(); + assert_eq!(details.candidates.len(), 1); + assert!(matches!(details.candidates[0].status, SpliceCandidateStatus::Negotiated { .. })); + assert_eq!(candidate_txid(&details.candidates[0]), splice_tx.compute_txid()); + assert_eq!(candidate_value(&details.candidates[0]), new_channel_value_sat); + assert_eq!(details.candidates[0].contribution, Some(contribution.clone())); + assert_eq!(details.confirmed_candidate, None); + assert_eq!(details.received_splice_locked_txid, None); + + // The acceptor did not contribute to the splice. + let details = splice_details(&nodes[1]).unwrap(); + assert_eq!(details.candidates.len(), 1); + assert!(matches!(details.candidates[0].status, SpliceCandidateStatus::Negotiated { .. })); + assert_eq!(candidate_txid(&details.candidates[0]), splice_tx.compute_txid()); + assert_eq!(details.candidates[0].contribution, None); + + // Initiate an RBF attempt at a higher feerate. + provide_utxo_reserves(&nodes, 2, added_value * 2); + let rbf_feerate_sat_per_kwu = FEERATE_FLOOR_SATS_PER_KW as u64 + 25; + let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); + let rbf_contribution = do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, rbf_feerate); + + // The RBF contribution is queued behind the still-pending original candidate until quiescence + // is re-reached; until then it surfaces as a second candidate awaiting negotiation, alongside the + // original candidate. + let details = splice_details(&nodes[0]).unwrap(); + assert_eq!(details.candidates.len(), 2); + assert_eq!(candidate_txid(&details.candidates[0]), splice_tx.compute_txid()); + assert_eq!(details.candidates[1].status, SpliceCandidateStatus::WaitingOnQuiescence); + assert_eq!(details.candidates[1].contribution, Some(rbf_contribution.clone())); + + // Reaching quiescence turns the queued RBF contribution into a negotiation. The initiator sends + // tx_init_rbf and awaits tx_ack_rbf, so the RBF round is reported as AwaitingAck alongside the + // still-pending original candidate. + let stfu_init = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_init); + let stfu_ack = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_ack); + + let details = splice_details(&nodes[0]).unwrap(); + assert_eq!(details.candidates.len(), 2); + assert_eq!(candidate_txid(&details.candidates[0]), splice_tx.compute_txid()); + assert_eq!( + details.candidates[1].status, + SpliceCandidateStatus::AwaitingAck { + is_initiator: true, + funding_feerate_sat_per_1000_weight: rbf_feerate_sat_per_kwu as u32, + }, + ); + assert_eq!(details.candidates[1].contribution, Some(rbf_contribution.clone())); + + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + let tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0); + nodes[0].node.handle_tx_ack_rbf(node_id_1, &tx_ack_rbf); + + // The RBF negotiation then moves to constructing the transaction, still alongside the original + // candidate. + let rbf_channel_value_sat = + (initial_channel_value_sat as i64 + rbf_contribution.net_value().to_sat()) as u64; + let details = splice_details(&nodes[0]).unwrap(); + assert_eq!(details.candidates.len(), 2); + assert_eq!(candidate_txid(&details.candidates[0]), splice_tx.compute_txid()); + assert_eq!(details.candidates[0].contribution, Some(contribution.clone())); + assert_eq!( + details.candidates[1].status, + SpliceCandidateStatus::ConstructingTransaction { + is_initiator: true, + funding_feerate_sat_per_1000_weight: rbf_feerate_sat_per_kwu as u32, + new_channel_value_satoshis: rbf_channel_value_sat, + }, + ); + assert_eq!(details.candidates[1].contribution, Some(rbf_contribution.clone())); + + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + rbf_contribution.clone(), + new_funding_script, + ); + let (rbf_tx, splice_locked) = sign_interactive_funding_tx( + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]).replacing(splice_tx.compute_txid()), + ); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_id_1); + // The acceptor did not contribute, so it gets no `SpliceNegotiated` event. + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); + + // Both the original splice and its RBF replacement are candidates, in negotiation order. + let details = splice_details(&nodes[0]).unwrap(); + assert_eq!(details.candidates.len(), 2); + assert!(details + .candidates + .iter() + .all(|c| matches!(c.status, SpliceCandidateStatus::Negotiated { .. }))); + assert_eq!(candidate_txid(&details.candidates[0]), splice_tx.compute_txid()); + assert_eq!(details.candidates[0].contribution, Some(contribution.clone())); + assert_eq!(candidate_txid(&details.candidates[1]), rbf_tx.compute_txid()); + assert_eq!(candidate_value(&details.candidates[1]), rbf_channel_value_sat); + assert_eq!(details.candidates[1].contribution, Some(rbf_contribution.clone())); + + let details = splice_details(&nodes[1]).unwrap(); + assert_eq!(details.candidates.len(), 2); + assert_eq!(details.candidates[1].contribution, None); + + // Pending splice state, including per-candidate contributions, survives a restart. + let encoded_monitor_0 = get_monitor!(nodes[0], channel_id).encode(); + reload_node!( + nodes[0], + &nodes[0].node.encode(), + &[&encoded_monitor_0], + persister_0, + chain_monitor_0, + node_0 + ); + let encoded_monitor_1 = get_monitor!(nodes[1], channel_id).encode(); + reload_node!( + nodes[1], + &nodes[1].node.encode(), + &[&encoded_monitor_1], + persister_1, + chain_monitor_1, + node_1 + ); + + let details = splice_details(&nodes[0]).unwrap(); + assert_eq!(details.candidates.len(), 2); + assert_eq!(candidate_txid(&details.candidates[0]), splice_tx.compute_txid()); + assert_eq!(details.candidates[0].contribution, Some(contribution)); + assert_eq!(candidate_txid(&details.candidates[1]), rbf_tx.compute_txid()); + assert_eq!(details.candidates[1].contribution, Some(rbf_contribution)); + + let details = splice_details(&nodes[1]).unwrap(); + assert_eq!(details.candidates.len(), 2); + assert!(details.candidates.iter().all(|candidate| candidate.contribution.is_none())); + + let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); + reconnect_args.send_announcement_sigs = (true, true); + reconnect_nodes(reconnect_args); + + // Mine the RBF transaction; only its candidate confirms, identified by its index. + mine_transaction(&nodes[0], &rbf_tx); + mine_transaction(&nodes[1], &rbf_tx); + + let details = splice_details(&nodes[0]).unwrap(); + let confirmed = details.confirmed_candidate.unwrap(); + assert_eq!(confirmed.txid, rbf_tx.compute_txid()); + assert_eq!(confirmed.confirmations, 1); + assert_eq!(confirmed.confirmations_required, 6); + // Not yet at the required depth, so we have not sent `splice_locked` for it. + assert!(!confirmed.splice_locked_sent); + + connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1); + connect_blocks(&nodes[1], ANTI_REORG_DELAY - 1); + + // Once sufficiently confirmed, the splice_locked we sent is reflected in the details until + // the counterparty's splice_locked is received and the splice is promoted. + let splice_locked = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1); + let details = splice_details(&nodes[0]).unwrap(); + assert_eq!(details.received_splice_locked_txid, None); + let confirmed = details.confirmed_candidate.unwrap(); + assert_eq!(confirmed.txid, rbf_tx.compute_txid()); + assert!(confirmed.splice_locked_sent); + assert_eq!(confirmed.confirmations, ANTI_REORG_DELAY); + + lock_splice(&nodes[0], &nodes[1], &splice_locked, false, &[splice_tx.compute_txid()]); + + // The splice is no longer pending once promoted. + assert_eq!(splice_details(&nodes[0]), None); + assert_eq!(splice_details(&nodes[1]), None); +} + +#[test] +fn test_channel_details_first_contribution_on_rbf() { + // When the counterparty's splice did not include a contribution from us and our first + // contribution comes in an RBF round we initiate, the in-flight contribution must not be + // attributed to the negotiated counterparty-only candidate. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Splice initiated by node 1; node 0 does not contribute. + let contribution = do_initiate_splice_in(&nodes[1], &nodes[0], channel_id, added_value); + let (splice_tx, _) = splice_channel(&nodes[1], &nodes[0], channel_id, contribution); + + // Node 0 initiates an RBF, contributing for the first time. + let rbf_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let rbf_contribution = funding_template + .without_prior_contribution(rbf_feerate, FeeRate::MAX) + .with_coin_selection_source_sync(&wallet) + .add_value(added_value) + .unwrap() + .build() + .unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, rbf_contribution.clone(), None) + .unwrap(); + complete_rbf_handshake(&nodes[0], &nodes[1]); + let _ = get_event_msg!(nodes[0], MessageSendEvent::SendTxAddInput, node_id_1); + + // While the RBF is being negotiated, node 0's contribution belongs to the negotiation, not + // to the negotiated counterparty-only candidate. + let channels = nodes[0].node.list_channels(); + let details = channels[0].splice_details.as_ref().unwrap(); + assert_eq!(details.candidates.len(), 2); + assert_eq!(candidate_txid(&details.candidates[0]), splice_tx.compute_txid()); + assert_eq!(details.candidates[0].contribution, None); + assert!(matches!( + details.candidates[1].status, + SpliceCandidateStatus::ConstructingTransaction { is_initiator: true, .. } + )); + assert_eq!(details.candidates[1].contribution, Some(rbf_contribution.clone())); + + // Node 1 adjusted its prior contribution for the RBF round; the negotiated candidate keeps + // its original contribution. Node 1 did not initiate this round, so `is_initiator` is + // `Some(false)` even though it carries a contribution into it. + let channels = nodes[1].node.list_channels(); + let details = channels[0].splice_details.as_ref().unwrap(); + assert!(matches!( + details.candidates[1].status, + SpliceCandidateStatus::ConstructingTransaction { is_initiator: false, .. } + )); + assert!(details.candidates[1].contribution.is_some()); + assert!(details.candidates[0].contribution.is_some()); + + // Abort the negotiation via disconnect. + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + + expect_splice_failed_events( + &nodes[0], + &channel_id, + rbf_contribution, + NegotiationFailureReason::PeerDisconnected, + ); + // Node 1's contribution to the RBF round (the prior round's contribution adjusted to the new + // feerate) has no inputs or outputs unique from the prior round, so nothing is discarded, but + // it still gets a `SpliceNegotiationFailed` so the wallet can resume funding. + let _ = get_event!(&nodes[1], Event::SpliceNegotiationFailed); + + // After the reset, the contribution alignment is restored on both nodes. + let channels = nodes[0].node.list_channels(); + let details = channels[0].splice_details.as_ref().unwrap(); + assert_eq!(details.candidates.len(), 1); + assert_eq!(details.candidates[0].contribution, None); + let channels = nodes[1].node.list_channels(); + let details = channels[0].splice_details.as_ref().unwrap(); + assert_eq!(details.candidates.len(), 1); + assert!(details.candidates[0].contribution.is_some()); +} + +#[test] +fn test_channel_details_zero_conf_splice() { + // On a zero-conf channel the splice is locked (we send `splice_locked`) before it has any + // confirmations, so `ChannelDetails::splice_details` must still report the locked candidate as + // the confirmed candidate at zero confirmations. Once both sides exchange `splice_locked` the + // splice is promoted to the channel funding and is no longer reported as pending. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut config = test_default_channel_config(); + config.channel_handshake_limits.trust_own_funding_0conf = true; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + // Leave the original funding unconfirmed -- a zero-conf channel is usable without it -- so the + // test stays focused on the zero-conf splice. + let (funding_tx, channel_id) = + open_zero_conf_channel_with_value(&nodes[0], &nodes[1], None, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 1, added_value * 2); + + let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let new_funding_script = complete_splice_handshake(&nodes[0], &nodes[1]); + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + contribution, + new_funding_script, + ); + + // Sign the splice. The original funding is still unconfirmed, so signing also (re-)broadcasts it + // alongside the splice; the helper asserts that and returns the splice transaction. We leave node 0 + // without the counterparty's `splice_locked`, so the splice stays pending on node 0. + let (splice_tx, splice_locked) = sign_interactive_funding_tx( + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]) + .zero_conf() + .with_unconfirmed_funding(funding_tx.compute_txid()), + ); + + expect_splice_pending_event(&nodes[0], &node_id_1); + // The acceptor did not contribute, so it gets no `SpliceNegotiated` event. + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); + + // Node 0 has sent `splice_locked` but has not yet received the counterparty's, so the splice is + // still pending. The candidate we locked is reported as the confirmed candidate even though it + // has zero confirmations. + let details = nodes[0] + .node + .list_channels() + .iter() + .find(|channel| channel.channel_id == channel_id) + .unwrap() + .splice_details + .clone() + .unwrap(); + let confirmed = + details.confirmed_candidate.expect("the locked zero-conf candidate should be reported"); + assert_eq!(confirmed.txid, splice_tx.compute_txid()); + assert_eq!(confirmed.confirmations, 0); + assert_eq!(confirmed.confirmations_required, 0); + assert!(confirmed.splice_locked_sent); + assert_eq!(details.received_splice_locked_txid, None); + + // Exchange both sides' `splice_locked` to lock the splice in. Node 0 sent its at signing (above); + // `lock_splice` delivers it to node 1 and brings node 1's back, promoting the splice to the + // channel funding on both sides. + let (splice_locked_for_node_1, _) = + splice_locked.expect("a zero-conf splice sends splice_locked at signing"); + lock_splice(&nodes[0], &nodes[1], &splice_locked_for_node_1, true, &[]); + + // With the splice promoted, it is no longer reported as a pending splice. + let splice_details = |node: &Node<'_, '_, '_>| { + node.node + .list_channels() + .iter() + .find(|channel| channel.channel_id == channel_id) + .unwrap() + .splice_details + .clone() + }; + assert_eq!(splice_details(&nodes[0]), None); + assert_eq!(splice_details(&nodes[1]), None); +} + +#[test] +fn test_channel_details_waiting_on_lock_zero_conf() { + // On a zero-conf channel a committed contribution can never RBF the pending candidate (RBF is + // incompatible with zero-conf), so it is reported as `WaitingOnLock` — waiting for the candidate + // to lock before it can be spliced. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut config = test_default_channel_config(); + config.channel_handshake_limits.trust_own_funding_0conf = true; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (funding_tx, channel_id) = + open_zero_conf_channel_with_value(&nodes[0], &nodes[1], None, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Complete a first splice; on a zero-conf channel node 0 sends `splice_locked` at signing, but the + // splice stays pending until the counterparty's `splice_locked` arrives. + let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let new_funding_script = complete_splice_handshake(&nodes[0], &nodes[1]); + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + contribution, + new_funding_script, + ); + let _ = sign_interactive_funding_tx( + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]) + .zero_conf() + .with_unconfirmed_funding(funding_tx.compute_txid()), + ); + expect_splice_pending_event(&nodes[0], &node_id_1); + // The acceptor did not contribute, so it gets no `SpliceNegotiated` event. + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); + nodes[0].node.get_and_clear_pending_msg_events(); + + // Commit a further contribution; it cannot RBF the pending candidate, so no `stfu` is sent and it + // is reported as awaiting the lock. + let queued = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + let details = nodes[0] + .node + .list_channels() + .iter() + .find(|channel| channel.channel_id == channel_id) + .unwrap() + .splice_details + .clone() + .unwrap(); + assert_eq!(details.candidates.len(), 2); + assert_eq!(details.candidates[1].status, SpliceCandidateStatus::WaitingOnLock); + assert_eq!(details.candidates[1].contribution, Some(queued)); + + // This test does not lock the splice in; drain the un-exchanged `splice_locked` messages so the + // nodes tear down cleanly. + nodes[0].node.get_and_clear_pending_msg_events(); + nodes[1].node.get_and_clear_pending_msg_events(); +} + +#[test] +fn test_channel_details_received_splice_locked() { + // `received_splice_locked_txid` reports the candidate the counterparty considers locked. Confirm + // the splice on only one node so it sends `splice_locked` while the other has not confirmed: the + // recipient records the received txid while the splice is still pending and unconfirmed for it. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, contribution); + + // Confirm the splice on node 0 only, so it sends `splice_locked` while node 1 has not confirmed. + mine_transaction(&nodes[0], &splice_tx); + connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1); + let splice_locked = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1); + + nodes[1].node.handle_splice_locked(node_id_0, &splice_locked); + + // Node 1 records the counterparty's locked candidate, but has not confirmed it itself, so it has + // no confirmed candidate of its own and the splice remains pending. + let details = nodes[1] + .node + .list_channels() + .iter() + .find(|channel| channel.channel_id == channel_id) + .unwrap() + .splice_details + .clone() + .unwrap(); + assert_eq!(details.received_splice_locked_txid, Some(splice_tx.compute_txid())); + assert_eq!(details.confirmed_candidate, None); + assert_eq!(details.candidates.len(), 1); + assert_eq!(candidate_txid(&details.candidates[0]), splice_tx.compute_txid()); + + // Committing a further contribution while the candidate is locking (we received its + // `splice_locked`) cannot RBF that candidate, so the queued contribution waits for the lock. This + // holds even though its feerate would satisfy the RBF minimum: the locking check takes priority. + nodes[1].node.get_and_clear_pending_msg_events(); + let queued = do_initiate_splice_in(&nodes[1], &nodes[0], channel_id, Amount::from_sat(25_000)); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + let details = nodes[1] + .node + .list_channels() + .iter() + .find(|channel| channel.channel_id == channel_id) + .unwrap() + .splice_details + .clone() + .unwrap(); + assert_eq!(details.candidates.len(), 2); + assert_eq!(details.candidates[1].status, SpliceCandidateStatus::WaitingOnLock); + assert_eq!(details.candidates[1].contribution, Some(queued)); +} + +#[test] +fn test_channel_details_splice_reorg_clears_confirmed_candidate() { + // A confirmed splice candidate we have locked is reported as the confirmed candidate; a reorg + // that unconfirms it clears the confirmed candidate, including the splice_locked we sent. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, contribution); + + let splice_details = |node: &Node<'_, '_, '_>| { + node.node + .list_channels() + .iter() + .find(|channel| channel.channel_id == channel_id) + .unwrap() + .splice_details + .clone() + }; + + // Confirm the splice on node 0 so it sends splice_locked and reports the confirmed candidate. + mine_transaction(&nodes[0], &splice_tx); + connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1); + let _ = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1); + + let confirmed = splice_details(&nodes[0]).unwrap().confirmed_candidate.unwrap(); + assert_eq!(confirmed.txid, splice_tx.compute_txid()); + assert!(confirmed.splice_locked_sent); + + // Reorg out the blocks that confirmed the splice. The confirmed candidate is cleared, along with + // the splice_locked we sent for it; the candidate itself remains pending. + disconnect_blocks(&nodes[0], ANTI_REORG_DELAY); + + let details = splice_details(&nodes[0]).unwrap(); + assert_eq!(details.confirmed_candidate, None); + assert_eq!(details.candidates.len(), 1); + assert_eq!(candidate_txid(&details.candidates[0]), splice_tx.compute_txid()); +} + +#[test] +fn test_channel_details_received_splice_locked_diverges_from_confirmed() { + // `confirmed_candidate` and `received_splice_locked_txid` can name different candidates: across a + // reorg the two sides may each see a different RBF candidate confirm. Here node 0 confirms (and + // locks) the RBF candidate while node 1 confirms (and locks) the original, so node 0 ends up with + // a `received_splice_locked_txid` that differs from its own `confirmed_candidate`. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (original_tx, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, contribution); + + // RBF the splice, producing a second candidate that double-spends the original. + provide_utxo_reserves(&nodes, 2, added_value * 2); + let rbf_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25); + let rbf_contribution = do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, rbf_feerate); + complete_rbf_handshake(&nodes[0], &nodes[1]); + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + rbf_contribution, + new_funding_script, + ); + let (rbf_tx, _) = sign_interactive_funding_tx( + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]) + .replacing(original_tx.compute_txid()), + ); + expect_splice_pending_event(&nodes[0], &node_id_1); + // The acceptor did not contribute, so it gets no `SpliceNegotiated` event. + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); + + let splice_details = |node: &Node<'_, '_, '_>| { + node.node + .list_channels() + .iter() + .find(|channel| channel.channel_id == channel_id) + .unwrap() + .splice_details + .clone() + }; + + // Node 0's chain confirms the RBF candidate, so it sends `splice_locked` for it. + mine_transaction(&nodes[0], &rbf_tx); + connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1); + let _ = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1); + + // Node 1's chain instead confirms the original candidate, so it sends `splice_locked` for that. + mine_transaction(&nodes[1], &original_tx); + connect_blocks(&nodes[1], ANTI_REORG_DELAY - 1); + let splice_locked_from_1 = + get_event_msg!(nodes[1], MessageSendEvent::SendSpliceLocked, node_id_0); + + // Node 0 records the counterparty's locked candidate (the original), which differs from the RBF + // candidate node 0 itself confirmed. The splice is not promoted, as the two sides disagree. + nodes[0].node.handle_splice_locked(node_id_1, &splice_locked_from_1); + + let details = splice_details(&nodes[0]).unwrap(); + let confirmed = details.confirmed_candidate.unwrap(); + assert_eq!(confirmed.txid, rbf_tx.compute_txid()); + assert!(confirmed.splice_locked_sent); + assert_eq!(details.received_splice_locked_txid, Some(original_tx.compute_txid())); + assert_ne!(Some(confirmed.txid), details.received_splice_locked_txid); +} + +#[test] +fn test_channel_details_acceptor_contribution_with_queued_rbf() { + // An acceptor that contributes to the counterparty's round (its committed contribution merging + // into that round via the quiescence tie-break) can also queue a further contribution for a + // future RBF. Both surface together as candidates: the in-flight counterparty round carries our + // part of it, alongside a separate candidate for the contribution we queued for the next round. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000)); + + // Both nodes commit a contribution and propose a splice. The tie-break makes node 0 (the funder) + // the initiator; node 1 becomes the acceptor and its contribution merges into node 0's round. + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let contribution_0 = nodes[0] + .node + .splice_channel(&channel_id, &node_id_1) + .unwrap() + .splice_in_sync(added_value, feerate, FeeRate::MAX, &wallet_0) + .unwrap(); + nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution_0, None).unwrap(); + + let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let contribution_1 = nodes[1] + .node + .splice_channel(&channel_id, &node_id_0) + .unwrap() + .splice_in_sync(added_value, feerate, FeeRate::MAX, &wallet_1) + .unwrap(); + nodes[1].node.funding_contributed(&channel_id, &node_id_0, contribution_1, None).unwrap(); + + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + assert_ne!( + splice_ack.funding_contribution_satoshis, 0, + "the acceptor should contribute to the counterparty's round", + ); + + // Node 1 queues a further contribution for a future RBF while node 0's round is still in flight. + let rbf_template = nodes[1].node.splice_channel(&channel_id, &node_id_0).unwrap(); + let rbf_feerate = rbf_template.min_rbf_feerate().unwrap(); + let queued = rbf_template + .splice_in_sync(Amount::from_sat(25_000), rbf_feerate, FeeRate::MAX, &wallet_1) + .unwrap(); + nodes[1].node.funding_contributed(&channel_id, &node_id_0, queued.clone(), None).unwrap(); + + // Node 1's view: it contributed to node 0's (counterparty) round AND has its own RBF queued. + let details = nodes[1] + .node + .list_channels() + .iter() + .find(|channel| channel.channel_id == channel_id) + .unwrap() + .splice_details + .clone() + .unwrap(); + assert_eq!(details.candidates.len(), 2); + // Our part of node 0's in-flight round, which we did not initiate. + assert!(matches!( + details.candidates[0].status, + SpliceCandidateStatus::ConstructingTransaction { is_initiator: false, .. } + )); + assert!(details.candidates[0].contribution.is_some()); + // Our further contribution, queued to RBF that round once it completes. + assert_eq!(details.candidates[1].status, SpliceCandidateStatus::WaitingOnQuiescence); + assert_eq!(details.candidates[1].contribution, Some(queued)); +} + +#[test] +fn test_channel_details_acceptor_contribution_reaches_signing() { + // An acceptor that contributes to a counterparty-initiated round is reported with + // `is_initiator: false` and its own contribution present, through the awaiting-signatures stage + // and into the negotiated candidate. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000)); + + // Both nodes commit a contribution at the same feerate; node 0 (the funder) wins the tie-break + // and initiates, node 1 becomes the acceptor and its contribution merges into node 0's round. + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let contribution_0 = nodes[0] + .node + .splice_channel(&channel_id, &node_id_1) + .unwrap() + .splice_in_sync(added_value, feerate, FeeRate::MAX, &wallet_0) + .unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, contribution_0.clone(), None) + .unwrap(); + + let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let contribution_1 = nodes[1] + .node + .splice_channel(&channel_id, &node_id_0) + .unwrap() + .splice_in_sync(added_value, feerate, FeeRate::MAX, &wallet_1) + .unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_id_0, contribution_1.clone(), None) + .unwrap(); + + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + assert_ne!( + splice_ack.funding_contribution_satoshis, 0, + "the acceptor should contribute to the counterparty's round", + ); + nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); + + let new_funding_script = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + contribution_0, + Some(contribution_1), + splice_ack.funding_contribution_satoshis, + new_funding_script, + ); + + let splice_details = |node: &Node<'_, '_, '_>| { + node.node + .list_channels() + .iter() + .find(|channel| channel.channel_id == channel_id) + .unwrap() + .splice_details + .clone() + .unwrap() + }; + + // The acceptor's in-flight round awaits signatures, carrying its own (adjusted) contribution. + let details = splice_details(&nodes[1]); + assert_eq!(details.candidates.len(), 1); + assert!(matches!( + details.candidates[0].status, + SpliceCandidateStatus::AwaitingSignatures { is_initiator: false, .. } + )); + assert!(details.candidates[0].contribution.is_some()); + + let (_splice_tx, splice_locked) = sign_interactive_funding_tx( + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]).with_acceptor_contribution(), + ); + assert!(splice_locked.is_none()); + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Once signed, the acceptor's negotiated candidate still carries its contribution. + let details = splice_details(&nodes[1]); + assert_eq!(details.candidates.len(), 1); + assert!(matches!(details.candidates[0].status, SpliceCandidateStatus::Negotiated { .. })); + assert!(details.candidates[0].contribution.is_some()); +} + +#[test] +fn test_channel_details_waiting_on_lock_below_rbf_feerate() { + // A committed contribution whose feerate is below the RBF minimum of the round currently in + // flight cannot replace it, so it is reported as `WaitingOnLock`. This exercises the feerate + // branch of the classification (the zero-conf and locking checks do not apply here) and produces + // the full negotiated -> in-flight -> queued three-candidate ordering. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + + // Complete a first splice at the floor feerate, leaving a negotiated candidate. + provide_utxo_reserves(&nodes, 1, added_value * 2); + let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, contribution); + + // The counterparty (node 1) initiates an RBF at a much higher feerate; we drive it in flight on + // node 0 (node 1 wins quiescence, as node 0 has nothing of its own queued yet). + provide_utxo_reserves(&nodes, 1, added_value * 2); + let high_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 * 4); + // Node 1 did not contribute to the original splice, so it RBFs with a first contribution. + let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let rbf_contribution = nodes[1] + .node + .splice_channel(&channel_id, &node_id_0) + .unwrap() + .without_prior_contribution(high_feerate, FeeRate::MAX) + .with_coin_selection_source_sync(&wallet_1) + .add_value(added_value) + .unwrap() + .build() + .unwrap(); + nodes[1].node.funding_contributed(&channel_id, &node_id_0, rbf_contribution, None).unwrap(); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + let tx_init_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxInitRbf, node_id_0); + nodes[0].node.handle_tx_init_rbf(node_id_1, &tx_init_rbf); + let _tx_ack_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxAckRbf, node_id_1); + + // Node 0 commits its own contribution at the floor RBF feerate. That is enough to replace the + // original candidate, but not the higher-feerate round now in flight, so it waits for the lock. + provide_utxo_reserves(&nodes, 1, added_value * 2); + let queued = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + + let details = nodes[0] + .node + .list_channels() + .iter() + .find(|channel| channel.channel_id == channel_id) + .unwrap() + .splice_details + .clone() + .unwrap(); + // Negotiated original, the counterparty's in-flight higher-feerate RBF, then our queued + // contribution awaiting the lock. + assert_eq!(details.candidates.len(), 3); + assert!(matches!(details.candidates[0].status, SpliceCandidateStatus::Negotiated { .. })); + assert_eq!(candidate_txid(&details.candidates[0]), splice_tx.compute_txid()); + assert!(matches!( + details.candidates[1].status, + SpliceCandidateStatus::ConstructingTransaction { is_initiator: false, .. } + )); + assert_eq!(details.candidates[2].status, SpliceCandidateStatus::WaitingOnLock); + assert_eq!(details.candidates[2].contribution, Some(queued)); + + // This test leaves an RBF round in flight; drain the un-exchanged messages for a clean teardown. + nodes[0].node.get_and_clear_pending_msg_events(); + nodes[1].node.get_and_clear_pending_msg_events(); +} diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 44926cf1a60..936d35ea471 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -4239,6 +4239,7 @@ mod tests { pending_inbound_htlcs: Vec::new(), pending_outbound_htlcs: Vec::new(), current_dust_exposure_msat: None, + splice_details: None, } } @@ -9809,6 +9810,7 @@ pub(crate) mod bench_utils { pending_inbound_htlcs: Vec::new(), pending_outbound_htlcs: Vec::new(), current_dust_exposure_msat: None, + splice_details: None, } } From 0beadb3b081efb6b2b373b7e72acbe8fa5b4b5fa Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz <jkczyz@gmail.com> Date: Tue, 16 Jun 2026 17:44:38 -0500 Subject: [PATCH 589/627] Test cross-version serialization of pending splices Add tests exercising the 0.2/current wire boundary for pending splices: - A current node with a single pending splice (whether or not we contributed to it) is loadable by LDK 0.2. - A current node with a splice under RBF is refused by 0.2 via the even RBF-gate TLV. - A single pending splice written by 0.2 is read by current with no contribution recorded, since 0.2 never tracked one. The downgrade reload configs enable anchors so 0.2 accepts the current channel type rather than refusing it before the splice state is reached. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../src/upgrade_downgrade_tests.rs | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) diff --git a/lightning-tests/src/upgrade_downgrade_tests.rs b/lightning-tests/src/upgrade_downgrade_tests.rs index 136ae919a97..6b0969c7237 100644 --- a/lightning-tests/src/upgrade_downgrade_tests.rs +++ b/lightning-tests/src/upgrade_downgrade_tests.rs @@ -11,6 +11,7 @@ //! LDK. use lightning_0_2::commitment_signed_dance as commitment_signed_dance_0_2; +use lightning_0_2::events::bump_transaction::sync::WalletSourceSync as WalletSourceSync_0_2; use lightning_0_2::events::Event as Event_0_2; use lightning_0_2::get_monitor as get_monitor_0_2; use lightning_0_2::ln::channelmanager::PaymentId as PaymentId_0_2; @@ -819,3 +820,210 @@ fn test_onion_message_intercepted_scid_downgrade_to_0_2() { let result = <Event_0_2 as MaybeReadable_0_2>::read(&mut reader); assert!(result.is_err(), "LDK 0.2 should fail to decode a ShortChannelId variant"); } + +fn downgrade_setup_single_splice() -> (Vec<u8>, Vec<u8>, Vec<u8>, Vec<u8>, ChannelId) { + // Build a current node with a single pending (negotiated, not yet locked) splice that node 0 + // funded (so node 0 is contributory, node 1 is a non-contributory acceptor). Return both + // nodes' serialized ChannelManager + ChannelMonitor and the channel id. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, contribution); + mine_transaction(&nodes[0], &splice_tx); + mine_transaction(&nodes[1], &splice_tx); + + let node_0_ser = nodes[0].node.encode(); + let node_1_ser = nodes[1].node.encode(); + let mon_0_ser = get_monitor!(nodes[0], channel_id).encode(); + let mon_1_ser = get_monitor!(nodes[1], channel_id).encode(); + (node_0_ser, node_1_ser, mon_0_ser, mon_1_ser, channel_id) +} + +#[test] +fn downgrade_single_splice_loads_on_0_2() { + // A current node with a single pending splice serializes in a form LDK 0.2 can still read, + // whether or not we funded it: only odd TLVs are written (the even RBF gate is omitted for a + // single round), so 0.2 skips the contribution it can't track and loads the channel. RBF is + // the only state that blocks downgrade (see downgrade_rbf_refused_by_0_2). + let (node_0_ser, node_1_ser, mon_0_ser, mon_1_ser, _) = downgrade_setup_single_splice(); + + let mut chanmon_cfgs = lightning_0_2_utils::create_chanmon_cfgs(2); + chanmon_cfgs[0].keys_manager.disable_all_state_policy_checks = true; + chanmon_cfgs[1].keys_manager.disable_all_state_policy_checks = true; + let node_cfgs = lightning_0_2_utils::create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = lightning_0_2_utils::create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = lightning_0_2_utils::create_network(2, &node_cfgs, &node_chanmgrs); + let mut config = lightning_0_2_utils::test_default_channel_config(); + // The current side uses the anchors channel type by default; 0.2 only accepts a channel whose + // type it advertises support for, so enable anchors here too (otherwise the read is refused on + // the channel type, before the splice serialization is ever exercised). + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; + + // Node 0 (contributory initiator): the contribution lives in an odd TLV that 0.2 skips. + let mgr_0 = lightning_0_2_utils::_reload_node( + &nodes[0], + config.clone(), + &node_0_ser, + &[&mon_0_ser[..]], + ); + assert_eq!(mgr_0.list_channels().len(), 1); + // Node 1 (non-contributory acceptor): nothing 0.2 can't represent. + let mgr_1 = + lightning_0_2_utils::_reload_node(&nodes[1], config, &node_1_ser, &[&mon_1_ser[..]]); + assert_eq!(mgr_1.list_channels().len(), 1); +} + +#[test] +fn downgrade_rbf_refused_by_0_2() { + // RBF (more than one negotiation round) is the one splice state LDK 0.2 cannot operate. Current + // writes the even RBF-gate TLV for it, which 0.2 rejects as an unknown even (required) field, + // so reading the ChannelManager fails rather than silently mishandling the extra candidate. + let (node_0_ser, mon_0_ser); + { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let node_id_1 = nodes[1].node.get_our_node_id(); + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (first_splice_tx, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, contribution); + + // RBF the splice, producing a second negotiated candidate. + provide_utxo_reserves(&nodes, 2, added_value * 2); + let rbf_feerate = bitcoin::FeeRate::from_sat_per_kwu(1000); + let rbf_contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, rbf_feerate); + complete_rbf_handshake(&nodes[0], &nodes[1]); + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + rbf_contribution, + new_funding_script, + ); + let _ = sign_interactive_funding_tx( + SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]) + .replacing(first_splice_tx.compute_txid()), + ); + expect_splice_pending_event(&nodes[0], &node_id_1); + // The acceptor did not contribute, so it gets no `SpliceNegotiated` event. + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); + + node_0_ser = nodes[0].node.encode(); + mon_0_ser = get_monitor!(nodes[0], channel_id).encode(); + } + + let mut chanmon_cfgs = lightning_0_2_utils::create_chanmon_cfgs(2); + chanmon_cfgs[0].keys_manager.disable_all_state_policy_checks = true; + chanmon_cfgs[1].keys_manager.disable_all_state_policy_checks = true; + let node_cfgs = lightning_0_2_utils::create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = lightning_0_2_utils::create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = lightning_0_2_utils::create_network(2, &node_cfgs, &node_chanmgrs); + let mut config = lightning_0_2_utils::test_default_channel_config(); + // Match the anchors channel type used on the current side, so the manager read reaches (and + // fails on) the even RBF-gate TLV rather than refusing the channel type itself. + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; + // _reload_node unwraps the manager read, which fails on the even RBF-gate TLV. Catch the panic + // here so it stays contained to the read we expect to fail. + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + lightning_0_2_utils::_reload_node(&nodes[0], config, &node_0_ser, &[&mon_0_ser[..]]); + })) + .expect_err("0.2 should refuse to read the RBF splice"); + let panic_msg = panic + .downcast_ref::<String>() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .unwrap_or(""); + assert!( + panic_msg.contains("UnknownRequiredFeature"), + "expected an UnknownRequiredFeature decode failure, got: {panic_msg}", + ); +} + +#[test] +fn upgrade_single_splice_from_0_2() { + // A pending single splice written by LDK 0.2 -- which never tracked our contribution -- is read + // by current: the candidate comes back via the TLV-3 fallback with `contribution: None`. + let (node_0_ser, node_1_ser, mon_0_ser, mon_1_ser, chan_id_bytes); + { + let chanmon_cfgs = lightning_0_2_utils::create_chanmon_cfgs(2); + let node_cfgs = lightning_0_2_utils::create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = lightning_0_2_utils::create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = lightning_0_2_utils::create_network(2, &node_cfgs, &node_chanmgrs); + let channel_id = lightning_0_2_utils::create_announced_chan_between_nodes_with_value( + &nodes, 0, 1, 100_000, 0, + ) + .2; + chan_id_bytes = channel_id.0; + + let contribution = lightning_0_2::ln::funding::SpliceContribution::SpliceOut { + outputs: vec![bitcoin::TxOut { + value: bitcoin::Amount::from_sat(1_000), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }], + }; + // 0.2 drives the splice through tx_signatures, leaving one negotiated (unlocked) candidate. + let _ = lightning_0_2::ln::splicing_tests::splice_channel( + &nodes[0], + &nodes[1], + channel_id, + contribution, + ); + + node_0_ser = nodes[0].node.encode(); + node_1_ser = nodes[1].node.encode(); + mon_0_ser = get_monitor_0_2!(nodes[0], channel_id).encode(); + mon_1_ser = get_monitor_0_2!(nodes[1], channel_id).encode(); + } + + let mut chanmon_cfgs = create_chanmon_cfgs(2); + chanmon_cfgs[0].keys_manager.disable_all_state_policy_checks = true; + chanmon_cfgs[1].keys_manager.disable_all_state_policy_checks = true; + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let (persister_a, persister_b, chain_mon_a, chain_mon_b); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let (node_a, node_b); + let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let config = test_default_channel_config(); + reload_node!( + nodes[0], + config.clone(), + &node_0_ser, + &[&mon_0_ser[..]], + persister_a, + chain_mon_a, + node_a + ); + reload_node!( + nodes[1], + config, + &node_1_ser, + &[&mon_1_ser[..]], + persister_b, + chain_mon_b, + node_b + ); + + // Current reads the 0.2 splice: one negotiated candidate, no contribution recorded. + let channel_id = ChannelId(chan_id_bytes); + for node in nodes.iter() { + let channels = node.node.list_channels(); + let details = channels.iter().find(|c| c.channel_id == channel_id).unwrap(); + let splice = details.splice_details.as_ref().expect("pending splice"); + assert_eq!(splice.candidates.len(), 1); + assert_eq!(splice.candidates[0].contribution, None); + } +} From 1127a38a46150814d6fb8db75e108149f06accbf Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz <jkczyz@gmail.com> Date: Wed, 17 Jun 2026 15:57:06 -0500 Subject: [PATCH 590/627] Queue a splice on a channel with an inherited splice until it locks A pending splice negotiated before an upgrade from a prior LDK version (e.g. 0.2) comes back without its feerate or our contribution: 0.2 persists neither and drops the odd TLVs that carry them. Without them the inherited splice cannot be RBF'd. Rather than refuse to splice the channel, leave the RBF feerate floor unset so the new splice is queued and begins as a fresh splice once the inherited splice locks -- the same path taken whenever a contribution cannot replace the pending candidate via RBF, such as on a zero-conf channel. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .../src/upgrade_downgrade_tests.rs | 72 +++++++++++++++++++ lightning/src/ln/channel.rs | 9 +-- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/lightning-tests/src/upgrade_downgrade_tests.rs b/lightning-tests/src/upgrade_downgrade_tests.rs index 6b0969c7237..0cc643b9c2d 100644 --- a/lightning-tests/src/upgrade_downgrade_tests.rs +++ b/lightning-tests/src/upgrade_downgrade_tests.rs @@ -52,6 +52,7 @@ use lightning_0_0_125::util::ser::Writeable as _; use lightning::blinded_path::message::NextMessageHop; use lightning::chain::channelmonitor::{ANTI_REORG_DELAY, HTLC_FAIL_BACK_BUFFER}; use lightning::events::{ClosureReason, Event, HTLCHandlingFailureType}; +use lightning::ln::channel_state::SpliceCandidateStatus; use lightning::ln::functional_test_utils::*; use lightning::ln::msgs; use lightning::ln::msgs::BaseMessageHandler as _; @@ -1026,4 +1027,75 @@ fn upgrade_single_splice_from_0_2() { assert_eq!(splice.candidates.len(), 1); assert_eq!(splice.candidates[0].contribution, None); } + + // The inherited splice cannot be RBF'd -- 0.2 persisted neither its feerate nor our contribution + // to reconstruct the prior request -- so splice_channel returns a fresh template with no RBF + // feerate floor rather than refusing. The new splice is queued to begin once the inherited + // splice locks. + let node_id_1 = nodes[1].node.get_our_node_id(); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + assert!(funding_template.min_rbf_feerate().is_none()); +} + +#[test] +fn splice_inherited_across_0_2_queues_until_lock() { + // Negotiate a contributory splice on current, downgrade to LDK 0.2, then upgrade back. LDK 0.2 + // persists neither our contribution nor the splice feerate and does not retain the odd TLVs that + // carry them, so the splice returns to current without either. It therefore cannot be RBF'd; + // splicing again instead queues a new splice that begins once the inherited splice locks. + // Same single-splice setup as the downgrade tests; we only need node 0 here. + let (v3_mgr, _, v3_mon, _, channel_id) = downgrade_setup_single_splice(); + let chan_id_bytes = channel_id.0; + + // Downgrade node 0 to LDK 0.2 and re-serialize there, stripping the contribution and feerate. + let (v2_mgr, v2_mon); + { + let mut chanmon_cfgs = lightning_0_2_utils::create_chanmon_cfgs(2); + chanmon_cfgs[0].keys_manager.disable_all_state_policy_checks = true; + chanmon_cfgs[1].keys_manager.disable_all_state_policy_checks = true; + let node_cfgs = lightning_0_2_utils::create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = lightning_0_2_utils::create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = lightning_0_2_utils::create_network(2, &node_cfgs, &node_chanmgrs); + let mut config = lightning_0_2_utils::test_default_channel_config(); + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; + let mgr = lightning_0_2_utils::_reload_node(&nodes[0], config, &v3_mgr, &[&v3_mon[..]]); + assert_eq!(mgr.list_channels().len(), 1); + let v2_channel_id = lightning_0_2::ln::types::ChannelId(chan_id_bytes); + v2_mgr = mgr.encode(); + v2_mon = get_monitor_0_2!(nodes[0], v2_channel_id).encode(); + } + + // Upgrade back to current and splice the channel carrying the inherited splice. + let mut chanmon_cfgs = create_chanmon_cfgs(2); + chanmon_cfgs[0].keys_manager.disable_all_state_policy_checks = true; + chanmon_cfgs[1].keys_manager.disable_all_state_policy_checks = true; + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let (persister, chain_mon, new_node); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let config = test_default_channel_config(); + reload_node!(nodes[0], config, &v2_mgr, &[&v2_mon[..]], persister, chain_mon, new_node); + + let channel_id = ChannelId(chan_id_bytes); + let node_id_1 = nodes[1].node.get_our_node_id(); + + // splice_channel returns a fresh template with no RBF feerate floor rather than refusing. + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + assert!(funding_template.min_rbf_feerate().is_none()); + + // Contributing queues the splice as `WaitingOnLock`: it cannot replace the inherited splice via + // RBF (its feerate and our contribution are absent), so it will be spliced once that splice + // locks. A splice-out needs no wallet funds, letting us drive the queue without connecting + // blocks to the reloaded node. + let outputs = vec![TxOut { + value: Amount::from_sat(1_000), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]; + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); + let channels = nodes[0].node.list_channels(); + let splice = channels[0].splice_details.as_ref().unwrap(); + assert!(matches!( + splice.candidates.last().unwrap().status, + SpliceCandidateStatus::WaitingOnLock, + )); } diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index ce357876c5a..ff0500efcc4 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12948,10 +12948,11 @@ where .as_ref() .map(|n| n.funding_feerate_sat_per_1000_weight()) }); - debug_assert!( - prev_feerate.is_some(), - "pending_splice should have last_funding_feerate or funding_negotiation", - ); + // The feerate and our contribution are only persisted by LDK 0.3+, so their absence + // means this splice was last written by an older version (negotiated there, or + // round-tripped 0.3 -> 0.2 -> 0.3) and cannot be RBF'd. Leave the RBF feerate floor + // unset so the new splice is queued and begins as a fresh splice once the pending + // candidate locks, rather than attempting to replace it. let min_rbf_feerate = prev_feerate.map(min_rbf_feerate); let prior = if pending_splice.last_funding_feerate_sat_per_1000_weight.is_some() { pending_splice.latest_contribution().cloned() From 5137c126580120c028724e24295a645f60a82d15 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz <jkczyz@gmail.com> Date: Tue, 7 Jul 2026 20:49:34 -0500 Subject: [PATCH 591/627] Compute the minimum splice RBF feerate via PendingFunding splice_channel derived the pending splice's minimum RBF feerate by mapping a standalone helper over the prior round's feerate, shadowing the helper's name with the resulting local. Encapsulate the derivation as PendingFunding::min_rbf_feerate, mirroring the FundingTemplate accessor it feeds, and keep the formula as an associated function for the call sites that derive the prior feerate differently. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- lightning/src/ln/channel.rs | 70 ++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 32 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index ff0500efcc4..a2cf254ef88 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -3307,6 +3307,34 @@ impl PendingFunding { } } + /// Returns the minimum feerate for RBF attempts given a previous feerate. + /// + /// The spec (tx_init_rbf) requires the new feerate to be >= the maximum of 25/24 of the + /// previous feerate and the previous feerate + 25 sat/kwu. The flat +25 sat/kwu increment + /// ensures BIP125's relay requirement of an absolute fee increase is satisfied at low feerates + /// where the multiplicative 25/24 rule alone would be insufficient. + fn min_rbf_feerate_above(prev_feerate: u32) -> FeeRate { + let flat_increment = (prev_feerate as u64).saturating_add(25); + let spec_increment = (prev_feerate as u64) * 25 / 24; + FeeRate::from_sat_per_kwu(cmp::max(flat_increment, spec_increment)) + } + + /// The minimum feerate a new contribution must pay to replace the pending splice via RBF, + /// derived from the most recent round's feerate: + /// - `last_funding_feerate_sat_per_1000_weight`: from a completed but unlocked negotiation + /// - the `funding_negotiation` feerate: from an in-progress negotiation + /// + /// Returns `None` when neither feerate is known. The feerate is only persisted by LDK 0.3+, + /// so its absence means the splice was last written by an older version (negotiated there, or + /// round-tripped 0.3 -> 0.2 -> 0.3), in which case the pending splice cannot be RBF'd. + fn min_rbf_feerate(&self) -> Option<FeeRate> { + self.last_funding_feerate_sat_per_1000_weight + .or_else(|| { + self.funding_negotiation.as_ref().map(|n| n.funding_feerate_sat_per_1000_weight()) + }) + .map(Self::min_rbf_feerate_above) + } + /// After several RBF attempts, checks that the feerate is high enough to confirm. Returns /// `true` if the feerate is sufficient or the threshold hasn't been reached. /// @@ -7153,18 +7181,6 @@ pub(crate) fn get_v2_channel_reserve_satoshis( Ok(cmp::max(q, dust_limit_satoshis)) } -/// Returns the minimum feerate for RBF attempts given a previous feerate. -/// -/// The spec (tx_init_rbf) requires the new feerate to be >= the maximum of 25/24 of the previous -/// feerate and the previous feerate + 25 sat/kwu. The flat +25 sat/kwu increment ensures BIP125's -/// relay requirement of an absolute fee increase is satisfied at low feerates where the -/// multiplicative 25/24 rule alone would be insufficient. -fn min_rbf_feerate(prev_feerate: u32) -> FeeRate { - let flat_increment = (prev_feerate as u64).saturating_add(25); - let spec_increment = (prev_feerate as u64) * 25 / 24; - FeeRate::from_sat_per_kwu(cmp::max(flat_increment, spec_increment)) -} - /// Context for negotiating channels (dual-funded V2 open, splicing) #[derive(Debug)] pub(super) struct FundingNegotiationContext { @@ -12933,27 +12949,17 @@ where } else if let Some(pending_splice) = self.pending_splice.as_ref() { // A splice is pending — either a completed negotiation that hasn't locked yet // or an in-progress negotiation. In either case, the user's splice will need - // to satisfy the minimum RBF feerate, derived from the most recent feerate: - // - last_funding_feerate: from a completed but unlocked negotiation - // - funding_negotiation feerate: from an in-progress negotiation + // to satisfy the minimum RBF feerate. When that feerate is unknown (the splice + // was last written by an LDK version prior to 0.3, which persisted neither it nor + // our contribution), the minimum RBF feerate is left unset so the new splice is + // queued and begins as a fresh splice once the pending candidate locks, rather than + // attempting to replace it. // - // If the in-progress negotiation later fails (e.g., tx_abort), the derived + // If an in-progress negotiation later fails (e.g., tx_abort), the derived // min_rbf_feerate becomes stale, causing a slightly higher feerate than // necessary. Call splice_channel again after receiving SpliceNegotiationFailed to get a // fresh template without the stale RBF constraint. - let prev_feerate = - pending_splice.last_funding_feerate_sat_per_1000_weight.or_else(|| { - pending_splice - .funding_negotiation - .as_ref() - .map(|n| n.funding_feerate_sat_per_1000_weight()) - }); - // The feerate and our contribution are only persisted by LDK 0.3+, so their absence - // means this splice was last written by an older version (negotiated there, or - // round-tripped 0.3 -> 0.2 -> 0.3) and cannot be RBF'd. Leave the RBF feerate floor - // unset so the new splice is queued and begins as a fresh splice once the pending - // candidate locks, rather than attempting to replace it. - let min_rbf_feerate = prev_feerate.map(min_rbf_feerate); + let min_rbf_feerate = pending_splice.min_rbf_feerate(); let prior = if pending_splice.last_funding_feerate_sat_per_1000_weight.is_some() { pending_splice.latest_contribution().cloned() } else { @@ -13022,7 +13028,7 @@ where None => return false, }, }; - contribution.feerate() >= min_rbf_feerate(prev_feerate) + contribution.feerate() >= PendingFunding::min_rbf_feerate_above(prev_feerate) } fn can_initiate_rbf(&self) -> Result<FeeRate, String> { @@ -13067,7 +13073,7 @@ where } match pending_splice.last_funding_feerate_sat_per_1000_weight { - Some(prev_feerate) => Ok(min_rbf_feerate(prev_feerate)), + Some(prev_feerate) => Ok(PendingFunding::min_rbf_feerate_above(prev_feerate)), None => Err(format!( "Channel {} has no prior feerate to compute RBF minimum", self.context.channel_id(), @@ -13741,7 +13747,7 @@ where fee_estimator.bounded_sat_per_1000_weight(ConfirmationTarget::UrgentOnChainSweep) }); let new_feerate = FeeRate::from_sat_per_kwu(msg.feerate_sat_per_1000_weight as u64); - if new_feerate < min_rbf_feerate(prev_feerate) { + if new_feerate < PendingFunding::min_rbf_feerate_above(prev_feerate) { return Err(ChannelError::Abort(AbortReason::InsufficientRbfFeerate)); } From 0af1ebbb1a4eddbeb86662cd53e3c1105b24c783 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz <jkczyz@gmail.com> Date: Thu, 18 Jun 2026 17:47:10 -0500 Subject: [PATCH 592/627] Add pending changelog entry for PR 4687 --- .../4687-pending-splice-details.txt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 pending_changelog/4687-pending-splice-details.txt diff --git a/pending_changelog/4687-pending-splice-details.txt b/pending_changelog/4687-pending-splice-details.txt new file mode 100644 index 00000000000..7b1ea2ca8ef --- /dev/null +++ b/pending_changelog/4687-pending-splice-details.txt @@ -0,0 +1,18 @@ +# API Updates + + * `ChannelDetails` now has a `splice_details` field + (`Option<SpliceDetails>`) reporting any pending splice attempts on a channel. + Each splice or RBF round is reported as a `SpliceCandidateDetails` in + `SpliceDetails::candidates`, with the stage it has reached given by + `SpliceCandidateStatus` (spanning a contribution committed via + `ChannelManager::funding_contributed` but not yet negotiating, the in-flight + negotiation, and a negotiated candidate awaiting confirmation). `SpliceDetails` + also reports the confirmed candidate's progress (`ConfirmedSpliceCandidate`) + and the txid of any `splice_locked` received from the counterparty. + +# Backwards Compatibility + + * A pending splice negotiated before upgrading from a prior LDK version (e.g. + 0.2) cannot be RBF'd, as older versions persisted neither its feerate nor our + contribution. Splicing such a channel instead queues a new splice that begins + once the inherited splice locks. From 5cd499e0600ae6e7c2513669517303e23c8b1519 Mon Sep 17 00:00:00 2001 From: Matt Corallo <git@bluematt.me> Date: Thu, 9 Jul 2026 16:20:12 +0000 Subject: [PATCH 593/627] Bump esplora-client to 0.13 to switch from `reqwest` to `bitreq` ...fixing MSRV builds --- ci/ci-tests-common.sh | 6 ------ lightning-transaction-sync/Cargo.toml | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/ci/ci-tests-common.sh b/ci/ci-tests-common.sh index a14928d3a35..f5313f701b0 100755 --- a/ci/ci-tests-common.sh +++ b/ci/ci-tests-common.sh @@ -17,12 +17,6 @@ PIN_RELEASE_DEPS # pin the release dependencies in our main workspace # The backtrace v0.3.75 crate relies on rustc 1.82 [ "$RUSTC_MINOR_VERSION" -lt 82 ] && cargo update -p backtrace --precise "0.3.74" --quiet -# Starting with version 1.2.0, the `idna_adapter` crate has an MSRV of rustc 1.81.0. -[ "$RUSTC_MINOR_VERSION" -lt 81 ] && cargo update -p idna_adapter --precise "1.1.0" --quiet - -# Starting with version 0.27.8, the `hyper-rustls` crate has an MSRV of rustc 1.85.0. -[ "$RUSTC_MINOR_VERSION" -lt 85 ] && cargo update -p hyper-rustls --precise "0.27.7" --quiet - # Starting with version 1.9.0, the `zeroize` crate uses Rust 2024. [ "$RUSTC_MINOR_VERSION" -lt 85 ] && cargo update -p zeroize --precise "1.8.2" --quiet diff --git a/lightning-transaction-sync/Cargo.toml b/lightning-transaction-sync/Cargo.toml index d504cd239f2..077ac2c4405 100644 --- a/lightning-transaction-sync/Cargo.toml +++ b/lightning-transaction-sync/Cargo.toml @@ -37,7 +37,7 @@ lightning = { version = "0.3.0", path = "../lightning", default-features = false lightning-macros = { version = "0.2", path = "../lightning-macros", default-features = false } bitcoin = { version = "0.32.2", default-features = false } futures = { version = "0.3", optional = true } -esplora-client = { version = "0.12", default-features = false, optional = true } +esplora-client = { version = "0.13", default-features = false, optional = true } electrum-client = { version = "0.25", optional = true, default-features = false, features = ["proxy"] } [dev-dependencies] From a4641403eb9fda8fa52985aab58b2cb2856ad3c4 Mon Sep 17 00:00:00 2001 From: Matt Corallo <git@bluematt.me> Date: Thu, 9 Jul 2026 17:18:45 +0000 Subject: [PATCH 594/627] Pin jobserver to 0.1.34 on pre-1.85 rustc --- ci/ci-tests-common.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ci/ci-tests-common.sh b/ci/ci-tests-common.sh index f5313f701b0..2d8956a40ba 100755 --- a/ci/ci-tests-common.sh +++ b/ci/ci-tests-common.sh @@ -20,4 +20,7 @@ PIN_RELEASE_DEPS # pin the release dependencies in our main workspace # Starting with version 1.9.0, the `zeroize` crate uses Rust 2024. [ "$RUSTC_MINOR_VERSION" -lt 85 ] && cargo update -p zeroize --precise "1.8.2" --quiet +# Starting with version 0.1.35, the `jobserver` crate relies on rustc 1.85. +[ "$RUSTC_MINOR_VERSION" -lt 85 ] && cargo update -p jobserver --precise "0.1.34" --quiet + export RUST_BACKTRACE=1 From 56b0c2e76c1134dca6e6745d60983b18281b5384 Mon Sep 17 00:00:00 2001 From: SaidAlaoui <saidalaoui@block.xyz> Date: Thu, 9 Jul 2026 13:38:23 +0100 Subject: [PATCH 595/627] Increase no-channel peer limit --- lightning/src/ln/channelmanager.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 27765f962c6..318b10b1006 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -3317,7 +3317,7 @@ const MAX_PEER_STORAGE_SIZE: usize = 1024; /// The maximum number of peers which we do not have a (funded) channel with. Once we reach this /// many peers we reject new (inbound) connections. -const MAX_NO_CHANNEL_PEERS: usize = 250; +const MAX_NO_CHANNEL_PEERS: usize = 2500; /// Used by [`ChannelManager::list_recent_payments`] to express the status of recent payments. /// These include payments that have yet to find a successful path, or have unresolved HTLCs. From 81a7b82d94b2b09223a02e91220a0ca576cadcde Mon Sep 17 00:00:00 2001 From: elnosh <elnosh@pm.me> Date: Thu, 16 Jul 2026 14:56:10 -0400 Subject: [PATCH 596/627] Remove taproot build check --- .forgejo/workflows/build.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 2a0d9177599..d58d0140f71 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -218,20 +218,12 @@ jobs: run: | rustup default stable - name: Run cargo check for release build. - run: | - cargo check --release - cargo check --no-default-features --features=std --release - cargo doc --release - - name: Run cargo check for Taproot build. run: | cargo check --release cargo check --no-default-features --release cargo check --no-default-features --features=std --release cargo doc --release cargo doc --no-default-features --release - env: - RUSTFLAGS: '--cfg=taproot' - RUSTDOCFLAGS: '--cfg=taproot' check_docs: runs-on: debian-trixie From c37b0dc4e8cd495d045aad595715888dcf3c23f5 Mon Sep 17 00:00:00 2001 From: Matt Corallo <git@bluematt.me> Date: Fri, 17 Jul 2026 18:04:38 +0000 Subject: [PATCH 597/627] Add infrastructure for handling `DNSSECError` onion messages https://github.com/lightning/blips/pull/71 updated the DNSSEC resolution bLIP to include an explicit error message when DNS(SEC) resolution was attempted but failed, allowing for faster fallback to LN-Address (for clients that do) and faster payment failure. Here we simply add the new message framing and empty handlers. Largely writen by an LLM --- lightning-dns-resolver/src/lib.rs | 11 +++- lightning/src/ln/peer_handler.rs | 3 +- lightning/src/onion_message/dns_resolution.rs | 55 ++++++++++++++++++- .../src/onion_message/functional_tests.rs | 3 +- lightning/src/onion_message/messenger.rs | 13 +++++ 5 files changed, 81 insertions(+), 4 deletions(-) diff --git a/lightning-dns-resolver/src/lib.rs b/lightning-dns-resolver/src/lib.rs index 90f1ac01f02..c80269237fa 100644 --- a/lightning-dns-resolver/src/lib.rs +++ b/lightning-dns-resolver/src/lib.rs @@ -14,7 +14,7 @@ use dnssec_prover::query::build_txt_proof_async; use lightning::blinded_path::message::DNSResolverContext; use lightning::ln::peer_handler::IgnoringMessageHandler; use lightning::onion_message::dns_resolution::{ - DNSResolverMessage, DNSResolverMessageHandler, DNSSECProof, DNSSECQuery, + DNSResolverMessage, DNSResolverMessageHandler, DNSSECError, DNSSECProof, DNSSECQuery, }; use lightning::onion_message::messenger::{ MessageSendInstructions, Responder, ResponseInstruction, @@ -103,6 +103,12 @@ impl<PH: DNSResolverMessageHandler> DNSResolverMessageHandler for OMDomainResolv } } + fn handle_dnssec_error(&self, error: DNSSECError, context: DNSResolverContext) { + if let Some(proof_handler) = &self.proof_handler { + proof_handler.handle_dnssec_error(error, context); + } + } + fn handle_dnssec_query( &self, q: DNSSECQuery, responder_opt: Option<Responder>, ) -> Option<(DNSResolverMessage, ResponseInstruction)> { @@ -229,6 +235,9 @@ mod test { core::mem::swap(&mut *self.resolved_uri.lock().unwrap(), &mut result); assert!(result.is_none()); } + fn handle_dnssec_error(&self, msg: DNSSECError, context: DNSResolverContext) { + // TODO + } fn release_pending_messages(&self) -> Vec<(DNSResolverMessage, MessageSendInstructions)> { core::mem::take(&mut *self.pending_messages.lock().unwrap()) } diff --git a/lightning/src/ln/peer_handler.rs b/lightning/src/ln/peer_handler.rs index 2cc2b9c843b..8a983c6d37e 100644 --- a/lightning/src/ln/peer_handler.rs +++ b/lightning/src/ln/peer_handler.rs @@ -35,7 +35,7 @@ use crate::onion_message::async_payments::{ ServeStaticInvoice, StaticInvoicePersisted, }; use crate::onion_message::dns_resolution::{ - DNSResolverMessage, DNSResolverMessageHandler, DNSSECProof, DNSSECQuery, + DNSResolverMessage, DNSResolverMessageHandler, DNSSECError, DNSSECProof, DNSSECQuery, }; use crate::onion_message::messenger::{ CustomOnionMessageHandler, MessageSendInstructions, Responder, ResponseInstruction, @@ -273,6 +273,7 @@ impl DNSResolverMessageHandler for IgnoringMessageHandler { None } fn handle_dnssec_proof(&self, _message: DNSSECProof, _context: DNSResolverContext) {} + fn handle_dnssec_error(&self, _message: DNSSECError, _context: DNSResolverContext) {} } impl CustomOnionMessageHandler for IgnoringMessageHandler { type CustomMessage = Infallible; diff --git a/lightning/src/onion_message/dns_resolution.rs b/lightning/src/onion_message/dns_resolution.rs index 67d91bc99eb..494b1bcfb2a 100644 --- a/lightning/src/onion_message/dns_resolution.rs +++ b/lightning/src/onion_message/dns_resolution.rs @@ -77,6 +77,19 @@ pub trait DNSResolverMessageHandler { /// [`OnionMessenger`]: crate::onion_message::messenger::OnionMessenger fn handle_dnssec_proof(&self, message: DNSSECProof, context: DNSResolverContext); + /// Handle a [`DNSSECError`] message (in response to a [`DNSSECQuery`] we presumably sent), + /// indicating that the resolver was unable to resolve the requested name. + /// + /// The provided [`DNSResolverContext`] was authenticated by the [`OnionMessenger`] as coming from + /// a blinded path that we created. + /// + /// Receiving this lets us avoid waiting for a [`DNSSECProof`] which will never come, failing the + /// pending operation early instead (at least if the name is + /// [definitely unresolvable](DNSSECError::definitely_unresolvable)). + /// + /// [`OnionMessenger`]: crate::onion_message::messenger::OnionMessenger + fn handle_dnssec_error(&self, message: DNSSECError, context: DNSResolverContext); + /// Gets the node feature flags which this handler itself supports. Useful for setting the /// `dns_resolver` flag if this handler supports returning [`DNSSECProof`] messages in response /// to [`DNSSECQuery`] messages. @@ -99,6 +112,9 @@ impl<T: DNSResolverMessageHandler + ?Sized, D: Deref<Target = T>> DNSResolverMes fn handle_dnssec_proof(&self, message: DNSSECProof, context: DNSResolverContext) { self.deref().handle_dnssec_proof(message, context) } + fn handle_dnssec_error(&self, message: DNSSECError, context: DNSResolverContext) { + self.deref().handle_dnssec_error(message, context) + } fn provided_node_features(&self) -> NodeFeatures { self.deref().provided_node_features() } @@ -115,10 +131,14 @@ pub enum DNSResolverMessage { DNSSECQuery(DNSSECQuery), /// A response containing a DNSSEC proof DNSSECProof(DNSSECProof), + /// An error in response to a [`DNSSECQuery`], indicating that the requested name could not be + /// resolved into a [`DNSSECProof`]. + DNSSECError(DNSSECError), } const DNSSEC_QUERY_TYPE: u64 = 65536; const DNSSEC_PROOF_TYPE: u64 = 65538; +const DNSSEC_ERROR_TYPE: u64 = 65550; #[derive(Clone, Debug, Hash, PartialEq, Eq)] /// A message which is sent to a DNSSEC prover requesting a DNSSEC proof for the given name. @@ -136,11 +156,30 @@ pub struct DNSSECProof { pub proof: Vec<u8>, } +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +/// A message which is sent in response to a [`DNSSECQuery`] when the resolver was unable to build a +/// [`DNSSECProof`] for the requested name. +/// +/// This lets the recipient stop waiting for a [`DNSSECProof`] which will not be forthcoming. +pub struct DNSSECError { + /// The name which the [`DNSSECQuery`] was for and which we were unable to resolve. + pub name: Name, + /// Whether the name is known to be permanently unresolvable, as opposed to having failed due to + /// some transient error. + /// + /// This is set if the requested name does not exist (i.e. the resolver received an NXDOMAIN + /// response) or if the name is not in a DNSSEC-signed zone, in which case retrying or querying a + /// different resolver will not help. It is not set for transient failures (e.g. a timeout + /// communicating with an upstream DNS server), where a retry or a different resolver may yet + /// succeed. + pub definitely_unresolvable: bool, +} + impl DNSResolverMessage { /// Returns whether `tlv_type` corresponds to a TLV record for DNS Resolvers. pub fn is_known_type(tlv_type: u64) -> bool { match tlv_type { - DNSSEC_QUERY_TYPE | DNSSEC_PROOF_TYPE => true, + DNSSEC_QUERY_TYPE | DNSSEC_PROOF_TYPE | DNSSEC_ERROR_TYPE => true, _ => false, } } @@ -158,6 +197,11 @@ impl Writeable for DNSResolverMessage { w.write_all(&name.as_str().as_bytes())?; proof.write(w) }, + Self::DNSSECError(DNSSECError { name, definitely_unresolvable }) => { + (name.as_str().len() as u8).write(w)?; + w.write_all(&name.as_str().as_bytes())?; + definitely_unresolvable.write(w) + }, } } } @@ -176,6 +220,12 @@ impl ReadableArgs<u64> for DNSResolverMessage { let proof = Readable::read(r)?; Ok(DNSResolverMessage::DNSSECProof(DNSSECProof { name, proof })) }, + DNSSEC_ERROR_TYPE => { + let s = Hostname::read(r)?; + let name = s.try_into().map_err(|_| DecodeError::InvalidValue)?; + let definitely_unresolvable = Readable::read(r)?; + Ok(DNSResolverMessage::DNSSECError(DNSSECError { name, definitely_unresolvable })) + }, _ => Err(DecodeError::InvalidValue), } } @@ -187,6 +237,7 @@ impl OnionMessageContents for DNSResolverMessage { match self { DNSResolverMessage::DNSSECQuery(_) => "DNS(SEC) Query".to_string(), DNSResolverMessage::DNSSECProof(_) => "DNSSEC Proof".to_string(), + DNSResolverMessage::DNSSECError(_) => "DNSSEC Error".to_string(), } } #[cfg(not(c_bindings))] @@ -194,12 +245,14 @@ impl OnionMessageContents for DNSResolverMessage { match self { DNSResolverMessage::DNSSECQuery(_) => "DNS(SEC) Query", DNSResolverMessage::DNSSECProof(_) => "DNSSEC Proof", + DNSResolverMessage::DNSSECError(_) => "DNSSEC Error", } } fn tlv_type(&self) -> u64 { match self { DNSResolverMessage::DNSSECQuery(_) => DNSSEC_QUERY_TYPE, DNSResolverMessage::DNSSECProof(_) => DNSSEC_PROOF_TYPE, + DNSResolverMessage::DNSSECError(_) => DNSSEC_ERROR_TYPE, } } } diff --git a/lightning/src/onion_message/functional_tests.rs b/lightning/src/onion_message/functional_tests.rs index 4adc126f4fd..94536e068b4 100644 --- a/lightning/src/onion_message/functional_tests.rs +++ b/lightning/src/onion_message/functional_tests.rs @@ -14,7 +14,7 @@ use super::async_payments::{ ServeStaticInvoice, StaticInvoicePersisted, }; use super::dns_resolution::{ - DNSResolverMessage, DNSResolverMessageHandler, DNSSECProof, DNSSECQuery, + DNSResolverMessage, DNSResolverMessageHandler, DNSSECError, DNSSECProof, DNSSECQuery, }; use super::messenger::{ CustomOnionMessageHandler, DefaultMessageRouter, Destination, MessageSendInstructions, @@ -155,6 +155,7 @@ impl DNSResolverMessageHandler for TestDNSResolverMessageHandler { None } fn handle_dnssec_proof(&self, _message: DNSSECProof, _context: DNSResolverContext) {} + fn handle_dnssec_error(&self, _message: DNSSECError, _context: DNSResolverContext) {} } #[derive(Clone, Debug, PartialEq)] diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index 04697d9854b..a434e5739e2 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -2309,6 +2309,19 @@ impl< }; self.dns_resolver_handler.handle_dnssec_proof(msg, context); }, + DNSResolverMessage::DNSSECError(msg) => { + let context = match context { + Some(ctx) => ctx, + None => { + log_trace!( + logger, + "Ignoring DNSSECError onion message due to missing context" + ); + return; + }, + }; + self.dns_resolver_handler.handle_dnssec_error(msg, context); + }, } }, Ok(PeeledOnion::Custom(message, context, reply_path)) => { From a705abdb8fc6c176e4336dc609dfdaf8a78162f8 Mon Sep 17 00:00:00 2001 From: Matt Corallo <git@bluematt.me> Date: Fri, 17 Jul 2026 18:55:32 +0000 Subject: [PATCH 598/627] Handle `DNSSECError` messages when attempting DNSSEC resolution https://github.com/lightning/blips/pull/71 updated the DNSSEC resolution bLIP to include an explicit error message when DNS(SEC) resolution was attempted but failed, allowing for faster fallback to LN-Address (for clients that do) and faster payment failure. Here we add client-side support for the new message, accepting `DNSSECError` messages and marking payments as failed when all our queries have failed. Largely writen by an LLM --- lightning-dns-resolver/src/lib.rs | 52 +---- lightning/src/onion_message/dns_resolution.rs | 211 ++++++++++++++++-- 2 files changed, 205 insertions(+), 58 deletions(-) diff --git a/lightning-dns-resolver/src/lib.rs b/lightning-dns-resolver/src/lib.rs index c80269237fa..c89849e3f36 100644 --- a/lightning-dns-resolver/src/lib.rs +++ b/lightning-dns-resolver/src/lib.rs @@ -273,8 +273,6 @@ mod test { #[tokio::test] async fn resolution_test() { - let secp_ctx = Secp256k1::new(); - let (resolver_messenger, resolver_id) = create_resolver(); let resolver_dest = Destination::Node(resolver_id); @@ -307,25 +305,11 @@ mod test { payer_messenger.peer_connected(resolver_id, &init_msg, true).unwrap(); resolver_messenger.get_om().peer_connected(payer_id, &init_msg, false).unwrap(); - let (msg, context) = - payer.resolver.resolve_name(payment_id, name.clone(), &*payer_keys).unwrap(); - let query_context = MessageContext::DNSResolver(context); - let receive_key = payer_keys.get_receive_auth_key(); - let reply_path = BlindedMessagePath::one_hop( - payer_id, - receive_key, - query_context, - false, - &*payer_keys, - &secp_ctx, - ); - payer.pending_messages.lock().unwrap().push(( - DNSResolverMessage::DNSSECQuery(msg), - MessageSendInstructions::WithSpecifiedReplyPath { - destination: resolver_dest, - reply_path, - }, - )); + let messages = payer + .resolver + .resolve_name(payment_id, name.clone(), vec![resolver_dest], &*payer_keys) + .unwrap(); + payer.pending_messages.lock().unwrap().extend(messages); let query = payer_messenger.next_onion_message_for_peer(resolver_id).unwrap(); resolver_messenger.get_om().handle_onion_message(payer_id, &query); @@ -351,8 +335,6 @@ mod test { async fn failed_query_does_not_leak_pending_counter() { use std::sync::atomic::Ordering; - let secp_ctx = Secp256k1::new(); - // Resolver points at a port that should refuse TCP, so build_txt_proof_async // returns Err quickly. let resolver_keys = Arc::new(KeysManager::new(&[99; 32], 42, 43, true)); @@ -405,25 +387,11 @@ mod test { payer_messenger.peer_connected(resolver_id, &init_msg, true).unwrap(); resolver_messenger.peer_connected(payer_id, &init_msg, false).unwrap(); - let (msg, context) = - payer.resolver.resolve_name(payment_id, name.clone(), &*payer_keys).unwrap(); - let query_context = MessageContext::DNSResolver(context); - let receive_key = payer_keys.get_receive_auth_key(); - let reply_path = BlindedMessagePath::one_hop( - payer_id, - receive_key, - query_context, - false, - &*payer_keys, - &secp_ctx, - ); - payer.pending_messages.lock().unwrap().push(( - DNSResolverMessage::DNSSECQuery(msg), - MessageSendInstructions::WithSpecifiedReplyPath { - destination: resolver_dest, - reply_path, - }, - )); + let messages = payer + .resolver + .resolve_name(payment_id, name.clone(), vec![resolver_dest], &*payer_keys) + .unwrap(); + payer.pending_messages.lock().unwrap().extend(messages); let query = payer_messenger.next_onion_message_for_peer(resolver_id).unwrap(); resolver_messenger.handle_onion_message(payer_id, &query); diff --git a/lightning/src/onion_message/dns_resolution.rs b/lightning/src/onion_message/dns_resolution.rs index 494b1bcfb2a..1842751f36f 100644 --- a/lightning/src/onion_message/dns_resolution.rs +++ b/lightning/src/onion_message/dns_resolution.rs @@ -40,12 +40,16 @@ use core::fmt; use core::ops::Deref; use crate::blinded_path::message::DNSResolverContext; +#[cfg(feature = "dnssec")] +use crate::blinded_path::message::MessageContext; use crate::io; #[cfg(feature = "dnssec")] use crate::ln::channelmanager::PaymentId; use crate::ln::msgs::DecodeError; #[cfg(feature = "dnssec")] use crate::offers::offer::Offer; +#[cfg(feature = "dnssec")] +use crate::onion_message::messenger::Destination; use crate::onion_message::messenger::{MessageSendInstructions, Responder, ResponseInstruction}; use crate::onion_message::packet::OnionMessageContents; use crate::prelude::*; @@ -372,7 +376,7 @@ impl fmt::Display for HumanReadableName { #[cfg(feature = "dnssec")] struct PendingResolution { start_height: u32, - context: DNSResolverContext, + pending_query_contexts: Vec<DNSResolverContext>, name: HumanReadableName, payment_id: PaymentId, } @@ -462,26 +466,44 @@ impl OMNameResolver { /// Begins the process of resolving a BIP 353 Human Readable Name. /// - /// Returns a [`DNSSECQuery`] onion message and a [`DNSResolverContext`] which should be sent - /// to a resolver (with the context used to generate the blinded response path) on success. + /// Returns a list of [`DNSSECQuery`] onion messages and the [`MessageSendInstructions`] over + /// which each should be sent - one entry per provided `destination`. pub fn resolve_name<ES: EntropySource + ?Sized>( - &self, payment_id: PaymentId, name: HumanReadableName, entropy_source: &ES, - ) -> Result<(DNSSECQuery, DNSResolverContext), ()> { + &self, payment_id: PaymentId, name: HumanReadableName, destinations: Vec<Destination>, + entropy_source: &ES, + ) -> Result<Vec<(DNSResolverMessage, MessageSendInstructions)>, ()> { + if destinations.is_empty() { + return Err(()); + } + let dns_name = Name::try_from(format!("{}.user._bitcoin-payment.{}.", name.user(), name.domain())); debug_assert!( dns_name.is_ok(), "The HumanReadableName constructor shouldn't allow names which are too long" ); - let mut context = DNSResolverContext { nonce: [0; 16] }; - context.nonce.copy_from_slice(&entropy_source.get_secure_random_bytes()[..16]); if let Ok(dns_name) = dns_name { let start_height = self.latest_block_height.load(Ordering::Acquire) as u32; + let query = DNSResolverMessage::DNSSECQuery(DNSSECQuery(dns_name.clone())); + let mut pending_query_contexts = Vec::with_capacity(destinations.len()); + let messages = destinations + .into_iter() + .map(|destination| { + let mut context = DNSResolverContext { nonce: [0; 16] }; + context.nonce.copy_from_slice(&entropy_source.get_secure_random_bytes()[..16]); + pending_query_contexts.push(context.clone()); + let instructions = MessageSendInstructions::WithReplyPath { + destination, + context: MessageContext::DNSResolver(context), + }; + (query.clone(), instructions) + }) + .collect(); let mut pending_resolves = self.pending_resolves.lock().unwrap(); - let context_ret = context.clone(); - let resolution = PendingResolution { start_height, context, name, payment_id }; - pending_resolves.entry(dns_name.clone()).or_insert_with(Vec::new).push(resolution); - Ok((DNSSECQuery(dns_name), context_ret)) + let resolution = + PendingResolution { start_height, pending_query_contexts, name, payment_id }; + pending_resolves.entry(dns_name).or_insert_with(Vec::new).push(resolution); + Ok(messages) } else { Err(()) } @@ -537,7 +559,7 @@ impl OMNameResolver { let DNSSECProof { name: answer_name, proof } = msg; let mut pending_resolves = self.pending_resolves.lock().unwrap(); if let hash_map::Entry::Occupied(entry) = pending_resolves.entry(answer_name) { - if !entry.get().iter().any(|query| query.context == context) { + if !entry.get().iter().any(|query| query.pending_query_contexts.contains(&context)) { // If we don't have any pending queries with the context included in the blinded // path (implying someone sent us this response not using the blinded path we gave // when making the query), return immediately to avoid the extra time for the proof @@ -607,12 +629,81 @@ impl OMNameResolver { } None } + + /// Handles a [`DNSSECError`] message, indicating that one of the resolvers we sent a + /// [`DNSSECQuery`] to was unable to provide a [`DNSSECProof`] for the requested name. + /// + /// A resolution will be considered failed once we have received a [`DNSSECError`] for all the + /// queries we made for it, as a [`DNSSECProof`] may still arrive from one of the other + /// resolvers we queried. When a resolution does fail, its [`HumanReadableName`] and + /// [`PaymentId`] (as passed to [`Self::resolve_name`]) are included in the returned list. + /// + /// As with [`Self::handle_dnssec_proof_for_uri`], the [`DNSResolverContext`] is checked against + /// the contexts of any pending resolutions for the name to ensure the error was received over a + /// blinded path we created when making the relevant [`DNSSECQuery`]. + pub fn handle_dnssec_error( + &self, msg: DNSSECError, context: DNSResolverContext, + ) -> Vec<(HumanReadableName, PaymentId)> { + let DNSSECError { name, .. } = msg; + let mut failed_resolutions = Vec::new(); + let mut pending_resolves = self.pending_resolves.lock().unwrap(); + if let hash_map::Entry::Occupied(mut entry) = pending_resolves.entry(name) { + entry.get_mut().retain_mut(|resolution| { + // Drop the context matching the blinded path this error was received over, if + // any. If no contexts match (including because a previous error already removed + // this context), the error does not pertain to this resolution and it is left + // untouched. + resolution.pending_query_contexts.retain(|c| *c != context); + if resolution.pending_query_contexts.is_empty() { + failed_resolutions.push((resolution.name, resolution.payment_id)); + false + } else { + true + } + }); + if entry.get().is_empty() { + entry.remove(); + } + } + failed_resolutions + } } #[cfg(test)] mod tests { use super::*; + #[cfg(feature = "dnssec")] + use crate::util::test_utils::pubkey; + + #[cfg(feature = "dnssec")] + fn dest(b: u8) -> Destination { + Destination::Node(pubkey(b)) + } + + /// Extracts the DNS [`Name`] and the per-query [`DNSResolverContext`]s from the messages + /// returned by [`OMNameResolver::resolve_name`]. + #[cfg(feature = "dnssec")] + fn dns_name_and_contexts( + messages: &[(DNSResolverMessage, MessageSendInstructions)], + ) -> (Name, Vec<DNSResolverContext>) { + let name = match &messages[0] { + (DNSResolverMessage::DNSSECQuery(DNSSECQuery(name)), _) => name.clone(), + _ => panic!("Unexpected resolve_name output"), + }; + let contexts = messages + .iter() + .map(|(_, instructions)| match instructions { + MessageSendInstructions::WithReplyPath { + context: MessageContext::DNSResolver(context), + .. + } => context.clone(), + _ => panic!("Unexpected resolve_name output"), + }) + .collect(); + (name, contexts) + } + #[test] fn test_hrn_display_format() { let user = "user"; @@ -651,6 +742,21 @@ mod tests { assert!(HumanReadableName::new("user", &huge_domain).is_err()); } + #[test] + fn test_dnssec_error_roundtrip() { + let name = Name::try_from("test.user._bitcoin-payment.example.com.".to_owned()).unwrap(); + for definitely_unresolvable in [false, true] { + let msg = DNSResolverMessage::DNSSECError(DNSSECError { + name: name.clone(), + definitely_unresolvable, + }); + let mut buf = Vec::new(); + msg.write(&mut buf).unwrap(); + let read = DNSResolverMessage::read(&mut &buf[..], DNSSEC_ERROR_TYPE).unwrap(); + assert_eq!(msg, read); + } + } + #[test] #[cfg(feature = "dnssec")] fn test_expiry() { @@ -659,22 +765,22 @@ mod tests { let name = HumanReadableName::new("user", "example.com").unwrap(); // Queue up a resolution - resolver.resolve_name(PaymentId([0; 32]), name.clone(), &keys).unwrap(); + resolver.resolve_name(PaymentId([0; 32]), name.clone(), vec![dest(42)], &keys).unwrap(); assert_eq!(resolver.pending_resolves.lock().unwrap().len(), 1); // and check that it expires after two blocks resolver.new_best_block(44, 42); assert_eq!(resolver.pending_resolves.lock().unwrap().len(), 0); // Queue up another resolution - resolver.resolve_name(PaymentId([1; 32]), name.clone(), &keys).unwrap(); + resolver.resolve_name(PaymentId([1; 32]), name.clone(), vec![dest(42)], &keys).unwrap(); assert_eq!(resolver.pending_resolves.lock().unwrap().len(), 1); // it won't expire after one block resolver.new_best_block(45, 42); assert_eq!(resolver.pending_resolves.lock().unwrap().len(), 1); assert_eq!(resolver.pending_resolves.lock().unwrap().iter().next().unwrap().1.len(), 1); // and queue up a second and third resolution of the same name - resolver.resolve_name(PaymentId([2; 32]), name.clone(), &keys).unwrap(); - resolver.resolve_name(PaymentId([3; 32]), name.clone(), &keys).unwrap(); + resolver.resolve_name(PaymentId([2; 32]), name.clone(), vec![dest(42)], &keys).unwrap(); + resolver.resolve_name(PaymentId([3; 32]), name.clone(), vec![dest(42)], &keys).unwrap(); assert_eq!(resolver.pending_resolves.lock().unwrap().len(), 1); assert_eq!(resolver.pending_resolves.lock().unwrap().iter().next().unwrap().1.len(), 3); // after another block the first will expire, but the second and third won't @@ -689,4 +795,77 @@ mod tests { resolver.new_best_block(47, 42); assert_eq!(resolver.pending_resolves.lock().unwrap().len(), 0); } + + #[test] + #[cfg(feature = "dnssec")] + fn test_dnssec_error() { + let keys = crate::sign::KeysManager::new(&[33; 32], 0, 0, true); + let resolver = OMNameResolver::new(42, 42); + let name = HumanReadableName::new("user", "example.com").unwrap(); + + // Resolve a name, sending the query to two resolvers. Each query gets its own unique + // context in its reply path. + let messages = resolver + .resolve_name(PaymentId([0; 32]), name.clone(), vec![dest(1), dest(2)], &keys) + .unwrap(); + assert_eq!(messages.len(), 2); + let (dns_name, contexts) = dns_name_and_contexts(&messages); + assert_ne!(contexts[0], contexts[1]); + assert_eq!(resolver.pending_resolves.lock().unwrap().len(), 1); + + // An error whose context doesn't match any pending query is ignored entirely, even if it + // claims the name is unresolvable. + let mut wrong_context = contexts[0].clone(); + wrong_context.nonce[0] ^= 0x01; + let wrong = DNSSECError { name: dns_name.clone(), definitely_unresolvable: true }; + assert!(resolver.handle_dnssec_error(wrong, wrong_context).is_empty()); + assert_eq!(resolver.pending_resolves.lock().unwrap().len(), 1); + + // An error over the first query's reply path fails that query but not the resolution + // itself - even though `definitely_unresolvable` is set - as a proof may yet arrive from + // the other query. + let err = DNSSECError { name: dns_name.clone(), definitely_unresolvable: true }; + assert!(resolver.handle_dnssec_error(err, contexts[0].clone()).is_empty()); + assert_eq!(resolver.pending_resolves.lock().unwrap().len(), 1); + + // A duplicate error over the same reply path is ignored - the first query's context was + // already dropped, so a single misbehaving resolver cannot fail the whole resolution. + let dup = DNSSECError { name: dns_name.clone(), definitely_unresolvable: true }; + assert!(resolver.handle_dnssec_error(dup, contexts[0].clone()).is_empty()); + assert_eq!(resolver.pending_resolves.lock().unwrap().len(), 1); + + // An error over the second query's reply path fails the last outstanding query, so the + // resolution now fails - even though this error only indicates a transient failure. + let err = DNSSECError { name: dns_name, definitely_unresolvable: false }; + let failed = resolver.handle_dnssec_error(err, contexts[1].clone()); + assert_eq!(failed, vec![(name, PaymentId([0; 32]))]); + assert_eq!(resolver.pending_resolves.lock().unwrap().len(), 0); + } + + #[test] + #[cfg(feature = "dnssec")] + fn test_dnssec_error_only_fails_matching_resolution() { + // An error only counts against the resolution whose blinded path (context) it was received + // over; other resolutions for the same name (queued with a different `PaymentId`, and thus a + // different context) are left untouched. + let keys = crate::sign::KeysManager::new(&[33; 32], 0, 0, true); + let resolver = OMNameResolver::new(42, 42); + let name = HumanReadableName::new("user", "example.com").unwrap(); + + let messages = + resolver.resolve_name(PaymentId([0; 32]), name.clone(), vec![dest(1)], &keys).unwrap(); + let (dns_name, contexts_a) = dns_name_and_contexts(&messages); + resolver.resolve_name(PaymentId([1; 32]), name.clone(), vec![dest(2)], &keys).unwrap(); + assert_eq!(resolver.pending_resolves.lock().unwrap().iter().next().unwrap().1.len(), 2); + + // A single error over payment 0's reply path fails only its (single-query) resolution. + let err = DNSSECError { name: dns_name, definitely_unresolvable: false }; + let failed = resolver.handle_dnssec_error(err, contexts_a[0].clone()); + assert_eq!(failed, vec![(name, PaymentId([0; 32]))]); + + // Payment 1's resolution is still pending. + let pending = resolver.pending_resolves.lock().unwrap(); + assert_eq!(pending.iter().next().unwrap().1.len(), 1); + assert_eq!(pending.iter().next().unwrap().1[0].payment_id, PaymentId([1; 32])); + } } From feb838dab1e9d453a17972e0338a6ec713f0e3cf Mon Sep 17 00:00:00 2001 From: Joost Jager <joost.jager@gmail.com> Date: Tue, 14 Jul 2026 12:05:30 +0200 Subject: [PATCH 599/627] Expose sources for pending outbound HTLCs Pending outbound HTLC details do not identify which payment or inbound HTLC produced them. A payment hash cannot provide that mapping when multiple parts of a multipart payment share both a hash and route. Expose an optional source enum containing the payment ID for locally initiated HTLCs, the inbound HTLC for normal forwards, or all inbound HTLCs aggregated by a trampoline forward. Derive it from the existing HTLC source for committed and holding-cell HTLCs. Serialize the source as an optional upgradable TLV so older data remains readable and unknown future source variants degrade to None. --- lightning/src/ln/channel.rs | 4 +- lightning/src/ln/channel_state.rs | 68 +++++++++++++++++++++++++- lightning/src/ln/channelmanager.rs | 22 ++++++++- lightning/src/ln/functional_tests.rs | 71 +++++++++++++++++++++++++++- 4 files changed, 160 insertions(+), 5 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index a4e79df5299..03a193276ea 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -6543,7 +6543,6 @@ impl<SP: SignerProvider> ChannelContext<SP> { #[rustfmt::skip] pub fn get_pending_outbound_htlc_details(&self, funding: &FundingScope) -> Vec<OutboundHTLCDetails> { let mut outbound_details = Vec::new(); - let dust_buffer_feerate = self.get_dust_buffer_feerate(None); let (_, htlc_timeout_tx_fee_sat) = second_stage_tx_fees_sat( funding.get_channel_type(), dust_buffer_feerate, @@ -6558,6 +6557,7 @@ impl<SP: SignerProvider> ChannelContext<SP> { skimmed_fee_msat: htlc.skimmed_fee_msat, state: Some((&htlc.state).into()), is_dust: htlc.amount_msat / 1000 < holder_dust_limit_timeout_sat, + source: Some(htlc.source.to_outbound()), }); } for holding_cell_update in self.holding_cell_htlc_updates.iter() { @@ -6566,6 +6566,7 @@ impl<SP: SignerProvider> ChannelContext<SP> { cltv_expiry, payment_hash, skimmed_fee_msat, + ref source, .. } = *holding_cell_update { outbound_details.push(OutboundHTLCDetails{ @@ -6576,6 +6577,7 @@ impl<SP: SignerProvider> ChannelContext<SP> { skimmed_fee_msat: skimmed_fee_msat, state: Some(OutboundHTLCStateDetails::AwaitingRemoteRevokeToAdd), is_dust: amount_msat / 1000 < holder_dust_limit_timeout_sat, + source: Some(source.to_outbound()), }); } } diff --git a/lightning/src/ln/channel_state.rs b/lightning/src/ln/channel_state.rs index ea99d4c676b..48379f9e4d0 100644 --- a/lightning/src/ln/channel_state.rs +++ b/lightning/src/ln/channel_state.rs @@ -17,6 +17,7 @@ use bitcoin::Txid; use crate::chain::chaininterface::{FeeEstimator, LowerBoundedFeeEstimator}; use crate::chain::transaction::OutPoint; use crate::ln::channel::Channel; +use crate::ln::channelmanager::PaymentId; use crate::ln::funding::FundingContribution; use crate::ln::types::ChannelId; use crate::sign::SignerProvider; @@ -160,6 +161,52 @@ impl_writeable_tlv_based_enum_upgradable!(OutboundHTLCStateDetails, (6, AwaitingRemoteRevokeToRemoveFailure) => {}, ); +/// Identifies an inbound HTLC. +#[derive(Clone, Debug, PartialEq)] +pub struct InboundHTLCReference { + /// The channel on which the HTLC was received. + pub channel_id: ChannelId, + /// The HTLC ID assigned by the inbound channel. + pub htlc_id: u64, +} + +impl_ser_tlv_based!(InboundHTLCReference, { + (0, channel_id, required), + (2, htlc_id, required), +}); + +/// Describes how an outbound HTLC originated. +#[derive(Clone, Debug, PartialEq)] +pub enum OutboundHTLCSource { + /// A locally initiated payment or probe. + Local { + /// The payment or probe identifier. + payment_id: PaymentId, + }, + /// A forward of a single inbound HTLC. + Forwarded { + /// The inbound HTLC. + inbound_htlc: InboundHTLCReference, + }, + /// A trampoline forward of one or more inbound HTLCs. + TrampolineForwarded { + /// The inbound HTLCs. + inbound_htlcs: Vec<InboundHTLCReference>, + }, +} + +impl_writeable_tlv_based_enum_upgradable!(OutboundHTLCSource, + (0, Local) => { + (0, payment_id, required), + }, + (2, Forwarded) => { + (0, inbound_htlc, required), + }, + (4, TrampolineForwarded) => { + (0, inbound_htlcs, required_vec), + }, +); + /// Exposes details around pending outbound HTLCs. #[derive(Clone, Debug, PartialEq)] pub struct OutboundHTLCDetails { @@ -175,6 +222,10 @@ pub struct OutboundHTLCDetails { /// The block height at which this HTLC expires. pub cltv_expiry: u32, /// The payment hash. + /// + /// A payment hash is not sufficient to correlate HTLCs in a multipart payment because multiple + /// parts sharing a payment hash may traverse the same channel. Use [`Self::source`] to correlate + /// the HTLC with its locally initiated payment or inbound HTLCs. pub payment_hash: PaymentHash, /// The state of the HTLC in the state machine. /// @@ -200,6 +251,12 @@ pub struct OutboundHTLCDetails { /// Note that dust limits are specific to each party. An HTLC can be dust for the local /// commitment transaction but not for the counterparty's commitment transaction and vice versa. pub is_dust: bool, + /// The source of this outbound HTLC. + /// + /// LDK will always fill this field in, but it will be `None` for objects serialized with LDK + /// versions prior to 0.4 or when downgrading to a version that does not understand the source + /// variant. + pub source: Option<OutboundHTLCSource>, } impl_ser_tlv_based!(OutboundHTLCDetails, { @@ -210,6 +267,7 @@ impl_ser_tlv_based!(OutboundHTLCDetails, { (7, state, upgradable_option), (8, skimmed_fee_msat, required), (10, is_dust, required), + (11, source, upgradable_option), }); /// Information needed for constructing an invoice route hint for this channel. @@ -937,8 +995,8 @@ mod tests { ln::{ chan_utils::make_funding_redeemscript, channel_state::{ - InboundHTLCDetails, InboundHTLCStateDetails, OutboundHTLCDetails, - OutboundHTLCStateDetails, + InboundHTLCDetails, InboundHTLCReference, InboundHTLCStateDetails, + OutboundHTLCDetails, OutboundHTLCSource, OutboundHTLCStateDetails, }, types::ChannelId, }, @@ -1014,6 +1072,12 @@ mod tests { state: Some(OutboundHTLCStateDetails::AwaitingRemoteRevokeToAdd), skimmed_fee_msat: Some(42), is_dust: false, + source: Some(OutboundHTLCSource::TrampolineForwarded { + inbound_htlcs: vec![ + InboundHTLCReference { channel_id: ChannelId([5; 32]), htlc_id: 11 }, + InboundHTLCReference { channel_id: ChannelId([6; 32]), htlc_id: 12 }, + ], + }), }], current_dust_exposure_msat: Some(150_000), splice_details: Some(SpliceDetails { diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 27765f962c6..e481513da0e 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -64,7 +64,7 @@ use crate::ln::channel::{ OutboundV1Channel, PendingV2Channel, ReconnectionMsg, ShutdownResult, StfuResponse, UpdateFulfillCommitFetch, WithChannelContext, }; -use crate::ln::channel_state::ChannelDetails; +use crate::ln::channel_state::{ChannelDetails, InboundHTLCReference, OutboundHTLCSource}; use crate::ln::funding::{FundingContribution, FundingTemplate}; use crate::ln::inbound_payment; use crate::ln::interactivetxs::InteractiveTxMessageSend; @@ -913,6 +913,26 @@ mod fuzzy_channelmanager { } impl HTLCSource { + pub(crate) fn to_outbound(&self) -> OutboundHTLCSource { + let inbound_htlc = |prev_hop: &HTLCPreviousHopData| InboundHTLCReference { + channel_id: prev_hop.channel_id, + htlc_id: prev_hop.htlc_id, + }; + match self { + Self::OutboundRoute { payment_id, .. } => { + OutboundHTLCSource::Local { payment_id: *payment_id } + }, + Self::PreviousHopData(prev_hop) => { + OutboundHTLCSource::Forwarded { inbound_htlc: inbound_htlc(prev_hop) } + }, + Self::TrampolineForward { previous_hop_data, .. } => { + OutboundHTLCSource::TrampolineForwarded { + inbound_htlcs: previous_hop_data.iter().map(inbound_htlc).collect(), + } + }, + } + } + pub fn failure_type( &self, counterparty_node: PublicKey, channel_id: ChannelId, ) -> HTLCHandlingFailureType { diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index 2e2197426ac..fdf092d8efe 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -33,6 +33,7 @@ use crate::ln::channel::{ get_holder_selected_channel_reserve_satoshis, Channel, DISCONNECT_PEER_AWAITING_RESPONSE_TICKS, MIN_CHAN_DUST_LIMIT_SATOSHIS, UNFUNDED_CHANNEL_AGE_LIMIT_TICKS, }; +use crate::ln::channel_state::OutboundHTLCSource; use crate::ln::channelmanager::{ PaymentId, RAACommitmentOrder, BREAKDOWN_TIMEOUT, DISABLE_GOSSIP_TICKS, ENABLE_GOSSIP_TICKS, MIN_CLTV_EXPIRY_DELTA, @@ -3425,6 +3426,7 @@ fn do_test_holding_cell_htlc_add_timeouts(forwarded_htlc: bool) { let sending_node = if forwarded_htlc { &nodes[0] } else { &nodes[1] }; let (route, second_payment_hash, _, second_payment_secret) = get_route_and_payment_hash!(sending_node, nodes[2], 100000); + assert_ne!(second_payment_hash, first_payment_hash); let onion = RecipientOnionFields::secret_only(second_payment_secret, 100000); let id = PaymentId(second_payment_hash.0); sending_node.node.send_payment_with_route(route, second_payment_hash, onion, id).unwrap(); @@ -3439,6 +3441,30 @@ fn do_test_holding_cell_htlc_add_timeouts(forwarded_htlc: bool) { expect_and_process_pending_htlcs(&nodes[1], false); } check_added_monitors(&nodes[1], 0); + if forwarded_htlc { + let channels = nodes[1].node.list_channels(); + let inbound_channel = + channels.iter().find(|details| details.counterparty.node_id == node_a_id).unwrap(); + let outbound_channel = + channels.iter().find(|details| details.counterparty.node_id == node_c_id).unwrap(); + let inbound_htlc = inbound_channel + .pending_inbound_htlcs + .iter() + .find(|details| details.payment_hash == second_payment_hash) + .unwrap(); + let outbound_htlc = outbound_channel + .pending_outbound_htlcs + .iter() + .find(|details| details.payment_hash == second_payment_hash) + .unwrap(); + assert_eq!(outbound_htlc.htlc_id, None); + let inbound_reference = match &outbound_htlc.source { + Some(OutboundHTLCSource::Forwarded { inbound_htlc }) => inbound_htlc, + _ => panic!("Unexpected outbound HTLC source"), + }; + assert_eq!(inbound_reference.channel_id, inbound_channel.channel_id); + assert_eq!(inbound_reference.htlc_id, inbound_htlc.htlc_id); + } connect_blocks(&nodes[1], TEST_FINAL_CLTV - LATENCY_GRACE_PERIOD_BLOCKS); assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); @@ -7219,7 +7245,50 @@ pub fn test_simple_mpp() { route.paths[1].hops[1].short_channel_id = chan_4_id; route.route_params.final_value_msat = 200_000; let paths: &[&[_]] = &[&[&nodes[1], &nodes[3]], &[&nodes[2], &nodes[3]]]; - send_along_route_with_secret(&nodes[0], route, paths, 200_000, payment_hash, payment_secret); + let payment_id = send_along_route_with_secret( + &nodes[0], + route, + paths, + 200_000, + payment_hash, + payment_secret, + ); + + let locally_originated = nodes[0] + .node + .list_channels() + .into_iter() + .flat_map(|channel| channel.pending_outbound_htlcs) + .collect::<Vec<_>>(); + assert_eq!(locally_originated.len(), 2); + assert!(locally_originated + .iter() + .all(|details| { details.source == Some(OutboundHTLCSource::Local { payment_id }) })); + + let node_a_id = nodes[0].node.get_our_node_id(); + let node_d_id = nodes[3].node.get_our_node_id(); + let mut inbound_references = Vec::new(); + for forwarder in [&nodes[1], &nodes[2]] { + let channels = forwarder.node.list_channels(); + let inbound_channel = + channels.iter().find(|details| details.counterparty.node_id == node_a_id).unwrap(); + let outbound_channel = + channels.iter().find(|details| details.counterparty.node_id == node_d_id).unwrap(); + assert_eq!(inbound_channel.pending_inbound_htlcs.len(), 1); + assert_eq!(outbound_channel.pending_outbound_htlcs.len(), 1); + + let outbound_htlc = &outbound_channel.pending_outbound_htlcs[0]; + assert_eq!(outbound_htlc.payment_hash, payment_hash); + let inbound_reference = match &outbound_htlc.source { + Some(OutboundHTLCSource::Forwarded { inbound_htlc }) => inbound_htlc, + _ => panic!("Unexpected outbound HTLC source"), + }; + assert_eq!(inbound_reference.channel_id, inbound_channel.channel_id); + assert_eq!(inbound_reference.htlc_id, inbound_channel.pending_inbound_htlcs[0].htlc_id); + inbound_references.push(inbound_reference.clone()); + } + assert_ne!(inbound_references[0], inbound_references[1]); + claim_payment_along_route(ClaimAlongRouteArgs::new(&nodes[0], paths, payment_preimage)); } From 687b851b6a64f900297a54f471342eed254979aa Mon Sep 17 00:00:00 2001 From: benthecarman <benthecarman@live.com> Date: Thu, 16 Jul 2026 14:35:45 -0500 Subject: [PATCH 600/627] Increase probabilistic scorer defaults 5x Increase the base and historical-liquidity penalties fivefold. This applies to both the fixed and payment-size parts, so their balance stays the same. The router will now pay more to avoid longer routes and routes the scorer considers less likely to work. I tested this using the graph and scorer pulled from my ldk-server. The scorer was kept unchanged during the test. Codex randomly assigned 600 different high-degree nodes to the old or new settings, with 300 probes per setting. The test was balanced across 100 rounds and used amounts of 1,000, 10,000, and 30,000 sats. Only one probe ran at a time. The old settings reached 167/300 targets (55.7%) within 10 seconds. The 5x settings reached 214/300 (71.3%), an improvement of 15.7 percentage points. Results were 69% vs 82% at 1,000 sats, 47% vs 72% at 10,000 sats, and 51% vs 60% at 30,000 sats. Across the 587 targets where both settings found a route, the quoted fee rose by 2.4 sats on average. These probes used one node and did not settle, so they do not guarantee payment success elsewhere. --- lightning/src/routing/scoring.rs | 57 +++++++++++++++++--------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/lightning/src/routing/scoring.rs b/lightning/src/routing/scoring.rs index c1efde341a0..c2d3e51ca1d 100644 --- a/lightning/src/routing/scoring.rs +++ b/lightning/src/routing/scoring.rs @@ -587,7 +587,7 @@ pub struct ProbabilisticScoringFeeParameters { /// (implying scaling all estimated probabilities down by a factor of ~79%) resulted in the /// most accurate total success probabilities. /// - /// Default value: 1,024 msat (i.e. we're willing to pay 1 sat to avoid each additional hop). + /// Default value: 5,120 msat (i.e. we're willing to pay 5.12 sats to avoid each additional hop). /// /// [`historical_liquidity_penalty_multiplier_msat`]: Self::historical_liquidity_penalty_multiplier_msat pub base_penalty_msat: u64, @@ -606,8 +606,8 @@ pub struct ProbabilisticScoringFeeParameters { /// probabilities down by a factor of ~79%) resulted in the most accurate total success /// probabilities. /// - /// Default value: 131,072 msat (i.e. we're willing to pay 0.125bps to avoid each additional - /// hop). + /// Default value: 655,360 msat (i.e. we're willing to pay roughly 6.1 basis points to avoid + /// each additional hop). /// /// [`base_penalty_msat`]: Self::base_penalty_msat /// [`historical_liquidity_penalty_amount_multiplier_msat`]: Self::historical_liquidity_penalty_amount_multiplier_msat @@ -673,8 +673,8 @@ pub struct ProbabilisticScoringFeeParameters { /// track which of several buckets those bounds fall into, exponentially decaying the /// probability of each bucket as new samples are added. /// - /// Default value: 10,000 msat (i.e. willing to pay 1 sat to avoid an 80% probability channel, - /// or 6 sats to avoid a 25% probability channel). + /// Default value: 50,000 msat (i.e. willing to pay 5 sats to avoid an 80% probability channel, + /// or 30 sats to avoid a 25% probability channel). /// /// [`liquidity_penalty_multiplier_msat`]: Self::liquidity_penalty_multiplier_msat pub historical_liquidity_penalty_multiplier_msat: u64, @@ -695,8 +695,8 @@ pub struct ProbabilisticScoringFeeParameters { /// channel, we track which of several buckets those bounds fall into, exponentially decaying /// the probability of each bucket as new samples are added. /// - /// Default value: 1,250 msat (i.e. willing to pay about 0.125 bps per hop to avoid 78% - /// probability channels, or 0.5bps to avoid a 38% probability + /// Default value: 6,250 msat (i.e. willing to pay about 6.4 bps per hop to avoid 78% + /// probability channels, or 25bps to avoid a 38% probability /// channel). /// /// [`liquidity_penalty_amount_multiplier_msat`]: Self::liquidity_penalty_amount_multiplier_msat @@ -715,7 +715,7 @@ pub struct ProbabilisticScoringFeeParameters { /// as this makes balance discovery attacks harder to execute, thereby creating an incentive /// to restrict `htlc_maximum_msat` and improve privacy. /// - /// Default value: 250 msat + /// Default value: 1,250 msat pub anti_probing_penalty_msat: u64, /// This penalty is applied when the total amount flowing over a channel exceeds our current @@ -787,15 +787,15 @@ pub struct ProbabilisticScoringFeeParameters { impl Default for ProbabilisticScoringFeeParameters { fn default() -> Self { Self { - base_penalty_msat: 1024, - base_penalty_amount_multiplier_msat: 131_072, + base_penalty_msat: 5_120, + base_penalty_amount_multiplier_msat: 655_360, liquidity_penalty_multiplier_msat: 0, liquidity_penalty_amount_multiplier_msat: 0, manual_node_penalties: new_hash_map(), - anti_probing_penalty_msat: 250, + anti_probing_penalty_msat: 1_250, considered_impossible_penalty_msat: 1_0000_0000_000, - historical_liquidity_penalty_multiplier_msat: 10_000, - historical_liquidity_penalty_amount_multiplier_msat: 1_250, + historical_liquidity_penalty_multiplier_msat: 50_000, + historical_liquidity_penalty_amount_multiplier_msat: 6_250, linear_success_probability: false, probing_diversity_penalty_msat: 0, } @@ -3630,47 +3630,47 @@ mod tests { info, short_channel_id: 42, }); - assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 42_252); + assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 211_262); let usage = ChannelUsage { effective_capacity: EffectiveCapacity::Total { capacity_msat: 1_950_000_000, htlc_maximum_msat: 1_000 }, ..usage }; - assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 36_005); + assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 180_032); let usage = ChannelUsage { effective_capacity: EffectiveCapacity::Total { capacity_msat: 2_950_000_000, htlc_maximum_msat: 1_000 }, ..usage }; - assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 32_851); + assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 164_259); let usage = ChannelUsage { effective_capacity: EffectiveCapacity::Total { capacity_msat: 3_950_000_000, htlc_maximum_msat: 1_000 }, ..usage }; - assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 30_832); + assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 154_165); let usage = ChannelUsage { effective_capacity: EffectiveCapacity::Total { capacity_msat: 4_950_000_000, htlc_maximum_msat: 1_000 }, ..usage }; - assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 29_886); + assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 149_434); let usage = ChannelUsage { effective_capacity: EffectiveCapacity::Total { capacity_msat: 5_950_000_000, htlc_maximum_msat: 1_000 }, ..usage }; - assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 28_939); + assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 144_702); let usage = ChannelUsage { effective_capacity: EffectiveCapacity::Total { capacity_msat: 6_950_000_000, htlc_maximum_msat: 1_000 }, ..usage }; - assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 28_435); + assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 142_178); let usage = ChannelUsage { effective_capacity: EffectiveCapacity::Total { capacity_msat: 7_450_000_000, htlc_maximum_msat: 1_000 }, ..usage }; - assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 27_993); + assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 139_969); let usage = ChannelUsage { effective_capacity: EffectiveCapacity::Total { capacity_msat: 7_950_000_000, htlc_maximum_msat: 1_000 }, ..usage }; - assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 27_993); + assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 139_969); let usage = ChannelUsage { effective_capacity: EffectiveCapacity::Total { capacity_msat: 8_950_000_000, htlc_maximum_msat: 1_000 }, ..usage }; - assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 27_488); + assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 137_446); let usage = ChannelUsage { effective_capacity: EffectiveCapacity::Total { capacity_msat: 9_950_000_000, htlc_maximum_msat: 1_000 }, ..usage }; - assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 27_047); + assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 135_238); } #[test] @@ -4005,8 +4005,11 @@ mod tests { let logger = TestLogger::new(); let network_graph = network_graph(&logger); let source = source_node_id(); + let anti_probing_penalty_msat = + ProbabilisticScoringFeeParameters::default().anti_probing_penalty_msat; + assert_eq!(anti_probing_penalty_msat, 1_250); let params = ProbabilisticScoringFeeParameters { - anti_probing_penalty_msat: 500, + anti_probing_penalty_msat, ..ProbabilisticScoringFeeParameters::zero_penalty() }; let scorer = ProbabilisticScorer::new(ProbabilisticScoringDecayParameters::default(), &network_graph, &logger); @@ -4032,7 +4035,7 @@ mod tests { inflight_htlc_msat: 0, effective_capacity: EffectiveCapacity::Total { capacity_msat: 1_024_000, htlc_maximum_msat: 1_024_000 }, }; - assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 500); + assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 1_250); // Check we receive anti-probing penalty for htlc_maximum_msat == channel_capacity/2. let usage = ChannelUsage { @@ -4040,7 +4043,7 @@ mod tests { inflight_htlc_msat: 0, effective_capacity: EffectiveCapacity::Total { capacity_msat: 1_024_000, htlc_maximum_msat: 512_000 }, }; - assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 500); + assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 1_250); // Check we receive no anti-probing penalty for htlc_maximum_msat == channel_capacity/2 - 1. let usage = ChannelUsage { From f734d1ebbce3b4f13f21dbd740113511fc8ee0ad Mon Sep 17 00:00:00 2001 From: Matt Corallo <git@bluematt.me> Date: Mon, 20 Jul 2026 19:29:33 +0000 Subject: [PATCH 601/627] Change assign-reviewer authorized integration token to a bot acct I didn't realize using my own authorized integration token would result in every assignment being listed as coming from me, which is weird so here we swap it for a bot. Fixes #4804 --- .forgejo/workflows/assign-reviewer.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/assign-reviewer.yml b/.forgejo/workflows/assign-reviewer.yml index 71fc81c8e6e..2e1ffb77cbf 100644 --- a/.forgejo/workflows/assign-reviewer.yml +++ b/.forgejo/workflows/assign-reviewer.yml @@ -34,7 +34,7 @@ jobs: id: jwt run: | set -eu - jwt="$(curl -fsS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=u:1:bec84b56-6f08-4622-9cd6-1aee5b18c5b9" | jq -r '.value')" + jwt="$(curl -fsS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=u:88:1a17a83c-eae2-4258-8b7f-34a9c9408cec" | jq -r '.value')" echo "::add-mask::$jwt" echo "jwt=$jwt" >> "$FORGEJO_OUTPUT" - name: Request review from a random developer From bce5ef2f918e1e36cd7f75cc080d07ff5e89c665 Mon Sep 17 00:00:00 2001 From: Alkamal01 <kamalaliyu212@gmail.com> Date: Wed, 22 Jul 2026 00:20:23 +0100 Subject: [PATCH 602/627] channelmanager: upgrade best_block_updated log from trace to info --- lightning/src/ln/channelmanager.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index a3c33b8320f..f6ec5814af3 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -15999,7 +15999,7 @@ impl< // See the docs for `ChannelManagerReadArgs` for more. let block_hash = header.block_hash(); - log_trace!(self.logger, "New best block: {} at height {}", block_hash, height); + log_info!(self.logger, "New best block: {} at height {}", block_hash, height); let _persistence_guard = PersistenceNotifierGuard::optionally_notify_skipping_background_events( From 00aabf81a69a63ffe8a138eb411fba932db5c0a4 Mon Sep 17 00:00:00 2001 From: Elias Rohrer <dev@tnull.de> Date: Thu, 16 Jul 2026 12:31:15 +0200 Subject: [PATCH 603/627] offers: Allow disabling invoice response MPP Preserve the MPP-enabled default for invoice-request response builders so existing OffersMessageFlow callers retain their prior behavior. Allow callers with single-path requirements to explicitly remove the advertised MPP feature before signing. This controls feature advertisement only; receive-side enforcement remains the caller's responsibility. Co-Authored-By: HAL 9000 --- lightning/src/offers/invoice_macros.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lightning/src/offers/invoice_macros.rs b/lightning/src/offers/invoice_macros.rs index 1ac6e40b896..0f21024d7bc 100644 --- a/lightning/src/offers/invoice_macros.rs +++ b/lightning/src/offers/invoice_macros.rs @@ -80,6 +80,15 @@ macro_rules! invoice_builder_methods_common { ( $invoice_fields.features.set_basic_mpp_optional(); $return_value } + + #[doc = concat!("Sets [`", stringify!($invoice_type), "::invoice_features`]")] + #[doc = "to indicate MPP must not be used."] + /// + /// This only controls what the invoice advertises. It does not enforce single-HTLC receipt. + pub fn disallow_mpp($($self_mut)* $self: $self_type) -> $return_type { + $invoice_fields.features.clear_basic_mpp(); + $return_value + } } } #[cfg(test)] From f93a7f0cea99f72a9f955d85c190e40e492bb11e Mon Sep 17 00:00:00 2001 From: Wilmer Paulino <wilmer@wilmerpaulino.com> Date: Mon, 6 Jul 2026 11:05:04 -0700 Subject: [PATCH 604/627] Handle missing splice tx_signatures on reestablish When reconnecting after one side has received `tx_signatures` for a splice but the peer has not, `channel_reestablish` may need to recover two different pieces of state: the missing `tx_signatures` and a later commitment update generated after quiescence ended locally. Previously the lost-remote-commitment path discarded any `tx_signatures` prepared while processing the peer's `next_funding` TLV, as we assumed that if a `tx_signatures` is owed, then no pending updates must exist. That left the peer awaiting splice signatures and still treating the channel as quiescent, so the subsequent HTLC commitment update was rejected as a normal update while quiescent. This was incorrect as the fuzzer highlighted that a new update can be made after the `tx_signatures` exchange (while the counterparty has yet to process the responding `tx_signatures`) and both messages need to be retransmistted after a reconnect. We fix this by carrying `tx_signatures` through that reestablish branch and making the `tx_signatures/commitment_update` order explicit. Initial splice funding retransmission remains commitment_signed-before-tx_signatures, while post-splice reconnect recovery sends tx_signatures before normal commitment updates so the peer can exit quiescence first. --- lightning/src/ln/async_signer_tests.rs | 18 +- lightning/src/ln/channel.rs | 54 ++- lightning/src/ln/channelmanager.rs | 52 ++- lightning/src/ln/functional_test_utils.rs | 66 ++- lightning/src/ln/splicing_tests.rs | 507 ++++++++++++++++++++-- 5 files changed, 600 insertions(+), 97 deletions(-) diff --git a/lightning/src/ln/async_signer_tests.rs b/lightning/src/ln/async_signer_tests.rs index f60e63a87e9..05508a42b0e 100644 --- a/lightning/src/ln/async_signer_tests.rs +++ b/lightning/src/ln/async_signer_tests.rs @@ -607,7 +607,7 @@ fn test_signer_unblocked_clears_monitor_pending_raa_after_reestablish() { // completes. nodes[1].enable_channel_signer_op(&node_c_id, &chan_bc.2, SignerOp::ReleaseCommitmentSecret); nodes[1].node.signer_unblocked(Some((node_c_id, chan_bc.2))); - let (_, signer_revoke_and_ack, signer_commitment_update, _, _, _, _, _) = + let (_, signer_revoke_and_ack, signer_commitment_update, _, _, _, _, _, _) = handle_chan_reestablish_msgs!(nodes[1], nodes[2]); assert!(signer_revoke_and_ack.is_some()); @@ -617,12 +617,12 @@ fn test_signer_unblocked_clears_monitor_pending_raa_after_reestablish() { let (latest_update, _) = nodes[1].chain_monitor.get_latest_mon_update_id(chan_bc.2); nodes[1].chain_monitor.chain_monitor.force_channel_monitor_updated(chan_bc.2, latest_update); check_added_monitors(&nodes[1], 0); - let (_, duplicate_revoke_and_ack, monitor_commitment_update, _, _, _, _, _) = + let (_, duplicate_revoke_and_ack, monitor_commitment_update, _, _, _, _, _, _) = handle_chan_reestablish_msgs!(nodes[1], nodes[2]); assert!(duplicate_revoke_and_ack.is_none()); nodes[2].node.handle_channel_reestablish(node_b_id, &bs_reestablish[0]); - let (_, c_revoke_and_ack, c_commitment_update, _, _, _, _, _) = + let (_, c_revoke_and_ack, c_commitment_update, _, _, _, _, _, _) = handle_chan_reestablish_msgs!(nodes[2], nodes[1]); assert!(c_revoke_and_ack.is_none()); assert!(c_commitment_update.is_none()); @@ -801,7 +801,7 @@ fn do_test_async_raa_peer_disconnect( } // Expect the RAA - let (_, revoke_and_ack, commitment_signed, resend_order, _, _, _, _) = + let (_, revoke_and_ack, commitment_signed, resend_order, _, _, _, _, _) = handle_chan_reestablish_msgs!(dst, src); if test_case == UnblockSignerAcrossDisconnectCase::AtEnd { assert!(revoke_and_ack.is_none()); @@ -817,14 +817,14 @@ fn do_test_async_raa_peer_disconnect( dst.node.signer_unblocked(Some((src_node_id, chan_id))); if test_case == UnblockSignerAcrossDisconnectCase::AtEnd { - let (_, revoke_and_ack, commitment_signed, resend_order, _, _, _, _) = + let (_, revoke_and_ack, commitment_signed, resend_order, _, _, _, _, _) = handle_chan_reestablish_msgs!(dst, src); assert!(revoke_and_ack.is_some()); assert!(commitment_signed.is_some()); assert!(resend_order == RAACommitmentOrder::RevokeAndACKFirst); } else { // Make sure we don't double send the RAA. - let (_, revoke_and_ack, commitment_signed, _, _, _, _, _) = + let (_, revoke_and_ack, commitment_signed, _, _, _, _, _, _) = handle_chan_reestablish_msgs!(dst, src); assert!(revoke_and_ack.is_none()); assert!(commitment_signed.is_none()); @@ -951,7 +951,7 @@ fn do_test_async_commitment_signature_peer_disconnect( } // Expect the RAA - let (_, revoke_and_ack, commitment_signed, _, _, _, _, _) = + let (_, revoke_and_ack, commitment_signed, _, _, _, _, _, _) = handle_chan_reestablish_msgs!(dst, src); assert!(revoke_and_ack.is_some()); if test_case == UnblockSignerAcrossDisconnectCase::AtEnd { @@ -965,11 +965,11 @@ fn do_test_async_commitment_signature_peer_disconnect( dst.node.signer_unblocked(Some((src_node_id, chan_id))); if test_case == UnblockSignerAcrossDisconnectCase::AtEnd { - let (_, _, commitment_signed, _, _, _, _, _) = handle_chan_reestablish_msgs!(dst, src); + let (_, _, commitment_signed, _, _, _, _, _, _) = handle_chan_reestablish_msgs!(dst, src); assert!(commitment_signed.is_some()); } else { // Make sure we don't double send the CS. - let (_, _, commitment_signed, _, _, _, _, _) = handle_chan_reestablish_msgs!(dst, src); + let (_, _, commitment_signed, _, _, _, _, _, _) = handle_chan_reestablish_msgs!(dst, src); assert!(commitment_signed.is_none()); } } diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index a4e79df5299..d4b6cf3332e 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -54,8 +54,8 @@ use crate::ln::channel_state::{ use crate::ln::channelmanager::{ self, BlindedFailure, ChannelReadyOrder, FundingConfirmedMessage, HTLCFailureMsg, HTLCPreviousHopData, HTLCSource, OpenChannelMessage, PaymentClaimDetails, PendingHTLCInfo, - PendingHTLCStatus, RAACommitmentOrder, SentHTLCId, TrustedChannelFeatures, BREAKDOWN_TIMEOUT, - MAX_LOCAL_BREAKDOWN_TIMEOUT, MIN_CLTV_EXPIRY_DELTA, + PendingHTLCStatus, RAACommitmentOrder, SentHTLCId, TrustedChannelFeatures, TxSignaturesOrder, + BREAKDOWN_TIMEOUT, MAX_LOCAL_BREAKDOWN_TIMEOUT, MIN_CLTV_EXPIRY_DELTA, }; use crate::ln::funding::{FeeRateAdjustmentError, FundingContribution, FundingTemplate}; use crate::ln::interactivetxs::{ @@ -1267,7 +1267,7 @@ pub(super) struct ReestablishResponses { pub commitment_order: RAACommitmentOrder, pub announcement_sigs: Option<msgs::AnnouncementSignatures>, pub shutdown_msg: Option<msgs::Shutdown>, - pub tx_signatures: Option<msgs::TxSignatures>, + pub tx_signatures: Option<(TxSignaturesOrder, msgs::TxSignatures)>, pub tx_abort: Option<msgs::TxAbort>, pub splice_locked: Option<msgs::SpliceLocked>, pub inferred_splice_locked: Option<msgs::SpliceLocked>, @@ -10915,7 +10915,20 @@ where // - if it has already received `tx_signatures` for that funding transaction: // - MUST send its `tx_signatures` for that funding transaction. if let Some(holder_tx_signatures) = session.holder_tx_signatures() { - if self.is_awaiting_monitor_update() { + // A completed exchange may precede an unrelated monitor update, so + // retransmitting the same signatures does not depend on that update. + let splice_signatures_exchange_complete = self + .pending_splice + .as_ref() + .map(|pending_splice| { + pending_splice.negotiated_candidates.iter().any(|candidate| { + candidate.funding.get_funding_txid() == Some(next_funding.txid) + }) + }) + .unwrap_or(false); + if self.is_awaiting_monitor_update() + && !splice_signatures_exchange_complete + { log_debug!(logger, "Waiting for monitor update before providing funding transaction signatures"); } else if self.context.signer_pending_funding { log_debug!(logger, "Waiting for signer to provide counterparty commitment_signed before releasing funding transaction signatures"); @@ -10990,7 +11003,8 @@ where raa: None, commitment_update, commitment_order: self.context.resend_order.clone(), shutdown_msg, announcement_sigs, - tx_signatures, + tx_signatures: tx_signatures + .map(|msg| (TxSignaturesOrder::CommitmentFirst, msg)), tx_abort: None, splice_locked: None, inferred_splice_locked: None, @@ -11004,7 +11018,8 @@ where raa: None, commitment_update, commitment_order: self.context.resend_order.clone(), shutdown_msg, announcement_sigs, - tx_signatures, + tx_signatures: tx_signatures + .map(|msg| (TxSignaturesOrder::CommitmentFirst, msg)), tx_abort, splice_locked: None, inferred_splice_locked: None, @@ -11111,6 +11126,15 @@ where log_debug!(logger, "Reconnected with no loss"); } + // A commitment update generated above retransmits the initial splice + // `commitment_signed` and must precede its funding signatures. Otherwise a completed + // exchange's retransmitted signatures must precede any `splice_locked` below. + let tx_signatures_order = if commitment_update.is_some() { + TxSignaturesOrder::CommitmentFirst + } else { + TxSignaturesOrder::SignaturesFirst + }; + Ok(ReestablishResponses { channel_ready, channel_ready_order: ChannelReadyOrder::SignaturesFirst, @@ -11119,17 +11143,17 @@ where raa: required_revoke, commitment_update, commitment_order: self.context.resend_order.clone(), - tx_signatures, + tx_signatures: tx_signatures.map(|msg| (tx_signatures_order, msg)), tx_abort, splice_locked, inferred_splice_locked, }) } else if msg.next_local_commitment_number == next_counterparty_commitment_number - 1 { - debug_assert!(commitment_update.is_none()); - - // TODO(splicing): Assert in a test that we don't retransmit tx_signatures instead - #[cfg(test)] - assert!(tx_signatures.is_none()); + if retransmit_funding_commit_sig.is_some() { + return Err(ChannelError::close( + "Peer requested retransmission of an initial commitment_signed while claiming to have lost a later commitment_signed".to_owned(), + )); + } if required_revoke.is_some() || self.context.signer_pending_revoke_and_ack { log_debug!(logger, "Reconnected channel with lost outbound RAA and lost remote commitment tx"); @@ -11145,7 +11169,8 @@ where shutdown_msg, announcement_sigs, commitment_update: None, raa: None, commitment_order: self.context.resend_order.clone(), - tx_signatures: None, + tx_signatures: tx_signatures + .map(|msg| (TxSignaturesOrder::SignaturesFirst, msg)), tx_abort, splice_locked, inferred_splice_locked, @@ -11173,7 +11198,8 @@ where shutdown_msg, announcement_sigs, raa, commitment_update, commitment_order: self.context.resend_order.clone(), - tx_signatures: None, + tx_signatures: tx_signatures + .map(|msg| (TxSignaturesOrder::SignaturesFirst, msg)), tx_abort, splice_locked, inferred_splice_locked, diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 318b10b1006..774f41da188 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -1241,6 +1241,25 @@ pub(super) enum ChannelReadyOrder { SignaturesFirst, } +/// Determines whether splice `tx_signatures` should be sent before or after other messages when +/// resuming a channel. +/// +/// The ordering matters because exchanging `tx_signatures` ends splice quiescence. A normal +/// commitment update generated after quiescence cannot be processed by the peer until it has +/// received our `tx_signatures`. Similarly, if the peer's signature exchange is incomplete, it +/// cannot process `splice_locked` until the exchange adds the splice transaction to its negotiated +/// candidates. However, an initial `commitment_signed` for the splice funding must itself be +/// exchanged before the corresponding funding signatures. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub(super) enum TxSignaturesOrder { + /// Send `tx_signatures` before a normal commitment update or `splice_locked` so the peer + /// completes the signature exchange first. + SignaturesFirst, + /// Send `tx_signatures` after an initial splice `commitment_signed` establishes the new funding + /// state. + CommitmentFirst, +} + /// Information about a payment which is currently being claimed. #[derive(Clone, Debug, PartialEq, Eq)] struct ClaimingPayment { @@ -11187,6 +11206,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ updates.funding_tx_signed, None, updates.channel_ready_order, + TxSignaturesOrder::SignaturesFirst, ); needs_persist |= !htlc_forwards.is_empty(); @@ -11348,7 +11368,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ funding_broadcastable: Option<Transaction>, channel_ready: Option<msgs::ChannelReady>, announcement_sigs: Option<msgs::AnnouncementSignatures>, mut funding_tx_signed: Option<FundingTxSigned>, tx_abort: Option<msgs::TxAbort>, - channel_ready_order: ChannelReadyOrder, + channel_ready_order: ChannelReadyOrder, tx_signatures_order: TxSignaturesOrder, ) -> (Vec<PendingAddHTLCInfo>, Option<(u64, Vec<msgs::UpdateAddHTLC>)>) { let logger = WithChannelContext::from(&self.logger, &channel.context, None); log_trace!(logger, "Handling channel resumption with {} RAA, {} commitment update, {} pending forwards, {} pending update_add_htlcs, {}broadcasting funding, {} channel ready, {} announcement, {} tx_signatures, {} tx_abort, {} splice_locked", @@ -11405,6 +11425,14 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ debug_assert!(funding_tx_signed.commitment_signed.is_none()); debug_assert!(funding_tx_signed.counterparty_initial_commitment_signed_result.is_none()); } + if let TxSignaturesOrder::SignaturesFirst = tx_signatures_order { + if let Some(msg) = funding_tx_signed.as_mut().and_then(|v| v.tx_signatures.take()) { + pending_msg_events.push(MessageSendEvent::SendTxSignatures { + node_id: counterparty_node_id, + msg, + }); + } + } if let Some(msg) = funding_tx_signed.as_mut().and_then(|v| v.splice_locked.take()) { pending_msg_events.push(MessageSendEvent::SendSpliceLocked { node_id: counterparty_node_id, @@ -11440,11 +11468,13 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }, } - if let Some(msg) = funding_tx_signed.as_mut().and_then(|v| v.tx_signatures.take()) { - pending_msg_events.push(MessageSendEvent::SendTxSignatures { - node_id: counterparty_node_id, - msg, - }); + if let TxSignaturesOrder::CommitmentFirst = tx_signatures_order { + if let Some(msg) = funding_tx_signed.as_mut().and_then(|v| v.tx_signatures.take()) { + pending_msg_events.push(MessageSendEvent::SendTxSignatures { + node_id: counterparty_node_id, + msg, + }); + } } if let Some(msg) = tx_abort { pending_msg_events.push(MessageSendEvent::SendTxAbort { @@ -13669,9 +13699,13 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } let need_lnd_workaround = chan.context.workaround_lnd_bug_4006.take(); let inferred_splice_locked = responses.inferred_splice_locked; - let funding_tx_signed = if responses.tx_signatures.is_some() || responses.splice_locked.is_some() { + let (tx_signatures_order, tx_signatures) = responses + .tx_signatures + .map(|(order, msg)| (order, Some(msg))) + .unwrap_or((TxSignaturesOrder::CommitmentFirst, None)); + let funding_tx_signed = if tx_signatures.is_some() || responses.splice_locked.is_some() { Some(FundingTxSigned { - tx_signatures: responses.tx_signatures, + tx_signatures, splice_locked: responses.splice_locked, ..Default::default() }) @@ -13681,7 +13715,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let (htlc_forwards, decode_update_add_htlcs) = self.handle_channel_resumption( &mut peer_state.pending_msg_events, chan, responses.raa, responses.commitment_update, responses.commitment_order, Vec::new(), Vec::new(), None, responses.channel_ready, responses.announcement_sigs, - funding_tx_signed, responses.tx_abort, responses.channel_ready_order, + funding_tx_signed, responses.tx_abort, responses.channel_ready_order, tx_signatures_order, ); debug_assert!(htlc_forwards.is_empty()); debug_assert!(decode_update_add_htlcs.is_none()); diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 36016759566..26fed9ee926 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -27,7 +27,7 @@ use crate::ln::chan_utils::{ }; use crate::ln::channelmanager::{ AChannelManager, ChainParameters, ChannelManager, ChannelManagerReadArgs, PaymentId, - RAACommitmentOrder, TrustedChannelFeatures, MIN_CLTV_EXPIRY_DELTA, + RAACommitmentOrder, TrustedChannelFeatures, TxSignaturesOrder, MIN_CLTV_EXPIRY_DELTA, }; use crate::ln::funding::FundingContribution; use crate::ln::msgs::{self, OpenChannel}; @@ -5214,6 +5214,18 @@ macro_rules! handle_chan_reestablish_msgs { stfu = Some(msg.clone()); } + let mut tx_signatures = None; + let mut tx_signatures_order = + $crate::ln::channelmanager::TxSignaturesOrder::CommitmentFirst; + if let Some(&MessageSendEvent::SendTxSignatures { ref node_id, ref msg }) = + msg_events.get(idx) + { + assert_eq!(*node_id, $dst_node.node.get_our_node_id()); + tx_signatures = Some(msg.clone()); + tx_signatures_order = $crate::ln::channelmanager::TxSignaturesOrder::SignaturesFirst; + idx += 1; + } + let mut revoke_and_ack = None; let mut commitment_update = None; let order = if let Some(ev) = msg_events.get(idx) { @@ -5262,13 +5274,14 @@ macro_rules! handle_chan_reestablish_msgs { } } - let mut tx_signatures = None; - if let Some(&MessageSendEvent::SendTxSignatures { ref node_id, ref msg }) = - msg_events.get(idx) - { - assert_eq!(*node_id, $dst_node.node.get_our_node_id()); - tx_signatures = Some(msg.clone()); - idx += 1; + if tx_signatures.is_none() { + if let Some(&MessageSendEvent::SendTxSignatures { ref node_id, ref msg }) = + msg_events.get(idx) + { + assert_eq!(*node_id, $dst_node.node.get_our_node_id()); + tx_signatures = Some(msg.clone()); + idx += 1; + } } if let Some(&MessageSendEvent::SendAnnouncementSignatures { ref node_id, ref msg }) = @@ -5298,6 +5311,7 @@ macro_rules! handle_chan_reestablish_msgs { tx_signatures, stfu, tx_abort, + tx_signatures_order, ) }}; } @@ -5453,8 +5467,25 @@ pub fn reconnect_nodes<'a, 'b, 'c, 'd>(args: ReconnectArgs<'a, 'b, 'c, 'd>) { && pending_cell_htlc_claims.1 == 0 && pending_cell_htlc_fails.1 == 0) ); + let pending_commitment_update = ( + pending_htlc_adds.0 != 0 + || pending_htlc_claims.0 != 0 + || pending_htlc_fails.0 != 0 + || pending_cell_htlc_claims.0 != 0 + || pending_cell_htlc_fails.0 != 0 + || pending_responding_commitment_signed.0, + pending_htlc_adds.1 != 0 + || pending_htlc_claims.1 != 0 + || pending_htlc_fails.1 != 0 + || pending_cell_htlc_claims.1 != 0 + || pending_cell_htlc_fails.1 != 0 + || pending_responding_commitment_signed.1, + ); for mut chan_msgs in resp_1.drain(..) { + if send_interactive_tx_sigs.0 && pending_commitment_update.0 { + assert_eq!(chan_msgs.8, TxSignaturesOrder::SignaturesFirst); + } if send_channel_ready.0 { node_a.node.handle_channel_ready(node_b_id, &chan_msgs.0.unwrap()); let announcement_event = node_a.node.get_and_clear_pending_msg_events(); @@ -5516,13 +5547,7 @@ pub fn reconnect_nodes<'a, 'b, 'c, 'd>(args: ReconnectArgs<'a, 'b, 'c, 'd>) { } else { assert!(chan_msgs.1.is_none()); } - if pending_htlc_adds.0 != 0 - || pending_htlc_claims.0 != 0 - || pending_htlc_fails.0 != 0 - || pending_cell_htlc_claims.0 != 0 - || pending_cell_htlc_fails.0 != 0 - || pending_responding_commitment_signed.0 - { + if pending_commitment_update.0 { let commitment_update = chan_msgs.2.unwrap(); assert_eq!(commitment_update.update_add_htlcs.len(), pending_htlc_adds.0); assert_eq!( @@ -5571,6 +5596,9 @@ pub fn reconnect_nodes<'a, 'b, 'c, 'd>(args: ReconnectArgs<'a, 'b, 'c, 'd>) { } for mut chan_msgs in resp_2.drain(..) { + if send_interactive_tx_sigs.1 && pending_commitment_update.1 { + assert_eq!(chan_msgs.8, TxSignaturesOrder::SignaturesFirst); + } if send_channel_ready.1 { node_b.node.handle_channel_ready(node_a_id, &chan_msgs.0.unwrap()); let announcement_event = node_b.node.get_and_clear_pending_msg_events(); @@ -5632,13 +5660,7 @@ pub fn reconnect_nodes<'a, 'b, 'c, 'd>(args: ReconnectArgs<'a, 'b, 'c, 'd>) { } else { assert!(chan_msgs.1.is_none()); } - if pending_htlc_adds.1 != 0 - || pending_htlc_claims.1 != 0 - || pending_htlc_fails.1 != 0 - || pending_cell_htlc_claims.1 != 0 - || pending_cell_htlc_fails.1 != 0 - || pending_responding_commitment_signed.1 - { + if pending_commitment_update.1 { let commitment_update = chan_msgs.2.unwrap(); assert_eq!(commitment_update.update_add_htlcs.len(), pending_htlc_adds.1); assert_eq!( diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 04fe241a14e..c762ca062b2 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -2740,10 +2740,11 @@ fn do_test_splice_reestablish(reload: bool, async_monitor_update: bool) { } #[test] -fn test_splice_locked_waits_for_channel_reestablish() { +fn test_reestablish_sends_tx_signatures_before_splice_locked() { // If a splice confirms after `peer_connected` but before `channel_reestablish` is handled, the // peer state is connected while the channel still has its disconnected bit set. We must not send - // `splice_locked` until the channel is reestablished, but should send it immediately after. + // `splice_locked` until the channel is reestablished. If the peer also lost our `tx_signatures`, + // we must retransmit them before `splice_locked` so it recognizes the negotiated candidate. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); @@ -2771,7 +2772,51 @@ fn test_splice_locked_waits_for_channel_reestablish() { ]; let funding_contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); - let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + negotiate_splice_tx(&nodes[0], &nodes[1], channel_id, funding_contribution); + + let event = get_event!(nodes[0], Event::FundingTransactionReadyForSigning); + if let Event::FundingTransactionReadyForSigning { unsigned_transaction, .. } = event { + let partially_signed_tx = nodes[0].wallet_source.sign_tx(unsigned_transaction).unwrap(); + nodes[0] + .node + .funding_transaction_signed(&channel_id, &node_id_1, partially_signed_tx) + .unwrap(); + } else { + panic!("Unexpected event {event:?}"); + } + + let commitment_update_0 = get_htlc_update_msgs(&nodes[0], &node_id_1); + nodes[1].node.handle_commitment_signed(node_id_0, &commitment_update_0.commitment_signed[0]); + check_added_monitors(&nodes[1], 1); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + if let MessageSendEvent::UpdateHTLCs { updates, .. } = &msg_events[0] { + assert!(updates.update_add_htlcs.is_empty()); + assert_eq!(updates.commitment_signed.len(), 1); + nodes[0].node.handle_commitment_signed(node_id_1, &updates.commitment_signed[0]); + check_added_monitors(&nodes[0], 1); + } else { + panic!("Unexpected event {:?}", msg_events[0]); + } + if let MessageSendEvent::SendTxSignatures { msg, .. } = &msg_events[1] { + nodes[0].node.handle_tx_signatures(node_id_1, msg); + check_added_monitors(&nodes[0], 0); + expect_splice_pending_event(&nodes[0], &node_id_1); + } else { + panic!("Unexpected event {:?}", msg_events[1]); + } + + // Node 0 completes the exchange locally and broadcasts the splice, but its responding + // `tx_signatures` are lost. Node 1 therefore still has no negotiated candidate for the splice. + let tx_signatures_0 = get_event_msg!(nodes[0], MessageSendEvent::SendTxSignatures, node_id_1); + let splice_txid = tx_signatures_0.tx_hash; + let mut broadcast_transactions = nodes[0].tx_broadcaster.txn_broadcast(); + assert_eq!(broadcast_transactions.len(), 1, "{broadcast_transactions:?}"); + let splice_tx = broadcast_transactions.remove(0); + assert_eq!(splice_tx.compute_txid(), splice_txid); + assert!(nodes[1].tx_broadcaster.txn_broadcast().is_empty()); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); nodes[0].node.peer_disconnected(node_id_1); nodes[1].node.peer_disconnected(node_id_0); @@ -2781,27 +2826,40 @@ fn test_splice_locked_waits_for_channel_reestablish() { get_event_msg!(nodes[0], MessageSendEvent::SendChannelReestablish, node_id_1); let reestablish_1 = get_event_msg!(nodes[1], MessageSendEvent::SendChannelReestablish, node_id_0); + assert!(reestablish_0.next_funding.is_none()); + assert_ne!( + reestablish_0.my_current_funding_locked.as_ref().map(|funding| funding.txid), + Some(splice_txid), + ); + assert_eq!(reestablish_1.next_funding.as_ref().map(|funding| funding.txid), Some(splice_txid)); confirm_transaction(&nodes[0], &splice_tx); assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); nodes[1].node.handle_channel_reestablish(node_id_0, &reestablish_0); let _ = get_event_msg!(nodes[1], MessageSendEvent::SendChannelUpdate, node_id_0); + nodes[0].node.handle_channel_reestablish(node_id_1, &reestablish_1); - let mut msg_events = nodes[0].node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), 2, "{msg_events:?}"); - let splice_locked_0 = - if let MessageSendEvent::SendSpliceLocked { node_id, msg } = msg_events.remove(0) { - assert_eq!(node_id, node_id_1); - msg - } else { - panic!(); - }; - if let MessageSendEvent::SendChannelUpdate { node_id, .. } = msg_events.remove(0) { - assert_eq!(node_id, node_id_1); + let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 3, "{msg_events:?}"); + if let MessageSendEvent::SendTxSignatures { msg, .. } = &msg_events[0] { + nodes[1].node.handle_tx_signatures(node_id_0, &msg); + check_added_monitors(&nodes[1], 0); } else { - panic!(); + panic!("Unexpected event {:?}", msg_events[0]); } + let splice_locked_0 = if let MessageSendEvent::SendSpliceLocked { msg, .. } = &msg_events[1] { + msg + } else { + panic!("Unexpected event {:?}", msg_events[1]); + }; + assert!(matches!(msg_events[2], MessageSendEvent::SendChannelUpdate { .. })); + + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); + let broadcast_transactions = nodes[1].tx_broadcaster.txn_broadcast(); + assert_eq!(broadcast_transactions.len(), 1, "{broadcast_transactions:?}"); + assert_eq!(broadcast_transactions[0], splice_tx); confirm_transaction(&nodes[1], &splice_tx); complete_splice_locked_exchange( @@ -4525,6 +4583,12 @@ fn fail_splice_on_tx_complete_error() { #[test] fn free_holding_cell_on_tx_signatures_quiescence_exit() { + do_test_free_holding_cell_on_tx_signatures_quiescence_exit(true); + do_test_free_holding_cell_on_tx_signatures_quiescence_exit(false); +} + +#[cfg(test)] +fn do_test_free_holding_cell_on_tx_signatures_quiescence_exit(update_from_initiator: bool) { // Test that if there's an update in the holding cell while we're quiescent, that it gets freed // upon exiting quiescence via the `tx_signatures` exchange. let chanmon_cfgs = create_chanmon_cfgs(2); @@ -4540,21 +4604,63 @@ fn free_holding_cell_on_tx_signatures_quiescence_exit() { let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); + if !update_from_initiator { + // Give the acceptor enough balance to queue the mirrored outbound HTLC. + send_payment(initiator, &[acceptor], 2_000_000); + provide_utxo_reserves(&nodes, 2, Amount::ONE_BTC); + } let outputs = vec![TxOut { value: Amount::from_sat(1_000), script_pubkey: initiator.wallet_source.get_change_script().unwrap(), }]; - let contribution = initiate_splice_out(initiator, acceptor, channel_id, outputs).unwrap(); - negotiate_splice_tx(initiator, acceptor, channel_id, contribution); + let initiator_contribution = + initiate_splice_out(initiator, acceptor, channel_id, outputs).unwrap(); + if update_from_initiator { + negotiate_splice_tx(initiator, acceptor, channel_id, initiator_contribution); + } else { + // Make the acceptor the second signer so receiving the initiator's `tx_signatures` causes it + // to send both its own `tx_signatures` and the commitment update held during quiescence. + let acceptor_contribution = + initiate_splice_in(acceptor, initiator, channel_id, Amount::from_sat(200_000)); + let stfu_initiator = + get_event_msg!(initiator, MessageSendEvent::SendStfu, node_id_acceptor); + let stfu_acceptor = get_event_msg!(acceptor, MessageSendEvent::SendStfu, node_id_initiator); + acceptor.node.handle_stfu(node_id_initiator, &stfu_initiator); + assert!(acceptor.node.get_and_clear_pending_msg_events().is_empty()); + initiator.node.handle_stfu(node_id_acceptor, &stfu_acceptor); + + let splice_init = + get_event_msg!(initiator, MessageSendEvent::SendSpliceInit, node_id_acceptor); + acceptor.node.handle_splice_init(node_id_initiator, &splice_init); + let splice_ack = + get_event_msg!(acceptor, MessageSendEvent::SendSpliceAck, node_id_initiator); + initiator.node.handle_splice_ack(node_id_acceptor, &splice_ack); + let new_funding_script = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + complete_interactive_funding_negotiation_for_both( + initiator, + acceptor, + channel_id, + initiator_contribution, + Some(acceptor_contribution), + splice_ack.funding_contribution_satoshis, + new_funding_script, + ); + } // Queue an outgoing HTLC to the holding cell. It should be freed once we exit quiescence. + let (update_sender, update_recipient) = + if update_from_initiator { (initiator, acceptor) } else { (acceptor, initiator) }; let (route, payment_hash, _payment_preimage, payment_secret) = - get_route_and_payment_hash!(initiator, acceptor, 1_000_000); + get_route_and_payment_hash!(update_sender, update_recipient, 1_000_000); let onion = RecipientOnionFields::secret_only(payment_secret, 1_000_000); let payment_id = PaymentId(payment_hash.0); - initiator.node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); - assert!(initiator.node.get_and_clear_pending_msg_events().is_empty()); + update_sender.node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); + assert!(update_sender.node.get_and_clear_pending_msg_events().is_empty()); let event = get_event!(initiator, Event::FundingTransactionReadyForSigning); if let Event::FundingTransactionReadyForSigning { @@ -4575,42 +4681,95 @@ fn free_holding_cell_on_tx_signatures_quiescence_exit() { let update = get_htlc_update_msgs(initiator, &node_id_acceptor); acceptor.node.handle_commitment_signed(node_id_initiator, &update.commitment_signed[0]); - check_added_monitors(&acceptor, 1); + if !update_from_initiator { + // The acceptor's initial commitment_signed is buffered until it signs its contributed input. + assert!(acceptor.node.get_and_clear_pending_msg_events().is_empty()); + let event = get_event!(acceptor, Event::FundingTransactionReadyForSigning); + if let Event::FundingTransactionReadyForSigning { + channel_id, + counterparty_node_id, + unsigned_transaction, + .. + } = event + { + let partially_signed_tx = acceptor.wallet_source.sign_tx(unsigned_transaction).unwrap(); + acceptor + .node + .funding_transaction_signed(&channel_id, &counterparty_node_id, partially_signed_tx) + .unwrap(); + } else { + unreachable!(); + } + } + check_added_monitors(acceptor, 1); - let msg_events = acceptor.node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), 2, "{msg_events:?}"); - if let MessageSendEvent::UpdateHTLCs { ref updates, .. } = &msg_events[0] { + let acceptor_msg_events = acceptor.node.get_and_clear_pending_msg_events(); + assert_eq!( + acceptor_msg_events.len(), + if update_from_initiator { 2 } else { 1 }, + "{acceptor_msg_events:?}" + ); + if let MessageSendEvent::UpdateHTLCs { ref updates, .. } = &acceptor_msg_events[0] { + assert!(updates.update_add_htlcs.is_empty()); + assert_eq!(updates.commitment_signed.len(), 1); let commitment_signed = &updates.commitment_signed[0]; initiator.node.handle_commitment_signed(node_id_acceptor, commitment_signed); check_added_monitors(&initiator, 1); } else { - panic!("Unexpected event {:?}", &msg_events[0]); - } - if let MessageSendEvent::SendTxSignatures { ref msg, .. } = &msg_events[1] { - initiator.node.handle_tx_signatures(node_id_acceptor, msg); - } else { - panic!("Unexpected event {:?}", &msg_events[1]); + panic!("Unexpected event {:?}", &acceptor_msg_events[0]); } - // With `tx_signatures` exchanged, we've exited quiescence and should now see the outgoing HTLC - // update be sent. - let msg_events = initiator.node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), 2, "{msg_events:?}"); - check_added_monitors(initiator, 1); // Outgoing HTLC monitor update - if let MessageSendEvent::SendTxSignatures { ref msg, .. } = &msg_events[0] { - acceptor.node.handle_tx_signatures(node_id_initiator, msg); + let expect_tx_signatures_then_htlc_update = |msg_events: &[MessageSendEvent]| match msg_events { + [MessageSendEvent::SendTxSignatures { .. }, MessageSendEvent::UpdateHTLCs { updates, .. }] => + { + assert_eq!(updates.update_add_htlcs.len(), 1); + assert_eq!(updates.commitment_signed.len(), 2); + }, + _ => panic!("Unexpected events {msg_events:?}"), + }; + if update_from_initiator { + if let MessageSendEvent::SendTxSignatures { node_id, ref msg } = &acceptor_msg_events[1] { + assert_eq!(*node_id, node_id_initiator); + initiator.node.handle_tx_signatures(node_id_acceptor, msg); + } else { + panic!("Unexpected event {:?}", &acceptor_msg_events[1]); + } + + // With `tx_signatures` exchanged, we've exited quiescence and should now see the outgoing + // HTLC update be sent. + let initiator_msg_events = initiator.node.get_and_clear_pending_msg_events(); + check_added_monitors(initiator, 1); // Outgoing HTLC monitor update + expect_tx_signatures_then_htlc_update(&initiator_msg_events); } else { - panic!("Unexpected event {:?}", &msg_events[0]); + let initiator_tx_signatures = + get_event_msg!(initiator, MessageSendEvent::SendTxSignatures, node_id_acceptor); + acceptor.node.handle_tx_signatures(node_id_initiator, &initiator_tx_signatures); + + let acceptor_msg_events = acceptor.node.get_and_clear_pending_msg_events(); + check_added_monitors(acceptor, 1); // Outgoing HTLC monitor update + expect_tx_signatures_then_htlc_update(&acceptor_msg_events); } - if let MessageSendEvent::UpdateHTLCs { updates, .. } = &msg_events[1] { - acceptor.node.handle_update_add_htlc(node_id_initiator, &updates.update_add_htlcs[0]); - do_commitment_signed_dance(acceptor, initiator, &updates.commitment_signed, false, false); + + // If the messages are dropped and the peers reconnect, the `tx_signatures` need to be + // retransmitted before the freed holding-cell update so the peer can leave quiescence before + // handling normal commitment updates. + initiator.node.peer_disconnected(node_id_acceptor); + acceptor.node.peer_disconnected(node_id_initiator); + let mut reconnect_args = ReconnectArgs::new(initiator, acceptor); + if update_from_initiator { + reconnect_args.send_announcement_sigs = (true, true); + reconnect_args.send_interactive_tx_sigs = (false, true); + reconnect_args.pending_htlc_adds = (0, 1); } else { - panic!("Unexpected event {:?}", &msg_events[1]); + reconnect_args.send_interactive_tx_sigs = (true, false); + reconnect_args.pending_htlc_adds = (1, 0); } + reconnect_nodes(reconnect_args); expect_splice_pending_event(initiator, &node_id_acceptor); - assert!(acceptor.node.get_and_clear_pending_events().is_empty()); + if !update_from_initiator { + expect_splice_pending_event(acceptor, &node_id_initiator); + } } #[test] @@ -5336,6 +5495,268 @@ fn do_splice_waits_for_initial_commitment_monitor_update_before_releasing_tx_sig } } +#[test] +fn test_monitor_restore_sends_tx_signatures_before_splice_locked() { + // When a 0-conf splice's RenegotiatedFunding monitor update completes asynchronously after + // the counterparty already sent its tx_signatures, restoring the channel releases both our + // tx_signatures and, with the signatures exchange now being complete, our 0-conf splice_locked. + // The tx_signatures must be sent first: the counterparty only learns the new funding txid is + // fully signed upon receiving our tx_signatures, and it will close the channel upon receiving + // splice_locked for a funding txid outside its negotiated candidates. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut config = test_default_channel_config(); + config.channel_handshake_limits.trust_own_funding_0conf = true; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + // The channel must be 0-conf so that the splice funding, which inherits the channel's + // minimum depth, locks as soon as tx_signatures are exchanged. + let initial_channel_value_sat = 100_000; + let (funding_tx, channel_id) = + open_zero_conf_channel_with_value(&nodes[0], &nodes[1], None, initial_channel_value_sat, 0); + mine_transaction(&nodes[0], &funding_tx); + mine_transaction(&nodes[1], &funding_tx); + let prev_funding_txid = funding_tx.compute_txid(); + + // Node 1 initiates a splice-in. The shared funding input counts towards the splice + // initiator's contributed input value, so node 0 -- contributing nothing -- will send its + // tx_signatures first, making node 1 the second signer. + provide_utxo_reserves(&nodes, 1, Amount::from_sat(100_000)); + let splice_in_sat = Amount::from_sat(50_000); + let funding_contribution = initiate_splice_in(&nodes[1], &nodes[0], channel_id, splice_in_sat); + negotiate_splice_tx(&nodes[1], &nodes[0], channel_id, funding_contribution); + + // Node 1 signs its contributed inputs and sends its commitment_signed for the new funding. + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + let event = get_event!(nodes[1], Event::FundingTransactionReadyForSigning); + if let Event::FundingTransactionReadyForSigning { unsigned_transaction, .. } = event { + let partially_signed_tx = nodes[1].wallet_source.sign_tx(unsigned_transaction).unwrap(); + nodes[1] + .node + .funding_transaction_signed(&channel_id, &node_id_0, partially_signed_tx) + .unwrap(); + } else { + panic!(); + } + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + if let MessageSendEvent::UpdateHTLCs { ref updates, .. } = &msg_events[0] { + nodes[0].node.handle_commitment_signed(node_id_1, &updates.commitment_signed[0]); + } else { + panic!("Unexpected event {:?}", msg_events[0]); + } + check_added_monitors(&nodes[0], 1); + + // Node 0 contributed no inputs, so it is the first signer: it sends its tx_signatures + // immediately, along with its commitment_signed. + let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + if let MessageSendEvent::UpdateHTLCs { updates, .. } = &msg_events[0] { + // Node 1 processes node 0's commitment_signed while its monitor persistence is async, leaving + // the RenegotiatedFunding monitor update in flight. + chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress); + nodes[1].node.handle_commitment_signed(node_id_0, &updates.commitment_signed[0]); + check_added_monitors(&nodes[1], 1); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + } else { + panic!("Unexpected event {:?}", msg_events[0]); + } + if let MessageSendEvent::SendTxSignatures { msg, .. } = &msg_events[1] { + // Node 1 receives node 0's tx_signatures while the monitor update is still in flight. Its + // responding tx_signatures (and everything resulting from the completed exchange) must be + // withheld until the monitor update completes. + nodes[1].node.handle_tx_signatures(node_id_0, msg); + check_added_monitors(&nodes[1], 0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); + assert!(nodes[1].tx_broadcaster.txn_broadcast().is_empty()); + } else { + panic!("Unexpected event {:?}", msg_events[1]); + } + + // Complete the monitor update. Node 1 now broadcasts the splice transaction and releases its + // tx_signatures. With both sides' signatures in hand and a 0-conf splice, it also generates + // splice_locked. + nodes[1].chain_monitor.complete_sole_pending_chan_update(&channel_id); + chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); + + expect_splice_pending_event(&nodes[1], &node_id_0); + let txn = nodes[1].tx_broadcaster.txn_broadcast(); + assert_eq!(txn.len(), 1, "{txn:?}"); + let splice_tx = txn[0].clone(); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + if let MessageSendEvent::SendTxSignatures { msg, .. } = &msg_events[0] { + nodes[0].node.handle_tx_signatures(node_id_1, msg); + } else { + panic!("Unexpected event {:?}", msg_events[0]); + } + if let MessageSendEvent::SendSpliceLocked { msg, .. } = &msg_events[1] { + nodes[0].node.handle_splice_locked(node_id_1, msg); + } else { + panic!("Unexpected event {:?}", msg_events[1]); + } + + // Node 0's signing session completed upon receiving node 1's tx_signatures: node 0 broadcasts + // the splice transaction and sends its own 0-conf splice_locked. Node 1's splice_locked then + // promotes the splice funding on node 0. + let txn = nodes[0].tx_broadcaster.txn_broadcast(); + assert!(!txn.is_empty()); + assert!(txn.iter().all(|tx| *tx == splice_tx), "{txn:?}"); + expect_channel_ready_event(&nodes[0], &node_id_1); + check_added_monitors(&nodes[0], 1); + + let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + if let MessageSendEvent::SendSpliceLocked { ref msg, .. } = msg_events[0] { + nodes[1].node.handle_splice_locked(node_id_0, msg); + } else { + panic!("Unexpected event {:?}", msg_events[0]); + } + expect_channel_ready_event(&nodes[1], &node_id_0); + check_added_monitors(&nodes[1], 1); + let txn = nodes[1].tx_broadcaster.txn_broadcast(); + assert!(txn.iter().all(|tx| *tx == splice_tx), "{txn:?}"); + + // The old funding is no longer tracked once the splice is locked on both sides. + nodes[0].chain_source.remove_watched_by_txid(prev_funding_txid); + nodes[1].chain_source.remove_watched_by_txid(prev_funding_txid); + + // The channel remains usable over the new funding. + send_payment(&nodes[0], &[&nodes[1]], 1_000_000); +} + +#[test] +fn retransmit_completed_tx_signatures_during_monitor_update_after_reestablish() { + // Test that splice `tx_signatures` owed to our peer are retransmitted on reestablish even if + // an unrelated monitor update is still in flight. The signature exchange already completed + // locally, so retransmitting the signatures does not depend on the pending monitor update and + // allows our peer to exit quiescence before the held commitment update is restored. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let config = test_default_channel_config(); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let initiator = &nodes[0]; + let acceptor = &nodes[1]; + let node_id_initiator = initiator.node.get_our_node_id(); + let node_id_acceptor = acceptor.node.get_our_node_id(); + + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); + + let outputs = vec![TxOut { + value: Amount::from_sat(1_000), + script_pubkey: initiator.wallet_source.get_change_script().unwrap(), + }]; + let contribution = initiate_splice_out(initiator, acceptor, channel_id, outputs).unwrap(); + negotiate_splice_tx(initiator, acceptor, channel_id, contribution); + + // Queue an outgoing HTLC to the holding cell. It should be freed once we exit quiescence. + let (route, payment_hash, _payment_preimage, payment_secret) = + get_route_and_payment_hash!(initiator, acceptor, 1_000_000); + let onion = RecipientOnionFields::secret_only(payment_secret, 1_000_000); + let payment_id = PaymentId(payment_hash.0); + initiator.node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); + assert!(initiator.node.get_and_clear_pending_msg_events().is_empty()); + + let event = get_event!(initiator, Event::FundingTransactionReadyForSigning); + if let Event::FundingTransactionReadyForSigning { + channel_id, + counterparty_node_id, + unsigned_transaction, + .. + } = event + { + let partially_signed_tx = initiator.wallet_source.sign_tx(unsigned_transaction).unwrap(); + initiator + .node + .funding_transaction_signed(&channel_id, &counterparty_node_id, partially_signed_tx) + .unwrap(); + } else { + unreachable!(); + } + + let update = get_htlc_update_msgs(initiator, &node_id_acceptor); + acceptor.node.handle_commitment_signed(node_id_initiator, &update.commitment_signed[0]); + check_added_monitors(&acceptor, 1); + + // The acceptor sends `tx_signatures` first since it contributed no inputs. + let msg_events = acceptor.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + if let MessageSendEvent::UpdateHTLCs { ref updates, .. } = &msg_events[0] { + let commitment_signed = &updates.commitment_signed[0]; + initiator.node.handle_commitment_signed(node_id_acceptor, commitment_signed); + check_added_monitors(&initiator, 1); + } else { + panic!("Unexpected event {:?}", &msg_events[0]); + } + assert!(initiator.node.get_and_clear_pending_msg_events().is_empty()); + + // Handle the acceptor's `tx_signatures` while the initiator's monitor persistence is async. + // This completes the exchange atomically: the initiator releases its `tx_signatures` and + // exits quiescence, freeing the holding cell HTLC, which itself results in a new monitor + // update that remains in flight. + chanmon_cfgs[0].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress); + let splice_txid = + if let MessageSendEvent::SendTxSignatures { node_id, ref msg } = &msg_events[1] { + assert_eq!(*node_id, node_id_initiator); + initiator.node.handle_tx_signatures(node_id_acceptor, msg); + msg.tx_hash + } else { + panic!("Unexpected event {:?}", &msg_events[1]); + }; + check_added_monitors(&initiator, 1); + expect_splice_pending_event(initiator, &node_id_acceptor); + + // The initiator's `tx_signatures` goes out immediately, but the freed holding cell update is + // withheld while the monitor update is in flight. Drop the `tx_signatures` (lost in + // transit), such that the initiator owes the acceptor both its `tx_signatures` and a + // commitment update. + let msg_events = initiator.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + if let MessageSendEvent::SendTxSignatures { node_id, ref msg } = &msg_events[0] { + assert_eq!(*node_id, node_id_acceptor); + assert_eq!(msg.tx_hash, splice_txid); + } else { + panic!("Unexpected event {:?}", &msg_events[0]); + } + + // Reconnect while the initiator's monitor update is still in flight. The acceptor's + // signing session is incomplete, so its `channel_reestablish` causes the initiator to + // retransmit its completed exchange's `tx_signatures` immediately. The normal commitment + // update remains withheld by the in-flight monitor update. + initiator.node.peer_disconnected(node_id_acceptor); + acceptor.node.peer_disconnected(node_id_initiator); + let mut reconnect_args = ReconnectArgs::new(acceptor, initiator); + reconnect_args.send_announcement_sigs = (true, true); + reconnect_args.send_interactive_tx_sigs = (true, false); + reconnect_nodes(reconnect_args); + check_added_monitors(acceptor, 0); + assert!(acceptor.node.get_and_clear_pending_msg_events().is_empty()); + assert!(initiator.node.get_and_clear_pending_msg_events().is_empty()); + + // Once the monitor update completes, only the freed holding cell update remains to be sent. + initiator.chain_monitor.complete_sole_pending_chan_update(&channel_id); + chanmon_cfgs[0].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); + + let msg_events = initiator.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + if let MessageSendEvent::UpdateHTLCs { updates, .. } = &msg_events[0] { + acceptor.node.handle_update_add_htlc(node_id_initiator, &updates.update_add_htlcs[0]); + do_commitment_signed_dance(acceptor, initiator, &updates.commitment_signed, false, false); + } else { + panic!("Unexpected event {:?}", &msg_events[0]); + } +} + #[test] fn test_splice_balance_falls_below_reserve() { // Test that we're able to proceed with a splice where the acceptor does not contribute From a6964ab9d126c12c5004efe525e053d7b715224c Mon Sep 17 00:00:00 2001 From: Matt Corallo <git@bluematt.me> Date: Fri, 24 Jul 2026 16:56:28 +0000 Subject: [PATCH 605/627] Correct + update scoring attempts right at the maximum amounts While sending a payment at exactly a channel's max or the liquidity upper bound we've calculated is unlikely to work, we shouldn't consider it impossible. Here we fix a few cases where we previously did so. This isn't really fixing anything, as we only shift the boundary by 1 msat, but it makes the next commit more logical. --- lightning/src/routing/scoring.rs | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/lightning/src/routing/scoring.rs b/lightning/src/routing/scoring.rs index c2d3e51ca1d..7130c92c2f8 100644 --- a/lightning/src/routing/scoring.rs +++ b/lightning/src/routing/scoring.rs @@ -1464,8 +1464,7 @@ impl< // liquidity penalty at all (as the success probability is 100%). } else if total_inflight_amount_msat >= max_liquidity_msat { // Equivalent to hitting the else clause below with the amount equal to the effective - // capacity and without any certainty on the liquidity upper bound, plus the - // impossibility penalty. + // capacity and without any certainty on the liquidity upper bound. let negative_log10_times_2048 = NEGATIVE_LOG10_UPPER_BOUND * 2048; res = Self::combined_penalty_msat(amount_msat, negative_log10_times_2048, score_params.liquidity_penalty_multiplier_msat, @@ -1489,12 +1488,11 @@ impl< } } - if total_inflight_amount_msat >= max_liquidity_msat { + if total_inflight_amount_msat > max_liquidity_msat { res = res.saturating_add(score_params.considered_impossible_penalty_msat); } if total_inflight_amount_msat >= available_capacity { - // We're trying to send more than the capacity, use a max penalty. res = res.saturating_add(Self::combined_penalty_msat(amount_msat, NEGATIVE_LOG10_UPPER_BOUND * 2048, score_params.historical_liquidity_penalty_multiplier_msat, @@ -3214,6 +3212,8 @@ mod tests { let usage = ChannelUsage { amount_msat: 250, ..usage }; assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 300); let usage = ChannelUsage { amount_msat: 500, ..usage }; + assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 2000); + let usage = ChannelUsage { amount_msat: 501, ..usage }; assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), u64::max_value()); let usage = ChannelUsage { amount_msat: 750, ..usage }; assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), u64::max_value()); @@ -3431,22 +3431,22 @@ mod tests { assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 0); let usage = ChannelUsage { amount_msat: 1, ..usage }; assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 0); - let usage = ChannelUsage { amount_msat: 1_023, ..usage }; - assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 2_000); let usage = ChannelUsage { amount_msat: 1_024, ..usage }; + assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 2_000); + let usage = ChannelUsage { amount_msat: 1_025, ..usage }; assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), u64::max_value()); // Fully decay liquidity upper bound. scorer.time_passed(Duration::from_secs(10 * 9)); let usage = ChannelUsage { amount_msat: 0, ..usage }; assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 0); - let usage = ChannelUsage { amount_msat: 1_024, ..usage }; + let usage = ChannelUsage { amount_msat: 1_025, ..usage }; assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), u64::max_value()); scorer.time_passed(Duration::from_secs(10 * 10)); let usage = ChannelUsage { amount_msat: 0, ..usage }; assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 0); - let usage = ChannelUsage { amount_msat: 1_024, ..usage }; + let usage = ChannelUsage { amount_msat: 1_025, ..usage }; assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), u64::max_value()); } @@ -3520,7 +3520,7 @@ mod tests { let mut scorer = ProbabilisticScorer::new(decay_params, &network_graph, &logger); let source = source_node_id(); let usage = ChannelUsage { - amount_msat: 500, + amount_msat: 501, inflight_htlc_msat: 0, effective_capacity: EffectiveCapacity::Total { capacity_msat: 1_000, htlc_maximum_msat: 1_000 }, }; @@ -3535,10 +3535,10 @@ mod tests { assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), u64::max_value()); scorer.time_passed(Duration::from_secs(10)); - assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 473); + assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 477); scorer.payment_path_failed(&payment_path_for_amount(250), 43, Duration::from_secs(10)); - assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 300); + assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 304); let mut serialized_scorer = Vec::new(); scorer.write(&mut serialized_scorer).unwrap(); @@ -3546,7 +3546,7 @@ mod tests { let mut serialized_scorer = io::Cursor::new(&serialized_scorer); let deserialized_scorer = <ProbabilisticScorer<_, _>>::read(&mut serialized_scorer, (decay_params, &network_graph, &logger)).unwrap(); - assert_eq!(deserialized_scorer.channel_penalty_msat(&candidate, usage, ¶ms), 300); + assert_eq!(deserialized_scorer.channel_penalty_msat(&candidate, usage, ¶ms), 304); } #[rustfmt::skip] @@ -3577,7 +3577,13 @@ mod tests { info, short_channel_id: 42, }); - assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), u64::max_value()); + assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 2000); + + let over_usage = ChannelUsage { + amount_msat: 501, + ..usage + }; + assert_eq!(scorer.channel_penalty_msat(&candidate, over_usage, ¶ms), u64::max_value()); if decay_before_reload { scorer.time_passed(Duration::from_secs(10)); From 6812387662e409f4fe4f641c07618c6aec2cc723 Mon Sep 17 00:00:00 2001 From: Matt Corallo <git@bluematt.me> Date: Fri, 24 Jul 2026 17:49:17 +0000 Subject: [PATCH 606/627] Report the used success probabilities in scorer accessor methods `live_estimated_payment_success_probability` and `historical_estimated_payment_success_probability` allow clients to fetch the estimated success probability of a channel directly, rather than as a score. Sadly, because they did not check that the amount was strictly smaller than `max_liquidity_msat`, `success_probability` could hit a (otherwise-harmless) debug assertion, which is fixed here. While doing so, we also update them to return the actual probability estimate used in scoring, applying the 1% lower-bound. --- lightning/src/routing/scoring.rs | 48 ++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/lightning/src/routing/scoring.rs b/lightning/src/routing/scoring.rs index 7130c92c2f8..e9c59b74f0b 100644 --- a/lightning/src/routing/scoring.rs +++ b/lightning/src/routing/scoring.rs @@ -1110,6 +1110,9 @@ impl<G: Deref<Target = NetworkGraph<L>>, L: Logger> ProbabilisticScorer<G, L> { /// with `scid` towards the given `target` node, based on the historical estimated liquidity /// bounds. /// + /// Note that probabilities for paths which are highly unlikely to succeed, but not impossible + /// are capped to a lower-bound of [`PROB_LOWER_BOUND`]. + /// /// Returns `None` if: /// - the given channel is not in the network graph, the provided `target` is not a party to /// the channel, or we don't have forwarding parameters for either direction in the channel. @@ -1130,13 +1133,20 @@ impl<G: Deref<Target = NetworkGraph<L>>, L: Logger> ProbabilisticScorer<G, L> { if let Some((directed_info, source)) = chan.as_directed_to(target) { if let Some(liq) = self.channel_liquidities.get(&scid) { let capacity_msat = directed_info.effective_capacity().as_msat(); + if amount_msat >= capacity_msat { + return Some(PROB_LOWER_BOUND); + } let dir_liq = liq.as_directed(source, target, capacity_msat); let res = dir_liq.liquidity_history.calculate_success_probability_times_billion( ¶ms, amount_msat, capacity_msat ).map(|p| p as f64 / (1024 * 1024 * 1024) as f64); - if res.is_some() { - return res; + if let Some(prob) = res { + if prob < PROB_LOWER_BOUND { + return Some(PROB_LOWER_BOUND); + } else { + return Some(prob); + } } } if allow_fallback_estimation { @@ -1163,19 +1173,29 @@ impl<G: Deref<Target = NetworkGraph<L>>, L: Logger> ProbabilisticScorer<G, L> { .as_directed(&source, &target, capacity_msat); let min_liq = liq.min_liquidity_msat(); let max_liq = liq.max_liquidity_msat(); - if amt <= liq.min_liquidity_msat() { + if amt <= min_liq { return 1.0; - } else if amt > liq.max_liquidity_msat() { + } else if amt > capacity_msat { return 0.0; + } else if amt >= max_liq { + return PROB_LOWER_BOUND; } let (num, den) = success_probability(amt, min_liq, max_liq, capacity_msat, ¶ms, min_zero_penalty); - num as f64 / den as f64 + let res = num as f64 / den as f64; + if res < PROB_LOWER_BOUND { + PROB_LOWER_BOUND + } else { + res + } } /// Query the probability of payment success sending the given `amount_msat` over the channel /// with `scid` towards the given `target` node, based on the live estimated liquidity bounds. /// + /// Note that probabilities for paths which are highly unlikely to succeed, but not impossible + /// are capped to a lower-bound of [`PROB_LOWER_BOUND`]. + /// /// This will return `Some` for any channel which is present in the [`NetworkGraph`], including /// if we have no bound information beside the channel's capacity. #[rustfmt::skip] @@ -1291,8 +1311,18 @@ impl ChannelLiquidity { /// Bounds `-log10` to avoid excessive liquidity penalties for payments with low success /// probabilities. +/// +/// The log10 equivalent of [`PROB_LOWER_BOUND`]. const NEGATIVE_LOG10_UPPER_BOUND: u64 = 2; +/// The minimum probability we will use when scoring a channel where we believe success may be +/// possible, even if its unlikely. +/// +/// Allowing the probability to go arbitrarily low results in penalties which grow unnecessarily +/// huge for small changes in probability (as penalties are based on the `log10` of the +/// probability). +pub const PROB_LOWER_BOUND: f64 = 0.01; + /// The rough cutoff at which our precision falls off and we should stop bothering to try to log a /// ratio, as X in 1/X. const PRECISION_LOWER_BOUND_DENOMINATOR: u64 = log_approx::LOWER_BITS_BOUND; @@ -3910,7 +3940,7 @@ mod tests { assert!(scorer.historical_estimated_payment_success_probability(42, &target, 1, ¶ms, false) .unwrap() > 0.35); assert_eq!(scorer.historical_estimated_payment_success_probability(42, &target, 500, ¶ms, false), - Some(0.0)); + Some(super::PROB_LOWER_BOUND)); // Even after we tell the scorer we definitely have enough available liquidity, it will // still remember that there was some failure in the past, and assign a non-0 penalty. @@ -4166,9 +4196,9 @@ mod tests { assert_eq!(scorer.historical_estimated_channel_liquidity_probabilities(42, &target), Some(([32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]))); - // The success probability estimate itself should be zero. + // The success probability estimate itself should be PROB_LOWER_BOUND. assert_eq!(scorer.historical_estimated_payment_success_probability(42, &target, amount_msat, ¶ms, false), - Some(0.0)); + Some(super::PROB_LOWER_BOUND)); // Now test again with the amount in the bottom bucket. amount_msat /= 2; @@ -4185,7 +4215,7 @@ mod tests { Some(([63, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [32, 31, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]))); assert_eq!(scorer.historical_estimated_payment_success_probability(42, &target, amount_msat, ¶ms, false), - Some(0.0)); + Some(super::PROB_LOWER_BOUND)); } #[test] From 05ce4204723b7c9f1fc67e68442e97d0b0887c92 Mon Sep 17 00:00:00 2001 From: Matt Corallo <git@bluematt.me> Date: Fri, 24 Jul 2026 16:55:50 +0000 Subject: [PATCH 607/627] Format changed functions --- lightning/src/routing/scoring.rs | 69 ++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/lightning/src/routing/scoring.rs b/lightning/src/routing/scoring.rs index e9c59b74f0b..53031ef5bc0 100644 --- a/lightning/src/routing/scoring.rs +++ b/lightning/src/routing/scoring.rs @@ -1122,10 +1122,9 @@ impl<G: Deref<Target = NetworkGraph<L>>, L: Logger> ProbabilisticScorer<G, L> { /// These are the same bounds as returned by /// [`Self::historical_estimated_channel_liquidity_probabilities`] (but not those returned by /// [`Self::estimated_channel_liquidity_range`]). - #[rustfmt::skip] pub fn historical_estimated_payment_success_probability( - &self, scid: u64, target: &NodeId, amount_msat: u64, params: &ProbabilisticScoringFeeParameters, - allow_fallback_estimation: bool, + &self, scid: u64, target: &NodeId, amount_msat: u64, + params: &ProbabilisticScoringFeeParameters, allow_fallback_estimation: bool, ) -> Option<f64> { let graph = self.network_graph.read_only(); @@ -1138,9 +1137,14 @@ impl<G: Deref<Target = NetworkGraph<L>>, L: Logger> ProbabilisticScorer<G, L> { } let dir_liq = liq.as_directed(source, target, capacity_msat); - let res = dir_liq.liquidity_history.calculate_success_probability_times_billion( - ¶ms, amount_msat, capacity_msat - ).map(|p| p as f64 / (1024 * 1024 * 1024) as f64); + let res = dir_liq + .liquidity_history + .calculate_success_probability_times_billion( + ¶ms, + amount_msat, + capacity_msat, + ) + .map(|p| p as f64 / (1024 * 1024 * 1024) as f64); if let Some(prob) = res { if prob < PROB_LOWER_BOUND { return Some(PROB_LOWER_BOUND); @@ -1151,26 +1155,32 @@ impl<G: Deref<Target = NetworkGraph<L>>, L: Logger> ProbabilisticScorer<G, L> { } if allow_fallback_estimation { let amt = amount_msat; - return Some( - self.calc_live_prob(scid, source, target, directed_info, amt, params, true) - ); + return Some(self.calc_live_prob( + scid, + source, + target, + directed_info, + amt, + params, + true, + )); } } } None } - #[rustfmt::skip] fn calc_live_prob( &self, scid: u64, source: &NodeId, target: &NodeId, directed_info: DirectedChannelInfo, - amt: u64, params: &ProbabilisticScoringFeeParameters, - min_zero_penalty: bool, + amt: u64, params: &ProbabilisticScoringFeeParameters, min_zero_penalty: bool, ) -> f64 { let capacity_msat = directed_info.effective_capacity().as_msat(); let dummy_liq = ChannelLiquidity::new(Duration::ZERO); - let liq = self.channel_liquidities.get(&scid) - .unwrap_or(&dummy_liq) - .as_directed(&source, &target, capacity_msat); + let liq = self.channel_liquidities.get(&scid).unwrap_or(&dummy_liq).as_directed( + &source, + &target, + capacity_msat, + ); let min_liq = liq.min_liquidity_msat(); let max_liq = liq.max_liquidity_msat(); if amt <= min_liq { @@ -1198,15 +1208,23 @@ impl<G: Deref<Target = NetworkGraph<L>>, L: Logger> ProbabilisticScorer<G, L> { /// /// This will return `Some` for any channel which is present in the [`NetworkGraph`], including /// if we have no bound information beside the channel's capacity. - #[rustfmt::skip] pub fn live_estimated_payment_success_probability( - &self, scid: u64, target: &NodeId, amount_msat: u64, params: &ProbabilisticScoringFeeParameters, + &self, scid: u64, target: &NodeId, amount_msat: u64, + params: &ProbabilisticScoringFeeParameters, ) -> Option<f64> { let graph = self.network_graph.read_only(); if let Some(chan) = graph.channels().get(&scid) { if let Some((directed_info, source)) = chan.as_directed_to(target) { - return Some(self.calc_live_prob(scid, source, target, directed_info, amount_msat, params, false)); + return Some(self.calc_live_prob( + scid, + source, + target, + directed_info, + amount_msat, + params, + false, + )); } } None @@ -1352,17 +1370,18 @@ fn three_f64_pow_9(a: f64, b: f64, c: f64) -> (f64, f64, f64) { const MIN_ZERO_IMPLIES_NO_SUCCESSES_PENALTY_ON_64: u64 = 78; #[inline(always)] -#[rustfmt::skip] fn linear_success_probability( total_inflight_amount_msat: u64, min_liquidity_msat: u64, max_liquidity_msat: u64, min_zero_implies_no_successes: bool, ) -> (u64, u64) { - let (numerator, mut denominator) = - (max_liquidity_msat - total_inflight_amount_msat, - (max_liquidity_msat - min_liquidity_msat).saturating_add(1)); - - if min_zero_implies_no_successes && min_liquidity_msat == 0 && - denominator < u64::max_value() / MIN_ZERO_IMPLIES_NO_SUCCESSES_PENALTY_ON_64 + let (numerator, mut denominator) = ( + max_liquidity_msat - total_inflight_amount_msat, + (max_liquidity_msat - min_liquidity_msat).saturating_add(1), + ); + + if min_zero_implies_no_successes + && min_liquidity_msat == 0 + && denominator < u64::max_value() / MIN_ZERO_IMPLIES_NO_SUCCESSES_PENALTY_ON_64 { denominator = denominator * MIN_ZERO_IMPLIES_NO_SUCCESSES_PENALTY_ON_64 / 64 } From 081a8e101ec85a11006dce8143667f65b0da7d5c Mon Sep 17 00:00:00 2001 From: Matt Corallo <git@bluematt.me> Date: Fri, 24 Jul 2026 23:14:45 +0000 Subject: [PATCH 608/627] f names in test lookups --- lightning/src/onion_message/dns_resolution.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lightning/src/onion_message/dns_resolution.rs b/lightning/src/onion_message/dns_resolution.rs index 1842751f36f..d8ef4a2e8a0 100644 --- a/lightning/src/onion_message/dns_resolution.rs +++ b/lightning/src/onion_message/dns_resolution.rs @@ -856,7 +856,11 @@ mod tests { resolver.resolve_name(PaymentId([0; 32]), name.clone(), vec![dest(1)], &keys).unwrap(); let (dns_name, contexts_a) = dns_name_and_contexts(&messages); resolver.resolve_name(PaymentId([1; 32]), name.clone(), vec![dest(2)], &keys).unwrap(); - assert_eq!(resolver.pending_resolves.lock().unwrap().iter().next().unwrap().1.len(), 2); + { + let pending_resolves = resolver.pending_resolves.lock().unwrap(); + let pending_queries_for_name = &pending_resolves.iter().next().unwrap().1; + assert_eq!(pending_queries_for_name.len(), 2); + } // A single error over payment 0's reply path fails only its (single-query) resolution. let err = DNSSECError { name: dns_name, definitely_unresolvable: false }; @@ -864,8 +868,9 @@ mod tests { assert_eq!(failed, vec![(name, PaymentId([0; 32]))]); // Payment 1's resolution is still pending. - let pending = resolver.pending_resolves.lock().unwrap(); - assert_eq!(pending.iter().next().unwrap().1.len(), 1); - assert_eq!(pending.iter().next().unwrap().1[0].payment_id, PaymentId([1; 32])); + let pending_resolves = resolver.pending_resolves.lock().unwrap(); + let pending_queries_for_name = &pending_resolves.iter().next().unwrap().1; + assert_eq!(pending_queries_for_name.len(), 1); + assert_eq!(pending_queries_for_name[0].payment_id, PaymentId([1; 32])); } } From 204134c0443e00596f08e71712e3e590613bac76 Mon Sep 17 00:00:00 2001 From: Matt Corallo <git+claude@bluematt.me> Date: Sat, 18 Jul 2026 18:02:08 +0000 Subject: [PATCH 609/627] Expose DNS query failure on invalid proofs or bad BIP 353 records In the previous commit we started handling the new `DNSSECError` onion messages and using them to expose when a BIP 353 resolution over onion messages should be considered failed due to all of our queries having filed. However, queries can also fail if all of our queries either errored or returned bogus proofs, or if we received a valid proof which proved there is no BIP 353 record or `Offer`. Here we consider such failures and expose them as well. --- lightning/src/onion_message/dns_resolution.rs | 159 +++++++++++------- 1 file changed, 102 insertions(+), 57 deletions(-) diff --git a/lightning/src/onion_message/dns_resolution.rs b/lightning/src/onion_message/dns_resolution.rs index d8ef4a2e8a0..d0746fe0b22 100644 --- a/lightning/src/onion_message/dns_resolution.rs +++ b/lightning/src/onion_message/dns_resolution.rs @@ -520,9 +520,14 @@ impl OMNameResolver { /// /// If an [`Offer`] is found, it, as well as the [`PaymentId`] and original `name` passed to /// [`Self::resolve_name`] are returned. + /// + /// If the proof is invalid and there are no remaining queries for this name, or if the proof is + /// valid and does not contain a valid BIP 353 entry or BOLT 12 [`Offer`], `Err` will be + /// returned with the set of [`HumanReadableName`] and [`PaymentId`] requests which should be + /// considered failed. pub fn handle_dnssec_proof_for_offer( &self, msg: DNSSECProof, context: DNSResolverContext, - ) -> Option<(Vec<(HumanReadableName, PaymentId)>, Offer)> { + ) -> Result<(Vec<(HumanReadableName, PaymentId)>, Offer), Vec<(HumanReadableName, PaymentId)>> { let (completed_requests, uri) = self.handle_dnssec_proof_for_uri(msg, context)?; if let Some((_onchain, params)) = uri.split_once('?') { for param in params.split('&') { @@ -533,13 +538,13 @@ impl OMNameResolver { }; if k.eq_ignore_ascii_case("lno") { if let Ok(offer) = Offer::from_str(v) { - return Some((completed_requests, offer)); + return Ok((completed_requests, offer)); } - return None; + return Err(completed_requests); } } } - None + Err(completed_requests) } /// Handles a [`DNSSECProof`] message, attempting to verify it and match it against any pending @@ -551,14 +556,19 @@ impl OMNameResolver { /// Note that a single proof for a wildcard DNS entry may complete several requests for /// different [`HumanReadableName`]s. /// + /// If the proof is invalid and there are no remaining queries for this name, or if the proof is + /// valid and does not contain a valid BIP 353 entry, `Err` will be returned with the set of + /// [`HumanReadableName`] and [`PaymentId`] requests which should be considered failed. + /// /// This method is useful for those who handle bitcoin: URIs already, handling more than just /// BOLT12 [`Offer`]s. pub fn handle_dnssec_proof_for_uri( &self, msg: DNSSECProof, context: DNSResolverContext, - ) -> Option<(Vec<(HumanReadableName, PaymentId)>, String)> { + ) -> Result<(Vec<(HumanReadableName, PaymentId)>, String), Vec<(HumanReadableName, PaymentId)>> + { let DNSSECProof { name: answer_name, proof } = msg; let mut pending_resolves = self.pending_resolves.lock().unwrap(); - if let hash_map::Entry::Occupied(entry) = pending_resolves.entry(answer_name) { + if let hash_map::Entry::Occupied(mut entry) = pending_resolves.entry(answer_name) { if !entry.get().iter().any(|query| query.pending_query_contexts.contains(&context)) { // If we don't have any pending queries with the context included in the blinded // path (implying someone sent us this response not using the blinded path we gave @@ -568,66 +578,101 @@ impl OMNameResolver { // If there was at least one query with the same context, we go ahead and complete // all queries for the same name, as there's no point in waiting for another proof // for the same name. - return None; + return Err(Vec::new()); } - let parsed_rrs = parse_rr_stream(&proof); - let validated_rrs = - parsed_rrs.as_ref().and_then(|rrs| verify_rr_stream(rrs).map_err(|_| &())); - if let Ok(validated_rrs) = validated_rrs { - #[allow(unused_assignments, unused_mut)] - let mut time = self.latest_block_time.load(Ordering::Acquire) as u64; - #[cfg(all(feature = "std", not(fuzzing)))] - { - use std::time::{SystemTime, UNIX_EPOCH}; - let now = SystemTime::now().duration_since(UNIX_EPOCH); - time = now.expect("Time must be > 1970").as_secs(); + let mut valid_resolution = false; + let res = parse_rr_stream(&proof).and_then(|rrs| { + if let Some(verified_rrs) = self.verify_dnssec_proof_for_rrs(entry.key(), &rrs) { + valid_resolution = true; + self.map_rrs_to_uri(verified_rrs).ok_or(()) + } else { + Err(()) } - if time != 0 { - // Block times may be up to two hours in the future and some time into the past - // (we assume no more than two hours, though the actual limits are rather - // complicated). - // Thus, we have to let the proof times be rather fuzzy. - let max_time_offset = - if cfg!(all(feature = "std", not(fuzzing))) { 0 } else { 60 * 2 }; - if validated_rrs.valid_from > time + max_time_offset { - return None; - } - if validated_rrs.expires < time - max_time_offset { - return None; + }); + if valid_resolution { + let requests = + entry.remove_entry().1.into_iter().map(|r| (r.name, r.payment_id)).collect(); + match res { + Ok(txt) => Ok((requests, txt)), + Err(()) => Err(requests), + } + } else { + let mut failed_resolutions = Vec::new(); + entry.get_mut().retain_mut(|query| { + query.pending_query_contexts.retain(|c| *c != context); + if query.pending_query_contexts.is_empty() { + failed_resolutions.push((query.name, query.payment_id)); + false + } else { + true } + }); + + if entry.get().is_empty() { + entry.remove_entry(); } - let resolved_rrs = validated_rrs.resolve_name(&entry.key()); - if resolved_rrs.is_empty() { + Err(failed_resolutions) + } + } else { + Err(Vec::new()) + } + } + + fn verify_dnssec_proof_for_rrs<'a>( + &self, resolved_name: &Name, rrs: &'a [RR], + ) -> Option<Vec<&'a RR>> { + let validated_rrs = verify_rr_stream(rrs); + if let Ok(validated_rrs) = validated_rrs { + #[allow(unused_assignments, unused_mut)] + let mut time = self.latest_block_time.load(Ordering::Acquire) as u64; + #[cfg(all(feature = "std", not(fuzzing)))] + { + use std::time::{SystemTime, UNIX_EPOCH}; + let now = SystemTime::now().duration_since(UNIX_EPOCH); + time = now.expect("Time must be > 1970").as_secs(); + } + if time != 0 { + // Block times may be up to two hours in the future and some time into the past + // (we assume no more than two hours, though the actual limits are rather + // complicated). + // Thus, we have to let the proof times be rather fuzzy. + let max_time_offset = + if cfg!(all(feature = "std", not(fuzzing))) { 0 } else { 60 * 2 }; + if validated_rrs.valid_from > time + max_time_offset { return None; } - - let (_, requests) = entry.remove_entry(); - - const URI_PREFIX: &str = "bitcoin:"; - let mut candidate_records = resolved_rrs - .iter() - .filter_map( - |rr| if let RR::Txt(txt) = rr { Some(txt.data.as_vec()) } else { None }, - ) - .filter_map(|data| String::from_utf8(data).ok()) - .filter(|data_string| data_string.len() > URI_PREFIX.len()) - .filter(|data_string| { - let pfx = &data_string.as_bytes()[..URI_PREFIX.len()]; - pfx.eq_ignore_ascii_case(URI_PREFIX.as_bytes()) - }); - // Check that there is exactly one TXT record that begins with - // bitcoin: as required by BIP 353 (and is valid UTF-8). - match (candidate_records.next(), candidate_records.next()) { - (Some(txt), None) => { - let completed_requests = - requests.into_iter().map(|r| (r.name, r.payment_id)).collect(); - return Some((completed_requests, txt)); - }, - _ => {}, + if validated_rrs.expires < time - max_time_offset { + return None; } } + let resolved_rrs = validated_rrs.resolve_name(resolved_name); + if resolved_rrs.is_empty() { + return None; + } + + Some(resolved_rrs) + } else { + None + } + } + + fn map_rrs_to_uri(&self, resolved_rrs: Vec<&RR>) -> Option<String> { + const URI_PREFIX: &str = "bitcoin:"; + let mut candidate_records = resolved_rrs + .iter() + .filter_map(|rr| if let RR::Txt(txt) = rr { Some(txt.data.as_vec()) } else { None }) + .filter_map(|data| String::from_utf8(data).ok()) + .filter(|data_string| data_string.len() > URI_PREFIX.len()) + .filter(|data_string| { + let pfx = &data_string.as_bytes()[..URI_PREFIX.len()]; + pfx.eq_ignore_ascii_case(URI_PREFIX.as_bytes()) + }); + // Check that there is exactly one TXT record that begins with + // bitcoin: as required by BIP 353 (and is valid UTF-8). + match (candidate_records.next(), candidate_records.next()) { + (Some(txt), None) => Some(txt), + _ => None, } - None } /// Handles a [`DNSSECError`] message, indicating that one of the resolvers we sent a From a2fe23f57a60374c1953baf564b9fc5b2b5a9902 Mon Sep 17 00:00:00 2001 From: Matt Corallo <git@bluematt.me> Date: Fri, 24 Jul 2026 23:22:19 +0000 Subject: [PATCH 610/627] f note that only one resolution will fail at a time --- lightning/src/onion_message/dns_resolution.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lightning/src/onion_message/dns_resolution.rs b/lightning/src/onion_message/dns_resolution.rs index d0746fe0b22..88501474d30 100644 --- a/lightning/src/onion_message/dns_resolution.rs +++ b/lightning/src/onion_message/dns_resolution.rs @@ -597,6 +597,9 @@ impl OMNameResolver { Err(()) => Err(requests), } } else { + // Note that because each context has a unique random nonce, at most one resolution + // can run out of pending queries here. Still, the API returns a Vec as we may join + // multiple queries for the same name in the future. let mut failed_resolutions = Vec::new(); entry.get_mut().retain_mut(|query| { query.pending_query_contexts.retain(|c| *c != context); @@ -698,6 +701,9 @@ impl OMNameResolver { // any. If no contexts match (including because a previous error already removed // this context), the error does not pertain to this resolution and it is left // untouched. + // Note that because each context has a unique random nonce, at most one resolution + // can run out of pending queries here. Still, the API returns a Vec as we may join + // multiple queries for the same name in the future. resolution.pending_query_contexts.retain(|c| *c != context); if resolution.pending_query_contexts.is_empty() { failed_resolutions.push((resolution.name, resolution.payment_id)); From 1a4694bf57f213b0f4b72be7a9d239435410c6a8 Mon Sep 17 00:00:00 2001 From: Matt Corallo <git@bluematt.me> Date: Sat, 18 Jul 2026 11:19:36 +0000 Subject: [PATCH 611/627] Generate `DNSSECError` messages when DNSSEC resolution fails https://github.com/lightning/blips/pull/71 updated the DNSSEC resolution bLIP to include an explicit error message when DNS(SEC) resolution was attempted but failed, allowing for faster fallback to LN-Address (for clients that do) and faster payment failure. Here we add service-side support for generating the error messages, informing requesters that their resolutions have failed. Largely written by an LLM --- lightning-dns-resolver/src/lib.rs | 106 ++++++++++++++++++++++++++++-- 1 file changed, 99 insertions(+), 7 deletions(-) diff --git a/lightning-dns-resolver/src/lib.rs b/lightning-dns-resolver/src/lib.rs index c89849e3f36..a5b4a926a85 100644 --- a/lightning-dns-resolver/src/lib.rs +++ b/lightning-dns-resolver/src/lib.rs @@ -9,7 +9,7 @@ use std::net::SocketAddr; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; -use dnssec_prover::query::build_txt_proof_async; +use dnssec_prover::query::{build_txt_proof_async, ProofBuildingError}; use lightning::blinded_path::message::DNSResolverContext; use lightning::ln::peer_handler::IgnoringMessageHandler; @@ -127,11 +127,25 @@ impl<PH: DNSResolverMessageHandler> DNSResolverMessageHandler for OMDomainResolv } let us = Arc::clone(&self.state); runtime.spawn(async move { - if let Ok((proof, _ttl)) = build_txt_proof_async(us.resolver, &q.0).await { - let contents = DNSResolverMessage::DNSSECProof(DNSSECProof { name: q.0, proof }); - let instructions = responder.respond().into_instructions(); - us.pending_replies.lock().unwrap().push((contents, instructions)); - } + let contents = match build_txt_proof_async(us.resolver, &q.0).await { + Ok((proof, _ttl)) => { + DNSResolverMessage::DNSSECProof(DNSSECProof { name: q.0, proof }) + }, + Err(e) => { + // We might get an Unauthenticated error if the DNS resolver does not support + // DNSSEC, so we only set `definitely_unresolvable` if we get an NXDOMAIN. + let definitely_unresolvable = matches!( + e.get_ref().and_then(|e| e.downcast_ref::<ProofBuildingError>()), + Some(ProofBuildingError::NoSuchName) + ); + DNSResolverMessage::DNSSECError(DNSSECError { + name: q.0, + definitely_unresolvable, + }) + }, + }; + let instructions = responder.respond().into_instructions(); + us.pending_replies.lock().unwrap().push((contents, instructions)); us.pending_query_count.fetch_sub(1, Ordering::Relaxed); }); None @@ -217,6 +231,7 @@ mod test { struct URIResolver { resolved_uri: Mutex<Option<(HumanReadableName, PaymentId, String)>>, + resolved_error: Mutex<Option<(HumanReadableName, PaymentId, bool)>>, resolver: OMNameResolver, pending_messages: Mutex<Vec<(DNSResolverMessage, MessageSendInstructions)>>, } @@ -236,7 +251,13 @@ mod test { assert!(result.is_none()); } fn handle_dnssec_error(&self, msg: DNSSECError, context: DNSResolverContext) { - // TODO + let definitely_unresolvable = msg.definitely_unresolvable; + let mut failed = self.resolver.handle_dnssec_error(msg, context); + assert_eq!(failed.len(), 1); + let (name, payment_id) = failed.pop().unwrap(); + let mut result = Some((name, payment_id, definitely_unresolvable)); + core::mem::swap(&mut *self.resolved_error.lock().unwrap(), &mut result); + assert!(result.is_none()); } fn release_pending_messages(&self) -> Vec<(DNSResolverMessage, MessageSendInstructions)> { core::mem::take(&mut *self.pending_messages.lock().unwrap()) @@ -286,6 +307,7 @@ mod test { let payer_id = payer_keys.get_node_id(Recipient::Node).unwrap(); let payer = Arc::new(URIResolver { resolved_uri: Mutex::new(None), + resolved_error: Mutex::new(None), resolver: OMNameResolver::new(now as u32, 1), pending_messages: Mutex::new(Vec::new()), }); @@ -331,6 +353,75 @@ mod test { assert!(resolution.2[.."bitcoin:".len()].eq_ignore_ascii_case("bitcoin:")); } + #[tokio::test] + async fn resolution_failure_test() { + // Test that querying for a name which does not exist results in a `DNSSECError` with + // `definitely_unresolvable` set being returned (rather than a `DNSSECProof`). + + let (resolver_messenger, resolver_id) = create_resolver(); + + let resolver_dest = Destination::Node(resolver_id); + let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs(); + + let payment_id = PaymentId([43; 32]); + // `mattcorallo.com` is DNSSEC-signed, so a name which does not exist under it will result in + // an authenticated NXDOMAIN, i.e. a definitely-unresolvable name. + let name = + HumanReadableName::from_encoded("nonexistent-user-ldk-test@mattcorallo.com").unwrap(); + + let payer_keys = Arc::new(KeysManager::new(&[3; 32], 42, 43, true)); + let payer_logger = TestLogger { node: "payer" }; + let payer_id = payer_keys.get_node_id(Recipient::Node).unwrap(); + let payer = Arc::new(URIResolver { + resolved_uri: Mutex::new(None), + resolved_error: Mutex::new(None), + resolver: OMNameResolver::new(now as u32, 1), + pending_messages: Mutex::new(Vec::new()), + }); + let payer_messenger = Arc::new(OnionMessenger::new( + Arc::clone(&payer_keys), + Arc::clone(&payer_keys), + payer_logger, + DummyNodeLookup {}, + DirectlyConnectedRouter {}, + IgnoringMessageHandler {}, + IgnoringMessageHandler {}, + Arc::clone(&payer), + IgnoringMessageHandler {}, + )); + + let init_msg = get_om_init(); + payer_messenger.peer_connected(resolver_id, &init_msg, true).unwrap(); + resolver_messenger.get_om().peer_connected(payer_id, &init_msg, false).unwrap(); + + let messages = payer + .resolver + .resolve_name(payment_id, name.clone(), vec![resolver_dest], &*payer_keys) + .unwrap(); + payer.pending_messages.lock().unwrap().extend(messages); + + let query = payer_messenger.next_onion_message_for_peer(resolver_id).unwrap(); + resolver_messenger.get_om().handle_onion_message(payer_id, &query); + + assert!(resolver_messenger.get_om().next_onion_message_for_peer(payer_id).is_none()); + let start = Instant::now(); + let response = loop { + tokio::time::sleep(Duration::from_millis(10)).await; + if let Some(msg) = resolver_messenger.get_om().next_onion_message_for_peer(payer_id) { + break msg; + } + assert!(start.elapsed() < Duration::from_secs(10), "Resolution took too long"); + }; + + payer_messenger.handle_onion_message(resolver_id, &response); + let (failed_name, failed_payment_id, definitely_unresolvable) = + payer.resolved_error.lock().unwrap().take().unwrap(); + assert_eq!(failed_name, name); + assert_eq!(failed_payment_id, payment_id); + assert!(definitely_unresolvable); + assert!(payer.resolved_uri.lock().unwrap().is_none()); + } + #[tokio::test] async fn failed_query_does_not_leak_pending_counter() { use std::sync::atomic::Ordering; @@ -368,6 +459,7 @@ mod test { let payer_id = payer_keys.get_node_id(Recipient::Node).unwrap(); let payer = Arc::new(URIResolver { resolved_uri: Mutex::new(None), + resolved_error: Mutex::new(None), resolver: OMNameResolver::new(now as u32, 1), pending_messages: Mutex::new(Vec::new()), }); From d14c22a392c39b0b0891a8b31c1b3692940549ef Mon Sep 17 00:00:00 2001 From: Matt Corallo <git@bluematt.me> Date: Sat, 18 Jul 2026 19:30:38 +0000 Subject: [PATCH 612/627] Correct DNSSEC proof validity time gap applied to header time We intended to apply DNSSEC proof validity tests to constrain them to within two hours of the latest block header time, but the code landed with a two minute gap instead. Given DNSSEC proof validity is usually many hours and grace periods are used to ensure records close to expiry aren't used, this is somewhat unlikely to have bitten anyone. --- lightning/src/onion_message/dns_resolution.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning/src/onion_message/dns_resolution.rs b/lightning/src/onion_message/dns_resolution.rs index 88501474d30..385ffe31ac7 100644 --- a/lightning/src/onion_message/dns_resolution.rs +++ b/lightning/src/onion_message/dns_resolution.rs @@ -640,7 +640,7 @@ impl OMNameResolver { // complicated). // Thus, we have to let the proof times be rather fuzzy. let max_time_offset = - if cfg!(all(feature = "std", not(fuzzing))) { 0 } else { 60 * 2 }; + if cfg!(all(feature = "std", not(fuzzing))) { 0 } else { 60 * 60 * 2 }; if validated_rrs.valid_from > time + max_time_offset { return None; } From 0460fda6a8b4e09f04d4b94a288f21a1d509f163 Mon Sep 17 00:00:00 2001 From: Matt Corallo <git@bluematt.me> Date: Fri, 24 Jul 2026 23:14:53 +0000 Subject: [PATCH 613/627] Rename `OMNameResolver::resolve_name` to `initiate_resolution` This better captures what the method actually does. --- lightning-dns-resolver/src/lib.rs | 6 +-- lightning/src/onion_message/dns_resolution.rs | 47 ++++++++++++------- 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/lightning-dns-resolver/src/lib.rs b/lightning-dns-resolver/src/lib.rs index a5b4a926a85..b2af1b8e942 100644 --- a/lightning-dns-resolver/src/lib.rs +++ b/lightning-dns-resolver/src/lib.rs @@ -329,7 +329,7 @@ mod test { let messages = payer .resolver - .resolve_name(payment_id, name.clone(), vec![resolver_dest], &*payer_keys) + .initiate_resolution(payment_id, name.clone(), vec![resolver_dest], &*payer_keys) .unwrap(); payer.pending_messages.lock().unwrap().extend(messages); @@ -396,7 +396,7 @@ mod test { let messages = payer .resolver - .resolve_name(payment_id, name.clone(), vec![resolver_dest], &*payer_keys) + .initiate_resolution(payment_id, name.clone(), vec![resolver_dest], &*payer_keys) .unwrap(); payer.pending_messages.lock().unwrap().extend(messages); @@ -481,7 +481,7 @@ mod test { let messages = payer .resolver - .resolve_name(payment_id, name.clone(), vec![resolver_dest], &*payer_keys) + .initiate_resolution(payment_id, name.clone(), vec![resolver_dest], &*payer_keys) .unwrap(); payer.pending_messages.lock().unwrap().extend(messages); diff --git a/lightning/src/onion_message/dns_resolution.rs b/lightning/src/onion_message/dns_resolution.rs index 385ffe31ac7..7ab210af7f2 100644 --- a/lightning/src/onion_message/dns_resolution.rs +++ b/lightning/src/onion_message/dns_resolution.rs @@ -466,9 +466,10 @@ impl OMNameResolver { /// Begins the process of resolving a BIP 353 Human Readable Name. /// - /// Returns a list of [`DNSSECQuery`] onion messages and the [`MessageSendInstructions`] over - /// which each should be sent - one entry per provided `destination`. - pub fn resolve_name<ES: EntropySource + ?Sized>( + /// Sets up the state to handle query responses and returns a list of [`DNSSECQuery`] onion + /// messages and the [`MessageSendInstructions`] over which each should be sent - one entry per + /// provided `destination`. + pub fn initiate_resolution<ES: EntropySource + ?Sized>( &self, payment_id: PaymentId, name: HumanReadableName, destinations: Vec<Destination>, entropy_source: &ES, ) -> Result<Vec<(DNSResolverMessage, MessageSendInstructions)>, ()> { @@ -519,7 +520,7 @@ impl OMNameResolver { /// different [`HumanReadableName`]s. /// /// If an [`Offer`] is found, it, as well as the [`PaymentId`] and original `name` passed to - /// [`Self::resolve_name`] are returned. + /// [`Self::initiate_resolution`] are returned. /// /// If the proof is invalid and there are no remaining queries for this name, or if the proof is /// valid and does not contain a valid BIP 353 entry or BOLT 12 [`Offer`], `Err` will be @@ -551,7 +552,7 @@ impl OMNameResolver { /// queries. /// /// If verification succeeds, all matching [`PaymentId`] and [`HumanReadableName`]s passed to - /// [`Self::resolve_name`], as well as the resolved bitcoin: URI are returned. + /// [`Self::initiate_resolution`], as well as the resolved bitcoin: URI are returned. /// /// Note that a single proof for a wildcard DNS entry may complete several requests for /// different [`HumanReadableName`]s. @@ -684,7 +685,8 @@ impl OMNameResolver { /// A resolution will be considered failed once we have received a [`DNSSECError`] for all the /// queries we made for it, as a [`DNSSECProof`] may still arrive from one of the other /// resolvers we queried. When a resolution does fail, its [`HumanReadableName`] and - /// [`PaymentId`] (as passed to [`Self::resolve_name`]) are included in the returned list. + /// [`PaymentId`] (as passed to [`Self::initiate_resolution`]) are included in the returned + /// list. /// /// As with [`Self::handle_dnssec_proof_for_uri`], the [`DNSResolverContext`] is checked against /// the contexts of any pending resolutions for the name to ensure the error was received over a @@ -733,14 +735,14 @@ mod tests { } /// Extracts the DNS [`Name`] and the per-query [`DNSResolverContext`]s from the messages - /// returned by [`OMNameResolver::resolve_name`]. + /// returned by [`OMNameResolver::initiate_resolution`]. #[cfg(feature = "dnssec")] fn dns_name_and_contexts( messages: &[(DNSResolverMessage, MessageSendInstructions)], ) -> (Name, Vec<DNSResolverContext>) { let name = match &messages[0] { (DNSResolverMessage::DNSSECQuery(DNSSECQuery(name)), _) => name.clone(), - _ => panic!("Unexpected resolve_name output"), + _ => panic!("Unexpected initiate_resolution output"), }; let contexts = messages .iter() @@ -749,7 +751,7 @@ mod tests { context: MessageContext::DNSResolver(context), .. } => context.clone(), - _ => panic!("Unexpected resolve_name output"), + _ => panic!("Unexpected initiate_resolution output"), }) .collect(); (name, contexts) @@ -816,22 +818,30 @@ mod tests { let name = HumanReadableName::new("user", "example.com").unwrap(); // Queue up a resolution - resolver.resolve_name(PaymentId([0; 32]), name.clone(), vec![dest(42)], &keys).unwrap(); + resolver + .initiate_resolution(PaymentId([0; 32]), name.clone(), vec![dest(42)], &keys) + .unwrap(); assert_eq!(resolver.pending_resolves.lock().unwrap().len(), 1); // and check that it expires after two blocks resolver.new_best_block(44, 42); assert_eq!(resolver.pending_resolves.lock().unwrap().len(), 0); // Queue up another resolution - resolver.resolve_name(PaymentId([1; 32]), name.clone(), vec![dest(42)], &keys).unwrap(); + resolver + .initiate_resolution(PaymentId([1; 32]), name.clone(), vec![dest(42)], &keys) + .unwrap(); assert_eq!(resolver.pending_resolves.lock().unwrap().len(), 1); // it won't expire after one block resolver.new_best_block(45, 42); assert_eq!(resolver.pending_resolves.lock().unwrap().len(), 1); assert_eq!(resolver.pending_resolves.lock().unwrap().iter().next().unwrap().1.len(), 1); // and queue up a second and third resolution of the same name - resolver.resolve_name(PaymentId([2; 32]), name.clone(), vec![dest(42)], &keys).unwrap(); - resolver.resolve_name(PaymentId([3; 32]), name.clone(), vec![dest(42)], &keys).unwrap(); + resolver + .initiate_resolution(PaymentId([2; 32]), name.clone(), vec![dest(42)], &keys) + .unwrap(); + resolver + .initiate_resolution(PaymentId([3; 32]), name.clone(), vec![dest(42)], &keys) + .unwrap(); assert_eq!(resolver.pending_resolves.lock().unwrap().len(), 1); assert_eq!(resolver.pending_resolves.lock().unwrap().iter().next().unwrap().1.len(), 3); // after another block the first will expire, but the second and third won't @@ -857,7 +867,7 @@ mod tests { // Resolve a name, sending the query to two resolvers. Each query gets its own unique // context in its reply path. let messages = resolver - .resolve_name(PaymentId([0; 32]), name.clone(), vec![dest(1), dest(2)], &keys) + .initiate_resolution(PaymentId([0; 32]), name.clone(), vec![dest(1), dest(2)], &keys) .unwrap(); assert_eq!(messages.len(), 2); let (dns_name, contexts) = dns_name_and_contexts(&messages); @@ -903,10 +913,13 @@ mod tests { let resolver = OMNameResolver::new(42, 42); let name = HumanReadableName::new("user", "example.com").unwrap(); - let messages = - resolver.resolve_name(PaymentId([0; 32]), name.clone(), vec![dest(1)], &keys).unwrap(); + let messages = resolver + .initiate_resolution(PaymentId([0; 32]), name.clone(), vec![dest(1)], &keys) + .unwrap(); let (dns_name, contexts_a) = dns_name_and_contexts(&messages); - resolver.resolve_name(PaymentId([1; 32]), name.clone(), vec![dest(2)], &keys).unwrap(); + resolver + .initiate_resolution(PaymentId([1; 32]), name.clone(), vec![dest(2)], &keys) + .unwrap(); { let pending_resolves = resolver.pending_resolves.lock().unwrap(); let pending_queries_for_name = &pending_resolves.iter().next().unwrap().1; From 0c352c555eea15dbe19205783cdbcb02ff6fccb2 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo <vincenzopalazzodev@gmail.com> Date: Mon, 27 Jul 2026 01:25:18 +0200 Subject: [PATCH 614/627] blinded_path: only compact introduction nodes via channels enabled both ways When selecting a channel to reference a compact (DirectedShortChannelId) introduction node, only consider channels that are enabled in both directions. Disabled channels cannot be used to reach the introduction node, and such channels may linger in the local network graph long after being disabled or even closed (e.g., when sourcing gossip from rapid gossip sync, which never removes closed channels). Previously, the oldest channel of the introduction node was selected unconditionally, which could produce blinded paths that senders cannot resolve or route to, silently breaking long-lived paths such as those embedded in BOLT 12 offers. If no enabled channel is found, the NodeId encoding is kept. Fixes #4826. --- lightning/src/blinded_path/message.rs | 161 ++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs index 67233e306c2..2f67cfdaca9 100644 --- a/lightning/src/blinded_path/message.rs +++ b/lightning/src/blinded_path/message.rs @@ -147,10 +147,18 @@ impl BlindedMessagePath { if let IntroductionNode::NodeId(pubkey) = &self.0.introduction_node { let node_id = NodeId::from_pubkey(pubkey); if let Some(node_info) = network_graph.node(&node_id) { + // We don't consider channels that are disabled in either direction, as it may be + // an indication that the channel has closed and simply hasn't been removed from + // our graph yet. If no such channel is found, the `NodeId` representation is + // kept. if let Some((scid, channel_info)) = node_info .channels .iter() .filter_map(|scid| network_graph.channel(*scid).map(|info| (*scid, info))) + .filter(|(_, info)| { + info.one_to_two.as_ref().map(|dir| dir.enabled).unwrap_or(false) + && info.two_to_one.as_ref().map(|dir| dir.enabled).unwrap_or(false) + }) .min_by_key(|(scid, _)| scid_utils::block_from_scid(*scid)) { let direction = if node_id == channel_info.node_one { @@ -823,3 +831,156 @@ pub(super) fn blinded_hops<T: secp256k1::Signing + secp256k1::Verification>( let path = pks.zip(tlvs); utils::construct_blinded_hops(secp_ctx, path, session_priv) } + +#[cfg(test)] +mod tests { + use bitcoin::constants::ChainHash; + use bitcoin::network::Network; + use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + + use crate::blinded_path::message::{BlindedMessagePath, MessageContext, MessageForwardNode}; + use crate::blinded_path::IntroductionNode; + use crate::ln::msgs::{UnsignedChannelUpdate, MAX_VALUE_MSAT}; + use crate::routing::gossip::{NetworkGraph, P2PGossipSync}; + use crate::routing::test_utils::{add_channel, update_channel}; + use crate::sign::ReceiveAuthKey; + use crate::sync::Arc; + use crate::types::features::ChannelFeatures; + use crate::util::test_utils::{TestKeysInterface, TestLogger}; + + fn channel_update( + short_channel_id: u64, timestamp: u32, channel_flags: u8, + ) -> UnsignedChannelUpdate { + UnsignedChannelUpdate { + chain_hash: ChainHash::using_genesis_block(Network::Testnet), + short_channel_id, + timestamp, + message_flags: 1, // Only must_be_one + channel_flags, + cltv_expiry_delta: 0, + htlc_minimum_msat: 0, + htlc_maximum_msat: MAX_VALUE_MSAT, + fee_base_msat: 0, + fee_proportional_millionths: 0, + excess_data: Vec::new(), + } + } + + fn one_hop_path( + secp_ctx: &Secp256k1<bitcoin::secp256k1::All>, introduction_node_id: PublicKey, + recipient_node_id: PublicKey, entropy: &TestKeysInterface, + ) -> BlindedMessagePath { + let intermediate_nodes = + [MessageForwardNode { node_id: introduction_node_id, short_channel_id: None }]; + BlindedMessagePath::new( + &intermediate_nodes, + recipient_node_id, + ReceiveAuthKey([42; 32]), + MessageContext::Custom(Vec::new()), + false, + entropy, + secp_ctx, + ) + } + + #[test] + fn compact_introduction_node_skips_disabled_channels() { + // The compact (DirectedShortChannelId) introduction node encoding must only use + // channels that are enabled in both directions: disabled or closed channels may + // linger in the local network graph (e.g., when sourcing gossip from rapid gossip + // sync, which never removes them), and must not be selected, as senders would be + // unable to resolve (or route to) the introduction node. + let secp_ctx = Secp256k1::new(); + let logger = Arc::new(TestLogger::new()); + let network_graph = Arc::new(NetworkGraph::new(Network::Testnet, Arc::clone(&logger))); + let gossip_sync = P2PGossipSync::new(Arc::clone(&network_graph), None, Arc::clone(&logger)); + let entropy = TestKeysInterface::new(&[0; 32], Network::Testnet); + + let node_a_privkey = SecretKey::from_slice(&[41; 32]).unwrap(); + let node_b_privkey = SecretKey::from_slice(&[43; 32]).unwrap(); + let node_a_pubkey = PublicKey::from_secret_key(&secp_ctx, &node_a_privkey); + let recipient_pubkey = PublicKey::from_secret_key(&secp_ctx, &node_b_privkey); + + let disabled_scid = 100 << 40 | 1 << 16; + let enabled_scid = 200 << 40 | 1 << 16; + + // Add an older channel which is disabled in both directions, as is the case for a + // closed channel lingering in the local graph. + add_channel( + &gossip_sync, + &secp_ctx, + &node_a_privkey, + &node_b_privkey, + ChannelFeatures::from_le_bytes(vec![1]), + disabled_scid, + ); + update_channel( + &gossip_sync, + &secp_ctx, + &node_a_privkey, + channel_update(disabled_scid, 1, 2), + ); + update_channel( + &gossip_sync, + &secp_ctx, + &node_b_privkey, + channel_update(disabled_scid, 1, 3), + ); + + // Add a newer channel which is enabled in both directions. + add_channel( + &gossip_sync, + &secp_ctx, + &node_a_privkey, + &node_b_privkey, + ChannelFeatures::from_le_bytes(vec![2]), + enabled_scid, + ); + update_channel( + &gossip_sync, + &secp_ctx, + &node_a_privkey, + channel_update(enabled_scid, 2, 0), + ); + update_channel( + &gossip_sync, + &secp_ctx, + &node_b_privkey, + channel_update(enabled_scid, 2, 1), + ); + + // Even though the disabled channel is older, the enabled one is selected. + { + let network_graph = network_graph.read_only(); + let mut path = one_hop_path(&secp_ctx, node_a_pubkey, recipient_pubkey, &entropy); + path.use_compact_introduction_node(&network_graph); + match path.introduction_node() { + IntroductionNode::DirectedShortChannelId(_, scid) => { + assert_eq!(*scid, enabled_scid) + }, + IntroductionNode::NodeId(..) => panic!("expected a compact introduction node"), + } + } + + // Once the enabled channel is disabled as well, the `NodeId` encoding is kept. + update_channel( + &gossip_sync, + &secp_ctx, + &node_a_privkey, + channel_update(enabled_scid, 3, 2), + ); + update_channel( + &gossip_sync, + &secp_ctx, + &node_b_privkey, + channel_update(enabled_scid, 3, 3), + ); + + let network_graph = network_graph.read_only(); + let mut path = one_hop_path(&secp_ctx, node_a_pubkey, recipient_pubkey, &entropy); + path.use_compact_introduction_node(&network_graph); + assert!( + matches!(path.introduction_node(), IntroductionNode::NodeId(pubkey) if *pubkey == node_a_pubkey) + ); + } +} From 5b8a8bf787ad96addaaddd732627a686272f6a9b Mon Sep 17 00:00:00 2001 From: Joost Jager <joost.jager@gmail.com> Date: Thu, 9 Jul 2026 16:43:26 +0200 Subject: [PATCH 615/627] fuzz: derive routes from payment paths Build direct, forwarded, and MPP routes from a shared per-path hop description. Derive route fees and node metadata from that description, and increase the CLTV delta by 100 for each successive hop. Later failure tracking needs to describe the exact route that was sent. Keeping route construction and bookkeeping on one representation prevents them from drifting and prepares the tracker to retain more per-hop failure context. --- fuzz/src/chanmon_consistency.rs | 207 +++++++++++++++----------------- 1 file changed, 96 insertions(+), 111 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 94872419104..7c17dc9d5f9 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1943,6 +1943,14 @@ impl PeerLink { } } +#[derive(Clone, Copy)] +struct PaymentHop { + amount_msat: u64, + short_channel_id: u64, +} + +type PaymentPath = Vec<PaymentHop>; + struct PendingPayment { payment_id: PaymentId, payment_hash: PaymentHash, @@ -2069,6 +2077,42 @@ impl PaymentTracker { (secret, hash, id) } + fn route_from_payment_paths( + payment_paths: &[PaymentPath], path_nodes: &[&HarnessNode<'_>], + route_params: RouteParameters, + ) -> Route { + let paths = payment_paths + .iter() + .map(|payment_path| { + assert_eq!(payment_path.len(), path_nodes.len()); + let hops = payment_path + .iter() + .enumerate() + .map(|(idx, hop)| { + let node = path_nodes[idx]; + let fee_msat = + payment_path.get(idx + 1).map_or(hop.amount_msat, |next_hop| { + hop.amount_msat.checked_sub(next_hop.amount_msat).expect( + "payment path amounts must not increase toward the recipient", + ) + }); + RouteHop { + pubkey: node.get_our_node_id(), + node_features: node.node_features(), + short_channel_id: hop.short_channel_id, + channel_features: node.channel_features(), + fee_msat, + cltv_expiry_delta: (idx as u32 + 1) * 100, + maybe_announced_channel: true, + } + }) + .collect(); + Path { hops, blinded_tail: None } + }) + .collect(); + Route { paths, route_params } + } + fn send( &mut self, nodes: &[HarnessNode<'_>; 3], source_idx: usize, dest_idx: usize, dest_chan_id: ChannelId, amt: u64, @@ -2088,25 +2132,13 @@ impl PaymentTracker { ) }) .unwrap_or((0, 0, 0)); + let payment_paths = + vec![vec![PaymentHop { amount_msat: amt, short_channel_id: dest_scid }]]; let route_params = RouteParameters::from_payment_params_and_value( PaymentParameters::from_node_id(source.get_our_node_id(), TEST_FINAL_CLTV), amt, ); - let route = Route { - paths: vec![Path { - hops: vec![RouteHop { - pubkey: dest.get_our_node_id(), - node_features: dest.node_features(), - short_channel_id: dest_scid, - channel_features: dest.channel_features(), - fee_msat: amt, - cltv_expiry_delta: 200, - maybe_announced_channel: true, - }], - blinded_tail: None, - }], - route_params, - }; + let route = Self::route_from_payment_paths(&payment_paths, &[dest], route_params); let onion = RecipientOnionFields::secret_only(secret, amt); let res = source.send_payment_with_route(route, hash, onion, id); let succeeded = match res { @@ -2157,36 +2189,15 @@ impl PaymentTracker { .and_then(|chan| chan.short_channel_id) .unwrap_or(0); let first_hop_fee = 50_000; + let payment_paths = vec![vec![ + PaymentHop { amount_msat: amt + first_hop_fee, short_channel_id: middle_scid }, + PaymentHop { amount_msat: amt, short_channel_id: dest_scid }, + ]]; let route_params = RouteParameters::from_payment_params_and_value( PaymentParameters::from_node_id(source.get_our_node_id(), TEST_FINAL_CLTV), amt, ); - let route = Route { - paths: vec![Path { - hops: vec![ - RouteHop { - pubkey: middle.get_our_node_id(), - node_features: middle.node_features(), - short_channel_id: middle_scid, - channel_features: middle.channel_features(), - fee_msat: first_hop_fee, - cltv_expiry_delta: 100, - maybe_announced_channel: true, - }, - RouteHop { - pubkey: dest.get_our_node_id(), - node_features: dest.node_features(), - short_channel_id: dest_scid, - channel_features: dest.channel_features(), - fee_msat: amt, - cltv_expiry_delta: 200, - maybe_announced_channel: true, - }, - ], - blinded_tail: None, - }], - route_params, - }; + let route = Self::route_from_payment_paths(&payment_paths, &[middle, dest], route_params); let onion = RecipientOnionFields::secret_only(secret, amt); let res = source.send_payment_with_route(route, hash, onion, id); let succeeded = match res { @@ -2231,43 +2242,37 @@ impl PaymentTracker { } let amt_per_path = amt / num_paths as u64; - let mut paths = Vec::with_capacity(num_paths); let dest_chans = dest.list_channels(); - let dest_scids = dest_chan_ids.iter().map(|chan_id| { - dest_chans - .iter() - .find(|chan| chan.channel_id == *chan_id) - .and_then(|chan| chan.short_channel_id) - .unwrap() - }); - - for (i, dest_scid) in dest_scids.enumerate() { - let path_amt = if i == num_paths - 1 { - amt - amt_per_path * (num_paths as u64 - 1) - } else { - amt_per_path - }; + let dest_scids: Vec<_> = dest_chan_ids + .iter() + .map(|chan_id| { + dest_chans + .iter() + .find(|chan| chan.channel_id == *chan_id) + .and_then(|chan| chan.short_channel_id) + .unwrap() + }) + .collect(); - paths.push(Path { - hops: vec![RouteHop { - pubkey: dest.get_our_node_id(), - node_features: dest.node_features(), - short_channel_id: dest_scid, - channel_features: dest.channel_features(), - fee_msat: path_amt, - cltv_expiry_delta: 200, - maybe_announced_channel: true, - }], - blinded_tail: None, - }); - } + let payment_paths: Vec<PaymentPath> = dest_scids + .iter() + .enumerate() + .map(|(i, dest_scid)| { + let path_amt = if i == num_paths - 1 { + amt - amt_per_path * (num_paths as u64 - 1) + } else { + amt_per_path + }; + vec![PaymentHop { amount_msat: path_amt, short_channel_id: *dest_scid }] + }) + .collect(); let route_params = RouteParameters::from_payment_params_and_value( PaymentParameters::from_node_id(dest.get_our_node_id(), TEST_FINAL_CLTV), amt, ); - let route = Route { paths, route_params }; + let route = Self::route_from_payment_paths(&payment_paths, &[dest], route_params); let onion = RecipientOnionFields::secret_only(secret, amt); let res = source.send_payment_with_route(route, hash, onion, id); let succeeded = match res { @@ -2301,7 +2306,6 @@ impl PaymentTracker { let first_hop_fee = 50_000; let amt_per_path = amt / num_paths as u64; let fee_per_path = first_hop_fee / num_paths as u64; - let mut paths = Vec::with_capacity(num_paths); let middle_chans = middle.list_channels(); let middle_scids: Vec<_> = middle_chan_ids @@ -2327,51 +2331,32 @@ impl PaymentTracker { }) .collect(); - for i in 0..num_paths { - let middle_scid = middle_scids[i % middle_scids.len()]; - let dest_scid = dest_scids[i % dest_scids.len()]; - - let path_amt = if i == num_paths - 1 { - amt - amt_per_path * (num_paths as u64 - 1) - } else { - amt_per_path - }; - let path_fee = if i == num_paths - 1 { - first_hop_fee - fee_per_path * (num_paths as u64 - 1) - } else { - fee_per_path - }; - - paths.push(Path { - hops: vec![ - RouteHop { - pubkey: middle.get_our_node_id(), - node_features: middle.node_features(), - short_channel_id: middle_scid, - channel_features: middle.channel_features(), - fee_msat: path_fee, - cltv_expiry_delta: 100, - maybe_announced_channel: true, - }, - RouteHop { - pubkey: dest.get_our_node_id(), - node_features: dest.node_features(), - short_channel_id: dest_scid, - channel_features: dest.channel_features(), - fee_msat: path_amt, - cltv_expiry_delta: 200, - maybe_announced_channel: true, - }, - ], - blinded_tail: None, - }); - } + let payment_paths: Vec<PaymentPath> = (0..num_paths) + .map(|i| { + let middle_scid = middle_scids[i % middle_scids.len()]; + let dest_scid = dest_scids[i % dest_scids.len()]; + let path_amt = if i == num_paths - 1 { + amt - amt_per_path * (num_paths as u64 - 1) + } else { + amt_per_path + }; + let path_fee = if i == num_paths - 1 { + first_hop_fee - fee_per_path * (num_paths as u64 - 1) + } else { + fee_per_path + }; + vec![ + PaymentHop { amount_msat: path_amt + path_fee, short_channel_id: middle_scid }, + PaymentHop { amount_msat: path_amt, short_channel_id: dest_scid }, + ] + }) + .collect(); let route_params = RouteParameters::from_payment_params_and_value( PaymentParameters::from_node_id(dest.get_our_node_id(), TEST_FINAL_CLTV), amt, ); - let route = Route { paths, route_params }; + let route = Self::route_from_payment_paths(&payment_paths, &[middle, dest], route_params); let onion = RecipientOnionFields::secret_only(secret, amt); let res = source.send_payment_with_route(route, hash, onion, id); let succeeded = match res { From 671ccb0da584611f4f4bfc86de7e7fb5fb7c01b0 Mon Sep 17 00:00:00 2001 From: Joost Jager <joost.jager@gmail.com> Date: Fri, 10 Jul 2026 11:47:54 +0200 Subject: [PATCH 616/627] fuzz: centralize payment send tracking Rename the send-state query and local variables to describe whether LDK still has pending work. Route the existing pending-payment registration through one helper without changing which sends are tracked. Later payment invariants must apply consistently to direct, forwarded, and MPP sends. Centralizing this lifecycle decision keeps tracking changes from diverging between send helpers. --- fuzz/src/chanmon_consistency.rs | 74 ++++++++++++++------------------- 1 file changed, 31 insertions(+), 43 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 7c17dc9d5f9..11a3ed19a0a 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -2043,8 +2043,7 @@ impl PaymentTracker { } } - // Returns a bool indicating whether the payment failed. - fn check_payment_send_events(source: &ChanMan, sent_payment_id: PaymentId) -> bool { + fn payment_has_pending_work(source: &ChanMan, sent_payment_id: PaymentId) -> bool { for payment in source.list_recent_payments() { match payment { RecentPaymentDetails::Pending { payment_id, .. } @@ -2113,6 +2112,19 @@ impl PaymentTracker { Route { paths, route_params } } + fn record_send_result( + &mut self, source_idx: usize, source: &HarnessNode<'_>, payment_id: PaymentId, + payment_hash: PaymentHash, has_pending_work: bool, + ) { + if has_pending_work { + self.nodes[source_idx].add_pending( + payment_id, + payment_hash, + source.next_manager_persistence_generation(), + ); + } + } + fn send( &mut self, nodes: &[HarnessNode<'_>; 3], source_idx: usize, dest_idx: usize, dest_chan_id: ChannelId, amt: u64, @@ -2141,25 +2153,19 @@ impl PaymentTracker { let route = Self::route_from_payment_paths(&payment_paths, &[dest], route_params); let onion = RecipientOnionFields::secret_only(secret, amt); let res = source.send_payment_with_route(route, hash, onion, id); - let succeeded = match res { + let has_pending_work = match res { Err(err) => { panic!("Errored with {:?} on initial payment send", err); }, Ok(()) => { let expect_failure = amt < min_value_sendable || amt > max_value_sendable; - let succeeded = Self::check_payment_send_events(source, id); - assert_eq!(succeeded, !expect_failure); - succeeded + let has_pending_work = Self::payment_has_pending_work(source, id); + assert_eq!(has_pending_work, !expect_failure); + has_pending_work }, }; - if succeeded { - self.nodes[source_idx].add_pending( - id, - hash, - source.next_manager_persistence_generation(), - ); - } - succeeded + self.record_send_result(source_idx, source, id, hash, has_pending_work); + has_pending_work } fn send_hop( @@ -2200,25 +2206,19 @@ impl PaymentTracker { let route = Self::route_from_payment_paths(&payment_paths, &[middle, dest], route_params); let onion = RecipientOnionFields::secret_only(secret, amt); let res = source.send_payment_with_route(route, hash, onion, id); - let succeeded = match res { + let has_pending_work = match res { Err(err) => { panic!("Errored with {:?} on initial payment send", err); }, Ok(()) => { let sent_amt = amt + first_hop_fee; let expect_failure = sent_amt < min_value_sendable || sent_amt > max_value_sendable; - let succeeded = Self::check_payment_send_events(source, id); - assert_eq!(succeeded, !expect_failure); - succeeded + let has_pending_work = Self::payment_has_pending_work(source, id); + assert_eq!(has_pending_work, !expect_failure); + has_pending_work }, }; - if succeeded { - self.nodes[source_idx].add_pending( - id, - hash, - source.next_manager_persistence_generation(), - ); - } + self.record_send_result(source_idx, source, id, hash, has_pending_work); } fn send_noret( @@ -2275,17 +2275,11 @@ impl PaymentTracker { let route = Self::route_from_payment_paths(&payment_paths, &[dest], route_params); let onion = RecipientOnionFields::secret_only(secret, amt); let res = source.send_payment_with_route(route, hash, onion, id); - let succeeded = match res { + let has_pending_work = match res { Err(_) => false, - Ok(()) => Self::check_payment_send_events(source, id), + Ok(()) => Self::payment_has_pending_work(source, id), }; - if succeeded { - self.nodes[source_idx].add_pending( - id, - hash, - source.next_manager_persistence_generation(), - ); - } + self.record_send_result(source_idx, source, id, hash, has_pending_work); } // MPP payment via hop - splits payment across multiple channels on either or both hops @@ -2359,17 +2353,11 @@ impl PaymentTracker { let route = Self::route_from_payment_paths(&payment_paths, &[middle, dest], route_params); let onion = RecipientOnionFields::secret_only(secret, amt); let res = source.send_payment_with_route(route, hash, onion, id); - let succeeded = match res { + let has_pending_work = match res { Err(_) => false, - Ok(()) => Self::check_payment_send_events(source, id), + Ok(()) => Self::payment_has_pending_work(source, id), }; - if succeeded { - self.nodes[source_idx].add_pending( - id, - hash, - source.next_manager_persistence_generation(), - ); - } + self.record_send_result(source_idx, source, id, hash, has_pending_work); } fn claim_payment(&mut self, node: &HarnessNode<'_>, payment_hash: PaymentHash, fail: bool) { From fed08b487b7303aac9f9478a6f2c001d6baf54aa Mon Sep 17 00:00:00 2001 From: Joost Jager <joost.jager@gmail.com> Date: Fri, 10 Jul 2026 13:37:17 +0200 Subject: [PATCH 617/627] fuzz: track payment send resolutions Register every send as pending, then move immediate outcomes through one strict pending-to-resolved transition. Reuse that transition for terminal events and manager rollback, while handling repeated terminal events only after confirming the payment was already resolved. Keep abandoned payments pending while they still have outbound HTLC state, including holding-cell HTLCs. Later success checks need complete tracker state and must reject terminal events for payments the harness never recorded. --- fuzz/src/chanmon_consistency.rs | 97 ++++++++++++++++++++------------- 1 file changed, 60 insertions(+), 37 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 11a3ed19a0a..033279f409c 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1971,6 +1971,8 @@ impl NodePayments { &mut self, payment_id: PaymentId, payment_hash: PaymentHash, first_persisted_manager_generation: u64, ) { + assert!(!self.pending.iter().any(|pending| pending.payment_id == payment_id)); + assert!(!self.resolved.contains_key(&payment_id)); self.pending.push(PendingPayment { payment_id, payment_hash, @@ -1978,51 +1980,64 @@ impl NodePayments { }); } + fn resolve_pending( + &mut self, payment_id: PaymentId, payment_hash: Option<PaymentHash>, + ) -> PendingPayment { + assert!(!self.resolved.contains_key(&payment_id)); + let idx = self + .pending + .iter() + .position(|pending| pending.payment_id == payment_id) + .expect("resolved payment must be pending"); + let pending = self.pending.remove(idx); + if let Some(payment_hash) = payment_hash { + assert_eq!(pending.payment_hash, payment_hash); + } + assert!(self.resolved.insert(payment_id, payment_hash).is_none()); + pending + } + fn mark_sent(&mut self, sent_id: PaymentId, payment_hash: PaymentHash) { - let idx_opt = self.pending.iter().position(|pending| pending.payment_id == sent_id); - if let Some(idx) = idx_opt { - self.pending.remove(idx); - self.resolved.insert(sent_id, Some(payment_hash)); + if self.pending.iter().any(|pending| pending.payment_id == sent_id) { + self.resolve_pending(sent_id, Some(payment_hash)); + } else if let Some(resolved_hash) = self.resolved.get_mut(&sent_id) { + if let Some(existing_hash) = *resolved_hash { + assert_eq!(existing_hash, payment_hash); + } else { + *resolved_hash = Some(payment_hash); + } } else { - assert!(self.resolved.contains_key(&sent_id)); + panic!("Payment {:?} sent without being tracked", sent_id); } } fn mark_resolved_without_hash(&mut self, payment_id: PaymentId) { - let idx_opt = self.pending.iter().position(|pending| pending.payment_id == payment_id); - if let Some(idx) = idx_opt { - self.pending.remove(idx); - self.resolved.insert(payment_id, None); - } else if !self.resolved.contains_key(&payment_id) { - // Some resolutions can arrive immediately, before the send helper records - // the payment as pending. Track them so later duplicate events are accepted. - self.resolved.insert(payment_id, None); + if self.pending.iter().any(|pending| pending.payment_id == payment_id) { + self.resolve_pending(payment_id, None); + } else { + assert!(self.resolved.contains_key(&payment_id)); } } fn mark_successful_probe(&mut self, payment_id: PaymentId) { - let idx_opt = self.pending.iter().position(|pending| pending.payment_id == payment_id); - if let Some(idx) = idx_opt { - self.pending.remove(idx); - self.resolved.insert(payment_id, None); - } else { - assert!(self.resolved.contains_key(&payment_id)); - } + self.mark_resolved_without_hash(payment_id); } fn sync_pending_with_manager_generation( &mut self, loaded_manager_generation: u64, ) -> Vec<PaymentHash> { - let mut rolled_back_payment_hashes = Vec::new(); - let pending = mem::take(&mut self.pending); - for pending_payment in pending { - if pending_payment.first_persisted_manager_generation > loaded_manager_generation { - rolled_back_payment_hashes.push(pending_payment.payment_hash); - } else { - self.pending.push(pending_payment); - } + let rolled_back_payments = self + .pending + .iter() + .filter(|pending| { + pending.first_persisted_manager_generation > loaded_manager_generation + }) + .map(|pending| (pending.payment_id, pending.payment_hash)) + .collect::<Vec<_>>(); + for (payment_id, _) in &rolled_back_payments { + self.resolve_pending(*payment_id, None); } - rolled_back_payment_hashes + rolled_back_payments.into_iter().map(|(_, payment_hash)| payment_hash).collect() } } @@ -2051,10 +2066,10 @@ impl PaymentTracker { { return true; }, - RecentPaymentDetails::Abandoned { payment_id, .. } + RecentPaymentDetails::Abandoned { payment_id, payment_hash, .. } if payment_id == sent_payment_id => { - return false; + return Self::has_outbound_htlc(source, payment_hash); }, _ => {}, } @@ -2112,16 +2127,24 @@ impl PaymentTracker { Route { paths, route_params } } + fn has_outbound_htlc(source: &ChanMan, payment_hash: PaymentHash) -> bool { + source.list_channels().iter().any(|chan| { + chan.pending_outbound_htlcs.iter().any(|htlc| htlc.payment_hash == payment_hash) + }) + } + fn record_send_result( &mut self, source_idx: usize, source: &HarnessNode<'_>, payment_id: PaymentId, payment_hash: PaymentHash, has_pending_work: bool, ) { - if has_pending_work { - self.nodes[source_idx].add_pending( - payment_id, - payment_hash, - source.next_manager_persistence_generation(), - ); + let node_payments = &mut self.nodes[source_idx]; + node_payments.add_pending( + payment_id, + payment_hash, + source.next_manager_persistence_generation(), + ); + if !has_pending_work { + node_payments.resolve_pending(payment_id, None); } } From e704b74ef84c645872d47145e08fdcb2ac68598d Mon Sep 17 00:00:00 2001 From: Joost Jager <joost.jager@gmail.com> Date: Mon, 13 Jul 2026 15:27:03 +0200 Subject: [PATCH 618/627] fuzz: prepare payment failure tracking Carry each send's minimum final CLTV expiry into payment registration. Thread PaymentTracker through HTLC message delivery and separate PaymentFailed from ProbeFailed dispatch. A later invariant commit uses this context to classify failure roots. Keeping the plumbing separate reduces its behavioral review even though the minimum expiry remains temporarily unread. --- fuzz/src/chanmon_consistency.rs | 81 +++++++++++++++++++++++++++++---- 1 file changed, 71 insertions(+), 10 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 033279f409c..5d493d5bc92 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1955,6 +1955,7 @@ struct PendingPayment { payment_id: PaymentId, payment_hash: PaymentHash, first_persisted_manager_generation: u64, + min_final_cltv_expiry: u32, } struct NodePayments { @@ -1969,7 +1970,7 @@ impl NodePayments { fn add_pending( &mut self, payment_id: PaymentId, payment_hash: PaymentHash, - first_persisted_manager_generation: u64, + first_persisted_manager_generation: u64, min_final_cltv_expiry: u32, ) { assert!(!self.pending.iter().any(|pending| pending.payment_id == payment_id)); assert!(!self.resolved.contains_key(&payment_id)); @@ -1977,6 +1978,7 @@ impl NodePayments { payment_id, payment_hash, first_persisted_manager_generation, + min_final_cltv_expiry, }); } @@ -2133,15 +2135,33 @@ impl PaymentTracker { }) } + fn route_min_final_cltv_expiry(route: &Route, source_best_block_height: u32) -> u32 { + let cur_height = source_best_block_height.saturating_add(1); + route + .paths + .iter() + .map(|path| { + cur_height.saturating_add( + path.hops + .last() + .expect("payment path should contain at least one hop") + .cltv_expiry_delta, + ) + }) + .min() + .expect("payment route should contain at least one path") + } + fn record_send_result( &mut self, source_idx: usize, source: &HarnessNode<'_>, payment_id: PaymentId, - payment_hash: PaymentHash, has_pending_work: bool, + payment_hash: PaymentHash, min_final_cltv_expiry: u32, has_pending_work: bool, ) { let node_payments = &mut self.nodes[source_idx]; node_payments.add_pending( payment_id, payment_hash, source.next_manager_persistence_generation(), + min_final_cltv_expiry, ); if !has_pending_work { node_payments.resolve_pending(payment_id, None); @@ -2174,6 +2194,8 @@ impl PaymentTracker { amt, ); let route = Self::route_from_payment_paths(&payment_paths, &[dest], route_params); + let min_final_cltv_expiry = + Self::route_min_final_cltv_expiry(&route, source.current_best_block().height); let onion = RecipientOnionFields::secret_only(secret, amt); let res = source.send_payment_with_route(route, hash, onion, id); let has_pending_work = match res { @@ -2187,7 +2209,14 @@ impl PaymentTracker { has_pending_work }, }; - self.record_send_result(source_idx, source, id, hash, has_pending_work); + self.record_send_result( + source_idx, + source, + id, + hash, + min_final_cltv_expiry, + has_pending_work, + ); has_pending_work } @@ -2227,6 +2256,8 @@ impl PaymentTracker { amt, ); let route = Self::route_from_payment_paths(&payment_paths, &[middle, dest], route_params); + let min_final_cltv_expiry = + Self::route_min_final_cltv_expiry(&route, source.current_best_block().height); let onion = RecipientOnionFields::secret_only(secret, amt); let res = source.send_payment_with_route(route, hash, onion, id); let has_pending_work = match res { @@ -2241,7 +2272,14 @@ impl PaymentTracker { has_pending_work }, }; - self.record_send_result(source_idx, source, id, hash, has_pending_work); + self.record_send_result( + source_idx, + source, + id, + hash, + min_final_cltv_expiry, + has_pending_work, + ); } fn send_noret( @@ -2296,13 +2334,22 @@ impl PaymentTracker { amt, ); let route = Self::route_from_payment_paths(&payment_paths, &[dest], route_params); + let min_final_cltv_expiry = + Self::route_min_final_cltv_expiry(&route, source.current_best_block().height); let onion = RecipientOnionFields::secret_only(secret, amt); let res = source.send_payment_with_route(route, hash, onion, id); let has_pending_work = match res { Err(_) => false, Ok(()) => Self::payment_has_pending_work(source, id), }; - self.record_send_result(source_idx, source, id, hash, has_pending_work); + self.record_send_result( + source_idx, + source, + id, + hash, + min_final_cltv_expiry, + has_pending_work, + ); } // MPP payment via hop - splits payment across multiple channels on either or both hops @@ -2374,13 +2421,22 @@ impl PaymentTracker { amt, ); let route = Self::route_from_payment_paths(&payment_paths, &[middle, dest], route_params); + let min_final_cltv_expiry = + Self::route_min_final_cltv_expiry(&route, source.current_best_block().height); let onion = RecipientOnionFields::secret_only(secret, amt); let res = source.send_payment_with_route(route, hash, onion, id); let has_pending_work = match res { Err(_) => false, Ok(()) => Self::payment_has_pending_work(source, id), }; - self.record_send_result(source_idx, source, id, hash, has_pending_work); + self.record_send_result( + source_idx, + source, + id, + hash, + min_final_cltv_expiry, + has_pending_work, + ); } fn claim_payment(&mut self, node: &HarnessNode<'_>, payment_hash: PaymentHash, fail: bool) { @@ -3067,7 +3123,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { fn handle_update_htlcs_event<Out: Output + MaybeSend + MaybeSync>( node_idx: usize, source_node_id: PublicKey, node_id: PublicKey, channel_id: ChannelId, updates: CommitmentUpdate, corrupt_forward: bool, limit_events: ProcessMessages, - nodes: &[HarnessNode<'_>; 3], out: &Out, + nodes: &[HarnessNode<'_>; 3], _payments: &mut PaymentTracker, out: &Out, ) -> Option<MessageSendEvent> { let dest_idx = find_destination_node(nodes, &node_id); let dest = &nodes[dest_idx]; @@ -3127,7 +3183,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { fn process_msg_event<Out: Output + MaybeSend + MaybeSync>( node_idx: usize, source_node_id: PublicKey, event: MessageSendEvent, corrupt_forward: bool, limit_events: ProcessMessages, nodes: &[HarnessNode<'_>; 3], - close_tracker: &ChannelCloseTracker, out: &Out, + payments: &mut PaymentTracker, close_tracker: &ChannelCloseTracker, out: &Out, ) -> Option<MessageSendEvent> { // Always deliver message events, even when the harness knows they are stale, // so message handlers exercise their normal error paths. @@ -3142,6 +3198,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { corrupt_forward, limit_events, nodes, + payments, out, ) }, @@ -3270,6 +3327,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { } let nodes = &self.nodes; + let payments = &mut self.payments; let close_tracker = &self.close_tracker; let out = &self.out; let queues = &mut self.queues; @@ -3291,6 +3349,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { corrupt_forward, limit_events, nodes, + payments, close_tracker, out, ); @@ -3341,8 +3400,10 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { events::Event::ProbeSuccessful { payment_id, .. } => { payments.nodes[node_idx].mark_successful_probe(payment_id); }, - events::Event::PaymentFailed { payment_id, .. } - | events::Event::ProbeFailed { payment_id, .. } => { + events::Event::PaymentFailed { payment_id, .. } => { + payments.nodes[node_idx].mark_resolved_without_hash(payment_id); + }, + events::Event::ProbeFailed { payment_id, .. } => { payments.nodes[node_idx].mark_resolved_without_hash(payment_id); }, events::Event::PaymentClaimed { .. } => {}, From a01754d702c93f1dce4c4895622bb9893bd0981a Mon Sep 17 00:00:00 2001 From: Joost Jager <joost.jager@gmail.com> Date: Tue, 14 Jul 2026 12:53:28 +0200 Subject: [PATCH 619/627] fuzz: retain channel ids in payment paths Keep each hop ChannelId beside its SCID and retain the complete set of paths while a payment is pending. This is mechanical preparation; the new fields are intentionally not interpreted yet. SCIDs remain the route-building input, while explicit closes identify channels by ChannelId. Carrying both representations in one path model lets a later force-close allowance match affected payments without reconstructing routes or maintaining parallel bookkeeping. --- fuzz/src/chanmon_consistency.rs | 69 ++++++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 18 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 5d493d5bc92..3363c99b30d 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1945,6 +1945,7 @@ impl PeerLink { #[derive(Clone, Copy)] struct PaymentHop { + channel_id: ChannelId, amount_msat: u64, short_channel_id: u64, } @@ -1955,6 +1956,7 @@ struct PendingPayment { payment_id: PaymentId, payment_hash: PaymentHash, first_persisted_manager_generation: u64, + paths: Vec<PaymentPath>, min_final_cltv_expiry: u32, } @@ -1970,14 +1972,17 @@ impl NodePayments { fn add_pending( &mut self, payment_id: PaymentId, payment_hash: PaymentHash, - first_persisted_manager_generation: u64, min_final_cltv_expiry: u32, + first_persisted_manager_generation: u64, paths: Vec<PaymentPath>, + min_final_cltv_expiry: u32, ) { assert!(!self.pending.iter().any(|pending| pending.payment_id == payment_id)); assert!(!self.resolved.contains_key(&payment_id)); + assert!(!paths.is_empty(), "tracked payment must have at least one path"); self.pending.push(PendingPayment { payment_id, payment_hash, first_persisted_manager_generation, + paths, min_final_cltv_expiry, }); } @@ -2154,13 +2159,15 @@ impl PaymentTracker { fn record_send_result( &mut self, source_idx: usize, source: &HarnessNode<'_>, payment_id: PaymentId, - payment_hash: PaymentHash, min_final_cltv_expiry: u32, has_pending_work: bool, + payment_hash: PaymentHash, payment_paths: Vec<PaymentPath>, min_final_cltv_expiry: u32, + has_pending_work: bool, ) { let node_payments = &mut self.nodes[source_idx]; node_payments.add_pending( payment_id, payment_hash, source.next_manager_persistence_generation(), + payment_paths, min_final_cltv_expiry, ); if !has_pending_work { @@ -2187,8 +2194,11 @@ impl PaymentTracker { ) }) .unwrap_or((0, 0, 0)); - let payment_paths = - vec![vec![PaymentHop { amount_msat: amt, short_channel_id: dest_scid }]]; + let payment_paths = vec![vec![PaymentHop { + channel_id: dest_chan_id, + amount_msat: amt, + short_channel_id: dest_scid, + }]]; let route_params = RouteParameters::from_payment_params_and_value( PaymentParameters::from_node_id(source.get_our_node_id(), TEST_FINAL_CLTV), amt, @@ -2214,6 +2224,7 @@ impl PaymentTracker { source, id, hash, + payment_paths, min_final_cltv_expiry, has_pending_work, ); @@ -2248,8 +2259,12 @@ impl PaymentTracker { .unwrap_or(0); let first_hop_fee = 50_000; let payment_paths = vec![vec![ - PaymentHop { amount_msat: amt + first_hop_fee, short_channel_id: middle_scid }, - PaymentHop { amount_msat: amt, short_channel_id: dest_scid }, + PaymentHop { + channel_id: middle_chan_id, + amount_msat: amt + first_hop_fee, + short_channel_id: middle_scid, + }, + PaymentHop { channel_id: dest_chan_id, amount_msat: amt, short_channel_id: dest_scid }, ]]; let route_params = RouteParameters::from_payment_params_and_value( PaymentParameters::from_node_id(source.get_our_node_id(), TEST_FINAL_CLTV), @@ -2277,6 +2292,7 @@ impl PaymentTracker { source, id, hash, + payment_paths, min_final_cltv_expiry, has_pending_work, ); @@ -2308,24 +2324,29 @@ impl PaymentTracker { let dest_scids: Vec<_> = dest_chan_ids .iter() .map(|chan_id| { - dest_chans + let scid = dest_chans .iter() .find(|chan| chan.channel_id == *chan_id) .and_then(|chan| chan.short_channel_id) - .unwrap() + .unwrap(); + (*chan_id, scid) }) .collect(); let payment_paths: Vec<PaymentPath> = dest_scids .iter() .enumerate() - .map(|(i, dest_scid)| { + .map(|(i, (chan_id, dest_scid))| { let path_amt = if i == num_paths - 1 { amt - amt_per_path * (num_paths as u64 - 1) } else { amt_per_path }; - vec![PaymentHop { amount_msat: path_amt, short_channel_id: *dest_scid }] + vec![PaymentHop { + channel_id: *chan_id, + amount_msat: path_amt, + short_channel_id: *dest_scid, + }] }) .collect(); @@ -2347,6 +2368,7 @@ impl PaymentTracker { source, id, hash, + payment_paths, min_final_cltv_expiry, has_pending_work, ); @@ -2375,11 +2397,12 @@ impl PaymentTracker { let middle_scids: Vec<_> = middle_chan_ids .iter() .map(|chan_id| { - middle_chans + let scid = middle_chans .iter() .find(|chan| chan.channel_id == *chan_id) .and_then(|chan| chan.short_channel_id) - .unwrap() + .unwrap(); + (*chan_id, scid) }) .collect(); @@ -2387,18 +2410,19 @@ impl PaymentTracker { let dest_scids: Vec<_> = dest_chan_ids .iter() .map(|chan_id| { - dest_chans + let scid = dest_chans .iter() .find(|chan| chan.channel_id == *chan_id) .and_then(|chan| chan.short_channel_id) - .unwrap() + .unwrap(); + (*chan_id, scid) }) .collect(); let payment_paths: Vec<PaymentPath> = (0..num_paths) .map(|i| { - let middle_scid = middle_scids[i % middle_scids.len()]; - let dest_scid = dest_scids[i % dest_scids.len()]; + let (middle_chan_id, middle_scid) = middle_scids[i % middle_scids.len()]; + let (dest_chan_id, dest_scid) = dest_scids[i % dest_scids.len()]; let path_amt = if i == num_paths - 1 { amt - amt_per_path * (num_paths as u64 - 1) } else { @@ -2410,8 +2434,16 @@ impl PaymentTracker { fee_per_path }; vec![ - PaymentHop { amount_msat: path_amt + path_fee, short_channel_id: middle_scid }, - PaymentHop { amount_msat: path_amt, short_channel_id: dest_scid }, + PaymentHop { + channel_id: middle_chan_id, + amount_msat: path_amt + path_fee, + short_channel_id: middle_scid, + }, + PaymentHop { + channel_id: dest_chan_id, + amount_msat: path_amt, + short_channel_id: dest_scid, + }, ] }) .collect(); @@ -2434,6 +2466,7 @@ impl PaymentTracker { source, id, hash, + payment_paths, min_final_cltv_expiry, has_pending_work, ); From 090181b1148d3be42102aa109d1d63e35899e1bc Mon Sep 17 00:00:00 2001 From: Joost Jager <joost.jager@gmail.com> Date: Fri, 10 Jul 2026 13:52:04 +0200 Subject: [PATCH 620/627] fuzz: require expected payment failures Start pending payments at MustSucceed and require every tracked PaymentFailed to follow an observed failure source. Cover receiver rejection, corruption, local send failure, local inbound forwarding failures, and the receive-side CLTV buffer. Local inbound failures are classified when forwarding leaves an inbound HTLC waiting for its removal revoke, before the failure can reach the payer. For relayed failures, use OutboundHTLCDetails::source to retain exact inbound channel and HTLC IDs with the payment hash. This prevents same-hash MPP parts from being mistaken for a local failure root. --- fuzz/src/chanmon_consistency.rs | 187 ++++++++++++++++++++++++++++++-- 1 file changed, 179 insertions(+), 8 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 3363c99b30d..b08b0f82ee8 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -51,7 +51,7 @@ use lightning::events::{self, EventsProvider}; use lightning::ln::channel::{ FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE, MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS, }; -use lightning::ln::channel_state::ChannelDetails; +use lightning::ln::channel_state::{ChannelDetails, InboundHTLCStateDetails, OutboundHTLCSource}; use lightning::ln::channelmanager::{ ChainParameters, ChannelManager, ChannelManagerReadArgs, PaymentId, RecentPaymentDetails, TrustedChannelFeatures, @@ -1943,6 +1943,12 @@ impl PeerLink { } } +#[derive(Clone, Copy, PartialEq)] +enum PaymentExpectation { + MustSucceed, + MayFail, +} + #[derive(Clone, Copy)] struct PaymentHop { channel_id: ChannelId, @@ -1958,6 +1964,7 @@ struct PendingPayment { first_persisted_manager_generation: u64, paths: Vec<PaymentPath>, min_final_cltv_expiry: u32, + expectation: PaymentExpectation, } struct NodePayments { @@ -1984,6 +1991,7 @@ impl NodePayments { first_persisted_manager_generation, paths, min_final_cltv_expiry, + expectation: PaymentExpectation::MustSucceed, }); } @@ -2004,6 +2012,32 @@ impl NodePayments { pending } + fn allow_failure_for_id(&mut self, payment_id: PaymentId) { + for pending in &mut self.pending { + if pending.payment_id == payment_id { + pending.expectation = PaymentExpectation::MayFail; + } + } + } + + fn allow_failure_for_hash(&mut self, payment_hash: PaymentHash) { + for pending in &mut self.pending { + if pending.payment_hash == payment_hash { + pending.expectation = PaymentExpectation::MayFail; + } + } + } + + fn allow_failure_for_receive_cltv_buffer(&mut self, current_height: u32) { + let unsafe_receive_height = + current_height.saturating_add(channelmonitor::HTLC_FAIL_BACK_BUFFER + 1); + for pending in &mut self.pending { + if pending.min_final_cltv_expiry <= unsafe_receive_height { + pending.expectation = PaymentExpectation::MayFail; + } + } + } + fn mark_sent(&mut self, sent_id: PaymentId, payment_hash: PaymentHash) { if self.pending.iter().any(|pending| pending.payment_id == sent_id) { self.resolve_pending(sent_id, Some(payment_hash)); @@ -2017,6 +2051,24 @@ impl NodePayments { panic!("Payment {:?} sent without being tracked", sent_id); } } + fn mark_failed(&mut self, source_idx: usize, payment_id: PaymentId) { + if self.pending.iter().any(|pending| pending.payment_id == payment_id) { + let pending = self.resolve_pending(payment_id, None); + assert!( + pending.expectation == PaymentExpectation::MayFail, + "Payment {:?} from node {} failed without an expected failure source", + pending.payment_hash, + source_idx + ); + } else { + assert!( + self.resolved.contains_key(&payment_id), + "Payment {:?} from node {} failed without being tracked", + payment_id, + source_idx + ); + } + } fn mark_resolved_without_hash(&mut self, payment_id: PaymentId) { if self.pending.iter().any(|pending| pending.payment_id == payment_id) { @@ -2052,6 +2104,8 @@ struct PaymentTracker { nodes: [NodePayments; 3], claimed_payment_hashes: HashSet<PaymentHash>, payment_preimages: HashMap<PaymentHash, PaymentPreimage>, + // Inbound HTLCs whose failures were received from downstream. + downstream_failed_inbound_htlcs: [HashSet<(ChannelId, u64, PaymentHash)>; 3], payment_ctr: u64, } @@ -2061,6 +2115,7 @@ impl PaymentTracker { nodes: [NodePayments::new(), NodePayments::new(), NodePayments::new()], claimed_payment_hashes: HashSet::new(), payment_preimages: new_hash_map(), + downstream_failed_inbound_htlcs: [HashSet::new(), HashSet::new(), HashSet::new()], payment_ctr: 0, } } @@ -2134,10 +2189,80 @@ impl PaymentTracker { Route { paths, route_params } } - fn has_outbound_htlc(source: &ChanMan, payment_hash: PaymentHash) -> bool { - source.list_channels().iter().any(|chan| { - chan.pending_outbound_htlcs.iter().any(|htlc| htlc.payment_hash == payment_hash) - }) + fn allow_failure_for_hash(&mut self, payment_hash: PaymentHash) { + for node in &mut self.nodes { + node.allow_failure_for_hash(payment_hash); + } + } + + fn allow_failure_for_receive_cltv_buffer(&mut self, current_height: u32) { + for node in &mut self.nodes { + node.allow_failure_for_receive_cltv_buffer(current_height); + } + } + + fn record_downstream_failure( + &mut self, node_idx: usize, node: &HarnessNode<'_>, counterparty_node_id: &PublicKey, + channel_id: ChannelId, htlc_id: u64, + ) { + let Some(htlc) = node + .list_channels() + .into_iter() + .find(|chan| { + chan.counterparty.node_id == *counterparty_node_id && chan.channel_id == channel_id + }) + .and_then(|chan| { + chan.pending_outbound_htlcs.into_iter().find(|htlc| htlc.htlc_id == Some(htlc_id)) + }) + else { + return; + }; + let payment_hash = htlc.payment_hash; + match htlc.source { + Some(OutboundHTLCSource::Forwarded { inbound_htlc }) => { + self.downstream_failed_inbound_htlcs[node_idx].insert(( + inbound_htlc.channel_id, + inbound_htlc.htlc_id, + payment_hash, + )); + }, + Some(OutboundHTLCSource::TrampolineForwarded { inbound_htlcs }) => { + self.downstream_failed_inbound_htlcs[node_idx].extend( + inbound_htlcs + .into_iter() + .map(|htlc| (htlc.channel_id, htlc.htlc_id, payment_hash)), + ); + }, + Some(OutboundHTLCSource::Local { .. }) | None => {}, + } + } + + fn allow_failure_for_local_inbound_htlcs(&mut self, node_idx: usize, node: &HarnessNode<'_>) { + // Classify failures immediately after forwarding so implicit LDK-local + // policy or state failures are observed before their failure messages can + // reach the payer. Downstream-originated failures are already tracked by + // the failure message that caused them. + let failed_htlcs: Vec<_> = node + .list_channels() + .iter() + .flat_map(|chan| { + chan.pending_inbound_htlcs.iter().filter_map(|htlc| { + matches!( + htlc.state.as_ref(), + Some(InboundHTLCStateDetails::AwaitingRemoteRevokeToRemoveFail) + ) + .then_some((chan.channel_id, htlc.htlc_id, htlc.payment_hash)) + }) + }) + .collect(); + for (channel_id, htlc_id, payment_hash) in failed_htlcs { + // Failed inbound HTLCs may appear in multiple state snapshots, so keep downstream + // markers after matching them. + let htlc = (channel_id, htlc_id, payment_hash); + if !self.downstream_failed_inbound_htlcs[node_idx].contains(&htlc) { + self.allow_failure_for_hash(payment_hash); + } + } } fn route_min_final_cltv_expiry(route: &Route, source_best_block_height: u32) -> u32 { @@ -2157,11 +2282,31 @@ impl PaymentTracker { .expect("payment route should contain at least one path") } + fn has_outbound_htlc(source: &ChanMan, payment_hash: PaymentHash) -> bool { + source.list_channels().iter().any(|chan| { + chan.pending_outbound_htlcs.iter().any(|htlc| htlc.payment_hash == payment_hash) + }) + } + fn payment_has_uncommitted_paths( + source: &HarnessNode<'_>, payment_hash: PaymentHash, path_count: usize, + ) -> bool { + let committed_htlc_count = source + .list_channels() + .iter() + .flat_map(|chan| chan.pending_outbound_htlcs.iter()) + .filter(|htlc| htlc.payment_hash == payment_hash && htlc.htlc_id.is_some()) + .count(); + committed_htlc_count < path_count + } + fn record_send_result( &mut self, source_idx: usize, source: &HarnessNode<'_>, payment_id: PaymentId, payment_hash: PaymentHash, payment_paths: Vec<PaymentPath>, min_final_cltv_expiry: u32, has_pending_work: bool, ) { + let path_count = payment_paths.len(); + let has_uncommitted_paths = has_pending_work + && Self::payment_has_uncommitted_paths(source, payment_hash, path_count); let node_payments = &mut self.nodes[source_idx]; node_payments.add_pending( payment_id, @@ -2170,7 +2315,13 @@ impl PaymentTracker { payment_paths, min_final_cltv_expiry, ); - if !has_pending_work { + if has_pending_work { + // Holding-cell HTLCs have no id, while paths that failed locally are absent. + // Either can make an otherwise tracked payment fail without a downstream cause. + if has_uncommitted_paths { + node_payments.allow_failure_for_id(payment_id); + } + } else { node_payments.resolve_pending(payment_id, None); } } @@ -2474,6 +2625,7 @@ impl PaymentTracker { fn claim_payment(&mut self, node: &HarnessNode<'_>, payment_hash: PaymentHash, fail: bool) { if fail { + self.allow_failure_for_hash(payment_hash); node.fail_htlc_backwards(&payment_hash); } else { let payment_preimage = *self @@ -3156,7 +3308,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { fn handle_update_htlcs_event<Out: Output + MaybeSend + MaybeSync>( node_idx: usize, source_node_id: PublicKey, node_id: PublicKey, channel_id: ChannelId, updates: CommitmentUpdate, corrupt_forward: bool, limit_events: ProcessMessages, - nodes: &[HarnessNode<'_>; 3], _payments: &mut PaymentTracker, out: &Out, + nodes: &[HarnessNode<'_>; 3], payments: &mut PaymentTracker, out: &Out, ) -> Option<MessageSendEvent> { let dest_idx = find_destination_node(nodes, &node_id); let dest = &nodes[dest_idx]; @@ -3171,6 +3323,9 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { for update_add in update_add_htlcs.iter() { log_msg_delivery(node_idx, dest_idx, "update_add_htlc", out); + if corrupt_forward { + payments.allow_failure_for_hash(update_add.payment_hash); + } handle_update_add_htlc(source_node_id, dest, update_add, corrupt_forward); } let processed_change = !update_add_htlcs.is_empty() @@ -3183,10 +3338,24 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { } for update_fail in update_fail_htlcs.iter() { log_msg_delivery(node_idx, dest_idx, "update_fail_htlc", out); + payments.record_downstream_failure( + dest_idx, + dest, + &source_node_id, + update_fail.channel_id, + update_fail.htlc_id, + ); dest.handle_update_fail_htlc(source_node_id, update_fail); } for update_fail_malformed in update_fail_malformed_htlcs.iter() { log_msg_delivery(node_idx, dest_idx, "update_fail_malformed_htlc", out); + payments.record_downstream_failure( + dest_idx, + dest, + &source_node_id, + update_fail_malformed.channel_id, + update_fail_malformed.htlc_id, + ); dest.handle_update_fail_malformed_htlc(source_node_id, update_fail_malformed); } if let Some(msg) = update_fee { @@ -3434,7 +3603,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { payments.nodes[node_idx].mark_successful_probe(payment_id); }, events::Event::PaymentFailed { payment_id, .. } => { - payments.nodes[node_idx].mark_resolved_without_hash(payment_id); + payments.nodes[node_idx].mark_failed(node_idx, payment_id); }, events::Event::ProbeFailed { payment_id, .. } => { payments.nodes[node_idx].mark_resolved_without_hash(payment_id); @@ -3526,6 +3695,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { had_events |= nodes[node_idx].process_monitor_pending_events(); while nodes[node_idx].needs_pending_htlc_processing() { nodes[node_idx].process_pending_htlc_forwards(); + payments.allow_failure_for_local_inbound_htlcs(node_idx, &nodes[node_idx]); had_events = true; } had_events @@ -3895,6 +4065,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { return 0; } let confirmed_txs = self.chain_state.mine_blocks(count); + self.payments.allow_failure_for_receive_cltv_buffer(self.chain_state.tip_height()); let wallets = [ self.nodes[0].wallet.as_ref(), self.nodes[1].wallet.as_ref(), From dbb12502fb4551575ea9eeb9d2a2a760099b2519 Mon Sep 17 00:00:00 2001 From: Joost Jager <joost.jager@gmail.com> Date: Thu, 9 Jul 2026 12:15:39 +0200 Subject: [PATCH 621/627] fuzz: allow empty-channel force close with in-flight payments The chanmon harness can now force-close a target channel as long as that channel itself has no pending HTLCs, even when another channel still carries an in-flight payment. When an explicit close succeeds, mark pending payments that routed over the closed channel as allowed to fail. This is the narrowest extension beyond globally HTLC-free closes. It covers a payment that crossed one hop before its next, still-empty channel is closed, without yet modeling force closes of channels that themselves contain HTLCs. Route-aware failure tracking limits the allowance to affected payments. --- fuzz/src/chanmon_consistency.rs | 39 +++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index b08b0f82ee8..ecc87c6e346 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -2028,6 +2028,18 @@ impl NodePayments { } } + fn allow_failure_for_closed_channel(&mut self, channel_id: ChannelId) { + for pending in &mut self.pending { + let uses_channel = pending + .paths + .iter() + .any(|path| path.iter().any(|hop| hop.channel_id == channel_id)); + if uses_channel { + pending.expectation = PaymentExpectation::MayFail; + } + } + } + fn allow_failure_for_receive_cltv_buffer(&mut self, current_height: u32) { let unsafe_receive_height = current_height.saturating_add(channelmonitor::HTLC_FAIL_BACK_BUFFER + 1); @@ -2195,6 +2207,12 @@ impl PaymentTracker { } } + fn allow_failure_for_closed_channel(&mut self, channel_id: ChannelId) { + for node in &mut self.nodes { + node.allow_failure_for_closed_channel(channel_id); + } + } + fn allow_failure_for_receive_cltv_buffer(&mut self, current_height: u32) { for node in &mut self.nodes { node.allow_failure_for_receive_cltv_buffer(current_height); @@ -3783,19 +3801,23 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { self.bc_link.reconnect(&self.nodes); } - fn has_pending_htlcs(&self) -> bool { + fn channel_has_pending_htlcs(&self, channel_id: ChannelId) -> bool { self.nodes.iter().any(|node| { node.list_channels().iter().any(|chan| { - !chan.pending_inbound_htlcs.is_empty() || !chan.pending_outbound_htlcs.is_empty() + chan.channel_id == channel_id + && (!chan.pending_inbound_htlcs.is_empty() + || !chan.pending_outbound_htlcs.is_empty()) }) }) } fn force_close(&mut self, closer_idx: usize, channel_id: ChannelId, counterparty_idx: usize) { - if self.close_tracker.is_closed_or_closing(&channel_id) || self.has_pending_htlcs() { - // This opcode only models HTLC-free local closes. Leave it as a no-op - // while any channel has pending HTLCs, rather than mixing local - // force-close coverage with HTLC settlement. + if self.close_tracker.is_closed_or_closing(&channel_id) + || self.channel_has_pending_htlcs(channel_id) + { + // This opcode only models closes whose target channel has no + // pending HTLCs. Other channels may still carry HTLCs that later + // fail back through normal peer messages during settlement. return; } assert!( @@ -3810,7 +3832,10 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { &self.nodes[counterparty_idx].get_our_node_id(), reason.clone(), ) { - Ok(()) => self.close_tracker.expect_channel_close(channel_id, reason), + Ok(()) => { + self.payments.allow_failure_for_closed_channel(channel_id); + self.close_tracker.expect_channel_close(channel_id, reason); + }, Err(e) => panic!("{e:?}"), } } From 5057809b808e1df90d80b8b7cead64e0e247af46 Mon Sep 17 00:00:00 2001 From: Matt Corallo <git@bluematt.me> Date: Mon, 27 Jul 2026 01:00:41 +0000 Subject: [PATCH 622/627] Correct docs on `ChannelSigner::get_per_commitment_point` This was apparently missed in 1f7b24900d16c841d55fc83e0e2057e241d5e --- lightning/src/sign/mod.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lightning/src/sign/mod.rs b/lightning/src/sign/mod.rs index 70bd9e68f60..b81d382eede 100644 --- a/lightning/src/sign/mod.rs +++ b/lightning/src/sign/mod.rs @@ -746,9 +746,12 @@ pub trait ChannelSigner { /// /// Note that the commitment number starts at `(1 << 48) - 1` and counts backwards. /// - /// This method is *not* asynchronous. This method is expected to always return `Ok` - /// immediately after we reconnect to peers, and returning an `Err` may lead to an immediate - /// `panic`. This method will be made asynchronous in a future release. + /// An `Err` can be returned to signal that the signer is unavailable/cannot produce a new + /// commitment point and should be retried later. Once the signer is ready to provide a new + /// commitment point after previously returning an `Err`, [`ChannelManager::signer_unblocked`] + /// must be called. + /// + /// [`ChannelManager::signer_unblocked`]: crate::ln::channelmanager::ChannelManager::signer_unblocked fn get_per_commitment_point( &self, idx: u64, secp_ctx: &Secp256k1<secp256k1::All>, ) -> Result<PublicKey, ()>; From e66522375b5eb4a69926716585d5b9006de0669f Mon Sep 17 00:00:00 2001 From: Matt Morehouse <mattmorehouse@gmail.com> Date: Tue, 28 Jul 2026 11:15:26 -0500 Subject: [PATCH 623/627] Include to_self_delay size in DelayedPaymentOutput weight calculation SpendableOutputDescriptor::create_spendable_outputs_psbt estimated the witness weight of a to_local (DelayedPaymentOutput) input using MAX_WITNESS_LENGTH, which assumes the maximum 4-byte OP_CSV push of to_self_delay in the redeemscript. The real push can be as small as 1 byte for small to_self_delays, causing the estimate to overshoot by up to 3 WU. If this overshoot occurred in addition to a short signature, the max-overshoot debug_assert in KeysManager::spend_spendable_outputs would fail. Add DelayedPaymentOutput::max_witness_length, which computes the witness length from the descriptor's actual to_self_delay, and use it in place of the MAX_WITNESS_LENGTH constant. This produces a more accurate weight estimate so that the debug_assert in spend_spendable_outputs never fails. This bug was discovered using Smite. --- lightning/src/ln/chan_utils.rs | 14 ++- lightning/src/sign/mod.rs | 86 +++++++++++++++++-- ...833-delayed-payment-max-witness-length.txt | 5 ++ 3 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 pending_changelog/4833-delayed-payment-max-witness-length.txt diff --git a/lightning/src/ln/chan_utils.rs b/lightning/src/ln/chan_utils.rs index dd334776736..781baecd356 100644 --- a/lightning/src/ln/chan_utils.rs +++ b/lightning/src/ln/chan_utils.rs @@ -668,6 +668,18 @@ impl TxCreationKeys { // on-chain funds. pub const REVOKEABLE_REDEEMSCRIPT_MAX_LENGTH: usize = 6 + 4 + 34 * 2; +/// The exact length of the script returned by [`get_revokeable_redeemscript`] for a given +/// `contest_delay`. +/// +/// This is always at most [`REVOKEABLE_REDEEMSCRIPT_MAX_LENGTH`], and shorter when `contest_delay` +/// encodes to fewer than the maximum 4 bytes. +pub fn revokeable_redeemscript_len(contest_delay: u16) -> usize { + // 6 bytes of opcodes + the `OP_CSV` value push + two 33-byte public keys (each with a 1-byte + // push). + let contest_delay_push_len = Builder::new().push_int(contest_delay as i64).into_script().len(); + 6 + contest_delay_push_len + 34 * 2 +} + /// A script either spendable by the revocation /// key or the broadcaster_delayed_payment_key and satisfying the relative-locktime OP_CSV constrain. /// Encumbering a `to_holder` output on a commitment transaction or 2nd-stage HTLC transactions. @@ -683,7 +695,7 @@ pub fn get_revokeable_redeemscript(revocation_key: &RevocationKey, contest_delay .push_opcode(opcodes::all::OP_ENDIF) .push_opcode(opcodes::all::OP_CHECKSIG) .into_script(); - debug_assert!(res.len() <= REVOKEABLE_REDEEMSCRIPT_MAX_LENGTH); + debug_assert_eq!(res.len(), revokeable_redeemscript_len(contest_delay)); res } diff --git a/lightning/src/sign/mod.rs b/lightning/src/sign/mod.rs index b81d382eede..f2907ae12a8 100644 --- a/lightning/src/sign/mod.rs +++ b/lightning/src/sign/mod.rs @@ -109,14 +109,21 @@ pub struct DelayedPaymentOutputDescriptor { impl DelayedPaymentOutputDescriptor { /// The maximum length a well-formed witness spending one of these should have. /// + /// This depends on the descriptor's [`to_self_delay`], whose `OP_CSV` push in the revocable + /// redeemscript varies in length. + /// /// Note: If you have the `grind_signatures` feature enabled, this will be at least 1 byte /// shorter. - pub const MAX_WITNESS_LENGTH: u64 = (1 /* witness items */ - + 1 /* sig push */ - + MAX_STANDARD_SIGNATURE_SIZE - + 1 /* empty vec push */ - + 1 /* redeemscript push */ - + chan_utils::REVOKEABLE_REDEEMSCRIPT_MAX_LENGTH) as u64; + /// + /// [`to_self_delay`]: Self::to_self_delay + pub fn max_witness_length(&self) -> u64 { + (1 /* witness items */ + + 1 /* sig push */ + + MAX_STANDARD_SIGNATURE_SIZE + + 1 /* empty vec push */ + + 1 /* redeemscript push */ + + chan_utils::revokeable_redeemscript_len(self.to_self_delay)) as u64 + } } impl_ser_tlv_based!(DelayedPaymentOutputDescriptor, { @@ -502,7 +509,7 @@ impl SpendableOutputDescriptor { sequence: Sequence(descriptor.to_self_delay as u32), witness: Witness::new(), }); - witness_weight += DelayedPaymentOutputDescriptor::MAX_WITNESS_LENGTH; + witness_weight += descriptor.max_witness_length(); #[cfg(feature = "grind_signatures")] { // Guarantees a low R signature @@ -2717,6 +2724,71 @@ pub fn dyn_sign() { let _signer: Box<dyn EcdsaChannelSigner>; } +// Regression test: the sweep-weight estimate for a `to_local` (`DelayedPaymentOutput`) output must +// reflect the channel's `to_self_delay`. +// +// The revocable redeemscript encodes `to_self_delay` with an `OP_CSV` push that can vary in size +// from 1 byte (for `to_self_delay <= 16`) up to 4 bytes. `create_spendable_outputs_psbt` used to +// estimate every such output with the maximum 4-byte push, overshooting the real sweep weight by up +// to 3 WU for a small `to_self_delay`. If this occurred along with a short signature, an assertion +// would fail in `KeysManager::spend_spendable_outputs`. +#[test] +fn sweep_weight_estimate_accounts_for_to_self_delay() { + let secp_ctx = Secp256k1::new(); + let per_commitment_point = + PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[1u8; 32]).unwrap()); + let delayed_payment_key = DelayedPaymentKey(PublicKey::from_secret_key( + &secp_ctx, + &SecretKey::from_slice(&[3u8; 32]).unwrap(), + )); + let revocation_pubkey = RevocationKey(PublicKey::from_secret_key( + &secp_ctx, + &SecretKey::from_slice(&[2u8; 32]).unwrap(), + )); + let change_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([7u8; 20])); + + let estimate = |to_self_delay: u16| { + let witness_script = + get_revokeable_redeemscript(&revocation_pubkey, to_self_delay, &delayed_payment_key); + let descriptor = + SpendableOutputDescriptor::DelayedPaymentOutput(DelayedPaymentOutputDescriptor { + outpoint: OutPoint { txid: Txid::from_byte_array([1u8; 32]), index: 0 }, + per_commitment_point, + to_self_delay, + output: TxOut { + value: Amount::from_sat(1_000_000), + script_pubkey: witness_script.to_p2wsh(), + }, + revocation_pubkey, + channel_keys_id: [1u8; 32], + channel_value_satoshis: 1_000_000, + channel_transaction_parameters: None, + }); + SpendableOutputDescriptor::create_spendable_outputs_psbt( + &secp_ctx, + &[&descriptor], + vec![], + change_script.clone(), + 253, + None, + ) + .unwrap() + .1 + }; + + // The estimate should adjust according to the `to_self_delay` push length. + let max_estimate = estimate(65_535); // 4-byte `OP_CSV` push + for (to_self_delay, push_len) in + [(0u16, 1u64), (16, 1), (17, 2), (127, 2), (128, 3), (32_767, 3), (32_768, 4), (65_535, 4)] + { + assert_eq!( + estimate(to_self_delay), + max_estimate - (4 - push_len), + "wrong sweep-weight estimate for to_self_delay={to_self_delay}", + ); + } +} + #[cfg(ldk_bench)] pub mod benches { use crate::sign::{EntropySource, KeysManager}; diff --git a/pending_changelog/4833-delayed-payment-max-witness-length.txt b/pending_changelog/4833-delayed-payment-max-witness-length.txt new file mode 100644 index 00000000000..e97f07f68be --- /dev/null +++ b/pending_changelog/4833-delayed-payment-max-witness-length.txt @@ -0,0 +1,5 @@ +# API Updates + * `DelayedPaymentOutputDescriptor::MAX_WITNESS_LENGTH` was removed in favor of + the new `DelayedPaymentOutputDescriptor::max_witness_length` method, which + returns a tighter witness weight by accounting for the descriptor's + `to_self_delay` (#4833). From 5cf053fc08b9f3e80f6e089b96ed3820dc25ea72 Mon Sep 17 00:00:00 2001 From: Erick Cestari <erickcestari03@gmail.com> Date: Wed, 17 Dec 2025 15:14:23 -0300 Subject: [PATCH 624/627] expose some internals --- lightning/src/ln/channelmanager.rs | 2 +- lightning/src/ln/msgs.rs | 3 --- lightning/src/ln/onion_payment.rs | 2 +- lightning/src/ln/onion_utils.rs | 16 ++++++++-------- 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 08a2cb7ee35..5dbbc19be07 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8057,7 +8057,7 @@ impl< &onion_packet.public_key.unwrap(), &onion_packet.hop_data, onion_packet.hmac, - payment_hash, + Some(payment_hash), None, &self.node_signer, ); diff --git a/lightning/src/ln/msgs.rs b/lightning/src/ln/msgs.rs index 5643bfd9498..2227c71f187 100644 --- a/lightning/src/ln/msgs.rs +++ b/lightning/src/ln/msgs.rs @@ -2728,10 +2728,7 @@ mod fuzzy_internal_msgs { pub attribution_data: Option<AttributionData>, } } -#[cfg(fuzzing)] pub use self::fuzzy_internal_msgs::*; -#[cfg(not(fuzzing))] -pub(crate) use self::fuzzy_internal_msgs::*; use super::onion_utils::AttributionData; diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index 3dbb274b8e6..08ebfe6b5bc 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -647,7 +647,7 @@ pub(super) fn decode_incoming_update_add_htlc_onion<NS: NodeSigner, L: Logger, T let next_hop = match onion_utils::decode_next_payment_hop( Recipient::Node, &msg.onion_routing_packet.public_key.unwrap(), &msg.onion_routing_packet.hop_data[..], msg.onion_routing_packet.hmac, - msg.payment_hash, msg.blinding_point, node_signer + Some(msg.payment_hash), msg.blinding_point, node_signer ) { Ok(res) => res, Err(onion_utils::OnionDecodeErr::Malformed { err_msg, reason }) => { diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 040139b46e6..19f67fc7762 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -2187,7 +2187,7 @@ impl HTLCFailReason { /// Allows `decode_next_hop` to return the next hop packet bytes for either payments or onion /// message forwards. -pub(crate) trait NextPacketBytes: AsMut<[u8]> { +pub trait NextPacketBytes: AsMut<[u8]> { fn new(len: usize) -> Self; } @@ -2204,7 +2204,7 @@ impl NextPacketBytes for Vec<u8> { } /// Data decrypted from a payment's onion payload. -pub(crate) enum Hop { +pub enum Hop { /// This onion payload needs to be forwarded to a next-hop. Forward { /// Onion payload data used in forwarding the payment. @@ -2329,7 +2329,7 @@ impl Hop { /// Error returned when we fail to decode the onion packet. #[derive(Debug)] -pub(crate) enum OnionDecodeErr { +pub enum OnionDecodeErr { /// The HMAC of the onion packet did not match the hop data. Malformed { err_msg: &'static str, reason: LocalHTLCFailureReason }, /// We failed to decode the onion payload. @@ -2344,9 +2344,9 @@ pub(crate) enum OnionDecodeErr { }, } -pub(crate) fn decode_next_payment_hop<NS: NodeSigner>( +pub fn decode_next_payment_hop<NS: NodeSigner>( recipient: Recipient, hop_pubkey: &PublicKey, hop_data: &[u8], hmac_bytes: [u8; 32], - payment_hash: PaymentHash, blinding_point: Option<PublicKey>, node_signer: NS, + payment_hash: Option<PaymentHash>, blinding_point: Option<PublicKey>, node_signer: NS, ) -> Result<Hop, OnionDecodeErr> { let blinded_node_id_tweak = blinding_point.map(|bp| { let blinded_tlvs_ss = node_signer.ecdh(recipient, &bp, None).unwrap().secret_bytes(); @@ -2361,7 +2361,7 @@ pub(crate) fn decode_next_payment_hop<NS: NodeSigner>( shared_secret.secret_bytes(), hop_data, hmac_bytes, - Some(payment_hash), + payment_hash, (blinding_point, &node_signer), ); match decoded_hop { @@ -2435,8 +2435,8 @@ pub(crate) fn decode_next_payment_hop<NS: NodeSigner>( trampoline_shared_secret, &hop_data.trampoline_packet.hop_data, hop_data.trampoline_packet.hmac, - Some(payment_hash), - (blinding_point, &node_signer), + payment_hash, + (blinding_point, node_signer), ); match decoded_trampoline_hop { Ok(( From a7dfc1c0cee8cb35d0fb1cb8f0d7e4920268ffc3 Mon Sep 17 00:00:00 2001 From: Erick Cestari <erickcestari03@gmail.com> Date: Tue, 13 Jan 2026 10:26:36 -0300 Subject: [PATCH 625/627] feat: expose next_hop_pubkey --- lightning/src/ln/onion_utils.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 19f67fc7762..fc16c7a6ca7 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -117,7 +117,7 @@ pub(super) fn gen_pad_from_shared_secret(shared_secret: &[u8]) -> [u8; 32] { } /// Calculates a pubkey for the next hop, such as the next hop's packet pubkey or blinding point. -pub(crate) fn next_hop_pubkey<T: secp256k1::Verification>( +pub fn next_hop_pubkey<T: secp256k1::Verification>( secp_ctx: &Secp256k1<T>, curr_pubkey: PublicKey, shared_secret: &[u8], ) -> Result<PublicKey, secp256k1::Error> { let blinding_factor = { From acf60fc2651194d065e50a17124db759c1de03ed Mon Sep 17 00:00:00 2001 From: Erick Cestari <erickcestari03@gmail.com> Date: Tue, 13 Jan 2026 10:53:05 -0300 Subject: [PATCH 626/627] feat: expose AttributionData fields --- lightning/src/ln/onion_utils.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index fc16c7a6ca7..b60d3ab2c6c 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -2839,8 +2839,8 @@ pub(crate) const HMAC_COUNT: usize = MAX_HOPS * (MAX_HOPS + 1) / 2; /// Additionally, it allows a sender to identify how long each hop along a path held an HTLC, with /// 100ms granularity. pub struct AttributionData { - hold_times: [u8; MAX_HOPS * HOLD_TIME_LEN], - hmacs: [u8; HMAC_LEN * HMAC_COUNT], + pub hold_times: [u8; MAX_HOPS * HOLD_TIME_LEN], + pub hmacs: [u8; HMAC_LEN * HMAC_COUNT], } impl AttributionData { From de5a0aad173448bfb0a35727b4d210d87ba1f8c7 Mon Sep 17 00:00:00 2001 From: Erick Cestari <erickcestari03@gmail.com> Date: Wed, 14 Jan 2026 10:15:53 -0300 Subject: [PATCH 627/627] feat: add a custom error to skip some specific cases --- lightning/src/ln/msgs.rs | 24 +++++++++++++++++------- lightning/src/ln/onion_utils.rs | 20 +++++++++++++++----- lightning/src/ln/peer_handler.rs | 3 +++ 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/lightning/src/ln/msgs.rs b/lightning/src/ln/msgs.rs index 2227c71f187..c6539552d84 100644 --- a/lightning/src/ln/msgs.rs +++ b/lightning/src/ln/msgs.rs @@ -102,6 +102,8 @@ pub enum DecodeError { /// [`ChannelManager`]: crate::ln::channelmanager::ChannelManager /// [`ChannelMonitor`]: crate::chain::channelmonitor::ChannelMonitor DangerousValue, + /// This a custom error used by Bitcoinfuzz to skip some errors. + SkipCase } /// An [`init`] message to be sent to or received from a peer. @@ -2854,6 +2856,9 @@ impl fmt::Display for DecodeError { DecodeError::DangerousValue => { f.write_str("Value would be dangerous to continue execution with") }, + DecodeError::SkipCase => { + f.write_str("Should be skipped by bitcoinfuzz") + }, } } } @@ -3794,6 +3799,9 @@ impl<NS: NodeSigner> ReadableArgs<(Option<PublicKey>, NS)> for InboundOnionPaylo let mut custom_tlvs = Vec::new(); let tlv_len = BigSize::read(r)?; + if tlv_len.0 < 2 { + return Err(DecodeError::SkipCase); + } let mut rd = FixedLengthReader::new(r, tlv_len.0); decode_tlv_stream_with_custom_tlv_decode!(&mut rd, { @@ -3818,7 +3826,7 @@ impl<NS: NodeSigner> ReadableArgs<(Option<PublicKey>, NS)> for InboundOnionPaylo }); if amt.unwrap_or(0) > MAX_VALUE_MSAT { - return Err(DecodeError::InvalidValue); + return Err(DecodeError::SkipCase); } if intro_node_blinding_point.is_some() && update_add_blinding_point.is_some() { return Err(DecodeError::InvalidValue); @@ -3865,7 +3873,8 @@ impl<NS: NodeSigner> ReadableArgs<(Option<PublicKey>, NS)> for InboundOnionPaylo used_aad, } => { if amt.is_some() - || cltv_value.is_some() || total_msat.is_some() + || cltv_value.is_some() + || total_msat.is_some() || keysend_preimage.is_some() || invoice_request.is_some() || used_aad != TriPolyAADUsed::None @@ -3912,7 +3921,7 @@ impl<NS: NodeSigner> ReadableArgs<(Option<PublicKey>, NS)> for InboundOnionPaylo receive_tlvs; if total_msat.unwrap_or(0) > MAX_VALUE_MSAT { - return Err(DecodeError::InvalidValue); + return Err(DecodeError::SkipCase); } Ok(Self::BlindedReceive(InboundOnionBlindedReceivePayload { sender_intended_htlc_amt_msat: amt.ok_or(DecodeError::InvalidValue)?, @@ -3935,7 +3944,7 @@ impl<NS: NodeSigner> ReadableArgs<(Option<PublicKey>, NS)> for InboundOnionPaylo || total_msat.is_some() || invoice_request.is_some() { - return Err(DecodeError::InvalidValue); + return Err(DecodeError::SkipCase); } Ok(Self::Forward(InboundOnionForwardPayload { short_channel_id, @@ -3944,11 +3953,11 @@ impl<NS: NodeSigner> ReadableArgs<(Option<PublicKey>, NS)> for InboundOnionPaylo })) } else { if encrypted_tlvs_opt.is_some() || total_msat.is_some() || invoice_request.is_some() { - return Err(DecodeError::InvalidValue); + return Err(DecodeError::SkipCase); } if let Some(data) = &payment_data { if data.total_msat > MAX_VALUE_MSAT { - return Err(DecodeError::InvalidValue); + return Err(DecodeError::SkipCase); } } Ok(Self::Receive(InboundOnionReceivePayload { @@ -4035,7 +4044,8 @@ impl<NS: NodeSigner> ReadableArgs<(Option<PublicKey>, NS)> for InboundTrampoline used_aad, } => { if amt.is_some() - || cltv_value.is_some() || total_msat.is_some() + || cltv_value.is_some() + || total_msat.is_some() || keysend_preimage.is_some() || invoice_request.is_some() || used_aad != TriPolyAADUsed::None diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index b60d3ab2c6c..15e795a5c27 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -2755,18 +2755,28 @@ fn decode_next_hop<T, R: ReadableArgs<T>, N: NextPacketBytes>( let mut chacha_stream = ChaChaReader { chacha: &mut chacha, read: Cursor::new(&hop_data[..]) }; match R::read(&mut chacha_stream, read_args) { Err(err) => { - let reason = match err { + let (reason, err_msg) = match err { // Unknown version - msgs::DecodeError::UnknownVersion => LocalHTLCFailureReason::InvalidOnionVersion, + msgs::DecodeError::UnknownVersion => { + (LocalHTLCFailureReason::InvalidOnionVersion, "Unable to decode our hop data") + }, // invalid_onion_payload + msgs::DecodeError::SkipCase => ( + LocalHTLCFailureReason::InvalidOnionPayload, + "Should be skipped by bitcoinfuzz", + ), msgs::DecodeError::UnknownRequiredFeature | msgs::DecodeError::InvalidValue - | msgs::DecodeError::ShortRead => LocalHTLCFailureReason::InvalidOnionPayload, + | msgs::DecodeError::ShortRead => { + (LocalHTLCFailureReason::InvalidOnionPayload, "Unable to decode our hop data") + }, // Should never happen - _ => LocalHTLCFailureReason::TemporaryNodeFailure, + _ => { + (LocalHTLCFailureReason::TemporaryNodeFailure, "Unable to decode our hop data") + }, }; return Err(OnionDecodeErr::Relay { - err_msg: "Unable to decode our hop data", + err_msg, reason, shared_secret: SharedSecret::from_bytes(shared_secret), trampoline_shared_secret: None, diff --git a/lightning/src/ln/peer_handler.rs b/lightning/src/ln/peer_handler.rs index 8a983c6d37e..27c844f42e5 100644 --- a/lightning/src/ln/peer_handler.rs +++ b/lightning/src/ln/peer_handler.rs @@ -1980,6 +1980,9 @@ impl< (msgs::DecodeError::UnknownVersion, _) => { return Err(PeerHandleError {}) }, + (msgs::DecodeError::SkipCase, _) => { + return Err(PeerHandleError {}) + }, (msgs::DecodeError::InvalidValue, _) => { log_debug!(logger, "Got an invalid value while deserializing message"); return Err(PeerHandleError {});